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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -173,10 +173,16 @@ internal sealed class SessionBattleEngine
|
||||
SeedDeck(mgr, seatADeck, isPlayer: true);
|
||||
SeedDeck(mgr, seatBDeck, isPlayer: false);
|
||||
|
||||
WireMulliganPhase(mgr); // wire OperateReceive.OnReceiveDeal -> StartDeal (deal seats the hand)
|
||||
|
||||
// Publish the mgr on the per-session ambient BEFORE wiring the mulligan phase: that ctor
|
||||
// chains into MulliganInfoControl.InitMulliganInfo, which reads BattleManagerBase.GetIns()
|
||||
// (MulliganInfoControl.cs:259). With the fallback gone (Task 8), an unset ambient.Mgr would
|
||||
// resolve to null and NRE on the very next field read. Set ambient.Mgr here so the wiring
|
||||
// resolves the per-session mgr cleanly.
|
||||
_mgr = mgr;
|
||||
_ctx.Mgr = _mgr;
|
||||
|
||||
WireMulliganPhase(mgr); // wire OperateReceive.OnReceiveDeal -> StartDeal (deal seats the hand)
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user