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

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