Files
AIGame/AIClient/AgentScripts/VerifyChest.cs
T

194 lines
17 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 Newtonsoft.Json.Linq;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public static class VerifyChest
{
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 = 20)
{ var end = DateTime.UtcNow.AddSeconds(seconds); while (!condition()) { Check(DateTime.UtcNow < end, "Chest timed out: " + Flow?.Page); await Task.Delay(100); } await Task.Delay(120); }
static Button Button(string name) => UnityEngine.Object.FindObjectsOfType<Button>().Single(b => b.name == "Btn_" + name && b.gameObject.activeInHierarchy);
static void Click(string name)
{
var b = Button(name); 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(180);
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/chest_" + name + ".png", false, 0, "screen" });
}
static void Ok() { var dialog = UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>(); dialog.Hide(); }
static readonly string Isolated = "http://127.0.0.1:18081";
static async Task Refresh()
{
Ok();
// Re-attach the isolated session saved by Start(); the flow may fall back to the dev account.
Set("_httpBase", Isolated); Set("_boundProfile", 0L);
await Login();
await Until(() => !Flow.IsBusy && Get<string>("_httpBase") == Isolated && Flow.Game.State.schema == 8);
await Task.Delay(120); Click("Rewards"); await Until(() => Flow.Page == "UI_PrototypeChest");
await Task.Delay(300); Canvas.ForceUpdateCanvases();
}
static async Task Login()
{
string user = SessionState.GetString("AIGame.Chest.user", "");
AuthResponse account = null; string error = null; bool done = false;
Flow.StartCoroutine(AuthApi.Login(Isolated, user, "isolated-chest-password", (r, e) => { account = r; error = e; done = true; }));
await Until(() => done);
Check(account != null, "Isolated session unavailable " + error);
var session = new SessionData { Pid = account.pid, Token = account.token, Username = user };
Set("_session", new Func<SessionData>(() => session)); Set("_profile", new Func<long>(() => session.Pid));
}
public static string Assets()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/UI_PrototypeChest.prefab");
Check(prefab != null, "Missing chest prefab");
Check(prefab.GetComponentsInChildren<TextMeshProUGUI>(true).All(t => t.font != null), "Missing chest font");
foreach (var image in prefab.GetComponentsInChildren<Image>(true).Where(i => i.type == Image.Type.Sliced))
Check(image.sprite != null && image.sprite.border.x > 0 && image.sprite.border.y > 0, "Invalid chest nine slice");
var names = prefab.GetComponentsInChildren<Transform>(true).Select(t => t.name).ToHashSet();
foreach (var name in new[] { "Btn_ClaimDailyChest", "Btn_OpenChest0", "Btn_OpenChest1", "Btn_OpenChest2", "Btn_ChestChoice0", "Btn_ChestChoice4", "Btn_GoStages", "Txt_ChestSummary", "Txt_ChestChoiceInfo", "ChestChoiceGroup" })
Check(names.Contains(name), "Missing chest node " + name);
Check(names.Contains("Dot_Rewards") || AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/UI_MainHUD.prefab").GetComponentsInChildren<Transform>(true).Any(t => t.name == "Dot_Rewards"), "Home chest dot missing");
var config = JsonUtility.FromJson<PrototypeConfig>(File.ReadAllText(Path.Combine(Application.dataPath, "../../Config/Gameplay/greenfield.json")));
Check(PrototypeConfig.Valid(config) && config.rewards?.chests?.Length == 3, "Chest config invalid");
Check(config.rewards.chests[1].IsChoice && config.rewards.chests[1].choices.Length == 5, "Choice chest contents invalid");
var oldJson = JObject.Parse(JsonUtility.ToJson(config)); oldJson.Remove("rewards");
Check(PrototypeConfig.Valid(JsonUtility.FromJson<PrototypeConfig>(oldJson.ToString())), "Pre-chest frozen config rejected");
var method = typeof(PrototypeApi).GetMethod("ValidChestReceipt", BindingFlags.Static | BindingFlags.NonPublic);
var request = new PrototypeCommand { action = "chest.open", requestId = "test", chest = 1, choice = 2 };
var reply = new PrototypeReply { action = request.action, ok = true, result = new PrototypeResult { id = "test", chest = 1, choice = 2, remaining = 0, kind = "choice", chestReward = new ChestReward { shardsPet = 2, shards = 10 } } };
Check((bool)method.Invoke(null, new object[] { reply, request }), "Valid chest receipt rejected");
var wrongChoice = Newtonsoft.Json.Linq.JObject.Parse(JsonUtility.ToJson(reply)); wrongChoice["result"]["choice"] = 3;
Check(!(bool)method.Invoke(null, new object[] { JsonUtility.FromJson<PrototypeReply>(wrongChoice.ToString()), request }), "Wrong choice receipt acknowledged");
var partial = Newtonsoft.Json.Linq.JObject.Parse(JsonUtility.ToJson(reply)); ((JObject)partial["result"]).Remove("chestReward");
Check(!(bool)method.Invoke(null, new object[] { JsonUtility.FromJson<PrototypeReply>(partial.ToString()), request }), "Missing chest reward acknowledged");
var dailyRequest = new PrototypeCommand { action = "chest.claimDaily", requestId = "daily", chest = 0 };
var dailyReply = new PrototypeReply { action = dailyRequest.action, ok = true, result = new PrototypeResult { id = "daily", chest = 0, count = 1 } };
Check((bool)method.Invoke(null, new object[] { dailyReply, dailyRequest }), "Valid daily chest receipt rejected");
dailyReply.result.count = 2;
Check(!(bool)method.Invoke(null, new object[] { dailyReply, dailyRequest }), "Over-counted daily receipt acknowledged");
// The server serializes every result field, so a daily receipt carries chest=0 and an
// all-zero chestReward. Zero must still deserialize as no chest contents.
dailyReply.result.count = 1;
var serialized = JObject.Parse(JsonUtility.ToJson(dailyReply));
serialized["result"]["remaining"] = 0; serialized["result"]["kind"] = null; serialized["result"]["chestReward"] = new JObject { ["coins"] = 0, ["candy"] = 0, ["balls"] = 0, ["tickets"] = 0, ["shardsPet"] = -1, ["shards"] = 0 };
Check((bool)method.Invoke(null, new object[] { JsonUtility.FromJson<PrototypeReply>(serialized.ToString()), dailyRequest }), "Serialized daily receipt acknowledged with zero chest=0");
return "PASS chest config, authored page, TMP fonts, nine-slices, home dot and complete versus malformed chest receipts.";
}
public static string State()
{
var game = new PrototypeSave { schema = 8, chests = new[] { 0, 0, 0 }, chestDailyClaimed = 0 };
game.stages = new[] { 1, 1, 1, 1, 1 }; game.shards = new int[5]; game.selectedStage = 1; game.clearedStages = new[] { 1, 1, 1, 1, 1 };
game.dailyDay = 1; game.signinDays = 1; game.dailyProgress = new int[3]; game.dailyClaimed = new int[3]; game.signinClaimed = new int[7];
game.shopBought = new int[3]; game.mails = new PrototypeMail[0]; game.campLayout = new[] { -1, -1, -1 }; game.decorations = new int[3]; game.restPet = 0;
Check(PrototypeGame.Valid(game), "Fresh schema8 save rejected");
game.chests = new[] { 1, 2, 3 };
Check(PrototypeGame.Valid(game), "Chest inventory rejected");
game.chestDailyClaimed = 1; Check(PrototypeGame.Valid(game), "Claimed daily flag rejected");
game.chestDailyClaimed = 2; Check(!PrototypeGame.Valid(game), "Invalid daily flag accepted");
game.chestDailyClaimed = 0; game.chests = new[] { 0, 0 }; Check(!PrototypeGame.Valid(game), "Short chest inventory accepted");
game.chests = new[] { 0, 0, -1 }; Check(!PrototypeGame.Valid(game), "Negative chest count accepted");
return "PASS client schema8 chest validation accepts owned boxes and rejects malformed inventory.";
}
public static async Task<string> Start()
{
await Until(() => Flow?.Game != null && !Flow.IsBusy);
SessionState.SetString("AIGame.Chest.original", JsonUtility.ToJson(Flow.Game.State));
SessionState.SetString("AIGame.Chest.endpoint", Get<string>("_httpBase"));
AuthResponse account = null; string error = null; bool done = false; string user = "g1live_chest_" + Guid.NewGuid().ToString("N");
Flow.StartCoroutine(AuthApi.Register("http://127.0.0.1:18081", user, "isolated-chest-password", (r, e) => { account = r; error = e; done = true; }));
await Until(() => done); Check(account != null, "Register failed " + error);
File.WriteAllText(Path.Combine(Application.dataPath, "../../Tools/output/g1-live/chest-account.txt"), user);
var session = new SessionData { Pid = account.pid, Token = account.token, Username = user };
SessionState.SetString("AIGame.Chest.user", user);
Set("_session", new Func<SessionData>(() => session)); Set("_profile", new Func<long>(() => session.Pid)); Set("_httpBase", Isolated); Set("_boundProfile", 0L);
Flow.Handle("Home"); await Until(() => !Flow.IsBusy); await Task.Delay(150);
Check(Flow.Game.State.schema == 8 && Flow.Game.State.chests.All(n => n == 0) && Flow.Game.State.chestDailyClaimed == 0, "New chest account started with boxes");
Check(GameObject.Find("Dot_Rewards") != null, "Daily chest red dot missing");
Click("Rewards"); await Until(() => Flow.Page == "UI_PrototypeChest");
await Task.Delay(300); Canvas.ForceUpdateCanvases(); await Capture("empty");
Check(!Button("OpenChest0").interactable && !Button("OpenChest1").interactable && !Button("OpenChest2").interactable, "Empty chest can be opened");
Check(!UnityEngine.Object.FindObjectsOfType<Button>().Any(b => b.name.StartsWith("Btn_ChestChoice", StringComparison.Ordinal) && b.gameObject.activeInHierarchy), "Choice list shown without a choice chest");
Click("ClaimDailyChest"); await Until(() => !Flow.IsBusy && Flow.Game.State.chestDailyClaimed == 1);
Check(Flow.Game.State.chests[0] == 1 && Flow.Game.State.coins == 0 && Flow.Game.State.candy == 0, "Daily chest awarded contents directly"); await Capture("daily"); Ok();
Check(!Button("ClaimDailyChest").interactable, "Daily chest claimable twice");
Click("OpenChest0"); await Until(() => !Flow.IsBusy && Flow.Game.State.chests[0] == 0);
Check(Flow.Game.State.coins == 60 && Flow.Game.State.candy == 30 && Flow.Game.State.balls == 22, "Fixed chest reward not exact"); await Capture("opened"); Ok();
Check(!Button("OpenChest0").interactable, "Opened chest still openable");
return "PASS actual chest entry, daily box without contents, exact fixed-box reward once, empty states and disabled open buttons.";
}
public static async Task<string> Choice()
{
await Refresh();
Check(Flow.Game.State.chests[1] == 1, "Choice chest fixture missing; run Tools/seed_chest_test.py choice");
Check(Button("OpenChest1").interactable && Button("ChestChoice0").gameObject.activeSelf && Button("ChestChoice4").gameObject.activeSelf, "Choice options not shown");
int shards = Flow.Game.State.shards[2];
Click("ChestChoice2"); await Task.Delay(250); Canvas.ForceUpdateCanvases();
Check(UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().Single(t => t.name == "Txt_ChestChoiceInfo").text.Contains("芽芽"), "Choice selection not reflected");
Click("OpenChest1"); await Until(() => !Flow.IsBusy && Flow.Game.State.chests[1] == 0);
Check(Flow.Game.State.shards[2] == shards + 10 && Flow.Game.State.coins == 60, "Choice chest granted the wrong shards"); await Capture("choice"); Ok();
return "PASS choice chest requires a selection and grants exactly the selected partner shards.";
}
public static async Task<string> LostAcknowledgement()
{
await Refresh();
if (Flow.Game.State.chestDailyClaimed == 0) { Click("ClaimDailyChest"); await Until(() => !Flow.IsBusy && Flow.Game.State.chestDailyClaimed == 1); Ok(); await Refresh(); }
Check(Flow.Game.State.chests[0] == 1, "Lost-ack fixture missing; run Tools/seed_chest_test.py lost");
var api = Get<PrototypeApi>("_api"); var session = Get<Func<SessionData>>("_session")();
string key = "AIGame.G1.pending." + Uri.EscapeDataString(Isolated) + "." + session.Pid;
var command = api.Command("chest.open", Flow.Game.State.revision, chest: 0);
PrototypeReply response = null; string error = null;
Flow.StartCoroutine(api.Send(command, (r, e) => { response = r; error = e; })); await Until(() => response != null || error != null);
Check(response?.ok == true, "First chest open failed " + error);
// api.Send returns the authoritative reply without mutating the flow view; snapshot that.
int balls = response.state.balls, coins = response.state.coins, shards = response.state.shards.Sum();
Check(coins == Flow.Game.State.coins + 60 && response.state.chests[0] == 0, "First chest open did not award exactly one fixed box");
PlayerPrefs.SetString(key, JsonUtility.ToJson(command)); PlayerPrefs.Save(); Set("_needsSync", true); Flow.Handle("Rewards");
await Until(() => !Flow.IsBusy && Get<PrototypeReply>("_reply").replayed);
{ var text = "Chest replay duplicated the reward or retained the request balls " + balls + "->" + Flow.Game.State.balls + " coins " + coins + "->" + Flow.Game.State.coins + " shards " + shards + "->" + Flow.Game.State.shards.Sum() + " pending=" + api.HasPending + " replayed=" + Get<PrototypeReply>("_reply").replayed; Check(Flow.Game.State.balls == balls && Flow.Game.State.coins == coins && Flow.Game.State.shards.Sum() == shards && !api.HasPending, text); }
var dialog = UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().CaptureState();
Check(dialog.Visible && dialog.Title == "宝箱已打开", "Recovered chest receipt not shown"); await Capture("recovery"); Ok();
return "PASS lost chest acknowledgement restores the original receipt, awards once and clears the durable queue.";
}
public static async Task<string> Cleanup()
{
Ok();
var testSession = Get<Func<SessionData>>("_session")();
string key = "AIGame.G1.pending." + Uri.EscapeDataString(Isolated) + "." + testSession.Pid; Check(!PlayerPrefs.HasKey(key), "Test request still pending");
var net = UnityEngine.Object.FindObjectOfType<GameNet>();
Set("_session", new Func<SessionData>(() => net.Session)); Set("_profile", new Func<long>(() => net.Session?.Pid ?? 0)); Set("_httpBase", SessionState.GetString("AIGame.Chest.endpoint", "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.Chest.original", ""));
Check(Flow.Game.State.coins == original.coins && Flow.Game.State.candy == original.candy && Flow.Game.State.balls == original.balls && Flow.Game.State.shots == original.shots && Flow.Game.State.level == original.level, "Original account resources changed during chest test");
return "PASS original account restored with unchanged wallet; no chest operation sent to the real account.";
}
}