refactor(engine-ambient): GameMgr.GetIns throws Require; wrap SessionBattleEngine entry points

Step 5 of multi-instancing migration. GameMgr.GetIns() now resolves through
BattleAmbient.Require() (throws when no scope active — fail-fast since engine
callers unconditionally dereference). SessionBattleEngine now owns a single
BattleAmbientContext, pushed via BattleAmbient.Enter at Setup/Receive/all
~30 read accessors and Debug* seams.

EngineGlobalInit.WirePerSessionGameMgr extracted out of the _done-gated block:
GameMgr is now per-session (ctx.GameMgr is a fresh `new()` per SessionBattleEngine),
so the DataMgr chara ids + NetworkUserInfoData seeding must run every Setup, not
process-once. The wiring itself is already idempotent. Without this, second-or-
later sessions in a process NRE in NetworkBattleManagerBase.CreateBackgroundId.

Expected state: SVSim.BattleEngine.Tests have known-failing tests that don't
go through SessionBattleEngine (Task 6 wraps HeadlessFixture). SVSim.UnitTests
mostly recover; residual failures (deal-frame Accepted:false in conductor
integration tests) are captured in
data_dumps/task5-test-output/failing-tests-after-task5-node-postwrap.txt for
Task 7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-07 21:56:34 -04:00
parent 18da7fd19e
commit 1ba75c565a
4 changed files with 173 additions and 59 deletions

View File

@@ -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<System.InvalidOperationException>(() => 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));
}
}

View File

@@ -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() { }

View File

@@ -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<string, object> { ["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<string, object> { ["fieldId"] = 1 },
isWatchReplayRecovery: false);
GameMgr.GetIns().SetNetworkUserInfoData(netUser);
}
}
// --- CardMaster (full load) ----------------------------------------------------------------------
// Production difference (1): enumerate EVERY card row — no want.Contains(id) filter.

View File

