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>
|
/// never retried, never fatal.</summary>
|
||||||
private bool _engineSetupAttempted;
|
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
|
/// <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
|
/// 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
|
/// 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 (A is RealParticipant rpA) rpA.Outbound.Clear();
|
||||||
if (B is RealParticipant rpB) rpB.Outbound.Clear();
|
if (B is RealParticipant rpB) rpB.Outbound.Clear();
|
||||||
|
|
||||||
await Task.WhenAll(
|
try
|
||||||
A.TerminateAsync(BattleFinishReason.NormalFinish),
|
{
|
||||||
B.TerminateAsync(BattleFinishReason.NormalFinish))
|
await Task.WhenAll(
|
||||||
.ConfigureAwait(false);
|
A.TerminateAsync(BattleFinishReason.NormalFinish),
|
||||||
|
B.TerminateAsync(BattleFinishReason.NormalFinish))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await A.DisposeAsync().ConfigureAwait(false);
|
await A.DisposeAsync().ConfigureAwait(false);
|
||||||
await B.DisposeAsync().ConfigureAwait(false);
|
await B.DisposeAsync().ConfigureAwait(false);
|
||||||
_dispatchGate.Dispose();
|
_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);
|
private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct);
|
||||||
@@ -240,7 +255,22 @@ public sealed class BattleSession
|
|||||||
{
|
{
|
||||||
if (_engineSetupAttempted) return;
|
if (_engineSetupAttempted) return;
|
||||||
_engineSetupAttempted = true;
|
_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)
|
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 Gungnir = engine::Gungnir;
|
||||||
using NetworkNullLogger = engine::NetworkNullLogger;
|
using NetworkNullLogger = engine::NetworkNullLogger;
|
||||||
using ToolboxGame = engine::Wizard.ToolboxGame;
|
using ToolboxGame = engine::Wizard.ToolboxGame;
|
||||||
|
using GameMgr = engine::GameMgr;
|
||||||
using BattleUIContainer = engine::BattleUIContainer;
|
using BattleUIContainer = engine::BattleUIContainer;
|
||||||
using BackGroundBase = engine::BackGroundBase;
|
using BackGroundBase = engine::BackGroundBase;
|
||||||
using NullPlayerEmotion = engine::Wizard.Battle.Player.Emotion.NullPlayerEmotion;
|
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).
|
/// <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
|
/// <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,
|
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
|
// 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").
|
// with the seed the node handed both clients (F-N-5; O-N-2 "bit-aligned anyway").
|
||||||
var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed));
|
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
|
InitHeadlessViews(mgr); // turn/play cycle dereferences UI-container + emotion refs
|
||||||
InstallHeadlessNetworkAgent(); // turn-flow resolve reads ToolboxGame.RealTimeNetworkAgent
|
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, seatADeck, isPlayer: true);
|
||||||
SeedDeck(mgr, seatBDeck, isPlayer: false);
|
SeedDeck(mgr, seatBDeck, isPlayer: false);
|
||||||
|
|
||||||
@@ -238,6 +257,19 @@ internal sealed class SessionBattleEngine
|
|||||||
ToolboxGame.SetRealTimeNetworkBattle(agent);
|
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)
|
private static void SetField(object obj, string name, object value)
|
||||||
{
|
{
|
||||||
var f = obj.GetType().GetField(name,
|
var f = obj.GetType().GetField(name,
|
||||||
|
|||||||
Reference in New Issue
Block a user