engine cleanup passes 4-7 + multi-instancing ambient rip

Squashes 146 commits from battle-engine-extraction. Net: 2,045 files changed,
+11,896 / -158,687 lines. Ships engine passes 4-7 (dead-code cull, view-layer
stub, receive-path shrink) plus the Phase-5 AsyncLocal ambient deletion that
turns concurrent battles into a type-system property rather than a scope
contract.

## What landed

**Passes 4-7 (chunks 1-34):** Extended the Phase-4 const-false collapse into a
cascading cull across the skill graph, view layer, and receive-path periphery.
Six mode flags (IsWatchBattle/IsReplayBattle/IsAdmin/IsAdminWatch/IsPuzzleQuest/
IsAINetwork) became `const false`, every guarded block deleted. Field*.cs
subclass ctors + BackGroundBase + ObjectChecker culled to no-ops. Mulligan
family reworked to take a mgr param through IMulliganMgr.InitMulligan.
Emotion/Recovery/Resource clusters null-stubbed. Prediction/OperationSimulator/
skill filters converted from static ambient reads to per-mgr reads via
SkillPrm.ownerCard.SelfBattlePlayer.BattleMgr / ins.BattleMgr / this.BattleMgr.

**Phase-5 ambient rip (chunks 35-47):** Deleted BattleAmbient / BattleAmbient-
Context / TestBattleScope in full. Every per-battle mutable slot now lives on
the mgr instance itself:
  mgr.InstanceIsForecast / InstanceIsRandomDraw / InstanceRecoveryInfo /
  InstanceViewerId / InstanceNetworkAgent / GameMgr
BattleManagerBase.GetIns() returns null unconditionally; the residual static
flags + 3 façades (Certification.ViewerId, Data.BattleRecoveryInfo,
ToolboxGame.RealTimeNetworkAgent) are null-tolerant defaults kept for the
handful of engine-internal readers that still reference their types. Zero
BattleAmbient references anywhere in engine + node + tests.

Added pre-seeded GameMgr ctor overload threaded through the mgr chain
(BattleManagerBase → SingleBattleMgr / NetworkBattleManagerBase → NetworkStandard-
BattleMgr → HeadlessBattleMgr / HeadlessNetworkBattleMgr). Fixtures build a
GameMgr, seed it via HeadlessEngineEnv.SeedCharaIds/SeedNetUser, and pass it
to the mgr's ctor — no ambient reach.

Node side (SVSim.BattleNode/SessionBattleEngine): _ctx replaced with a plain
GameMgr field; 34 `using var _ambient = BattleAmbient.Enter(_ctx)` scope wraps
ripped from every accessor and mutator; EngineGlobalInit.WirePerSessionGameMgr
takes GameMgr as a param and runs from SessionBattleEngine.SetupInternal
BEFORE mgr construction.

Test side: TestBattleScope deleted; 18 fixture [SetUp]s migrated to
`HeadlessEngineEnv.EnsureProcessGlobals()`; MultiInstanceEngineTests rewritten
around per-mgr construction (GetIns() → null is the pinned invariant).

## Regression fixes

- **chunk-48** (MulliganCtrl): chunk-35's `= null` stubs on card lookups broke
  the live receive-driven Deal path (BattlePlayerBase.DrawCard NRE'd downstream
  of NetworkPlayerMulliganCtrl.StartMulliganVfx). Restored the three lookups
  via `_battlePlayer.BattleMgr.GetBattleCardIdx`. Engine tests were satisfied
  by the WireMulliganPhase seam; unit tests exposed the live-path gap.

## Ship state

- SVSim.BattleEngine.Tests: 56/56 pass, 2 skip
- SVSim.UnitTests: 1554/1554 pass (was 1523/31-fail before chunk 48)
- Solution build: 0 source warnings (40 pre-existing NU1902 MessagePack CVEs
  in SVSim.EmulatedEntrypoint, unrelated)
- Sequential PVP smoke: verified live (two back-to-back battles, no regression
  on cleanup/spinup)
- Concurrent PVP smoke: verified live

Adds tools/engine-port/ClosureAnalyzer/ — the Roslyn transitive-type-closure
analyzer needed to make future cascade cleanup safe (per feedback memory
"Engine cleanup needs closure tool" from the 2026-06-28 pass-3 failure).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-03 19:18:54 -04:00
parent 5c1db83967
commit 2d9a6eea4b
2045 changed files with 11704 additions and 158495 deletions

View File

