test(engine-ambient): TestBattleScope + HeadlessFixture split for multi-instance

Step 6 of multi-instancing migration. HeadlessEngineEnv.EnsureInitialized
is split into EnsureProcessGlobals (idempotent, process-once) +
SeedCharaIdsOnCurrentAmbient (per-test). New TestBattleScope IDisposable
sets up a fresh BattleAmbientContext per test. NonParallelizable removed
from converted classes; assembly-level Parallelizable(Fixtures) enabled.

SVSim.BattleEngine.Tests fully green under parallel test execution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-07 22:24:21 -04:00
parent 1ba75c565a
commit 8af1be6555
22 changed files with 238 additions and 39 deletions

View File

@@ -172,10 +172,26 @@ namespace SVSim.BattleEngine.Tests
public const int RngDeckCardC = SummonedTokenId; // neutral 2/2 -> Index-order position 2
private static bool _done;
private static readonly object _processGlobalsGate = new object();
public static void EnsureInitialized()
// Process-globals only: load card master, install master data, seed LoadDetail/Crossover,
// seed Certification.udid. Per-battle/per-test state (IsForecast, chara ids on the DataMgr,
// NetworkUserInfoData) is now seeded inside TestBattleScope's ctor against the per-scope
// GameMgr — calling it here would crash because GameMgr.GetIns() Requires an ambient scope.
// Thread-safe (assembly-level Parallelizable(Fixtures) means many fixtures' [SetUp] race here).
public static void EnsureProcessGlobals()
{
if (_done) return;
lock (_processGlobalsGate)
{
if (_done) return;
EnsureProcessGlobalsCore();
_done = true;
}
}
private static void EnsureProcessGlobalsCore()
{
// Wizard.Data.Load: static /load/index snapshot. The ctor's CreateBackgroundId reads
// Data.Load.data._userTutorial (LoadDetail self-inits _userTutorial). Suppress VFX too.
Wizard.Data.Load = new Load { data = new LoadDetail() };
@@ -184,7 +200,6 @@ namespace SVSim.BattleEngine.Tests
typeof(Wizard.Data).GetProperty("Crossover",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public)
.SetValue(null, new Wizard.Crossover());
BattleManagerBase.IsForecast = true;
// CardMaster must be non-null before construction (the leader/class card looks up id 0).
// Load the M2 vanilla follower + the M3 fixed-damage spell + the M4 self-buff follower +
// the M5 summon-token spell AND the token it summons so each oracle can create + look up
@@ -195,6 +210,22 @@ namespace SVSim.BattleEngine.Tests
DynamicDamageSpellId);
// Master reference data (class-character list) for leader/class card resolution.
HeadlessMasterData.Install();
// The network emit path's payload builder (RealTimeNetworkAgent.CreateEmitData) reads
// Cute.Certification.Udid (RealTimeNetworkAgent.cs:1407). The Udid getter lazily decodes from
// Toolbox.SavedataManager (Certification.cs:35), which is null headless. Seed the private static
// backing field with a non-empty placeholder so the getter short-circuits before touching the
// savedata manager. The value is opaque to the engine (it's just echoed into the emit dict).
typeof(Cute.Certification)
.GetField("udid", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)
.SetValue(null, "headless-udid");
}
// Per-ambient seeder: writes the player/enemy chara ids onto the AMBIENT GameMgr's DataMgr.
// Called by TestBattleScope after the scope is entered so GameMgr.GetIns() routes to the
// per-test GameMgr, not whichever one happened to be ambient last.
public static void SeedCharaIdsOnCurrentAmbient()
{
// Player/enemy leaders (chara ids must map to a ClassCharacterMasterData in Master).
// Set the backing fields directly: the public SetPlayerCharaId() also pulls MyRotation/
// AvatarBattle info (more null statics) which the resolution path doesn't need (the
@@ -202,6 +233,13 @@ namespace SVSim.BattleEngine.Tests
var dm = GameMgr.GetIns().GetDataMgr();
SetField(dm, "_playerCharaId", HeadlessMasterData.PlayerCharaId);
SetField(dm, "_enemyCharaId", HeadlessMasterData.EnemyCharaId);
}
// Per-ambient seeder: installs a no-op NetworkUserInfoData on the AMBIENT GameMgr so
// NetworkBattleManagerBase.CreateBackgroundId()'s GetNetworkUserInfoData().GetFieldId() call
// resolves (M13). Field id 1 == ForestField, a valid background.
public static void SeedNetUserOnCurrentAmbient()
{
// NetworkBattleManagerBase.CreateBackgroundId() (M13) reads
// GameMgr.GetIns().GetNetworkUserInfoData().GetFieldId() when the RecoveryManager yields no
// bg id (NullRecoveryManager.BackGroundId == -1). In production RealTimeNetworkAgent seeds
@@ -216,17 +254,6 @@ namespace SVSim.BattleEngine.Tests
new System.Collections.Generic.Dictionary<string, object> { ["fieldId"] = 1 },
isWatchReplayRecovery: false);
GameMgr.GetIns().SetNetworkUserInfoData(netUser);
// The network emit path's payload builder (RealTimeNetworkAgent.CreateEmitData) reads
// Cute.Certification.Udid (RealTimeNetworkAgent.cs:1407). The Udid getter lazily decodes from
// Toolbox.SavedataManager (Certification.cs:35), which is null headless. Seed the private static
// backing field with a non-empty placeholder so the getter short-circuits before touching the
// savedata manager. The value is opaque to the engine (it's just echoed into the emit dict).
typeof(Cute.Certification)
.GetField("udid", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)
.SetValue(null, "headless-udid");
_done = true;
}
// Seed each leader's starting life on a freshly-constructed mgr. The engine does this in
@@ -336,7 +363,7 @@ namespace SVSim.BattleEngine.Tests
// Returns the constructed HeadlessBattleMgr; the caller seeds hands/decks/boards and plays.
public static HeadlessBattleMgr NewAuthoritativeBattle(IRandomSource rng)
{
EnsureInitialized(); // sets IsForecast = true among other globals
EnsureProcessGlobals(); // sets IsForecast = true among other globals
BattleManagerBase.IsRandomDraw = true; // the second RNG gate (F-RNG-2)
var mgr = new HeadlessBattleMgr(new HeadlessContentsCreator(), rng);
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
@@ -361,7 +388,7 @@ namespace SVSim.BattleEngine.Tests
public static (HeadlessNetworkBattleMgr mgr, System.Collections.Generic.List<NetworkBattleDefine.NetworkBattleURI> emitted)
NewNetworkEmitBattle(IRandomSource rng = null)
{
EnsureInitialized(); // sets IsForecast = true among other globals
EnsureProcessGlobals(); // sets IsForecast = true among other globals
var mgr = new HeadlessNetworkBattleMgr(new HeadlessContentsCreator(), rng);
// NOTE: IsRecovery is left FALSE here (unlike the solo NewAuthoritativeBattle). The network
// emit path is gated on !IsRecovery in BOTH places: NetworkStandardBattleMgr.SendPlayCard