diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs
index 6c02f06..7d14a93 100644
--- a/SVSim.BattleNode/Sessions/BattleSession.cs
+++ b/SVSim.BattleNode/Sessions/BattleSession.cs
@@ -35,6 +35,11 @@ public sealed class BattleSession
/// never retried, never fatal.
private bool _engineSetupAttempted;
+ /// True once this session has acquired the process-wide
+ /// (and is therefore the single active engine owner). Drives the matching Release at battle
+ /// end so the next session can take the engine.
+ private bool _engineOwned;
+
/// Serializes dispatch. Both participants' read loops raise FrameEmitted on their own
/// threads, and a dispatch ( + the relay PushAsync calls) mutates
/// shared, non-thread-safe state — the 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)
diff --git a/SVSim.BattleNode/Sessions/Engine/EngineSessionGate.cs b/SVSim.BattleNode/Sessions/Engine/EngineSessionGate.cs
new file mode 100644
index 0000000..ff25e51
--- /dev/null
+++ b/SVSim.BattleNode/Sessions/Engine/EngineSessionGate.cs
@@ -0,0 +1,17 @@
+namespace SVSim.BattleNode.Sessions.Engine;
+
+/// Process-wide single-active-engine gate. The engine's process-global turn state
+/// (ToolboxGame.RealTimeNetworkAgent, GameMgr) cannot safely back two concurrent battles, so exactly
+/// one BattleSession may own the engine at a time (AskUserQuestion 2026-06-06: serialize + document).
+/// A session that cannot acquire runs WITHOUT the engine and logs it loudly — NOT a silent fallback
+/// (the operator sees the limitation). Per-session isolation (removing this gate) is the tracked
+/// follow-up. Non-blocking TryAcquire keeps it out of the synchronous dispatch path; in local
+/// single-user dev battles are sequential, so contention never arises.
+internal static class EngineSessionGate
+{
+ private static int _owned; // 0 = free, 1 = owned
+
+ public static bool TryAcquire() => System.Threading.Interlocked.CompareExchange(ref _owned, 1, 0) == 0;
+
+ public static void Release() => System.Threading.Interlocked.Exchange(ref _owned, 0);
+}
diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs
index 708cc4e..239fe72 100644
--- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs
+++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs
@@ -17,6 +17,7 @@ using RealTimeNetworkAgent = engine::RealTimeNetworkAgent;
using Gungnir = engine::Gungnir;
using NetworkNullLogger = engine::NetworkNullLogger;
using ToolboxGame = engine::Wizard.ToolboxGame;
+using GameMgr = engine::GameMgr;
using BattleUIContainer = engine::BattleUIContainer;
using BackGroundBase = engine::BackGroundBase;
using NullPlayerEmotion = engine::Wizard.Battle.Player.Emotion.NullPlayerEmotion;
@@ -45,10 +46,23 @@ internal sealed class SessionBattleEngine
/// Construct the two-seat network battle from both decks + the master seed (design F-N-5).
/// / are the per-side deck orders the node
- /// already computed (BattleSessionState.GetShuffledDeck) and handed each client.
+ /// already computed (BattleSessionState.GetShuffledDeck) and handed each client.
+ /// / are each seat's class ordinal (1..8,
+ /// the CardClass 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.
+ /// NOTE: GameMgr (the leader chara ids set below) is a PROCESS GLOBAL. Setting per-session
+ /// chara ids is therefore only safe while exactly one engine-backed battle exists at a time — the
+ /// invariant enforces on the caller side.
public void Setup(int masterSeed,
- IReadOnlyList seatADeck, IReadOnlyList seatBDeck)
+ IReadOnlyList seatADeck, IReadOnlyList seatBDeck,
+ int seatAClass = 1, int seatBClass = 2)
{
+ // Prime the engine's process-global statics (CardMaster, Wizard.Data, all-8-class Master,
+ // GameMgr/netUser/udid). Idempotent (process-once); makes the LIVE host ready so Setup succeeds
+ // here rather than throwing into the shadow's no-op path (Phase 2 N2, carried-risk A).
+ EngineGlobalInit.EnsureInitialized();
+
// rng defaults to SeededRandomSource(masterSeed) inside the mgr — the stream is born aligned
// with the seed the node handed both clients (F-N-5; O-N-2 "bit-aligned anyway").
var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed));
@@ -75,6 +89,11 @@ internal sealed class SessionBattleEngine
InitHeadlessViews(mgr); // turn/play cycle dereferences UI-container + emotion refs
InstallHeadlessNetworkAgent(); // turn-flow resolve reads ToolboxGame.RealTimeNetworkAgent
+ // Per-session leader class: chara_id == class_id for 1..8 in the all-8-class ClassCharacterList,
+ // so writing the seats' class ordinals into GameMgr's DataMgr resolves each leader's correct
+ // class. Process-global — safe only under EngineSessionGate (see method remarks above).
+ SetGameMgrCharaIds(seatAClass, seatBClass);
+
SeedDeck(mgr, seatADeck, isPlayer: true);
SeedDeck(mgr, seatBDeck, isPlayer: false);
@@ -238,6 +257,19 @@ internal sealed class SessionBattleEngine
ToolboxGame.SetRealTimeNetworkBattle(agent);
}
+ // Write the two seats' class ordinals into GameMgr's 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 a process global → safe only under EngineSessionGate (one engine-backed battle at a
+ // time).
+ private static void SetGameMgrCharaIds(int a, int b)
+ {
+ var dm = GameMgr.GetIns().GetDataMgr();
+ SetField(dm, "_playerCharaId", a <= 0 ? 1 : a);
+ SetField(dm, "_enemyCharaId", b <= 0 ? 2 : b);
+ }
+
private static void SetField(object obj, string name, object value)
{
var f = obj.GetType().GetField(name,