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,