feat(battlenode): per-session charaId + single-active-engine gate (Phase 2 N2 carried-risk B)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-06 16:35:42 -04:00
parent 6e8af4e68b
commit eb52890251
3 changed files with 89 additions and 10 deletions

View File

@@ -35,6 +35,11 @@ public sealed class BattleSession
/// never retried, never fatal.</summary>
private bool _engineSetupAttempted;
/// <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
@@ -169,14 +174,24 @@ public sealed class BattleSession
if (A is RealParticipant rpA) rpA.Outbound.Clear();
if (B is RealParticipant rpB) rpB.Outbound.Clear();
await Task.WhenAll(
A.TerminateAsync(BattleFinishReason.NormalFinish),
B.TerminateAsync(BattleFinishReason.NormalFinish))
.ConfigureAwait(false);
try
{
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();
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();
}
}
private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct);
@@ -240,7 +255,22 @@ public sealed class BattleSession
{
if (_engineSetupAttempted) return;
_engineSetupAttempted = true;
_engine.Setup(_state.MasterSeed, _state.GetShuffledDeck(A), _state.GetShuffledDeck(B));
// 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;
_engine.Setup(_state.MasterSeed,
_state.GetShuffledDeck(A), _state.GetShuffledDeck(B),
(int)A.Context.ClassId, (int)B.Context.ClassId);
}
private void ShadowIngest(IBattleParticipant from, MsgEnvelope env)