extern alias engine; using System.Reflection; using engine::SVSim.BattleEngine.Rng; using SVSim.BattleNode.Protocol; using NetworkBattleReceiver = engine::NetworkBattleReceiver; using NetworkBattleDefine = engine::NetworkBattleDefine; 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 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; private NetworkBattleReceiver? _receiver; /// True once Setup has built the two-seat battle. public bool IsReady => _mgr is not null; /// Construct the two-seat network battle from both decks + the master seed (design F-N-5). /// / are the per-side deck orders the node /// already computed (BattleSessionState.GetShuffledDeck) and handed each client. public void Setup(int masterSeed, IReadOnlyList seatADeck, IReadOnlyList seatBDeck) { // 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; // Use the mgr's OWN receiver — the ctor already wired it to the mgr's OperateReceive + // NetworkBattleData (NetworkBattleManagerBase.cs:266, non-recovery branch). This is the same // receiver the engine's RecoveryDataHandler drives when replaying recorded frames. _receiver = mgr.GetNetworkBattleReceiver(); } /// 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). A throw/reject is /// returned as a detected-desync EVENT (ND6), never silently absorbed. public EngineIngestResult Receive(MsgEnvelope env, bool isPlayerSeat) { if (_mgr is null || _receiver is null) throw new InvalidOperationException("Receive before Setup."); var dict = ToEngineDict((env.Body as RawBody)?.Entries); var uri = MapUri(env.Uri); try { // Mirror the engine's own recorded-frame replay (RecoveryDataHandler.cs:283): every // ingested action resolves through the isHaveSequence ConductReceiveData path, and // checkBreakData:false so a partial/handshake frame is not rejected as a break. bool accepted = _receiver.ReceivedMessage( uri, isHaveSequence: true, dict, isPlayerSeat, handler: null, checkBreakData: false); return accepted ? EngineIngestResult.Ok() : EngineIngestResult.Reject($"receiver rejected {env.Uri}"); } catch (Exception ex) { return EngineIngestResult.Reject($"{env.Uri} threw: {ex.GetType().Name}: {ex.Message}"); } } private static NetworkBattleDefine.NetworkBattleURI MapUri(NetworkBattleUri uri) => Enum.Parse(uri.ToString()); // The receiver reads keys via Enum.IsDefined over NetworkParameter and casts nested values to // List / Dictionary; the node decodes nested data as the nullable // List / Dictionary. Rebox to the non-nullable shape, dropping nulls // (the receiver presence-checks keys, so an absent key is the correct encoding of a null). private static Dictionary ToEngineDict(Dictionary? entries) { var result = new Dictionary(); if (entries is null) return result; foreach (var (k, v) in entries) if (v is not null) result[k] = Rebox(v); return result; } private static object Rebox(object v) => v switch { Dictionary d => d.Where(kv => kv.Value is not null) .ToDictionary(kv => kv.Key, kv => Rebox(kv.Value!)), List l => l.Where(x => x is not null).Select(x => Rebox(x!)).ToList(), _ => v, }; // --- 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); } }