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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user