@@ -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<long> seatADeck, IReadOnlyList<long> 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);
}
/// <summary>TEST/DEBUG SEAM (Phase 4 Option-A viability PROBE — NOT a production fix). Identical to
/// <see cref="Setup(int, IReadOnlyList{long}, IReadOnlyList{long}, int, int)"/> but installs a logging
@@ -78,6 +89,7 @@ internal sealed class SessionBattleEngine
IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck,
int seatAClass = 1, int seatBClass = 2)
{
using var _ambient = BattleAmbient.Enter(_ctx);
var log = new List<RollEntry>();
// 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.</summary>
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; }
/// <summary>Followers in play, excluding the leader (the Class card occupies one slot of
/// ClassAndInPlayCardList).</summary>
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); }
/// <summary>The engine <c>Index</c> 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.</summary>
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; }
/// <summary>The real <c>CardId</c> (wire identity) of the hand card at <paramref name="handPos"/>. Lets a
/// test locate a specific card in a SHUFFLED opening hand by identity (then read its <see cref="HandCardIndex"/>
/// to drive a play), without depending on which shuffled position the card landed at.</summary>
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; }
/// <summary>The real <c>CardId</c> (wire identity) of the in-play follower at <paramref name="boardPos"/>
/// (0-based, skipping the leader/Class card at ClassAndInPlayCardList[0] — same convention as
/// <see cref="BoardCount"/>). 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.</summary>
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;
}
/// <summary>The engine <c>Index</c> of the in-play follower at <paramref name="boardPos"/> (0-based,
/// leader excluded — same convention as <see cref="BoardCount"/>/<see cref="InPlayCardId"/>). An ATTACK
/// frame addresses the attacker by this in-play Index (the wire <c>playIdx</c>), so a test reads it after
/// a follower resolves onto the board to build the attack (M-HC-4a).</summary>
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;
}
/// <summary>The current life/health of the in-play follower at <paramref name="boardPos"/> (0-based,
/// leader excluded). Reads <see cref="BattleCardBase.Life"/> (skill-resolved current health). Lets an
/// attack test assert a follower took the attacker's damage (M-HC-4a follower-vs-follower trade).</summary>
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;
}
/// <summary>The attack stat of the in-play follower at <paramref name="boardPos"/> (skill-resolved
/// <see cref="BattleCardBase.Atk"/>). The damage it deals when it attacks.</summary>
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;
}
/// <summary>True when the in-play follower at <paramref name="boardPos"/> can still attack this turn
/// (<see cref="BattleCardBase.Attackable"/>). After it attacks (consuming its single attack) this reads
/// false — the "attacker is spent" assertion (M-HC-4a).</summary>
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;
}
/// <summary>True once the in-play follower at <paramref name="boardPos"/> (0-based, leader excluded)
/// has evolved (<see cref="UnitBattleCard.IsEvolution"/>, set true inside the engine's own
/// <c>UnitBattleCard.Evolution</c> mutation). Only <see cref="UnitBattleCard"/> followers carry the
/// flag; a non-follower (or the leader) reads false. The evolve test's decisive engine-state assertion
/// (M-HC-4b).</summary>
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;
}
/// <summary>The seat's current evolve-point count (<see cref="BattlePlayerBase.CurrentEpCount"/>). An
/// evolve spends one EP, so the evolve test asserts this decrements by 1. EP is granted at setup by
/// the engine's <c>SetupEvolCount</c> (2 for the game-first seat, 3 for the second) and unlocks once
/// <c>EvolveWaitTurnCount</c> has counted down (M-HC-4b).</summary>
public int EpCount(bool playerSeat) => Seat(playerSeat).CurrentEpCount;
public int EpCount(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).CurrentEpCount; }
/// <summary>Turns remaining until <paramref name="playerSeat"/> may evolve
/// (<see cref="BattlePlayerBase.EvolveWaitTurnCount"/>); 0 means evolve is unlocked. Lets a test ramp to
/// the evolve-enabled turn deterministically (M-HC-4b).</summary>
public int EvolveWaitTurnCount(bool playerSeat) => Seat(playerSeat).EvolveWaitTurnCount;
public int EvolveWaitTurnCount(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).EvolveWaitTurnCount; }
/// <summary>The engine-RESOLVED play-time cost of the card whose engine <c>Index</c> == <paramref name="idx"/>
/// on <paramref name="playerSeat"/> (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.</para></summary>
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.</para></summary>
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.</summary>
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
/// <see cref="PlayedCardCost"/>: no engine / no card → fallback, so a non-engine session never crashes.</para></summary>
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.</para></summary>
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.</summary>
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</c>, BattlePlayerBase.cs:3049/3073). Under the live recovery setup
/// (<c>CreateXorShift(-1,-1)</c> via NullRecoveryManager.IdxChangeSeed == -1) this is FALSE, so the
/// engine SKIPS the reshuffle the real clients performed.</summary>
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; }
}
/// <summary>TEST/DEBUG: same as <see cref="SelfXorShiftActive"/> for the OPPONENT seat.</summary>
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; }
}
/// <summary>DIAGNOSTIC: check if OnReceiveDeal is wired and report deck/hand counts.</summary>
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.</summary>
internal void SeedOppoIdxChange(int oppoSeed)
{
using var _ambient = BattleAmbient.Enter(_ctx);
_mgr?.CreateXorShift(-1, oppoSeed);
}
@@ -599,6 +645,7 @@ internal sealed class SessionBattleEngine
/// seed + <see cref="SeedOppoIdxChange"/> for the opponent seed.</summary>
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
/// <c>SetupInitialGameState(areCardsRandomlyDrawn:true)</c>). This seam exists so tests can
/// force it false to reproduce the old top-of-deck bug. Static field → set per run under
/// [NonParallelizable].</summary>
internal void DebugSetRandomDraw(bool value) => BattleManagerBase.IsRandomDraw = value;
internal void DebugSetRandomDraw(bool value)
{
using var _ambient = BattleAmbient.Enter(_ctx);
BattleManagerBase.IsRandomDraw = value;
}
/// <summary>TEST/DEBUG (Phase 4 draw-recompute hypothesis): advance the SHARED <c>_stableRandom</c>
/// stream by <paramref name="n"/> draws, exactly as <c>OperateReceive.StartOperate</c> 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.</summary>
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).</summary>
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 <c>SBattleLoad.InitPlayer</c> tail, SBattleLoad.cs:1292), so the FIRST
/// generated token gets Index 41 — clear of deck-loaded indices 1..40 — and matches the wire
/// <c>add.idx</c>. A stale value of 0 causes tokens to take Index 0, 1, ... and collide.</summary>
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;
}
/// <summary>TEST/DEBUG: the engine's running <c>StableRandom</c>/<c>StableRandomDouble</c> call count
/// (private <c>BattleManagerBase.stableRandomCount</c>), so a divergence dump can report how far the
/// shared stream has advanced at the moment of a mismatch.</summary>
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);