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)
|
||||
|
||||
17
SVSim.BattleNode/Sessions/Engine/EngineSessionGate.cs
Normal file
17
SVSim.BattleNode/Sessions/Engine/EngineSessionGate.cs
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user