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); + } }