refactor(engine-ambient): wrap residual UnitTests + delete EngineSessionGate

Step 7 of multi-instancing migration. Residual SVSim.UnitTests that touch
engine code directly are wrapped in TestBattleScope. EngineSessionGate is
deleted along with the _engineOwned bookkeeping in BattleSession; engine
setup is unconditional now that per-battle state is isolated on the ambient.
Gate-specific fallback branches in BattleSession.ShadowIngest are simplified.

Suite fully green (SVSim.UnitTests, SVSim.BattleEngine.Tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-07 22:43:18 -04:00
parent 8af1be6555
commit 9e93a7b198
6 changed files with 57 additions and 80 deletions

View File

@@ -52,11 +52,6 @@ public sealed class BattleSession
/// was already processed during the Swap feed.</summary>
private bool _engineReadyFed;
/// <summary>True once this session has acquired the process-wide <see cref="Engine.EngineSessionGate"/>
/// (and is therefore the single active engine owner). Drives the matching <c>Release</c> at battle
/// end so the next session can take the engine.</summary>
private bool _engineOwned;
/// <summary>Serializes dispatch. Both participants' read loops raise FrameEmitted on their own
/// threads, and a dispatch (<see cref="ComputeFrames"/> + the relay <c>PushAsync</c> calls) mutates
/// shared, non-thread-safe state — the <see cref="BattleSessionState"/> dictionaries and each
@@ -191,24 +186,18 @@ public sealed class BattleSession
if (A is RealParticipant rpA) rpA.Outbound.Clear();
if (B is RealParticipant rpB) rpB.Outbound.Clear();
try
{
await Task.WhenAll(
A.TerminateAsync(BattleFinishReason.NormalFinish),
B.TerminateAsync(BattleFinishReason.NormalFinish))
.ConfigureAwait(false);
// 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.
await Task.WhenAll(
A.TerminateAsync(BattleFinishReason.NormalFinish),
B.TerminateAsync(BattleFinishReason.NormalFinish))
.ConfigureAwait(false);
await A.DisposeAsync().ConfigureAwait(false);
await B.DisposeAsync().ConfigureAwait(false);
_dispatchGate.Dispose();
}
finally
{
// Release the single-active-engine gate exactly once, in a finally so a throw from the
// terminate/dispose teardown above can never leak it to the next session (the gate is a
// process-global static shared across all sessions, incl. across tests in one process).
if (_engineOwned) Engine.EngineSessionGate.Release();
}
await A.DisposeAsync().ConfigureAwait(false);
await B.DisposeAsync().ConfigureAwait(false);
_dispatchGate.Dispose();
}
private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct);
@@ -349,18 +338,13 @@ public sealed class BattleSession
if (_engineSetupAttempted) return;
_engineSetupAttempted = true;
// Single-active-engine gate: the engine's process-global turn state can't back two concurrent
// battles, so only one session may own it (carried-risk B). On failure we DON'T set the engine
// up — it stays not-ready and ShadowIngest no-ops on !IsReady — and log the limitation loudly
// (not a silent fallback). Per-session isolation (dropping the gate) is the tracked follow-up.
if (!Engine.EngineSessionGate.TryAcquire())
{
_log.LogWarning("BattleSession {Bid}: another battle owns the engine; this battle runs " +
"WITHOUT engine-sourced fields (single-active-engine limitation — per-session isolation pending)",
BattleId);
return;
}
_engineOwned = 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.
//
// 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
// System.Random with Matched.seed (BattleManagerBase.cs:721), so the engine's stream must