test(battlenode): N1 shadow replay tracks captured battle state (Phase 2 N1)

Full single-client capture replay (cl1 send=player seat, receive=opponent seat,
ts-ordered) ingests end-to-end: 33 frames, 0 rejects, 0 invariant violations at
turn boundaries (leader life/PP/board/hand).

Headless gaps filled per playbook (no Engine/ drift):
- IsRecovery=true after construction: the engine's own headless replay mode gates
  the live view/UI layer off (BattleUIContainer, turn-control UI, VFX waits) while
  keeping the live NetworkBattleReceiver (ND4) and authoritative state.
- Seed ToolboxGame.RealTimeNetworkAgent, BattleUIContainer, _backGround, and
  per-player NullPlayerEmotion no-ops the receive/turn cycle dereferences.
- _IfaceImpl.g.cs (shim, not Engine/): BattleCardView.BattleCardIconAnimations
  returns a lazy non-null no-op so the opponent card-reveal icon-init (deferred
  VFX) doesn't NRE.
- HeadlessCardMaster.Load made cumulative: it replaced the global CardMaster each
  call, so a Load(deck) evicted the oracle card set and broke tests run after.

Adds board-state accessors (LeaderLife/Pp/HandCount/BoardCount) and CaptureReplay
ts ordering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-06 15:28:08 -04:00
parent 6740313446
commit fa86739ac2
5 changed files with 195 additions and 6 deletions

View File

@@ -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<int> _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<int>(cardIds);
foreach (var id in cardIds) _everLoaded.Add(id);
var want = new HashSet<int>(_everLoaded);
var rows = new List<CardCSVData>();
if (want.Count > 0)
{

View File

@@ -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);
/// <summary>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;
}

View File

@@ -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<string> 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<string>();
var violations = new List<string>();
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<string> 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}");
}
}
}
}

View File

@@ -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<bool> 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!; }

View File

@@ -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;
/// <summary>Followers in play, excluding the leader (the Class card occupies one slot of
/// ClassAndInPlayCardList).</summary>
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<NetworkBattleDefine.NetworkBattleURI>(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());
}
/// <summary>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).</summary>
@@ -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;
}
}
}