feat(engine-ambient): delete static fallbacks; add MultiInstanceEngineTests

Step 8 (final) of multi-instancing migration. All per-battle statics now
require a BattleAmbient scope — unwrapped writes throw InvalidOperationException
(fail-fast forcing function). MultiInstanceEngineTests proves correctness:
two parallel battles resolve independently, N=4/8/16 stress matches sequential
baseline, GameMgr.GetIns throws without scope.

Migration complete. EngineSessionGate gone. Suite fully green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-07 23:19:37 -04:00
parent 9e93a7b198
commit c789d836f1
15 changed files with 449 additions and 131 deletions

View File

@@ -89,14 +89,11 @@ internal static class EngineGlobalInit
// Suppress VFX / take the virtual-battle resolution path (no live view layer).
BattleManagerBase.IsForecast = true;
// The receive-conductor deal path runs under IsRecovery (SessionBattleEngine sets it after
// construction) and reads Data.BattleRecoveryInfo.IsMulliganEnd in MulliganMgrBase.StartDeal
// (line 43) — null by default -> NRE. Seed a no-op instance with IsMulliganEnd=false (the
// default) so the deal returns its real parallel VFX rather than the mulligan-end short
// circuit. GetUninitializedObject skips the JsonData ctor. Only when absent (coexistence).
if (Data.BattleRecoveryInfo == null)
Data.BattleRecoveryInfo =
(BattleRecoveryInfo)FormatterServices.GetUninitializedObject(typeof(BattleRecoveryInfo));
// Post-Task-8: BattleRecoveryInfo lives on the per-session BattleAmbientContext (RecoveryInfo,
// pre-seeded with an uninitialized no-op instance in SessionBattleEngine's field initializer).
// Each session owns its own (IsMulliganEnd=false default), so the MulliganMgrBase.StartDeal
// read resolves per-session — the historical process-global Data.BattleRecoveryInfo write that
// lived here was dead the moment the ambient seam landed (reviewer-flagged in Task 7).
// --- static CardMaster (full cards.json) ----------------------------------------------
// ALWAYS rebuild + re-inject the FULL master. We must not defer to a possibly-thin
@@ -124,20 +121,14 @@ internal static class EngineGlobalInit
udidField.SetValue(null, "headless-udid");
// --- Cute.Certification.viewer_id ------------------------------------------------------
// The IsRecovery target parse (NetworkBattleReceiver.CreateTargetList, isWatch branch) derives
// a target's owner from `vid != PlayerStaticData.UserViewerID`, where
// UserViewerID => Certification.ViewerId, whose getter LAZILY reads
// Toolbox.SavedataManager.GetInt("VIEWER_ID") when its backing field is 0 — and that savedata
// read throws headless. The exception is SWALLOWED by ConvertReceiveDataToMakeData's blanket
// catch, which (with the node's checkBreakData:false ingest) silently drops the parsed
// targetList, leaving an attack/evolve with an EMPTY target list -> the action throws on
// targetList[0]. Seed the backing field with a stable nonzero id so the getter short-circuits.
// It defines the engine's "player" perspective: a target vid == ThisViewerId resolves on
// BattlePlayer (engine seat A), vid != it on BattleEnemy (seat B). Only set when 0 (coexistence).
var viewerIdField = typeof(Certification).GetField("_viewerIdFallback",
BindingFlags.Static | BindingFlags.NonPublic)!;
if ((int)(viewerIdField.GetValue(null) ?? 0) == 0)
viewerIdField.SetValue(null, ThisViewerId);
// Post-Task-8: viewer id lives on the per-session BattleAmbientContext (ViewerId, init-only).
// SessionBattleEngine seeds it from ThisViewerId in its field initializer and enters the
// ambient on every Setup/Receive — so the engine's Certification.ViewerId getter
// (BattleAmbient.Require().ViewerId) always resolves cleanly when the engine code is
// reached. The reflection write that used to seed `Certification._viewerIdFallback` is now
// dead (the static is deleted). Left as a comment-only marker so the design intent (this
// viewer id defines the engine's "player" perspective for the IsRecovery target parse)
// stays discoverable from the global init path.
_done = true;
}