diff --git a/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineConstructionTests.cs b/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineConstructionTests.cs
index 402efb7..60432c6 100644
--- a/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineConstructionTests.cs
+++ b/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineConstructionTests.cs
@@ -13,5 +13,19 @@ namespace SVSim.BattleEngine.Tests.SessionEngine
var engine = new SessionBattleEngine();
Assert.That(engine.IsReady, Is.False);
}
+
+ [Test]
+ public void Setup_builds_two_seat_network_battle_headless()
+ {
+ HeadlessEngineEnv.EnsureInitialized();
+ // Load every card id the two test decks reference so CardMaster can resolve them.
+ var deckA = Enumerable.Repeat(100011010L, 40).ToList(); // vanilla 1/2 follower x40
+ var deckB = Enumerable.Repeat(100011010L, 40).ToList();
+ HeadlessCardMaster.Load(100011010);
+
+ var engine = new SessionBattleEngine();
+ Assert.DoesNotThrow(() => engine.Setup(masterSeed: 12345, seatADeck: deckA, seatBDeck: deckB));
+ Assert.That(engine.IsReady, Is.True);
+ }
}
}
diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs
index f3dbf2c..4fa2430 100644
--- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs
+++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs
@@ -1,14 +1,32 @@
extern alias engine;
+using System.Reflection;
using engine::SVSim.BattleEngine.Rng;
using SVSim.BattleNode.Protocol;
+using BattleManagerBase = engine::BattleManagerBase;
+using BattlePlayerBase = engine::BattlePlayerBase;
+using BattleCardBase = engine::BattleCardBase;
+using ClassBattleCardBase = engine::ClassBattleCardBase;
+using CardCreatorBase = engine::CardCreatorBase;
+using SBattleLoad = engine::SBattleLoad;
+using CardTemplate = engine::CardTemplate;
+using GameObject = engine::UnityEngine.GameObject;
namespace SVSim.BattleNode.Sessions.Engine;
/// One authoritative engine per BattleSession, seated as both players (design ND2). A faithful
/// SHADOW: it mirrors each client's resolved play, never overrides/rejects/originates (ND1). Ingest is
-/// the engine's own NetworkBattleReceiver.ReceivedMessage (ND4); isPlayer selects the seat (F-N-2).
+/// the engine's own NetworkBattleReceiver.ReceivedMessage (ND4); isPlayer selects the seat (F-N-2).
+///
+/// The headless wiring here is the production analogue of the test HeadlessFixture
+/// (NewNetworkEmitBattle / SeedDeck / InitLeaderLife / InitCardTemplates). It deliberately omits the
+/// emit-only RealTimeNetworkAgent scaffolding the test uses for the SEND path — the shadow engine only
+/// RECEIVES (F-N-2), so no socket-agent is constructed. The engine's global init (CardMaster, GameMgr,
+/// Wizard.Data) is the caller's responsibility (the test does HeadlessEngineEnv.EnsureInitialized;
+/// the live node guards Setup in try/catch so an un-initialized host degrades to a no-op shadow).
internal sealed class SessionBattleEngine
{
+ private const int DefaultLeaderLife = 20;
+
private HeadlessNetworkBattleMgr? _mgr;
/// True once Setup has built the two-seat battle.
@@ -19,10 +37,90 @@ internal sealed class SessionBattleEngine
/// already computed (BattleSessionState.GetShuffledDeck) and handed each client.
public void Setup(int masterSeed,
IReadOnlyList seatADeck, IReadOnlyList seatBDeck)
- => throw new NotImplementedException("Filled by Task 3 (construction probe).");
+ {
+ // 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));
+
+ // Seat each player as the other's opponent (private field on BattlePlayerBase, as the real
+ // match-load does). Mirrors HeadlessFixture.NewNetworkEmitBattle.
+ BattlePlayerBase player = mgr.GetBattlePlayer(isPlayer: true);
+ BattlePlayerBase enemy = mgr.GetBattlePlayer(isPlayer: false);
+ SetField(player, "_opponentBattlePlayer", enemy);
+ SetField(enemy, "_opponentBattlePlayer", player);
+ player.IsSelfTurn = true;
+ enemy.IsSelfTurn = false;
+
+ 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
+
+ SeedDeck(mgr, seatADeck, isPlayer: true);
+ SeedDeck(mgr, seatBDeck, isPlayer: false);
+
+ _mgr = mgr;
+ }
/// Ingest one client frame into the engine for the given seat.
/// maps the sender to the engine's player(true)/opponent(false) seat (F-N-2).
public EngineIngestResult Receive(MsgEnvelope env, bool isPlayerSeat)
=> throw new NotImplementedException("Filled by Task 4 (ingest probe).");
+
+ // --- headless wiring (production analogue of HeadlessFixture) -----------------------------------
+
+ private static void InitLeaderLife(BattleManagerBase mgr, int life = DefaultLeaderLife)
+ {
+ ((ClassBattleCardBase)mgr.GetBattlePlayer(true).Class).InitBaseMaxLife(life);
+ ((ClassBattleCardBase)mgr.GetBattlePlayer(false).Class).InitBaseMaxLife(life);
+ }
+
+ private static void InitCardTemplates(BattleManagerBase mgr)
+ {
+ mgr.SBattleLoad = new SBattleLoad
+ {
+ UnitCardTemplate = new CardTemplate(),
+ SpellCardTemplate = new CardTemplate(),
+ FieldCardTemplate = new CardTemplate(),
+ };
+ mgr.Battle3DContainer = new GameObject();
+ mgr.CardHolder = new GameObject();
+ mgr.ECardHolder = new GameObject();
+ mgr.PCardPlace = new GameObject();
+ mgr.ChoiceCardHolder = new GameObject();
+ mgr.EvolveCardHolder = new GameObject();
+ }
+
+ /// 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).
+ private static void SeedDeck(BattleManagerBase mgr, IReadOnlyList deck, bool isPlayer)
+ {
+ BattlePlayerBase owner = mgr.GetBattlePlayer(isPlayer);
+ for (int i = 0; i < deck.Count; i++)
+ {
+ var card = CreateHeadlessCard(mgr, (int)deck[i], index: i + 1, isPlayer);
+ owner.AddToDeck(card);
+ }
+ }
+
+ private static readonly MethodInfo CreateCardWithoutResources =
+ typeof(CardCreatorBase).GetMethod("CreateCardWithoutResources",
+ BindingFlags.NonPublic | BindingFlags.Static)
+ ?? throw new InvalidOperationException("CardCreatorBase.CreateCardWithoutResources not found");
+
+ private static BattleCardBase CreateHeadlessCard(BattleManagerBase mgr, int cardId, int index, bool isPlayer)
+ {
+ var io = mgr.CreatePlayerInnerOptionsBuilder();
+ var card = (BattleCardBase)CreateCardWithoutResources.Invoke(
+ null, new object[] { cardId, index, isPlayer, mgr, io })!;
+ mgr.GetBattlePlayer(isPlayer).SetupCardEvent(card);
+ return card;
+ }
+
+ private static void SetField(object obj, string name, object value)
+ {
+ var f = obj.GetType().GetField(name,
+ BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
+ ?? throw new InvalidOperationException($"{obj.GetType().Name} has no field '{name}'");
+ f.SetValue(obj, value);
+ }
}