extern alias engine; using System.Reflection; using System.Runtime.Serialization; using engine::SVSim.BattleEngine.Rng; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Sessions.Dispatch; 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; using RealTimeNetworkAgent = engine::RealTimeNetworkAgent; using Gungnir = engine::Gungnir; using NetworkNullLogger = engine::NetworkNullLogger; using ToolboxGame = engine::Wizard.ToolboxGame; using GameMgr = engine::GameMgr; using BattleUIContainer = engine::BattleUIContainer; using BackGroundBase = engine::BackGroundBase; using NullPlayerEmotion = engine::Wizard.Battle.Player.Emotion.NullPlayerEmotion; 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; private int _lastPlayedSpellboost; /// True once Setup has built the two-seat battle. public bool IsReady => _mgr is not null; /// The spellboost (spell-charge) COUNT of the card the most-recently-ingested PlayActions /// frame played, read from the acting seat's hand BEFORE the frame resolved (the count is fixed as the /// card leaves hand; a play that grants spellboost targets the REST of the hand, not the card just /// played). 0 for a non-play frame, a token/unmapped idx, or a card not in hand. PlayActionsHandler /// reads this right after Receive — the BattleSession _dispatchGate serializes Receive→Handle, so this /// is unambiguously this frame's value. public int PlayedCardSpellboost => _lastPlayedSpellboost; /// 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. /// / are each seat's class ordinal (1..8, /// the CardClass int value); they select the leader's class via the all-8-class /// ClassCharacterList EngineGlobalInit installs (chara_id == class_id for 1..8). The 3-arg overload /// behavior is preserved by the defaults (1/2), matching the test-harness charaIds. /// NOTE: GameMgr (the leader chara ids set below) is a PROCESS GLOBAL. Setting per-session /// chara ids is therefore only safe while exactly one engine-backed battle exists at a time — the /// invariant enforces on the caller side. public void Setup(int masterSeed, IReadOnlyList seatADeck, IReadOnlyList seatBDeck, int seatAClass = 1, int seatBClass = 2) { // Prime the engine's process-global statics (CardMaster, Wizard.Data, all-8-class Master, // GameMgr/netUser/udid). Idempotent (process-once); makes the LIVE host ready so Setup succeeds // here rather than throwing into the shadow's no-op path (Phase 2 N2, carried-risk A). EngineGlobalInit.EnsureInitialized(); // 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. 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 InitHeadlessViews(mgr); // turn/play cycle dereferences UI-container + emotion refs InstallHeadlessNetworkAgent(); // turn-flow resolve reads ToolboxGame.RealTimeNetworkAgent // Per-session leader class: chara_id == class_id for 1..8 in the all-8-class ClassCharacterList, // so writing the seats' class ordinals into GameMgr's DataMgr resolves each leader's correct // class. Process-global — safe only under EngineSessionGate (see method remarks above). SetGameMgrCharaIds(seatAClass, seatBClass); 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); // Peek the played card's accumulated spellboost count BEFORE resolution: the count is fixed as // the card leaves hand, so it must be read while the card is still in hand. 0 for any non-play. _lastPlayedSpellboost = uri == NetworkBattleDefine.NetworkBattleURI.PlayActions ? PeekPlayedCardSpellboost(env, isPlayerSeat) : 0; 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) { var site = ex.StackTrace?.Split('\n').FirstOrDefault()?.Trim(); return EngineIngestResult.Reject($"{env.Uri} threw: {ex.GetType().Name}: {ex.Message} @ {site}"); } } /// Read the played card's accumulated spellboost count off the acting seat's hand, matching /// the card by Index == wire playIdx. Returns 0 when the body has no playIdx, or no hand card matches /// (a token/unmapped idx, or a card already gone from hand). Pre-resolve read (see ). private int PeekPlayedCardSpellboost(MsgEnvelope env, bool isPlayerSeat) { if (_mgr is null) return 0; var entries = (env.Body as RawBody)?.Entries; if (entries is null) return 0; int playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault(WireKeys.PlayIdx)); foreach (var card in _mgr.GetBattlePlayer(isPlayerSeat).HandCardList) if (card.Index == playIdx) return card.SpellChargeCount; return 0; } // --- 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; /// Followers in play, excluding the leader (the Class card occupies one slot of /// ClassAndInPlayCardList). 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(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(); } // 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()); } /// 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; } // 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); } // Write the two seats' class ordinals into GameMgr's DataMgr leader chara ids. Mirrors the test // seam HeadlessFixture.cs:202-204 (SetField(dm, "_playerCharaId"/"_enemyCharaId", ...)). chara_id == // class_id for 1..8 in EngineGlobalInit's all-8-class ClassCharacterList, so the ordinal selects the // class. A non-positive ordinal (e.g. CardClass.None == 0) clamps to the default seat (1/2). // GameMgr is a process global → safe only under EngineSessionGate (one engine-backed battle at a // time). private static void SetGameMgrCharaIds(int a, int b) { var dm = GameMgr.GetIns().GetDataMgr(); SetField(dm, "_playerCharaId", a <= 0 ? 1 : a); SetField(dm, "_enemyCharaId", b <= 0 ? 2 : b); } 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); } 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; } } }