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

@@ -3,6 +3,11 @@
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- NU1903 (MessagePack 2.5.172 has a moderate-severity vulnerability) is the only
audit hit at this revision. Suppress so MSBuildWorkspace (used by tools/engine-port/
ClosureAnalyzer) doesn't bail out before wiring the extern-alias project reference,
which silently breaks cross-project symbol resolution for this whole project. -->
<NoWarn>$(NoWarn);NU1903</NoWarn>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />

View File

@@ -186,10 +186,10 @@ public sealed class BattleSession
if (A is RealParticipant rpA) rpA.Outbound.Clear();
if (B is RealParticipant rpB) rpB.Outbound.Clear();
// Per-session BattleAmbientContext on the engine isolates per-battle state across concurrent
// sessions (Task 7 of multi-instancing migration), so the historical single-active-engine gate
// (and its matching try/finally Release) is gone — engine setup is unconditional per session,
// and there is no teardown obligation that must run on a throw from the participant tear-down.
// Per-session mgr instance (Phase-5 ambient rip, chunk 47) isolates per-battle state across
// concurrent sessions, so the historical single-active-engine gate (and its matching
// try/finally Release) is gone — engine setup is unconditional per session, and there is no
// teardown obligation that must run on a throw from the participant tear-down.
await Task.WhenAll(
A.TerminateAsync(BattleFinishReason.NormalFinish),
B.TerminateAsync(BattleFinishReason.NormalFinish))
@@ -278,10 +278,10 @@ public sealed class BattleSession
{
case NetworkBattleUri.Deal when !_engineDealFed:
_engineDealFed = true;
_log.LogWarning("BattleSession {Bid}: DEAL DIAG BEFORE: {Diag}",
_log.LogDebug("BattleSession {Bid}: DEAL diag BEFORE: {Diag}",
BattleId, _engine.DiagnoseDealState());
ShadowFeed(frame, isPlayerSeat: true, "Deal");
_log.LogWarning("BattleSession {Bid}: DEAL DIAG AFTER: {Diag}",
_log.LogDebug("BattleSession {Bid}: DEAL diag AFTER: {Diag}",
BattleId, _engine.DiagnoseDealState());
break;
@@ -338,12 +338,12 @@ public sealed class BattleSession
if (_engineSetupAttempted) return;
_engineSetupAttempted = true;
// Per-session BattleAmbientContext on the engine isolates per-battle state across concurrent
// sessions (Task 7 of multi-instancing migration), so the historical single-active-engine gate
// (EngineSessionGate.TryAcquire) is gone and engine setup is unconditional. A genuine setup
// failure still surfaces via ComputeFrames' shadow-engine try/catch (it logs + swallows so the
// relay never sees an engine exception, ND6), and IsReady stays false in that case so
// ShadowIngest/ShadowFeedServerFrames no-op for the rest of the battle.
// Per-session mgr instance (Phase-5 ambient rip, chunk 47) isolates per-battle state across
// concurrent sessions, so the historical single-active-engine gate (EngineSessionGate.TryAcquire)
// is gone and engine setup is unconditional. A genuine setup failure still surfaces via
// ComputeFrames' shadow-engine try/catch (it logs + swallows so the relay never sees an engine
// exception, ND6), and IsReady stays false in that case so ShadowIngest/ShadowFeedServerFrames
// no-op for the rest of the battle.
//
// Seed the engine's StableRandom with BattleSeeds.Stable(MasterSeed) — the SAME value the
// Matched frame ships to both clients (InitBattleHandler.cs:28). The clients seed their
@@ -362,6 +362,14 @@ public sealed class BattleSession
private void ShadowIngest(IBattleParticipant from, MsgEnvelope env)
{
if (!_engine.IsReady) return;
// Node-only handshake URIs (InitNetwork, InitBattle) have no counterpart on the engine's
// NetworkBattleURI enum — they're pure relay plumbing (see InitNetworkHandler /
// InitBattleHandler). Feeding them to the engine's MapUri throws ArgumentException that
// the outer try/catch swallows, but produces a per-frame warn+stack-trace for every
// battle setup. Skip at the ingest source rather than plumb an ignore-branch through the
// engine (the whole point of the shadow being separate is that node protocol changes
// shouldn't require engine touches).
if (env.Uri is NetworkBattleUri.InitNetwork or NetworkBattleUri.InitBattle) return;
bool isPlayerSeat = ReferenceEquals(from, A);
var r = _engine.Receive(env, isPlayerSeat);
if (r.Diverged)

View File

@@ -60,11 +60,9 @@ internal static class EngineGlobalInit
public static void EnsureInitialized()
{
// GameMgr is now per-session (BattleAmbientContext.GameMgr), so its DataMgr/NetworkUserInfoData
// wiring must run on EVERY call — once-per-process gating it (under _done) leaves a second-or-
// later session with an unwired ctx.GameMgr and NREs in NetworkBattleManagerBase.CreateBackgroundId.
// The wiring itself is idempotent (zero-or-null guards), so re-running is safe.
WirePerSessionGameMgr();
// Phase-5 chunk 46: per-session GameMgr wiring moved to SessionBattleEngine.SetupInternal
// where the session's own GameMgr is in scope. EnsureInitialized only handles the true
// process-globals below.
if (_done) return;
lock (_gate)
@@ -86,14 +84,15 @@ internal static class EngineGlobalInit
.SetValue(null, new Crossover());
}
// Suppress VFX / take the virtual-battle resolution path (no live view layer).
BattleManagerBase.IsForecast = true;
// Phase-5 chunk 43: was `BattleManagerBase.IsForecast = true` — a static ambient-bridge
// write that ran before any mgr was constructed, so it stashed in ambient.IsForecast for
// pre-Phase-5 propagation onto the mgr's InstanceIsForecast at attach. With chunk 42's
// ambient rip that propagation is gone and the write became a silent no-op. Init here is
// now redundant: BattleManagerBase.InstanceIsForecast = true by default.
// 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).
// BattleRecoveryInfo lives on the mgr instance itself (InstanceRecoveryInfo). Historical
// process-global write here was dead the moment the mgr's per-instance slot landed
// (Task 7 / Phase-5a).
// --- static CardMaster (full cards.json) ----------------------------------------------
// ALWAYS rebuild + re-inject the FULL master. We must not defer to a possibly-thin
@@ -121,46 +120,31 @@ internal static class EngineGlobalInit
udidField.SetValue(null, "headless-udid");
// --- Cute.Certification.viewer_id ------------------------------------------------------
// 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.
// Viewer id lives on the mgr instance (InstanceViewerId, default 1001 = ThisViewerId).
// Certification.ViewerId's static getter reads the mgr via GetIns()?.InstanceViewerId
// — no seeding needed here.
_done = true;
}
}
// Per-session GameMgr wiring: under the ambient seam, GameMgr.GetIns() resolves to the SESSION's
// BattleAmbientContext.GameMgr — a fresh instance per SessionBattleEngine. So the DataMgr chara ids
// and NetworkUserInfoData seeding must run on every Setup, not just process-once. The reflection
// helpers below are idempotent (SetFieldIfZeroOrNull is a no-op once set; netUser is null-guarded).
private static void WirePerSessionGameMgr()
// Per-session GameMgr wiring: seeds DataMgr chara ids + NetworkUserInfoData on the session's own
// GameMgr. Caller (SessionBattleEngine.SetupInternal) passes _gameMgr BEFORE mgr construction so
// the base ctor's BattlePlayer/DataMgr reads resolve. Idempotent (SetFieldIfZeroOrNull no-ops once
// set; netUser is null-guarded).
public static void WirePerSessionGameMgr(GameMgr gm)
{
// --- GameMgr DataMgr leader chara ids --------------------------------------------------
// Set the backing fields directly: the public SetPlayerCharaId() also pulls MyRotation/
// AvatarBattle info (more null statics) the resolution path doesn't need. Idempotent
// (plain assignment); only meaningful when still 0.
var dm = GameMgr.GetIns().GetDataMgr();
var dm = gm.GetDataMgr();
SetFieldIfZeroOrNull(dm, "_playerCharaId", PlayerCharaId);
SetFieldIfZeroOrNull(dm, "_enemyCharaId", EnemyCharaId);
// --- NetworkUserInfoData (background lookup on the network mgr's CreateBackgroundId) ----
// NetworkBattleManagerBase.CreateBackgroundId reads
// GameMgr.GetIns().GetNetworkUserInfoData().GetFieldId() when the RecoveryManager yields no
// bg id. GameMgr leaves _netUser null with no lazy init; seed a no-op instance whose
// _selfInfo carries just fieldId=1 (== ForestField, a valid background). Only seed when
// absent so a HeadlessEngineEnv-set instance is preserved.
if (GameMgr.GetIns().GetNetworkUserInfoData() == null)
if (gm.GetNetworkUserInfoData() == null)
{
var netUser = new NetworkUserInfoData();
netUser.SetSelfInfo(
new Dictionary<string, object> { ["fieldId"] = 1 },
isWatchReplayRecovery: false);
GameMgr.GetIns().SetNetworkUserInfoData(netUser);
gm.SetNetworkUserInfoData(netUser);
}
}

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);
}

