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

@@ -1,6 +1,4 @@
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;
@@ -18,7 +16,6 @@ using SBattleLoad = engine::SBattleLoad;
using CardTemplate = engine::CardTemplate;
using GameObject = engine::UnityEngine.GameObject;
using RealTimeNetworkAgent = engine::RealTimeNetworkAgent;
using Gungnir = engine::Gungnir;
using NetworkNullLogger = engine::NetworkNullLogger;
using ToolboxGame = engine::Wizard.ToolboxGame;
using GameMgr = engine::GameMgr;
@@ -49,20 +46,13 @@ internal sealed class SessionBattleEngine
{
private const int DefaultLeaderLife = 20;
private readonly BattleAmbientContext _ctx = new() {
ViewerId = EngineGlobalInit.ThisViewerId,
IsForecast = true,
IsRandomDraw = true,
// Per-session BattleRecoveryInfo: the receive-conductor deal path runs under IsRecovery
// (set after mgr construction below) and reads Data.BattleRecoveryInfo.IsMulliganEnd in
// MulliganMgrBase.StartDeal — null reads NRE. Each session owns its own no-op instance with
// IsMulliganEnd=false (the default); GetUninitializedObject skips the JsonData ctor. Each
// SessionBattleEngine carries its own ambient _ctx, so per-session isolation is by construction
// (the EngineGlobalInit fallback only seeded once-per-process and silently fell over for the
// second + later session that entered a fresh ambient — diagnosed Task 7).
RecoveryInfo = (engine::Wizard.BattleRecoveryInfo)FormatterServices
.GetUninitializedObject(typeof(engine::Wizard.BattleRecoveryInfo)),
};
// Phase-5 chunk 47: was `BattleAmbientContext _ctx` — an ambient-scoped bundle of
// per-session state (GameMgr / ViewerId / IsForecast / IsRandomDraw / RecoveryInfo /
// NetworkAgent) that engine code read via BattleAmbient.Current. The ambient is dead
// (chunk 46 made GetIns() return null); everything that was on _ctx now lives on the mgr
// instance directly. What remains is the per-session GameMgr, kept as a plain field so it
// can be seeded (WirePerSessionGameMgr) BEFORE the mgr ctor reads it via the (cc, gm) overload.
private readonly GameMgr _gameMgr = new();
private HeadlessNetworkBattleMgr? _mgr;
private NetworkBattleReceiver? _receiver;
@@ -77,16 +67,15 @@ internal sealed class SessionBattleEngine
/// the <c>CardClass</c> int value); they select the leader's class via the all-8-class
/// ClassCharacterList EngineGlobalInit installs (chara_id == class_id for 1..8). The 3-arg overload
/// behavior is preserved by the defaults (1/2), matching the test-harness charaIds.
/// <para>NOTE: GameMgr is now per-session via <see cref="BattleAmbientContext.GameMgr"/>; the leader
/// chara ids are set on the SESSION's GameMgr (resolved through the ambient by
/// <c>EngineGlobalInit.WirePerSessionGameMgr</c>), not on a process-wide singleton. This is the Task-7
/// payoff: concurrent sessions each own their own GameMgr + engine state, so the historical
/// single-active-engine gate (deleted EngineSessionGate) is no longer needed.</para></summary>
/// <para>NOTE: GameMgr is per-session on the mgr instance (Phase-5 ambient rip, chunk 47); leader
/// chara ids are set on the SESSION's <c>_gameMgr</c> (seeded pre-ctor by
/// <c>EngineGlobalInit.WirePerSessionGameMgr</c>), not on a process-wide singleton. This is the
/// multi-instancing payoff: concurrent sessions each own their own GameMgr + engine state, so the
/// historical single-active-engine gate (deleted EngineSessionGate) is no longer needed.</para></summary>
public void Setup(int masterSeed,
IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck,
int seatAClass = 1, int seatBClass = 2)
{
using var _ambient = BattleAmbient.Enter(_ctx);
SetupInternal(masterSeed, seatADeck, seatBDeck, seatAClass, seatBClass, rng: null);
}
@@ -100,7 +89,6 @@ 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.
@@ -120,12 +108,29 @@ internal sealed class SessionBattleEngine
// here rather than throwing into the shadow's no-op path (Phase 2 N2, carried-risk A).
EngineGlobalInit.EnsureInitialized();
// Phase-5 chunk 46: seed the session's own GameMgr (chara ids + net-user) BEFORE mgr
// construction so the base ctor's BattlePlayer/DataMgr reads resolve. Was piggybacked on
// EnsureInitialized via ambient reach; now explicit and per-session.
EngineGlobalInit.WirePerSessionGameMgr(_gameMgr);
// rng defaults to SeededRandomSource(masterSeed) inside the mgr — masterSeed here is the
// engine's StableRandom seed (parameter name preserved for API compatibility; callers pass
// BattleSeeds.Stable(rootMasterSeed) so the stream is born aligned with the seed the node
// ships to both clients in Matched.seed). F-N-5; O-N-2 "bit-aligned anyway".
var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed), rng);
// Phase-5 chunk 45: seed the mgr's GameMgr via _ctx (the session's ambient-attached GameMgr)
// and hand it to the mgr's pre-seeded ctor. Eliminates the "ambient reaches into mgr ctor"
// step; equivalent behavior since _gameMgr is the same instance the node reads later.
var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed), rng, _gameMgr);
if (mgrBox is not null) mgrBox[0] = mgr; // publish for the test roll-logger closure (DebugSetupWithRollLog)
// Publish the mgr on the ambient EARLY (Phase 5a, 2026-07-02). Pre-Phase-5a the ambient
// stored NetworkAgent/RecoveryInfo/IsForecast/etc. directly, so late-attach was fine.
// Post-5a those fields are instance-backed on the mgr, and every accessor routes through
// BattleManagerBase.GetIns() → ambient.Mgr — so any subsequent setup call that reaches for
// one of them (InstallHeadlessNetworkAgent, SetGameMgrCharaIds, etc.) needs the mgr already
// attached. The later `_mgr = mgr; // Phase-5 chunk 47: was _ctx.Mgr = _mgr — ambient publish is dead.` line is now a no-op re-assignment;
// kept there for readability at the "wire mulligan" seam it originally guarded.
// Phase-5 chunk 47: was _ctx.Mgr = mgr — ambient publish is dead (GetIns() returns null unconditionally).
// Recovery mode is the engine's OWN headless replay path: the live view/UI touches on the
// receive cycle (BattleUIContainer.DisableMenu, turn-control UI, card-view creation, VFX
// waits) are all gated `!IsRecovery` (BattleUIContainer.cs:130, BattleManagerBase.cs:1499+),
@@ -157,18 +162,17 @@ internal sealed class SessionBattleEngine
// (BattleManagerBase.cs:1110), routing LotteryRandomDrawCard through seeded StableRandom
// instead of top-of-deck. Without it the shadow draws DeckCardList[0] every time while
// clients draw seeded-random — desynchronizing the hand and every downstream field.
BattleManagerBase.IsRandomDraw = true;
mgr.InstanceIsRandomDraw = true; // Phase-5 chunk 43: was BattleManagerBase.IsRandomDraw static
InitLeaderLife(mgr); // a 0-life leader reads as game-over and silently blocks plays
InitCardTemplates(mgr); // play/draw resolution touches the (no-op) card view layer
InitHeadlessViews(mgr); // turn/play cycle dereferences UI-container + emotion refs
SeedBattleLogManager(); // per-frame filter cleanup reads BattleLogManager fusion lists
InstallHeadlessNetworkAgent(); // turn-flow resolve reads ToolboxGame.RealTimeNetworkAgent
InstallHeadlessNetworkAgent(mgr); // turn-flow resolve reads mgr.InstanceNetworkAgent
// Per-session leader class: chara_id == class_id for 1..8 in the all-8-class ClassCharacterList,
// so writing the seats' class ordinals into the SESSION's GameMgr DataMgr (resolved through the
// ambient — see Setup remarks) resolves each leader's correct class.
SetGameMgrCharaIds(seatAClass, seatBClass);
SetGameMgrCharaIds(mgr.GameMgr, seatAClass, seatBClass);
SeedDeck(mgr, seatADeck, isPlayer: true);
SeedDeck(mgr, seatBDeck, isPlayer: false);
@@ -179,7 +183,7 @@ internal sealed class SessionBattleEngine
// 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;
// Phase-5 chunk 47: was _ctx.Mgr = _mgr — ambient publish is dead.
WireMulliganPhase(mgr); // wire OperateReceive.OnReceiveDeal -> StartDeal (deal seats the hand)
@@ -194,7 +198,6 @@ 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.");
@@ -364,25 +367,25 @@ internal sealed class SessionBattleEngine
// when _mgr is null (Setup failed and the ComputeFrames try/catch swallowed it, ND6), so a
// non-engine session never crashes. Production handlers read ONLY that band.
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; }
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;
/// <summary>Followers in play, excluding the leader (the Class card occupies one slot of
/// ClassAndInPlayCardList).</summary>
public int BoardCount(bool playerSeat) { using var _ambient = BattleAmbient.Enter(_ctx); return Math.Max(0, Seat(playerSeat).ClassAndInPlayCardList.Count - 1); }
public int BoardCount(bool playerSeat) => 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) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).HandCardList[handPos].Index; }
public int HandCardIndex(bool playerSeat, int handPos) => 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) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).HandCardList[handPos].CardId; }
public int HandCardId(bool playerSeat, int handPos) => 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
@@ -391,7 +394,6 @@ internal sealed class SessionBattleEngine
/// engine-resolved actual card carries the wire cardId.</summary>
public int InPlayCardId(bool playerSeat, int boardPos)
{
using var _ambient = BattleAmbient.Enter(_ctx);
return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].CardId;
}
@@ -401,7 +403,6 @@ internal sealed class SessionBattleEngine
/// a follower resolves onto the board to build the attack (M-HC-4a).</summary>
public int InPlayCardIndex(bool playerSeat, int boardPos)
{
using var _ambient = BattleAmbient.Enter(_ctx);
return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Index;
}
@@ -410,7 +411,6 @@ internal sealed class SessionBattleEngine
/// 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)
{
using var _ambient = BattleAmbient.Enter(_ctx);
return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Life;
}
@@ -418,7 +418,6 @@ internal sealed class SessionBattleEngine
/// <see cref="BattleCardBase.Atk"/>). The damage it deals when it attacks.</summary>
public int InPlayCardAtk(bool playerSeat, int boardPos)
{
using var _ambient = BattleAmbient.Enter(_ctx);
return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Atk;
}
@@ -427,7 +426,6 @@ internal sealed class SessionBattleEngine
/// false — the "attacker is spent" assertion (M-HC-4a).</summary>
public bool InPlayCardAttackable(bool playerSeat, int boardPos)
{
using var _ambient = BattleAmbient.Enter(_ctx);
return Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Attackable;
}
@@ -438,7 +436,6 @@ internal sealed class SessionBattleEngine
/// (M-HC-4b).</summary>
public bool IsEvolved(bool playerSeat, int boardPos)
{
using var _ambient = BattleAmbient.Enter(_ctx);
return (Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1] as UnitBattleCard)?.IsEvolution ?? false;
}
@@ -446,12 +443,12 @@ internal sealed class SessionBattleEngine
/// 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) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).CurrentEpCount; }
public int EpCount(bool playerSeat) => 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) { using var _ambient = BattleAmbient.Enter(_ctx); return Seat(playerSeat).EvolveWaitTurnCount; }
public int EvolveWaitTurnCount(bool playerSeat) => 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 —
@@ -471,7 +468,6 @@ 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;
@@ -497,7 +493,6 @@ 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;
@@ -521,7 +516,6 @@ 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;
@@ -537,7 +531,6 @@ 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;
@@ -561,7 +554,6 @@ internal sealed class SessionBattleEngine
/// entry), so this path must hand back a 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;
@@ -599,7 +591,6 @@ 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)
@@ -623,19 +614,18 @@ internal sealed class SessionBattleEngine
/// engine SKIPS the reshuffle the real clients performed.</summary>
internal bool SelfXorShiftActive
{
get { using var _ambient = BattleAmbient.Enter(_ctx); return (_mgr?.XorShiftRandom(isSelf: true)?.IsActive) ?? false; }
get => (_mgr?.XorShiftRandom(isSelf: true)?.IsActive) ?? false;
}
/// <summary>TEST/DEBUG: same as <see cref="SelfXorShiftActive"/> for the OPPONENT seat.</summary>
internal bool OppoXorShiftActive
{
get { using var _ambient = BattleAmbient.Enter(_ctx); return (_mgr?.XorShiftRandom(isSelf: false)?.IsActive) ?? false; }
get => (_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;
@@ -653,7 +643,6 @@ internal sealed class SessionBattleEngine
/// after feeding the Ready.</summary>
internal void SeedOppoIdxChange(int oppoSeed)
{
using var _ambient = BattleAmbient.Enter(_ctx);
_mgr?.CreateXorShift(-1, oppoSeed);
}
@@ -662,7 +651,6 @@ 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);
}
@@ -674,8 +662,9 @@ internal sealed class SessionBattleEngine
/// [NonParallelizable].</summary>
internal void DebugSetRandomDraw(bool value)
{
using var _ambient = BattleAmbient.Enter(_ctx);
BattleManagerBase.IsRandomDraw = value;
// Phase-5 chunk 43: was ambient-scoped BattleManagerBase.IsRandomDraw = value; now
// writes the mgr instance directly. _mgr is the session's authoritative mgr.
if (_mgr is not null) _mgr.InstanceIsRandomDraw = value;
}
/// <summary>TEST/DEBUG (Phase 4 draw-recompute hypothesis): advance the SHARED <c>_stableRandom</c>
@@ -685,7 +674,6 @@ 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();
}
@@ -699,7 +687,6 @@ 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();
}
@@ -712,7 +699,6 @@ internal sealed class SessionBattleEngine
/// <c>add.idx</c>. A stale value of 0 causes tokens to take Index 0, 1, ... and collide.</summary>
internal int DebugCardTotalNum(bool playerSeat)
{
using var _ambient = BattleAmbient.Enter(_ctx);
return _mgr is null ? -1 : _mgr.GetBattlePlayer(playerSeat).cardTotalNum;
}
@@ -723,8 +709,7 @@ internal sealed class SessionBattleEngine
{
get
{
using var _ambient = BattleAmbient.Enter(_ctx);
return _mgr is null ? -1
return _mgr is null ? -1
: (int)(typeof(BattleManagerBase)
.GetField("stableRandomCount", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(_mgr) ?? -1);
@@ -832,7 +817,7 @@ internal sealed class SessionBattleEngine
// MulliganInfoControl. Node seed (allowed); the control is never shown/updated headless.
var prefab = new GameObject();
SeedMulliganInfoControl(prefab);
var prefabData = GameMgr.GetIns().GetPrefabMgr().GetPrefabData();
var prefabData = mgr.GameMgr.GetPrefabMgr().GetPrefabData();
prefabData["Prefab/UI/MulliganInfo"] = prefab;
var phase = new NetworkMulliganPhase(mgr, mgr.NetworkSender);
@@ -910,44 +895,30 @@ internal sealed class SessionBattleEngine
return card;
}
// The per-frame skill-filter cleanup (BattleManagerBase.RemoveUnUseCalledFilterDictionary, run on
// EVERY receive) reads BattleLogManager.GetInstance().EnemyFusionCard.Contains(...) when a card with a
// registered CalledCreateFilter is alive — e.g. a follower with a when_play spell_charge/fanfare skill
// (BattleManagerBase.cs:155). The shim BattleLogManager singleton leaves PlayerFusionCard/EnemyFusionCard
// null (no UI ran SetUp), so that .Contains NREs. Seed both to empty lists — a pure no-op view-state
// seed (the fusion log is cosmetic; nothing headless adds to it). Process-global like the other seeds.
private static void SeedBattleLogManager()
{
var log = BattleLogManager.GetInstance();
log.PlayerFusionCard ??= new List<BattleCardBase>();
log.EnemyFusionCard ??= new List<BattleCardBase>();
}
// The turn-flow + emit bookkeeping reads the global ToolboxGame.RealTimeNetworkAgent (e.g.
// RealTimeNetworkAgent.GetIsFirstPlayer/GetTurnState, which delegate to GameMgr's
// NetworkUserInfoData.TurnState; AddActionSequence touches _gungnir). Headless there is no socket
// agent, so seed a no-op one — mirroring HeadlessFixture.NewNetworkEmitBattle. _notEmit short-
// circuits the byte-push before any socket I/O; the shadow engine never originates a send anyway.
// NetworkUserInfoData.TurnState). Headless there is no socket agent, so seed a no-op one —
// mirroring HeadlessFixture.NewNetworkEmitBattle. Since the engine RTA is now a stub with
// no-op method bodies (pass-7 engine cleanup), the historical internal seeds (_gungnir field,
// _notEmit short-circuit) are no longer needed — the stub can't NRE inside its own methods.
// NOTE: this is a process-global; one engine per process is assumed for the shadow (revisit for
// live multi-session — see design O-N status). Idempotent enough for the per-battle setup.
private static void InstallHeadlessNetworkAgent()
private static void InstallHeadlessNetworkAgent(HeadlessNetworkBattleMgr mgr)
{
var agent = (RealTimeNetworkAgent)FormatterServices.GetUninitializedObject(typeof(RealTimeNetworkAgent));
agent.SetCurrentMatchingStatus(RealTimeNetworkAgent.MatchingStatus.Prepared);
SetField(agent, "_gungnir", FormatterServices.GetUninitializedObject(typeof(Gungnir)));
SetProperty(agent, "NetworkLogger", new NetworkNullLogger());
SetField(agent, "_notEmit", true);
ToolboxGame.SetRealTimeNetworkBattle(agent);
mgr.InstanceNetworkAgent = agent; // Phase-5 chunk 41: was ToolboxGame.SetRealTimeNetworkBattle(agent)
}
// Write the two seats' class ordinals into the SESSION's GameMgr DataMgr leader chara ids. Mirrors
// the test seam HeadlessFixture.cs:202-204 (SetField(dm, "_playerCharaId"/"_enemyCharaId", ...)).
// chara_id == class_id for 1..8 in EngineGlobalInit's all-8-class ClassCharacterList, so the ordinal
// selects the class. A non-positive ordinal (e.g. CardClass.None == 0) clamps to the default seat
// (1/2). GameMgr is per-session (BattleAmbientContext.GameMgr); writes resolve through the ambient.
private static void SetGameMgrCharaIds(int a, int b)
// the test seam HeadlessFixture.cs (SetField(dm, "_playerCharaId"/"_enemyCharaId", ...)). chara_id
// == class_id for 1..8 in EngineGlobalInit's all-8-class ClassCharacterList, so the ordinal selects
// the class. A non-positive ordinal (e.g. CardClass.None == 0) clamps to the default seat (1/2).
// GameMgr is per-session (mgr.GameMgr, seeded pre-ctor from _gameMgr).
private static void SetGameMgrCharaIds(GameMgr gm, int a, int b)
{
var dm = GameMgr.GetIns().GetDataMgr();
var dm = gm.GetDataMgr();
SetField(dm, "_playerCharaId", a <= 0 ? 1 : a);
SetField(dm, "_enemyCharaId", b <= 0 ? 2 : b);
}