diff --git a/SVSim.BattleEngine.Tests/BattleAmbientTests.cs b/SVSim.BattleEngine.Tests/BattleAmbientTests.cs index 00952b4..12085db 100644 --- a/SVSim.BattleEngine.Tests/BattleAmbientTests.cs +++ b/SVSim.BattleEngine.Tests/BattleAmbientTests.cs @@ -200,4 +200,41 @@ public class BattleAmbientTests Wizard.Data.BattleRecoveryInfo = info; Assert.That(ctx.RecoveryInfo, Is.SameAs(info)); } + + [Test] + public void GameMgr_GetIns_InsideScope_ReturnsScopeInstance() + { + var mgr = new GameMgr(); + var ctx = new BattleAmbientContext { GameMgr = mgr }; + using var _ = BattleAmbient.Enter(ctx); + Assert.That(GameMgr.GetIns(), Is.SameAs(mgr)); + } + + [Test] + public void GameMgr_GetIns_OutsideScope_Throws() + { + Assert.That(BattleAmbient.Current, Is.Null); + Assert.Throws(() => GameMgr.GetIns()); + } + + [Test] + public async Task GameMgr_GetIns_IsolatedBetweenConcurrentTasks() + { + var mgrA = new GameMgr(); + var mgrB = new GameMgr(); + + var taskA = Task.Run(async () => { + using var _ = BattleAmbient.Enter(new BattleAmbientContext { GameMgr = mgrA }); + await Task.Delay(20); + return GameMgr.GetIns(); + }); + var taskB = Task.Run(async () => { + using var _ = BattleAmbient.Enter(new BattleAmbientContext { GameMgr = mgrB }); + await Task.Delay(20); + return GameMgr.GetIns(); + }); + var results = await Task.WhenAll(taskA, taskB); + Assert.That(results[0], Is.SameAs(mgrA)); + Assert.That(results[1], Is.SameAs(mgrB)); + } } diff --git a/SVSim.BattleEngine/Shim/GodObjects/GodObjects.cs b/SVSim.BattleEngine/Shim/GodObjects/GodObjects.cs index dd19d72..333d63c 100644 --- a/SVSim.BattleEngine/Shim/GodObjects/GodObjects.cs +++ b/SVSim.BattleEngine/Shim/GodObjects/GodObjects.cs @@ -79,8 +79,7 @@ public partial class GameObjMgr { public GameObjMgr() { } } public class GameMgr { - public static GameMgr GetIns() => _ins ??= new GameMgr(); - private static GameMgr _ins; + public static GameMgr GetIns() => SVSim.BattleEngine.Ambient.BattleAmbient.Require().GameMgr; public GameObject m_GameManagerObj; public bool IsNewReplayBattle; @@ -128,7 +127,10 @@ public class GameMgr public Wizard.CardCreateTask GetCardCreateTask() => null; public Wizard.MissionInfoTask GetMissionInfoTask() => null; - public static void CreateIns() { _ins ??= new GameMgr(); } + public static void CreateIns() + { + // No-op: GameMgr is now provisioned by BattleAmbientContext at scope entry. + } public void CreateMgrIns(GameObject gameobj) { } public void BuildDeckData() { } public void DestroyBattleManagements() { } diff --git a/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs b/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs index a157de6..656ed2b 100644 --- a/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs +++ b/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs @@ -60,6 +60,12 @@ internal static class EngineGlobalInit public static void EnsureInitialized() { + // GameMgr is now per-session (BattleAmbientContext.GameMgr), so its DataMgr/NetworkUserInfoData + // wiring must run on EVERY call — once-per-process gating it (under _done) leaves a second-or- + // later session with an unwired ctx.GameMgr and NREs in NetworkBattleManagerBase.CreateBackgroundId. + // The wiring itself is idempotent (zero-or-null guards), so re-running is safe. + WirePerSessionGameMgr(); + if (_done) return; lock (_gate) { @@ -107,29 +113,6 @@ internal static class EngineGlobalInit // the postcondition is all-8-class ClassCharacterList. Idempotent (replaces Data.Master). InstallMaster(); - // --- GameMgr DataMgr leader chara ids -------------------------------------------------- - // Set the backing fields directly: the public SetPlayerCharaId() also pulls MyRotation/ - // AvatarBattle info (more null statics) the resolution path doesn't need. Idempotent - // (plain assignment); only meaningful when still 0. - var dm = GameMgr.GetIns().GetDataMgr(); - SetFieldIfZeroOrNull(dm, "_playerCharaId", PlayerCharaId); - SetFieldIfZeroOrNull(dm, "_enemyCharaId", EnemyCharaId); - - // --- NetworkUserInfoData (background lookup on the network mgr's CreateBackgroundId) ---- - // NetworkBattleManagerBase.CreateBackgroundId reads - // GameMgr.GetIns().GetNetworkUserInfoData().GetFieldId() when the RecoveryManager yields no - // bg id. GameMgr leaves _netUser null with no lazy init; seed a no-op instance whose - // _selfInfo carries just fieldId=1 (== ForestField, a valid background). Only seed when - // absent so a HeadlessEngineEnv-set instance is preserved. - if (GameMgr.GetIns().GetNetworkUserInfoData() == null) - { - var netUser = new NetworkUserInfoData(); - netUser.SetSelfInfo( - new Dictionary { ["fieldId"] = 1 }, - isWatchReplayRecovery: false); - GameMgr.GetIns().SetNetworkUserInfoData(netUser); - } - // --- Cute.Certification.udid ----------------------------------------------------------- // The emit-path payload builder reads Certification.Udid, whose getter lazily decodes from // Toolbox.SavedataManager (null headless). Seed the private static backing field with a @@ -160,6 +143,36 @@ internal static class EngineGlobalInit } } + // Per-session GameMgr wiring: under the ambient seam, GameMgr.GetIns() resolves to the SESSION's + // BattleAmbientContext.GameMgr — a fresh instance per SessionBattleEngine. So the DataMgr chara ids + // and NetworkUserInfoData seeding must run on every Setup, not just process-once. The reflection + // helpers below are idempotent (SetFieldIfZeroOrNull is a no-op once set; netUser is null-guarded). + private static void WirePerSessionGameMgr() + { + // --- GameMgr DataMgr leader chara ids -------------------------------------------------- + // Set the backing fields directly: the public SetPlayerCharaId() also pulls MyRotation/ + // AvatarBattle info (more null statics) the resolution path doesn't need. Idempotent + // (plain assignment); only meaningful when still 0. + var dm = GameMgr.GetIns().GetDataMgr(); + SetFieldIfZeroOrNull(dm, "_playerCharaId", PlayerCharaId); + SetFieldIfZeroOrNull(dm, "_enemyCharaId", EnemyCharaId); + + // --- NetworkUserInfoData (background lookup on the network mgr's CreateBackgroundId) ---- + // NetworkBattleManagerBase.CreateBackgroundId reads + // GameMgr.GetIns().GetNetworkUserInfoData().GetFieldId() when the RecoveryManager yields no + // bg id. GameMgr leaves _netUser null with no lazy init; seed a no-op instance whose + // _selfInfo carries just fieldId=1 (== ForestField, a valid background). Only seed when + // absent so a HeadlessEngineEnv-set instance is preserved. + if (GameMgr.GetIns().GetNetworkUserInfoData() == null) + { + var netUser = new NetworkUserInfoData(); + netUser.SetSelfInfo( + new Dictionary { ["fieldId"] = 1 }, + isWatchReplayRecovery: false); + GameMgr.GetIns().SetNetworkUserInfoData(netUser); + } + } + // --- CardMaster (full load) ---------------------------------------------------------------------- // Production difference (1): enumerate EVERY card row — no want.Contains(id) filter. diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs index cf8f577..9a0739b 100644 --- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs +++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs @@ -1,4 +1,6 @@ extern alias engine; +using BattleAmbient = engine::SVSim.BattleEngine.Ambient.BattleAmbient; +using BattleAmbientContext = engine::SVSim.BattleEngine.Ambient.BattleAmbientContext; using System.Reflection; using System.Runtime.Serialization; using engine::SVSim.BattleEngine.Rng; @@ -47,6 +49,12 @@ internal sealed class SessionBattleEngine { private const int DefaultLeaderLife = 20; + private readonly BattleAmbientContext _ctx = new() { + ViewerId = EngineGlobalInit.ThisViewerId, + IsForecast = true, + IsRandomDraw = true, + }; + private HeadlessNetworkBattleMgr? _mgr; private NetworkBattleReceiver? _receiver; @@ -66,7 +74,10 @@ internal sealed class SessionBattleEngine public void Setup(int masterSeed, IReadOnlyList seatADeck, IReadOnlyList seatBDeck, int seatAClass = 1, int seatBClass = 2) - => SetupInternal(masterSeed, seatADeck, seatBDeck, seatAClass, seatBClass, rng: null); + { + using var _ambient = BattleAmbient.Enter(_ctx); + SetupInternal(masterSeed, seatADeck, seatBDeck, seatAClass, seatBClass, rng: null); + } /// TEST/DEBUG SEAM (Phase 4 Option-A viability PROBE — NOT a production fix). Identical to /// but installs a logging @@ -78,6 +89,7 @@ internal sealed class SessionBattleEngine IReadOnlyList seatADeck, IReadOnlyList seatBDeck, int seatAClass = 1, int seatBClass = 2) { + using var _ambient = BattleAmbient.Enter(_ctx); var log = new List(); // The logger needs the mgr to read seat signals at roll time; the mgr is built inside Setup, so the // logger reads it lazily via a closure populated right after construction. @@ -153,6 +165,7 @@ internal sealed class SessionBattleEngine WireMulliganPhase(mgr); // wire OperateReceive.OnReceiveDeal -> StartDeal (deal seats the hand) _mgr = mgr; + _ctx.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. @@ -164,6 +177,7 @@ internal sealed class SessionBattleEngine /// returned as a detected-desync EVENT (ND6), never silently absorbed. public EngineIngestResult Receive(MsgEnvelope env, bool isPlayerSeat) { + using var _ambient = BattleAmbient.Enter(_ctx); if (_mgr is null || _receiver is null) throw new InvalidOperationException("Receive before Setup."); @@ -333,76 +347,94 @@ internal sealed class SessionBattleEngine // when the engine isn't owned (single-active-engine gate), so a non-engine session never // crashes. Production handlers read ONLY that band. - 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; - public int DeckCount(bool playerSeat) => Seat(playerSeat).DeckCardList.Count; - public int Turn(bool playerSeat) => Seat(playerSeat).Turn; + public int LeaderLife(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).Class.Life; } + public int Pp(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).Pp; } + public int HandCount(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).HandCardList.Count; } + public int DeckCount(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).DeckCardList.Count; } + public int Turn(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).Turn; } /// 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); + public int BoardCount(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Math.Max(0, Seat(playerSeat).ClassAndInPlayCardList.Count - 1); } /// The engine Index of the hand card at the given hand position. The receive-path /// Play frame addresses a card by its engine Index (playIdx), which equals deck position + 1 for /// a card dealt from the seeded deck. - public int HandCardIndex(bool playerSeat, int handPos) => Seat(playerSeat).HandCardList[handPos].Index; + public int HandCardIndex(bool playerSeat, int handPos) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).HandCardList[handPos].Index; } /// The real CardId (wire identity) of the hand card at . Lets a /// test locate a specific card in a SHUFFLED opening hand by identity (then read its /// to drive a play), without depending on which shuffled position the card landed at. - public int HandCardId(bool playerSeat, int handPos) => Seat(playerSeat).HandCardList[handPos].CardId; + public int HandCardId(bool playerSeat, int handPos) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).HandCardList[handPos].CardId; } /// The real CardId (wire identity) of the in-play follower at /// (0-based, skipping the leader/Class card at ClassAndInPlayCardList[0] — same convention as /// ). Used to assert an opponent reveal seated the substituted card with its /// true identity (M-HC-2): before the reveal the slot holds a hidden dummy (cardId 0); after, the /// engine-resolved actual card carries the wire cardId. - public int InPlayCardId(bool playerSeat, int boardPos) => - Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].CardId; + public int InPlayCardId(bool playerSeat, int boardPos) + { + using var _ambient = BattleAmbient.Enter(_ctx); + return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].CardId; + } /// The engine Index of the in-play follower at (0-based, /// leader excluded — same convention as /). An ATTACK /// frame addresses the attacker by this in-play Index (the wire playIdx), so a test reads it after /// a follower resolves onto the board to build the attack (M-HC-4a). - public int InPlayCardIndex(bool playerSeat, int boardPos) => - Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Index; + public int InPlayCardIndex(bool playerSeat, int boardPos) + { + using var _ambient = BattleAmbient.Enter(_ctx); + return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Index; + } /// The current life/health of the in-play follower at (0-based, /// leader excluded). Reads (skill-resolved current health). Lets an /// attack test assert a follower took the attacker's damage (M-HC-4a follower-vs-follower trade). - public int InPlayCardLife(bool playerSeat, int boardPos) => - Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Life; + public int InPlayCardLife(bool playerSeat, int boardPos) + { + using var _ambient = BattleAmbient.Enter(_ctx); + return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Life; + } /// The attack stat of the in-play follower at (skill-resolved /// ). The damage it deals when it attacks. - public int InPlayCardAtk(bool playerSeat, int boardPos) => - Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Atk; + public int InPlayCardAtk(bool playerSeat, int boardPos) + { + using var _ambient = BattleAmbient.Enter(_ctx); + return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Atk; + } /// True when the in-play follower at can still attack this turn /// (). After it attacks (consuming its single attack) this reads /// false — the "attacker is spent" assertion (M-HC-4a). - public bool InPlayCardAttackable(bool playerSeat, int boardPos) => - Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Attackable; + public bool InPlayCardAttackable(bool playerSeat, int boardPos) + { + using var _ambient = BattleAmbient.Enter(_ctx); + return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Attackable; + } /// True once the in-play follower at (0-based, leader excluded) /// has evolved (, set true inside the engine's own /// UnitBattleCard.Evolution mutation). Only followers carry the /// flag; a non-follower (or the leader) reads false. The evolve test's decisive engine-state assertion /// (M-HC-4b). - public bool IsEvolved(bool playerSeat, int boardPos) => - (Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1] as UnitBattleCard)?.IsEvolution ?? false; + public bool IsEvolved(bool playerSeat, int boardPos) + { + using var _ambient = BattleAmbient.Enter(_ctx); + return (Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1] as UnitBattleCard)?.IsEvolution ?? false; + } /// The seat's current evolve-point count (). An /// evolve spends one EP, so the evolve test asserts this decrements by 1. EP is granted at setup by /// the engine's SetupEvolCount (2 for the game-first seat, 3 for the second) and unlocks once /// EvolveWaitTurnCount has counted down (M-HC-4b). - public int EpCount(bool playerSeat) => Seat(playerSeat).CurrentEpCount; + public int EpCount(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).CurrentEpCount; } /// Turns remaining until may evolve /// (); 0 means evolve is unlocked. Lets a test ramp to /// the evolve-enabled turn deterministically (M-HC-4b). - public int EvolveWaitTurnCount(bool playerSeat) => Seat(playerSeat).EvolveWaitTurnCount; + public int EvolveWaitTurnCount(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).EvolveWaitTurnCount; } /// The engine-RESOLVED play-time cost of the card whose engine Index == /// on (M-HC-3a). This is the discounted cost the play actually paid — @@ -422,6 +454,7 @@ internal sealed class SessionBattleEngine /// session never crashes and a vanilla play simply emits its base cost via the caller's fallback. public int PlayedCardCost(bool playerSeat, int idx, int fallback = 0) { + using var _ambient = BattleAmbient.Enter(_ctx); if (_mgr is null) return fallback; var card = FindByIndex(Seat(playerSeat), idx); if (card is null) return fallback; @@ -447,6 +480,7 @@ internal sealed class SessionBattleEngine /// card — so a non-engine session never crashes and a vanilla play emits 0 via the caller's fallback. public int PlayedCardSpellboost(bool playerSeat, int idx, int fallback = 0) { + using var _ambient = BattleAmbient.Enter(_ctx); if (_mgr is null) return fallback; var card = FindByIndex(Seat(playerSeat), idx); return card?.SpellChargeCount ?? fallback; @@ -470,6 +504,7 @@ internal sealed class SessionBattleEngine /// the caller's fallback, never crashing. public long PlayedCardId(bool playerSeat, int idx, long fallback = 0) { + using var _ambient = BattleAmbient.Enter(_ctx); if (_mgr is null) return fallback; var card = FindByIndex(Seat(playerSeat), idx); return card is null ? fallback : card.CardId; @@ -485,6 +520,7 @@ internal sealed class SessionBattleEngine /// : no engine / no card → fallback, so a non-engine session never crashes. public int PlayedCardClan(bool playerSeat, int idx, int fallback = 0) { + using var _ambient = BattleAmbient.Enter(_ctx); if (_mgr is null) return fallback; var card = FindByIndex(Seat(playerSeat), idx); return card is null ? fallback : (int)card.Clan; @@ -508,6 +544,7 @@ internal sealed class SessionBattleEngine /// legal wire value. public string PlayedCardTribe(bool playerSeat, int idx, string fallback = "0") { + using var _ambient = BattleAmbient.Enter(_ctx); if (_mgr is null) return fallback; var card = FindByIndex(Seat(playerSeat), idx); if (card is null) return fallback; @@ -545,6 +582,7 @@ internal sealed class SessionBattleEngine /// No-op-returns -1 if the engine isn't set up or no hand card has that Index. internal int SeedHandCardSpellboostCost(bool playerSeat, int idx, int charge) { + using var _ambient = BattleAmbient.Enter(_ctx); if (_mgr is null) return -1; BattleCardBase? card = null; foreach (var c in Seat(playerSeat).HandCardList) @@ -566,14 +604,21 @@ internal sealed class SessionBattleEngine /// .IsActive, BattlePlayerBase.cs:3049/3073). Under the live recovery setup /// (CreateXorShift(-1,-1) via NullRecoveryManager.IdxChangeSeed == -1) this is FALSE, so the /// engine SKIPS the reshuffle the real clients performed. - internal bool SelfXorShiftActive => (_mgr?.XorShiftRandom(isSelf: true)?.IsActive) ?? false; + internal bool SelfXorShiftActive + { + get { using var _ambient = BattleAmbient.Enter(_ctx); return (_mgr?.XorShiftRandom(isSelf: true)?.IsActive) ?? false; } + } /// TEST/DEBUG: same as for the OPPONENT seat. - internal bool OppoXorShiftActive => (_mgr?.XorShiftRandom(isSelf: false)?.IsActive) ?? false; + internal bool OppoXorShiftActive + { + get { using var _ambient = BattleAmbient.Enter(_ctx); return (_mgr?.XorShiftRandom(isSelf: false)?.IsActive) ?? false; } + } /// DIAGNOSTIC: check if OnReceiveDeal is wired and report deck/hand counts. internal string DiagnoseDealState() { + using var _ambient = BattleAmbient.Enter(_ctx); if (_mgr is null) return "mgr=null"; var or = _mgr.OperateReceive; bool dealWired = or.OnReceiveDeal != null; @@ -591,6 +636,7 @@ internal sealed class SessionBattleEngine /// after feeding the Ready. internal void SeedOppoIdxChange(int oppoSeed) { + using var _ambient = BattleAmbient.Enter(_ctx); _mgr?.CreateXorShift(-1, oppoSeed); } @@ -599,6 +645,7 @@ internal sealed class SessionBattleEngine /// seed + for the opponent seed. internal void DebugSeedIdxChange(int selfSeed, int oppoSeed) { + using var _ambient = BattleAmbient.Enter(_ctx); if (_mgr is null) throw new InvalidOperationException("DebugSeedIdxChange before Setup."); _mgr.CreateXorShift(selfSeed, oppoSeed); } @@ -608,7 +655,11 @@ internal sealed class SessionBattleEngine /// SetupInitialGameState(areCardsRandomlyDrawn:true)). This seam exists so tests can /// force it false to reproduce the old top-of-deck bug. Static field → set per run under /// [NonParallelizable]. - internal void DebugSetRandomDraw(bool value) => BattleManagerBase.IsRandomDraw = value; + internal void DebugSetRandomDraw(bool value) + { + using var _ambient = BattleAmbient.Enter(_ctx); + BattleManagerBase.IsRandomDraw = value; + } /// TEST/DEBUG (Phase 4 draw-recompute hypothesis): advance the SHARED _stableRandom /// stream by draws, exactly as OperateReceive.StartOperate does on a @@ -617,6 +668,7 @@ internal sealed class SessionBattleEngine /// is offset; this applies the pre-roll at the same point the real client would. internal void DebugSpinPreroll(int n) { + using var _ambient = BattleAmbient.Enter(_ctx); if (_mgr is null) throw new InvalidOperationException("DebugSpinPreroll before Setup."); for (int i = 0; i < n; i++) _mgr.StableRandomDouble(); } @@ -630,6 +682,7 @@ internal sealed class SessionBattleEngine /// InitBattleHandler.cs:28). internal double DebugStableRandomDouble() { + using var _ambient = BattleAmbient.Enter(_ctx); if (_mgr is null) throw new InvalidOperationException("DebugStableRandomDouble before Setup."); return _mgr.StableRandomDouble(); } @@ -640,17 +693,26 @@ internal sealed class SessionBattleEngine /// the real client's SBattleLoad.InitPlayer tail, SBattleLoad.cs:1292), so the FIRST /// generated token gets Index 41 — clear of deck-loaded indices 1..40 — and matches the wire /// add.idx. A stale value of 0 causes tokens to take Index 0, 1, ... and collide. - internal int DebugCardTotalNum(bool playerSeat) => - _mgr is null ? -1 : _mgr.GetBattlePlayer(playerSeat).cardTotalNum; + internal int DebugCardTotalNum(bool playerSeat) + { + using var _ambient = BattleAmbient.Enter(_ctx); + return _mgr is null ? -1 : _mgr.GetBattlePlayer(playerSeat).cardTotalNum; + } /// TEST/DEBUG: the engine's running StableRandom/StableRandomDouble call count /// (private BattleManagerBase.stableRandomCount), so a divergence dump can report how far the /// shared stream has advanced at the moment of a mismatch. - internal int DebugStableRandomCount => - _mgr is null ? -1 - : (int)(typeof(BattleManagerBase) - .GetField("stableRandomCount", BindingFlags.Instance | BindingFlags.NonPublic)! - .GetValue(_mgr) ?? -1); + internal int DebugStableRandomCount + { + get + { + using var _ambient = BattleAmbient.Enter(_ctx); + return _mgr is null ? -1 + : (int)(typeof(BattleManagerBase) + .GetField("stableRandomCount", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(_mgr) ?? -1); + } + } private engine::BattlePlayerBase Seat(bool playerSeat) => (_mgr ?? throw new InvalidOperationException("read before Setup")).GetBattlePlayer(playerSeat);