diff --git a/SVSim.BattleEngine.Tests/HeadlessCardMaster.cs b/SVSim.BattleEngine.Tests/HeadlessCardMaster.cs index 63a6aa8..e8834c5 100644 --- a/SVSim.BattleEngine.Tests/HeadlessCardMaster.cs +++ b/SVSim.BattleEngine.Tests/HeadlessCardMaster.cs @@ -19,10 +19,18 @@ namespace SVSim.BattleEngine.Tests private static readonly string CardsJsonPath = Path.Combine(AppContext.BaseDirectory, "Data", "cards.json"); - // Load the given card ids (empty = none) into a fresh CardMaster registered as Default. + // Every id ever requested this process. Load is CUMULATIVE: each call rebuilds the master from + // the union, so a later Load(subset) never evicts cards an earlier Load (e.g. EnsureInitialized's + // oracle set) installed. Without this, the static CardMaster is shared mutable state across the + // whole NUnit run and a Load(deck) in one test silently breaks an oracle test that runs after. + private static readonly HashSet _everLoaded = new(); + + // Load the given card ids (empty = none) into a CardMaster registered as Default, MERGED with all + // previously-loaded ids. public static void Load(params int[] cardIds) { - var want = new HashSet(cardIds); + foreach (var id in cardIds) _everLoaded.Add(id); + var want = new HashSet(_everLoaded); var rows = new List(); if (want.Count > 0) { diff --git a/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplay.cs b/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplay.cs index 35d9e42..d75895c 100644 --- a/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplay.cs +++ b/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplay.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text.Json; @@ -8,7 +9,7 @@ using SVSim.BattleNode.Protocol; namespace SVSim.BattleEngine.Tests.SessionEngine { - internal sealed record CapturedFrame(string Direction, string Uri, MsgEnvelope Env, string RawBody); + internal sealed record CapturedFrame(DateTime Ts, string Direction, string Uri, MsgEnvelope Env, string RawBody); /// Parses a battle_test ndjson capture into MsgEnvelopes the engine can ingest. /// @@ -29,6 +30,9 @@ namespace SVSim.BattleEngine.Tests.SessionEngine using var doc = JsonDocument.Parse(line); var root = doc.RootElement; var direction = root.TryGetProperty("direction", out var dEl) ? dEl.GetString() ?? "" : ""; + var ts = root.TryGetProperty("ts", out var tsEl) && tsEl.ValueKind == JsonValueKind.String + ? DateTime.Parse(tsEl.GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + : default; if (!root.TryGetProperty("body", out var bodyEl) || bodyEl.ValueKind != JsonValueKind.Object) continue; @@ -50,7 +54,7 @@ namespace SVSim.BattleEngine.Tests.SessionEngine MsgEnvelope env; try { env = MsgEnvelope.FromJson(normalized); } catch { continue; } // out-of-model / unparseable line - frames.Add(new CapturedFrame(direction, uri, env, normalized)); + frames.Add(new CapturedFrame(ts, direction, uri, env, normalized)); } return frames; } diff --git a/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineShadowReplayTests.cs b/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineShadowReplayTests.cs new file mode 100644 index 0000000..36eaf61 --- /dev/null +++ b/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineShadowReplayTests.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using SVSim.BattleNode.Protocol; +using SVSim.BattleNode.Sessions.Engine; + +namespace SVSim.BattleEngine.Tests.SessionEngine +{ + [TestFixture] + public class SessionEngineShadowReplayTests + { + // Frames that are transport/keepalive, not game actions — not ingested. + private static readonly HashSet SkipUris = new() + { + nameof(NetworkBattleUri.Echo), + nameof(NetworkBattleUri.ChatStamp), + nameof(NetworkBattleUri.Gungnir), + }; + + [Test] + public void Shadow_replay_of_captured_battle_tracks_state_without_desync() + { + HeadlessEngineEnv.EnsureInitialized(); + + var cl1 = CaptureReplay.Load("battle_test_cl1.ndjson"); + var cl2 = CaptureReplay.Load("battle_test_cl2.ndjson"); + var deckA = CaptureReplay.SelfDeckFrom(cl1); + var deckB = CaptureReplay.SelfDeckFrom(cl2); + // One Load call with every id — Load replaces the static master each call. + HeadlessCardMaster.Load(deckA.Concat(deckB).Select(x => (int)x).Distinct().ToArray()); + + var engine = new SessionBattleEngine(); + engine.Setup(masterSeed: CaptureReplay.SeedFrom(cl1), seatADeck: deckA, seatBDeck: deckB); + + // Single-client full-stream replay (cl1 as the player seat): cl1's SENT frames are its own + // actions (seat=true); its RECEIVED frames are the opponent/server actions (seat=false), + // incl. the Deal that establishes both hands. This is exactly the stream cl1's receiver + // processed, in capture (ts) order. (The node-side both-clients-sends model is exercised + // live in Task 7; here we validate engine tracking against ground truth.) + var stream = cl1.Where(f => !SkipUris.Contains(f.Uri)) + .OrderBy(f => f.Ts) + .ToList(); + + var rejects = new List(); + var violations = new List(); + + foreach (var f in stream) + { + bool seat = f.Direction == "send"; + var r = engine.Receive(f.Env, isPlayerSeat: seat); + if (r.RejectReason is not null) + rejects.Add($"{f.Direction} {f.Uri}: {r.RejectReason}"); + + if (f.Uri == nameof(NetworkBattleUri.TurnEnd)) + CheckInvariants(engine, violations, atUri: f.Uri); + } + + foreach (var line in rejects) TestContext.WriteLine("REJECT " + line); + foreach (var line in violations) TestContext.WriteLine("VIOLATION " + line); + TestContext.WriteLine($"frames={stream.Count} rejects={rejects.Count} violations={violations.Count}"); + + Assert.Multiple(() => + { + Assert.That(rejects, Is.Empty, "engine diverged / rejected a captured frame"); + Assert.That(violations, Is.Empty, "engine state left a structural invariant"); + }); + } + + private static void CheckInvariants(SessionBattleEngine engine, List violations, string atUri) + { + foreach (var seat in new[] { true, false }) + { + int life = engine.LeaderLife(seat), pp = engine.Pp(seat); + int board = engine.BoardCount(seat), hand = engine.HandCount(seat); + if (life is < 0 or > 20) violations.Add($"{atUri} seat={seat} life={life}"); + if (pp is < 0 or > 10) violations.Add($"{atUri} seat={seat} pp={pp}"); + if (board is < 0 or > 7) violations.Add($"{atUri} seat={seat} board={board}"); + if (hand is < 0 or > 9) violations.Add($"{atUri} seat={seat} hand={hand}"); + } + } + } +} diff --git a/SVSim.BattleEngine/Shim/Generated/_IfaceImpl.g.cs b/SVSim.BattleEngine/Shim/Generated/_IfaceImpl.g.cs index 2fe1dc0..3bd85c4 100644 --- a/SVSim.BattleEngine/Shim/Generated/_IfaceImpl.g.cs +++ b/SVSim.BattleEngine/Shim/Generated/_IfaceImpl.g.cs @@ -17,7 +17,8 @@ namespace Wizard.Battle.View { Transform global::Wizard.Battle.View.IBattleCardView.Transform { get => default!; } CardTemplate global::Wizard.Battle.View.IBattleCardView.CardTemplate { get => default!; } BoxCollider global::Wizard.Battle.View.IBattleCardView.Collider { get => default!; } - BattleCardIconAnimations global::Wizard.Battle.View.IBattleCardView.BattleCardIconAnimations { get => default!; } + private BattleCardIconAnimations _headlessIconAnims; // HEADLESS-FIX (N1) + BattleCardIconAnimations global::Wizard.Battle.View.IBattleCardView.BattleCardIconAnimations { get => _headlessIconAnims ??= new BattleCardIconAnimations(); } // HEADLESS-FIX (N1): non-null no-op so ReplaceReceivedCard.CreateActualCard's follower icon-init (a deferred VFX; InitializeIcon never runs headless) doesn't NRE on the opponent card-reveal path Func global::Wizard.Battle.View.IBattleCardView.GetIsOnMove { get => default!; } bool global::Wizard.Battle.View.IBattleCardView.InPlayModelActive { get => default!; set { } } BattleCamera global::Wizard.Battle.View.IBattleCardView.m_BattleCamera { get => default!; } diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs index c94b1ef..708cc4e 100644 --- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs +++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs @@ -1,5 +1,6 @@ extern alias engine; using System.Reflection; +using System.Runtime.Serialization; using engine::SVSim.BattleEngine.Rng; using SVSim.BattleNode.Protocol; using NetworkBattleReceiver = engine::NetworkBattleReceiver; @@ -12,6 +13,13 @@ using CardCreatorBase = engine::CardCreatorBase; using SBattleLoad = engine::SBattleLoad; using CardTemplate = engine::CardTemplate; using GameObject = engine::UnityEngine.GameObject; +using RealTimeNetworkAgent = engine::RealTimeNetworkAgent; +using Gungnir = engine::Gungnir; +using NetworkNullLogger = engine::NetworkNullLogger; +using ToolboxGame = engine::Wizard.ToolboxGame; +using BattleUIContainer = engine::BattleUIContainer; +using BackGroundBase = engine::BackGroundBase; +using NullPlayerEmotion = engine::Wizard.Battle.Player.Emotion.NullPlayerEmotion; namespace SVSim.BattleNode.Sessions.Engine; @@ -44,6 +52,14 @@ internal sealed class SessionBattleEngine // rng defaults to SeededRandomSource(masterSeed) inside the mgr — the stream is born aligned // with the seed the node handed both clients (F-N-5; O-N-2 "bit-aligned anyway"). var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed)); + // Recovery mode is the engine's OWN headless replay path: the live view/UI touches on the + // receive cycle (BattleUIContainer.DisableMenu, turn-control UI, card-view creation, VFX + // waits) are all gated `!IsRecovery` (BattleUIContainer.cs:130, BattleManagerBase.cs:1499+), + // so this collapses them to no-ops without changing authoritative state. Set AFTER construction + // so the ctor still wired the LIVE NetworkBattleReceiver (ND4) rather than the replay receiver. + // Safe for shadow: the only thing !IsRecovery additionally enables is EMIT, which a pure shadow + // never does (it never originates a send). + mgr.IsRecovery = true; // Seat each player as the other's opponent (private field on BattlePlayerBase, as the real // match-load does). Mirrors HeadlessFixture.NewNetworkEmitBattle. @@ -56,6 +72,8 @@ internal sealed class SessionBattleEngine InitLeaderLife(mgr); // a 0-life leader reads as game-over and silently blocks plays InitCardTemplates(mgr); // play/draw resolution touches the (no-op) card view layer + InitHeadlessViews(mgr); // turn/play cycle dereferences UI-container + emotion refs + InstallHeadlessNetworkAgent(); // turn-flow resolve reads ToolboxGame.RealTimeNetworkAgent SeedDeck(mgr, seatADeck, isPlayer: true); SeedDeck(mgr, seatBDeck, isPlayer: false); @@ -89,10 +107,27 @@ internal sealed class SessionBattleEngine } catch (Exception ex) { - return EngineIngestResult.Reject($"{env.Uri} threw: {ex.GetType().Name}: {ex.Message}"); + var site = ex.StackTrace?.Split('\n').FirstOrDefault()?.Trim(); + return EngineIngestResult.Reject($"{env.Uri} threw: {ex.GetType().Name}: {ex.Message} @ {site}"); } } + // --- live board-state reads (N1 oracle surface; design F-N-4 board-state reads) ---------------- + // Each returns LIVE engine state off the seated player, mirroring the Phase-1 oracle reads + // (VanillaFollowerOracleTests: player.Pp, player.HandCardList.Count, ClassAndInPlayCardList, + // leader == the Class card). seat:true == player, false == opponent (F-N-2). + + public int LeaderLife(bool playerSeat) => Seat(playerSeat).Class.Life; + public int Pp(bool playerSeat) => Seat(playerSeat).Pp; + public int HandCount(bool playerSeat) => Seat(playerSeat).HandCardList.Count; + + /// Followers in play, excluding the leader (the Class card occupies one slot of + /// ClassAndInPlayCardList). + public int BoardCount(bool playerSeat) => Math.Max(0, Seat(playerSeat).ClassAndInPlayCardList.Count - 1); + + private engine::BattlePlayerBase Seat(bool playerSeat) => + (_mgr ?? throw new InvalidOperationException("read before Setup")).GetBattlePlayer(playerSeat); + private static NetworkBattleDefine.NetworkBattleURI MapUri(NetworkBattleUri uri) => Enum.Parse(uri.ToString()); @@ -141,6 +176,24 @@ internal sealed class SessionBattleEngine mgr.EvolveCardHolder = new GameObject(); } + // Seed the no-op UI refs the receive/turn cycle dereferences. Under IsRecovery the methods on + // these (e.g. BattleUIContainer.DisableMenu) no-op, but the receiver still CALLS them, so the + // references must be non-null. PlayerEmotion is the engine's own NullPlayerEmotion. + private static void InitHeadlessViews(BattleManagerBase mgr) + { + mgr.BattleUIContainer = (BattleUIContainer)FormatterServices.GetUninitializedObject(typeof(BattleUIContainer)); + // Revealed-card creation (ReplaceReceivedCard.CreateActualCard -> CreateBaseCardGameObject) + // clones the card prefab under _backGround.m_Battle3DContainer — a field distinct from + // mgr.Battle3DContainer. Seed a no-op BackGround with a non-null container. + var bg = (BackGroundBase)FormatterServices.GetUninitializedObject(typeof(BackGroundBase)); + SetProperty(bg, "m_Battle3DContainer", new GameObject()); + SetField(mgr, "_backGround", bg); + // PlayerEmotion is declared on BattlePlayer (the player seat); BattleEnemy has none — set + // where present. + TrySetProperty(mgr.GetBattlePlayer(true), "PlayerEmotion", new NullPlayerEmotion()); + TrySetProperty(mgr.GetBattlePlayer(false), "PlayerEmotion", new NullPlayerEmotion()); + } + /// Seat one side's full deck in order (idx == list position + 1). Each card is created /// through the engine's own null-view seam and pushed via AddToDeck — the SeedDeck primitive the /// test harness proved (HeadlessFixture.SeedDeck). @@ -168,6 +221,23 @@ internal sealed class SessionBattleEngine return card; } + // The turn-flow + emit bookkeeping reads the global ToolboxGame.RealTimeNetworkAgent (e.g. + // RealTimeNetworkAgent.GetIsFirstPlayer/GetTurnState, which delegate to GameMgr's + // NetworkUserInfoData.TurnState; AddActionSequence touches _gungnir). Headless there is no socket + // agent, so seed a no-op one — mirroring HeadlessFixture.NewNetworkEmitBattle. _notEmit short- + // circuits the byte-push before any socket I/O; the shadow engine never originates a send anyway. + // NOTE: this is a process-global; one engine per process is assumed for the shadow (revisit for + // live multi-session — see design O-N status). Idempotent enough for the per-battle setup. + private static void InstallHeadlessNetworkAgent() + { + var agent = (RealTimeNetworkAgent)FormatterServices.GetUninitializedObject(typeof(RealTimeNetworkAgent)); + agent.SetCurrentMatchingStatus(RealTimeNetworkAgent.MatchingStatus.Prepared); + SetField(agent, "_gungnir", FormatterServices.GetUninitializedObject(typeof(Gungnir))); + SetProperty(agent, "NetworkLogger", new NetworkNullLogger()); + SetField(agent, "_notEmit", true); + ToolboxGame.SetRealTimeNetworkBattle(agent); + } + private static void SetField(object obj, string name, object value) { var f = obj.GetType().GetField(name, @@ -175,4 +245,28 @@ internal sealed class SessionBattleEngine ?? throw new InvalidOperationException($"{obj.GetType().Name} has no field '{name}'"); f.SetValue(obj, value); } + + private static void SetProperty(object obj, string name, object value) + { + var t = obj.GetType(); + PropertyInfo? p = null; + while (t is not null && p is null) + { + p = t.GetProperty(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + t = t.BaseType; + } + (p ?? throw new InvalidOperationException($"{obj.GetType().Name} has no property '{name}'")) + .SetValue(obj, value); + } + + private static void TrySetProperty(object obj, string name, object value) + { + var t = obj.GetType(); + while (t is not null) + { + var p = t.GetProperty(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (p is not null) { p.SetValue(obj, value); return; } + t = t.BaseType; + } + } }