View File

@@ -204,9 +204,23 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
{
if (_diagnosticLogging)
{
_log.LogWarning(
"[ws-loop-exit] viewer={Vid} reason={Reason} wsState={State} cancelled={Cancelled}",
ViewerId, exitReason, _ws.State, cancellation.IsCancellationRequested);
// Clean-teardown terminators (normal client close, or a null read after
// cancellation was requested) log at Info; anything else stays Warn so
// genuine crashes stay visible even with diagnostics on.
bool cleanExit = exitReason == "read-returned-null" &&
(cancellation.IsCancellationRequested || _ws.State is WebSocketState.CloseReceived or WebSocketState.Closed);
if (cleanExit)
{
_log.LogInformation(
"[ws-loop-exit] viewer={Vid} reason={Reason} wsState={State} cancelled={Cancelled}",
ViewerId, exitReason, _ws.State, cancellation.IsCancellationRequested);
}
else
{
_log.LogWarning(
"[ws-loop-exit] viewer={Vid} reason={Reason} wsState={State} cancelled={Cancelled}",
ViewerId, exitReason, _ws.State, cancellation.IsCancellationRequested);
}
}
}
}
@@ -495,7 +509,14 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
catch (OperationCanceledException)
{
if (_diagnosticLogging)
_log.LogWarning("[ws-recv-exit] viewer={Vid} reason=OperationCanceled wsState={State}", ViewerId, _ws.State);
{
// A cancellation-requested cancel is a normal session teardown; a spontaneous
// OperationCanceled without cancellation being requested is a real anomaly.
if (ct.IsCancellationRequested)
_log.LogInformation("[ws-recv-exit] viewer={Vid} reason=OperationCanceled wsState={State}", ViewerId, _ws.State);
else
_log.LogWarning("[ws-recv-exit] viewer={Vid} reason=OperationCanceled wsState={State}", ViewerId, _ws.State);
}
return null;
}
catch (WebSocketException wsex)
@@ -508,8 +529,15 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
if (result.MessageType == WebSocketMessageType.Close)
{
if (_diagnosticLogging)
_log.LogWarning("[ws-recv-exit] viewer={Vid} reason=ClientCloseFrame wsState={State} closeStatus={Status} desc={Desc}",
ViewerId, _ws.State, result.CloseStatus, result.CloseStatusDescription);
{
// NormalClosure ("Bye!") at battle-end is expected; abnormal close statuses stay Warn.
if (result.CloseStatus == WebSocketCloseStatus.NormalClosure)
_log.LogInformation("[ws-recv-exit] viewer={Vid} reason=ClientCloseFrame wsState={State} closeStatus={Status} desc={Desc}",
ViewerId, _ws.State, result.CloseStatus, result.CloseStatusDescription);
else
_log.LogWarning("[ws-recv-exit] viewer={Vid} reason=ClientCloseFrame wsState={State} closeStatus={Status} desc={Desc}",
ViewerId, _ws.State, result.CloseStatus, result.CloseStatusDescription);
}
return null;
}
ms.Write(buffer, 0, result.Count);