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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user