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)

View File

@@ -0,0 +1,17 @@
namespace SVSim.BattleNode.Sessions.Engine;
/// <summary>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.</summary>
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);
}

View File

@@ -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
/// <summary>Construct the two-seat network battle from both decks + the master seed (design F-N-5).
/// <paramref name="seatADeck"/>/<paramref name="seatBDeck"/> are the per-side deck orders the node
/// already computed (BattleSessionState.GetShuffledDeck) and handed each client.</summary>
/// already computed (BattleSessionState.GetShuffledDeck) and handed each client.
/// <paramref name="seatAClass"/>/<paramref name="seatBClass"/> are each seat's class ordinal (1..8,
/// the <c>CardClass</c> 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.
/// <para>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 <see cref="EngineSessionGate"/> enforces on the caller side.</para></summary>
public void Setup(int masterSeed,
IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck)
IReadOnlyList<long> seatADeck, IReadOnlyList<long> 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,