Files

167 lines
12 KiB
C#

using System;
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 UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
/// <summary>Checks that the five formal enemy types replaced the arena stand-ins and animate in a real isolated battle.</summary>
public static class VerifyFormalEnemies
{
const string Endpoint = "http://127.0.0.1:18081";
const string Arena = "Assets/Bundles/Scenes/Prototype/PrototypeArena.prefab";
const int Types = 5;
/// <summary>Matches BuildPrototype.EnemyHeight (run_script compiles each file on its own).</summary>
const float EnemyHeight = 1.2f;
static PrototypeFlow Flow => UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static void Check(bool value, string message) { if (!value) throw new Exception(message); }
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 async Task Until(Func<bool> condition, int seconds = 25)
{
var end = DateTime.UtcNow.AddSeconds(seconds);
while (!condition()) { Check(DateTime.UtcNow < end, "Formal enemies timed out: " + Flow?.Page); await Task.Delay(100); }
}
static void Click(string name)
{
var b = UnityEngine.Object.FindObjectsOfType<Button>().Single(x => x.name == "Btn_" + name && x.gameObject.activeInHierarchy);
Check(b.interactable, "Disabled " + name); Canvas.ForceUpdateCanvases();
var rect = b.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) == b.gameObject, "Blocked " + name);
ExecuteEvents.Execute(b.gameObject, pointer, ExecuteEvents.pointerClickHandler);
}
static async Task Capture(string name)
{
await Task.Delay(200);
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/formal_enemies_" + name + ".png", false, 0, "screen" });
}
public static string Assets()
{
var arena = AssetDatabase.LoadAssetAtPath<GameObject>(Arena);
var slots = arena.transform.Find("Enemies").Cast<Transform>().ToArray();
Check(slots.Length == PrototypeBattle.TotalEnemies, "Arena has " + slots.Length + " enemy slots");
var heights = new string[Types];
var instance = (GameObject)PrefabUtility.InstantiatePrefab(arena);
try
{
foreach (var slot in instance.transform.Find("Enemies").Cast<Transform>())
{
Check(slot.Cast<Transform>().All(c => c.name == "SlowMark" || c.name=="SkillMuzzle" || c.name.StartsWith("Type")), slot.name + " still has primitive parts");
for (int t = 0; t < Types; t++)
{
var visual = slot.Find("Type" + t);
Check(visual != null && PrefabUtility.IsAnyPrefabInstanceRoot(visual.gameObject) && !visual.gameObject.activeSelf, slot.name + " Type" + t + " missing or active");
var actor = new[] { "Hornlet", "Dashlet", "Shellhorn", "HornCaptain", "GreatHorn" }[t];
Check(PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(visual.gameObject) == "Assets/Bundles/Characters/Enemy/" + actor + "Studio/" + actor + "Studio_Enemy.prefab", slot.name + " Type" + t + " still references the old model");
Check(visual.GetComponentsInChildren<Transform>(true).All(x => x.gameObject.layer == 30), slot.name + " Type" + t + " not on battle layer 30");
var animator = visual.GetComponentInChildren<Animator>(true);
Check(animator != null && animator.runtimeAnimatorController != null && !animator.applyRootMotion, slot.name + " Type" + t + " animator missing");
var original = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Characters/Enemy/" + actor + "/" + actor + "_Enemy.prefab").GetComponentInChildren<Animator>(true);
Check(animator.avatar == original.avatar && animator.runtimeAnimatorController == original.runtimeAnimatorController, actor + " must reuse original Avatar/controller");
if (slot.name != "Enemy0") continue;
slot.gameObject.SetActive(true); visual.gameObject.SetActive(true);
float min = float.MaxValue, max = float.MinValue; var mesh = new Mesh();
foreach (var skin in visual.GetComponentsInChildren<SkinnedMeshRenderer>())
{
skin.BakeMesh(mesh, true);
foreach (var v in mesh.vertices) { float y = slot.InverseTransformPoint(skin.transform.TransformPoint(v)).y; min = Mathf.Min(min, y); max = Mathf.Max(max, y); }
}
UnityEngine.Object.DestroyImmediate(mesh); visual.gameObject.SetActive(false);
Check(Mathf.Abs(max - EnemyHeight) < .05f && Mathf.Abs(min) < .05f, "Type" + t + " height/ground wrong " + min + ".." + max);
heights[t] = "Type" + t + "=" + Path.GetFileNameWithoutExtension(PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(visual.gameObject)) + " y=" + min.ToString("F2") + ".." + max.ToString("F2");
}
}
}
finally { UnityEngine.Object.DestroyImmediate(instance); }
return "PASS " + slots.Length + " slots x " + Types + " types; " + string.Join("; ", heights);
}
/// <summary>Logs into an isolated :18081 account seeded to reach <paramref name="stage"/>, fights it at 2x and checks each spawned type walks and strikes.</summary>
public static async Task<string> PlaySeeded(string seededUser, int stage)
{
Check(EditorApplication.isPlaying, "Enter Play Mode first");
await Until(() => Flow?.Game != null && !Flow.IsBusy);
// Re-entry after an interrupted run: already on the isolated account, so keep the saved real-account snapshot.
if (Get<string>("_httpBase") == Endpoint && Flow.Page == "UI_PrototypeBattle" && Flow.Battle != null) return await Observe(stage);
SessionState.SetString("AIGame.FormalEnemies.original", JsonUtility.ToJson(Flow.Game.State));
SessionState.SetString("AIGame.FormalEnemies.originalEndpoint", Get<string>("_httpBase"));
AuthResponse account = null; string error = null; bool done = false;
Flow.StartCoroutine(AuthApi.Login(Endpoint, seededUser, "isolated-formal-heroes-password", (r, e) => { account = r; error = e; done = true; }));
await Until(() => done);
Check(account != null, "Isolated auth failed " + error);
var session = new SessionData { Pid = account.pid, Token = account.token, Username = seededUser };
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);
PlayerPrefs.DeleteKey("AIGame.G1.pending." + Uri.EscapeDataString(Endpoint) + "." + session.Pid);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy);
Click("Adventure"); await Until(() => Flow.Page == "UI_PrototypeStages");
Click("Stage" + stage); await Until(() => Flow.Page == "UI_PrototypeFormation");
Click("StartBattle"); await Until(() => Flow.Page == "UI_PrototypeBattle" && Flow.Battle != null);
return await Observe(stage);
}
static async Task<string> Observe(int stage)
{
// The battle panel is briefly covered while it opens; retry the 2x toggle instead of failing on the first frame.
for (int attempt = 0; ; attempt++)
{
try { if (Get<int>("_speed") < 2) Click("Speed"); break; }
catch (Exception) when (attempt < 30) { await Task.Delay(100); }
}
var arena = Get<GameObject>("_arena");
var slots = Enumerable.Range(0, PrototypeBattle.TotalEnemies).Select(i => arena.transform.Find("Enemies/Enemy" + i)).ToArray();
var walked = new HashSet<int>(); var struck = new HashSet<int>(); var shown = new HashSet<int>();
bool walkShot = false, attackShot = false; var end = DateTime.UtcNow.AddSeconds(75);
while (DateTime.UtcNow < end && !Flow.Battle.Finished && (struck.Count < shown.Count || shown.Count < Flow.Battle.Enemies.Select(e => e.Type).Distinct().Count() || walked.Count < shown.Count || shown.Count == 0))
{
foreach (var e in Flow.Battle.Enemies.Where(e => e.Alive))
{
var slot = slots[e.Index]; if (slot == null || !slot.gameObject.activeInHierarchy) continue;
var actives = slot.Cast<Transform>().Where(c => c.name.StartsWith("Type") && c.gameObject.activeSelf).ToArray();
Check(actives.Length == 1 && actives[0].name == "Type" + e.Type, "Enemy" + e.Index + " shows " + string.Join(",", actives.Select(a => a.name)) + " for type " + e.Type);
shown.Add(e.Type);
var state = actives[0].GetComponentInChildren<Animator>().GetCurrentAnimatorStateInfo(0);
if (state.IsName("Walk")) walked.Add(e.Type);
if (state.IsName("Attack")) struck.Add(e.Type);
}
if (!walkShot && shown.Count >= 2) { walkShot = true; await Capture("walk"); }
if (!attackShot && struck.Count >= 1) { attackShot = true; await Capture("attack"); }
await Task.Delay(50);
}
Check(shown.Count > 0 && walked.IsSupersetOf(shown), "Walk never played for types " + string.Join(",", shown.Except(walked)));
return "PASS stage " + stage + ": types shown " + string.Join(",", shown.OrderBy(t => t)) + ", walked " + string.Join(",", walked.OrderBy(t => t)) +
", struck " + string.Join(",", struck.OrderBy(t => t)) + (Flow.Battle.Finished ? " (battle finished, won=" + Flow.Battle.Won + ")" : "") + "; screenshots formal_enemies_walk/attack.png";
}
public static async Task<string> Cleanup()
{
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.FormalEnemies.originalEndpoint", "http://127.0.0.1:8080"));
Set("_boundProfile", 0L);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy);
var original = JsonUtility.FromJson<PrototypeSave>(SessionState.GetString("AIGame.FormalEnemies.original", ""));
Check(Flow.Game.State.coins == original.coins && Flow.Game.State.candy == original.candy && Flow.Game.State.level == original.level, "Real account was changed by enemy test");
return "PASS cleanup: real account restored; battle check used isolated :18081 only.";
}
}