@@ -175,10 +175,11 @@ namespace SVSim.BattleEngine.Tests
private static readonly object _processGlobalsGate = new object();
// 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).
// seed Certification.udid. Per-battle/per-test state (chara ids on the DataMgr,
// NetworkUserInfoData) is seeded on the mgr's own GameMgr via NewSeededSingleBattleMgr /
// NewSeededHeadlessBattleMgr / NewSeededHeadlessNetworkBattleMgr in the fixture entry points
// — no shared state to race here. Thread-safe (assembly-level Parallelizable(Fixtures) means
// many fixtures' [SetUp] race this one function).
public static void EnsureProcessGlobals()
{
if (_done) return;
@@ -233,39 +234,57 @@ namespace SVSim.BattleEngine.Tests
return deck;
}
// 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()
// Per-GameMgr chara-id seeder. Fixtures construct a GameMgr up-front and pass it to the
// mgr ctor (chunk-45 overload); this stamps the player/enemy leader chara ids on its DataMgr.
// Set the backing fields directly: the public SetPlayerCharaId() also pulls MyRotation /
// AvatarBattle info (more null statics) the resolution path doesn't need.
public static void SeedCharaIds(GameMgr gm)
{
// 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
// TryGet* accessors are null-tolerant).
var dm = GameMgr.GetIns().GetDataMgr();
var dm = gm.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()
// Per-GameMgr net-user seeder. NetworkBattleManagerBase.CreateBackgroundId reads
// gm.GetNetworkUserInfoData().GetFieldId() when the RecoveryManager yields no bg id; the bare
// ctor path leaves _netUser null (no lazy init). Seed a no-op instance whose _selfInfo carries
// fieldId=1 (== ForestField, valid). Only satisfies the bg lookup — no game-state effect.
public static void SeedNetUser(GameMgr gm)
{
// NetworkBattleManagerBase.CreateBackgroundId() (M13) reads
// GameMgr.GetIns().GetNetworkUserInfoData().GetFieldId() when the RecoveryManager yields no
// bg id (NullRecoveryManager.BackGroundId == -1). In production RealTimeNetworkAgent seeds
// this NetworkUserInfoData at match start; the bare construction path leaves GameMgr's
// _netUser null (no lazy init, unlike the other GodObject getters). Seed a no-op instance
// whose _selfInfo carries just "fieldId" (GetFieldId reads _selfInfo["fieldId"]); field id 1
// == ForestField, a valid background. Nothing here drives game state — it only satisfies the
// network mgr's background lookup, a background lookup the single-battle path
// (`SingleBattleMgr`) never performs.
var netUser = new NetworkUserInfoData();
netUser.SetSelfInfo(
new System.Collections.Generic.Dictionary<string, object> { ["fieldId"] = 1 },
isWatchReplayRecovery: false);
GameMgr.GetIns().SetNetworkUserInfoData(netUser);
gm.SetNetworkUserInfoData(netUser);
}
// Phase-5 chunk 46: canonical seeded SingleBattleMgr factory. Replaces the historical
// `new SingleBattleMgr(new HeadlessContentsCreator())` pattern from every oracle test,
// which relied on the ambient bridge to reach a chara-id/net-user-seeded GameMgr. Now the
// GameMgr is built + seeded here and passed to the mgr's chunk-45 ctor overload directly.
public static SingleBattleMgr NewSeededSingleBattleMgr()
{
var gm = new GameMgr();
SeedCharaIds(gm);
SeedNetUser(gm);
return new SingleBattleMgr(new HeadlessContentsCreator(), gm);
}
// Same idea for the RNG-injectable HeadlessBattleMgr / HeadlessNetworkBattleMgr twins.
public static HeadlessBattleMgr NewSeededHeadlessBattleMgr(IRandomSource rng = null)
{
var gm = new GameMgr();
SeedCharaIds(gm);
SeedNetUser(gm);
return new HeadlessBattleMgr(new HeadlessContentsCreator(), rng, gm);
}
public static HeadlessNetworkBattleMgr NewSeededHeadlessNetworkBattleMgr(IRandomSource rng = null)
{
var gm = new GameMgr();
SeedCharaIds(gm);
SeedNetUser(gm);
return new HeadlessNetworkBattleMgr(new HeadlessContentsCreator(), rng, gm);
}
// Seed each leader's starting life on a freshly-constructed mgr. The engine does this in
@@ -376,8 +395,16 @@ namespace SVSim.BattleEngine.Tests
public static HeadlessBattleMgr NewAuthoritativeBattle(IRandomSource rng)
{
EnsureProcessGlobals(); // sets IsForecast = true among other globals
BattleManagerBase.IsRandomDraw = true; // the second RNG gate (F-RNG-2)
var mgr = new HeadlessBattleMgr(new HeadlessContentsCreator(), rng);
// Phase-5 chunk 45: build a pre-seeded GameMgr and hand it to the mgr ctor, bypassing
// the ambient bridge. The base ctor's BattlePlayer construction reads chara-id/net-user
// through GameMgr.GetIns() (which routes to mgr.GameMgr once base ctor runs), so the
// GameMgr must be seeded BEFORE the mgr ctor completes.
var gm = new GameMgr();
SeedCharaIds(gm);
SeedNetUser(gm);
var mgr = new HeadlessBattleMgr(new HeadlessContentsCreator(), rng, gm);
// Phase-5 chunk 42: write InstanceIsRandomDraw directly (mgr not yet ambient-attached).
mgr.InstanceIsRandomDraw = true; // the second RNG gate (F-RNG-2)
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;
@@ -401,7 +428,11 @@ namespace SVSim.BattleEngine.Tests
NewNetworkEmitBattle(IRandomSource rng = null)
{
EnsureProcessGlobals(); // sets IsForecast = true among other globals
var mgr = new HeadlessNetworkBattleMgr(new HeadlessContentsCreator(), rng);
// Phase-5 chunk 45: build + seed a per-mgr GameMgr up-front (no ambient bridge).
var gm = new GameMgr();
SeedCharaIds(gm);
SeedNetUser(gm);
var mgr = new HeadlessNetworkBattleMgr(new HeadlessContentsCreator(), rng, gm);
// NOTE: IsRecovery is left FALSE here (unlike the solo NewAuthoritativeBattle). The network
// emit path is gated on !IsRecovery in BOTH places: NetworkStandardBattleMgr.SendPlayCard
// (NetworkStandardBattleMgr.cs:155) and the OnSetCardComplete->SendPlayCard subscription in
@@ -419,7 +450,10 @@ namespace SVSim.BattleEngine.Tests
// play exercises the real view layer — those view touches are satisfied by the no-op view shims
// (InitCardTemplates, the HandView/DetailPanel fills below). M3's damage is literal, immune to
// any play-count bump the OperateMgr path adds vs the direct path.
BattleManagerBase.IsForecast = false;
// Phase-5 chunk 42: mgr isn't yet attached to the ambient at this point (_scope.Ctx.Mgr
// is set by the caller after this fixture returns), so BattleManagerBase.IsForecast=false
// would silently no-op (ambient bridge is gone). Write InstanceIsForecast directly.
mgr.InstanceIsForecast = false;
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
SetField(player, "_opponentBattlePlayer", enemy);
@@ -447,28 +481,17 @@ namespace SVSim.BattleEngine.Tests
// public setter runs cleanly headless. Prepared (50) is the real enum member (RealTimeNetworkAgent.cs:35).
agent.SetCurrentMatchingStatus(RealTimeNetworkAgent.MatchingStatus.Prepared);
// EmitMsgPack -> AddActionSequence (RealTimeNetworkAgent.cs:1773, fired for the PlayActions URI)
// does `_gungnir._actionSequenceNum++` and `NetworkLogger.LogInfo(...)`. On the
// GetUninitializedObject agent both are null (the real ctor builds them at :289/:301). Seed an
// uninitialized Gungnir (its ctor news a ConnectionReporter + Ticks — unneeded; AddActionSequence
// only touches the int counter) and the engine's own NetworkNullLogger no-op so the action-seq
// bookkeeping runs without crashing. Neither drives game state.
SetField(agent, "_gungnir",
System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(Gungnir)));
// The engine RTA is a pass-7 stub with no-op method bodies. AddActionSequence /
// EmitMsgPack / OnEmit-plumbing were dropped; the historical Gungnir + _notEmit seeds
// are no longer needed. NetworkLogger stays as a NetworkNullLogger since it's a
// typed property (INetworkLogger<NetworkLog>) that some tests may still inspect.
SetProperty(agent, "NetworkLogger", new NetworkNullLogger());
// Suppress the actual socket transmission. After OnEmit fires (RealTimeNetworkAgent.cs:1270, the
// O1 liveness signal), EmitMsgPack -> EmitMsgUriPack reaches the stockEmitMessageMgr / _manager.Socket
// network I/O (RealTimeNetworkAgent.cs:1444+/1487) — none of which exists headless. The engine's
// OWN _notEmit flag (set in recovery/replay) short-circuits EmitMsgUriPack at :1438 BEFORE any of
// that, so the emit stays genuine (OnEmit already fired through the real send path) while the
// byte-push is skipped. This is the only honest way to terminate the path headless: we are NOT
// faking OnEmit, only declining to open a socket we cannot open.
SetField(agent, "_notEmit", true);
var emitted = new System.Collections.Generic.List<NetworkBattleDefine.NetworkBattleURI>();
agent.OnEmit += uri => emitted.Add(uri);
Wizard.ToolboxGame.SetRealTimeNetworkBattle(agent);
// Phase-5 chunk 40: ambient fallback removed from ToolboxGame.SetRealTimeNetworkBattle;
// fixture has mgr in scope, so seed it directly instead of via the ambient-routed static.
mgr.InstanceNetworkAgent = agent;
return (mgr, emitted);
}