Files
AIGame/AIClient/AgentScripts/VerifyBattleRuntime.cs
T

132 lines
18 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.Net;
using HotUpdate.UI;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
/// <summary>Real UI/HTTP on an isolated database. Supports restarting the test server between two entries.</summary>
public static class VerifyBattleRuntime
{
const string Endpoint="http://127.0.0.1:18083";
static string Repo=>Path.GetFullPath(Path.Combine(Application.dataPath,"../.."));
static PrototypeFlow Flow=>UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static T Get<T>(string name)=>(T)typeof(PrototypeFlow).GetField(name,BindingFlags.Instance|BindingFlags.NonPublic).GetValue(Flow);
static void Set(string name,object value)=>typeof(PrototypeFlow).GetField(name,BindingFlags.Instance|BindingFlags.NonPublic).SetValue(Flow,value);
static object Call(string name)=>typeof(PrototypeFlow).GetMethod(name,BindingFlags.Instance|BindingFlags.NonPublic).Invoke(Flow,null);
static void Check(bool v,string message){if(!v)throw new Exception(message);}
static async Task Until(Func<bool> condition,int seconds=35){var end=DateTime.UtcNow.AddSeconds(seconds);while(!condition()){Check(DateTime.UtcNow<end,"Timed out "+Flow?.Page);await Task.Delay(50);}}
static void Click(string name)
{
var button=UnityEngine.Object.FindObjectsOfType<Button>().Single(b=>b.name=="Btn_"+name&&b.gameObject.activeInHierarchy);Check(button.interactable,"Disabled "+name);Canvas.ForceUpdateCanvases();var rect=button.GetComponent<RectTransform>();
var pointer=new PointerEventData(EventSystem.current){position=RectTransformUtility.WorldToScreenPoint(null,rect.TransformPoint(rect.rect.center)),button=PointerEventData.InputButton.Left};var hits=new List<RaycastResult>();EventSystem.current.RaycastAll(pointer,hits);
Check(hits.Count>0&&ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject)==button.gameObject,"Blocked "+name);ExecuteEvents.Execute(button.gameObject,pointer,ExecuteEvents.pointerClickHandler);
}
static async Task ClickReady(string name){for(int n=0;;n++){try{Click(name);return;}catch(Exception)when(n<40){await Task.Delay(50);}}}
static async Task Capture()
{
var type=AppDomain.CurrentDomain.GetAssemblies().Select(a=>a.GetType("Unity.Pipeline.Editor.Commands.Capture.CaptureCommands")).First(t=>t!=null);
type.GetMethod("CaptureGameView",BindingFlags.Public|BindingFlags.Static).Invoke(null,new object[]{1080,1920,null,"Screenshots/battle_skills_network.png",false,0,"screen"});await Task.Delay(150);
}
public static string Assets()
{
var arena=AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Scenes/Prototype/PrototypeArena.prefab");var player=arena.GetComponentInChildren<BattleSkillPresentation>(true);Check(player!=null&&player.EffectKinds==15&&player.ProfileCount==10,"Missing formal skill bank");var so=new SerializedObject(player);int count=0;
for(int i=0;i<15;i++){var list=so.FindProperty("_effects").GetArrayElementAtIndex(i).FindPropertyRelative("instances");count+=list.arraySize;for(int n=0;n<list.arraySize;n++)Check(list.GetArrayElementAtIndex(n).objectReferenceValue!=null,"Missing particle instance");}
Check(count==76&&so.FindProperty("_clips").arraySize==9&&so.FindProperty("_voices").arraySize==8,"Pool budget changed");Check(so.FindProperty("_petImpacts").arraySize==10&&so.FindProperty("_enemyImpacts").arraySize==5,"Missing action phase mappings");
int anchors=0;foreach(string group in new[]{"Heroes","Enemies"})foreach(Transform actor in arena.transform.Find(group)){var point=actor.Find("SkillMuzzle");Check(point!=null&&point.localPosition.z<0,"Missing local -Z muzzle");anchors++;}Check(anchors==19,"Expected 19 authored muzzle anchors");
foreach(string name in new[]{"battle_skills_network","battle_skills_anchors"})if(File.Exists(Path.Combine(Application.dataPath,"Screenshots/"+name+".png")))AssetDatabase.ImportAsset("Assets/Screenshots/"+name+".png",ImportAssetOptions.ForceUpdate);
File.WriteAllText(Path.Combine(Repo,"Doc/Design/Combat/Runtime/unity_assets.json"),JsonConvert.SerializeObject(new{status="pass",effects=15,profiles=10,audioClips=9,poolInstances=count,voices=8,anchors,petPhases=10,enemyPhases=5},Formatting.Indented));return "PASS formal battle bank: 76 instances, eight voices, 19 local -Z muzzles, 15 action phase mappings";
}
public static string Domain()
{
var files=Directory.GetFiles(Path.Combine(Repo,"Tools/output/combat_runtime/traces"),"*.json");Check(files.Length==23,"Expected 20 T1 stages plus three T2 capstone fixtures");var rows=new List<object>();
foreach(string file in files)
{
var data=JObject.Parse(File.ReadAllText(file));var manifest=JsonUtility.FromJson<CombatTraceManifest>(data["manifest"].ToString());var events=data["events"].ToObject<CombatEvent[]>();Check(CombatTraceValidation.Events(manifest,events),"Invalid exported trace");
var snapshot=JsonUtility.FromJson<BattleSnapshot>(data["save"].ToString());var config=JsonUtility.FromJson<PrototypeConfig>(data["config"].ToString());var stage=JsonUtility.FromJson<StageConfig>(data["stage"].ToString());var game=new PrototypeGame(PrototypeSave.BattleSnapshot(snapshot.level,snapshot.stages,snapshot.formation));var battle=new PrototypeBattle("domain-authoritative-"+stage.id,game,config,stage.id);
battle.LoadTrace(manifest,events);for(int i=0;i<2401&&!battle.Finished;i++){battle.Tick();while(battle.TryDequeueCue(out var _)){}while(battle.TryDequeueSkillCue(out var _)){};}
Check(battle.TraceFinalStateMatches&&battle.TraceEventsConsumed==events.Length,"Final read model mismatch "+file);
foreach(var stat in data["manifest"]["stats"]??new JArray()){var h=battle.Heroes.Single(x=>x.Pet==(int)stat["pet"]);Check(Mathf.Abs(h.DamageDealt-(float)stat["damage"])<.01f&&Mathf.Abs(h.Healing-(float)stat["healing"])<.01f&&Mathf.Abs(h.Absorbed-(float)stat["absorbed"])<.01f&&h.SkillCasts==(int)stat["skills"],"Read model stats mismatch");}
var changed=events[0].seq;events[0].seq=999;Check(!CombatTraceValidation.Events(manifest,events),"Corrupt sequence accepted");events[0].seq=changed;
rows.Add(new{stage=stage.id,events=events.Length,won=battle.Won});
}
string folder=Path.Combine(Repo,"Doc/Design/Combat/Runtime");Directory.CreateDirectory(folder);File.WriteAllText(Path.Combine(folder,"unity_domain.json"),JsonConvert.SerializeObject(new{status="pass",cases=files.Length,all20Stages=true,rows,corruptTraceRejected=true},Formatting.Indented));return "PASS 23 authoritative C++ traces -> C# read model: all 20 stages, three T2 capstones, exact final state/stats, corrupt sequences rejected";
}
public static Task<string> Visual()=>CaptureVisual(false);
public static Task<string> VisualSkills()=>CaptureVisual(true);
static async Task<string> CaptureVisual(bool signature)
{
Check(EditorApplication.isPlaying,"Use Play Mode");var data=JObject.Parse(File.ReadAllText(Path.Combine(Repo,"Tools/output/combat_runtime/traces/stage_20_t2_f0.json")));var snapshot=JsonUtility.FromJson<BattleSnapshot>(data["save"].ToString());var config=JsonUtility.FromJson<PrototypeConfig>(data["config"].ToString());var game=new PrototypeGame(PrototypeSave.BattleSnapshot(snapshot.level,snapshot.stages,snapshot.formation));var battle=new PrototypeBattle("isolated-trace-visual",game,config,20);
battle.LoadTrace(JsonUtility.FromJson<CombatTraceManifest>(data["manifest"].ToString()),data["events"].ToObject<CombatEvent[]>());
var arena=UnityEngine.Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Scenes/Prototype/PrototypeArena.prefab"));var ui=UnityEngine.Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/UI_PrototypeBattle.prefab"));ui.name="UI_PrototypeBattle";ui.SetActive(false);
var host=new GameObject("IsolatedAuthoritativeVisual");host.SetActive(false);var flow=host.AddComponent<PrototypeFlow>();Action<string,object> set=(n,v)=>typeof(PrototypeFlow).GetField(n,BindingFlags.Instance|BindingFlags.NonPublic).SetValue(flow,v);
try
{
set("_profile",new Func<long>(()=>-1));set("_boundProfile",-1L);set("_game",game);set("_config",config);set("_battle",battle);set("_arena",arena);set("_settled",true);set("_speed",2);set("_page",Activator.CreateInstance(typeof(PrototypeFlow).GetNestedType("Panel",BindingFlags.NonPublic),new object[]{ui}));
var heroes=Enumerable.Range(0,10).Select(i=>arena.transform.Find("Heroes/Hero"+i)).ToArray();var enemies=Enumerable.Range(0,9).Select(i=>arena.transform.Find("Enemies/Enemy"+i)).ToArray();var types=enemies.Select(s=>Enumerable.Range(0,5).Select(t=>s.Find("Type"+t)).ToArray()).ToArray();var t1=heroes.Select(h=>h.Find("Visual")).ToArray();var t2=heroes.Select(h=>h.Find("VisualT2")).ToArray();
set("_heroViews",heroes);set("_enemyViews",enemies);set("_enemyTypes",types);set("_heroT1Visuals",t1);set("_heroT2Visuals",t2);set("_heroAnimators",t1.Select(v=>v.GetComponentInChildren<Animator>(true)).ToArray());set("_heroT2Animators",t2.Select(v=>v==null?null:v.GetComponentInChildren<Animator>(true)).ToArray());set("_heroPulse",new float[10]);set("_heroSkillPulse",new float[10]);set("_enemyAnimators",types.Select(ts=>ts.Select(t=>t.GetComponentInChildren<Animator>(true)).ToArray()).ToArray());set("_enemyModels",new PrototypeBattle.Enemy[9]);set("_enemyStrikes",new int[9]);
foreach(var name in new[]{"_projectiles","_skillViews","_shieldViews"})set(name,Enumerable.Range(0,10).Select(i=>arena.transform.Find((name=="_projectiles"?"Projectiles/Shot":name=="_skillViews"?"SkillEffects/Skill":"SkillEffects/Shield")+i)).ToArray());
var fx=arena.GetComponentInChildren<BattleSkillPresentation>();set("_skillFeedback",fx);set("_enemyFeedback",arena.GetComponentInChildren<EnemyCombatPresentation>());
Check(heroes.Concat(enemies).All(t=>t.Find("SkillMuzzle")!=null),"Missing authored muzzle");host.SetActive(true);
var end=DateTime.UtcNow.AddSeconds(18);Func<bool> ready=()=>signature?fx.GetComponentsInChildren<LineRenderer>().Any(l=>l.enabled):fx.ActiveShots>0;while(!ready()&&DateTime.UtcNow<end)await Task.Delay(10);Check(ready()&&fx.PlayedEffects>0,"No authoritative visual cue");set("_paused",true);await Task.Delay(60);
foreach(var ps in fx.GetComponentsInChildren<ParticleSystem>().Where(ps=>ps.transform.parent.GetComponent<ParticleSystem>()==null&&(ps.isPlaying||ps.isPaused))){ps.Simulate(.03f,true,false,true);ps.Pause(true);}
var point=(Vector3)typeof(PrototypeFlow).GetMethod("CombatPoint",BindingFlags.Instance|BindingFlags.NonPublic).Invoke(flow,new object[]{1});var muzzle=heroes[1].Find("SkillMuzzle");Check(Vector3.Distance(point,muzzle.position)<.001f,"T2 point must use authored scaled muzzle");
var camera=arena.GetComponentInChildren<Camera>();var rt=new RenderTexture(900,1200,24);var texture=new Texture2D(900,1200,TextureFormat.RGB24,false);var previous=RenderTexture.active;
try{camera.targetTexture=rt;camera.Render();RenderTexture.active=rt;texture.ReadPixels(new Rect(0,0,900,1200),0,0);texture.Apply();File.WriteAllBytes(Path.Combine(Repo,"AIClient/Assets/Screenshots/"+(signature?"partner_skills_battle":"battle_skills_anchors")+".png"),texture.EncodeToPNG());}
finally{camera.targetTexture=null;RenderTexture.active=previous;rt.Release();UnityEngine.Object.Destroy(rt);UnityEngine.Object.Destroy(texture);}
File.WriteAllText(Path.Combine(Repo,signature?"Doc/Design/Combat/PartnerSkills/battle_visual.json":"Doc/Design/Combat/Runtime/unity_anchors.json"),JsonConvert.SerializeObject(new{status="pass",authoritative=true,persistentMuzzles=19,t2ScaleApplied=true,flight=fx.ActiveShots>0,signature,effects=fx.PlayedEffects,networkAccountWrites=false},Formatting.Indented));return "PASS final T2 authored muzzles and authoritative flight; no account writes";
}
finally{UnityEngine.Object.Destroy(host);UnityEngine.Object.Destroy(ui);UnityEngine.Object.Destroy(arena);}
}
public static async Task<string> Begin(string username)
{
Check(EditorApplication.isPlaying,"Use Play Mode");await Until(()=>Flow?.Game!=null&&!Flow.IsBusy);
SessionState.SetString("AIGame.Runtime.original",JsonUtility.ToJson(Flow.Game.State));SessionState.SetString("AIGame.Runtime.endpoint",Get<string>("_httpBase"));SessionState.SetBool("AIGame.Runtime.sfx",PrototypeClientPrefs.Sfx);
AuthResponse auth=null;bool done=false;string error=null;Flow.StartCoroutine(AuthApi.Login(Endpoint,username,"isolated-formal-heroes-password",(r,e)=>{auth=r;error=e;done=true;}));await Until(()=>done);Check(auth!=null,"Isolated login "+error);
var session=new SessionData{Pid=auth.pid,Token=auth.token,Username=username};UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session",new Func<SessionData>(()=>session));Set("_profile",new Func<long>(()=>session.Pid));Set("_httpBase",Endpoint);Set("_boundProfile",0L);Flow.Handle("Home");await Until(()=>Flow.Game!=null&&!Flow.IsBusy&&Get<long>("_boundProfile")==session.Pid);
await ClickReady("Adventure");await Until(()=>Flow.Page=="UI_PrototypeStages");await ClickReady("Stage5");await Until(()=>Flow.Page=="UI_PrototypeFormation");await ClickReady("StartBattle");await Until(()=>Flow.Page=="UI_PrototypeBattle"&&Flow.Battle!=null);
Check(Flow.Battle.Authoritative,"Client fell back to local simulation");var remote=Get<PrototypeReply>("_reply").battle;Check(remote.snapshot.rulesVersion==3,"Missing rule version");SessionState.SetString("AIGame.Runtime.battle",remote.id);SessionState.SetString("AIGame.Runtime.trace",remote.trace.traceHash);
var fx=Get<BattleSkillPresentation>("_skillFeedback");Check(fx!=null&&fx.EffectKinds==15&&fx.ProfileCount==10,"Missing authored feedback");PrototypeClientPrefs.Sfx=true;
await Until(()=>fx.ActiveShots>0);await ClickReady("Pause");float clock=Flow.Battle.Time;await Task.Delay(350);Check(clock==Flow.Battle.Time,"Pause did not freeze data");
PrototypeClientPrefs.Sfx=false;await Task.Delay(50);Check(fx.GetComponentsInChildren<AudioSource>().All(a=>!a.isPlaying),"Mute not applied");PrototypeClientPrefs.Sfx=true;
await Capture();return "PASS real HTTP begin + full paged hash validation + T2 stage 5, flight/pause/mute; paused for isolated server restart";
}
public static async Task<string> ResumeAndFinish()
{
Check(EditorApplication.isPlaying&&Get<string>("_httpBase")==Endpoint,"Expected isolated battle");
Call("Home");Flow.StartCoroutine((IEnumerator)Call("Synchronize"));await Until(()=>Flow.Battle!=null&&Flow.Page=="UI_PrototypeBattle"&&!Flow.IsBusy);
var remote=Get<PrototypeReply>("_reply").battle;Check(remote.id==SessionState.GetString("AIGame.Runtime.battle","")&&remote.trace.traceHash==SessionState.GetString("AIGame.Runtime.trace",""),"Restart regenerated pending battle");
Check(Flow.Battle.Authoritative,"Resume lost trace mode");await ClickReady("Speed");Check(Get<int>("_speed")==2,"2x toggle failed");
var fx=Get<BattleSkillPresentation>("_skillFeedback");bool shot=false,shield=false,attack=false;var until=DateTime.UtcNow.AddSeconds(55);
while(!Flow.Battle.Finished&&DateTime.UtcNow<until)
{
shot|=fx.ActiveShots>0;shield|=fx.ActiveShields>0;attack|=Flow.Battle.Enemies.Any(e=>e.Strikes>0);
await Task.Delay(25);
}
Check(Flow.Battle.Finished&&Flow.Battle.TraceFinalStateMatches,"Client final state differs from authoritative trace");Check(shot&&shield&&attack,"Incomplete visible fight");
await Until(()=>!Flow.IsBusy&&string.IsNullOrEmpty(Flow.Game.State.pendingBattle));var receipt=Get<PrototypeReply>("_reply").result;Check(receipt!=null&&receipt.won==Flow.Battle.Won,"Server result mismatch");
foreach(var stat in receipt.stats){var hero=Flow.Battle.Heroes.Single(h=>h.Pet==stat.pet);Check(Mathf.Abs(hero.DamageDealt-stat.damage)<.01f&&Mathf.Abs(hero.Healing-stat.healing)<.01f&&Mathf.Abs(hero.Absorbed-stat.absorbed)<.01f&&hero.SkillCasts==stat.skills,"Stats mismatch for "+stat.pet);}
Check(fx.ActiveShots==0&&fx.ActiveCharges==0&&fx.ActiveShields==0,"End leaked presentation leases");
string folder=Path.Combine(Repo,"Doc/Design/Combat/Runtime");Directory.CreateDirectory(folder);File.WriteAllText(Path.Combine(folder,"unity_network.json"),JsonConvert.SerializeObject(new{status="pass",endpoint=Endpoint,stage=5,tier=2,authoritative=true,hashVerified=true,serverRestartRecovery=true,pause=true,mute=true,speed2=true,flight=shot,shield,enemyAttack=attack,finalStateExact=true,statsMatch=true,events=Flow.Battle.TraceEventsConsumed,won=receipt.won,sounds=fx.PlayedSounds,cleanup=true,realAccountWrites=false},Formatting.Indented));
return "PASS real paged HTTP battle after server restart: T2 stage 5, 2x, exact state/stats and server reward; screenshot battle_skills_network.png";
}
public static async Task<string> Cleanup()
{
PrototypeClientPrefs.Sfx=SessionState.GetBool("AIGame.Runtime.sfx",true);var net=UnityEngine.Object.FindObjectOfType<GameNet>();UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session",new Func<SessionData>(()=>net.Session));Set("_profile",new Func<long>(()=>net.Session?.Pid??0));Set("_httpBase",SessionState.GetString("AIGame.Runtime.endpoint","http://127.0.0.1:8080"));Set("_boundProfile",0L);Flow.Handle("Home");await Until(()=>Flow.Game!=null&&!Flow.IsBusy&&Get<string>("_httpBase")!=Endpoint);
var original=JsonUtility.FromJson<PrototypeSave>(SessionState.GetString("AIGame.Runtime.original",""));var actual=Flow.Game.State;Check(actual.coins==original.coins&&actual.candy==original.candy&&actual.level==original.level&&actual.shots==original.shots&&actual.stages.SequenceEqual(original.stages)&&actual.formation.SequenceEqual(original.formation),"Real account changed");
return "Restored real account and preferences; wallet, level, roster, formation unchanged";
}
}