feat(battlenode): inject SessionBattleEngine into BattleSession in pure shadow (Phase 2 N1 exit)

The engine is constructed per session, seated once from the master seed + both
shuffled decks (F-N-5), and fed each frame via ShadowIngest — all inside a
try/catch in ComputeFrames so a shadow failure can never break live dispatch
(ND1/ND6). Routes still come from the existing handlers: wire output is
byte-for-byte unchanged. FrameDispatchContext gains the Engine ref for N2+.

csproj: PrivateAssets=compile on the engine ref so its global-namespace type
surface (MessagePackSerializer, UserConfig, UserCard, ChallengeConfig, ...) does
not leak transitively into SVSim.EmulatedEntrypoint (which references BattleNode)
and collide with that project's own types; the runtime DLL still flows.

All 238 BattleNode unit tests pass; EmulatedEntrypoint builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-06 15:35:35 -04:00
parent fa86739ac2
commit e982300c6d
3 changed files with 59 additions and 3 deletions

View File

@@ -11,8 +11,12 @@
<ItemGroup> <ItemGroup>
<!-- Aliased: the copied decompiled engine puts a huge type surface in the GLOBAL namespace <!-- Aliased: the copied decompiled engine puts a huge type surface in the GLOBAL namespace
(BattlePlayer, MessagePackSerializer, ...) which would otherwise leak into and collide (BattlePlayer, MessagePackSerializer, ...) which would otherwise leak into and collide
with every node file. The extern alias confines it; only Sessions/Engine/* opts in. --> with every node file. The extern alias confines it; only Sessions/Engine/* opts in.
<ProjectReference Include="..\SVSim.BattleEngine\SVSim.BattleEngine.csproj" Aliases="engine" /> PrivateAssets=compile: the engine is an implementation detail of the node — hide its TYPES
from the COMPILE closure of consumers (SVSim.EmulatedEntrypoint references BattleNode and
would otherwise re-hit the MessagePackSerializer global-namespace collision), while still
flowing the RUNTIME asset so SVSim.BattleEngine.dll deploys into consumers' output. -->
<ProjectReference Include="..\SVSim.BattleEngine\SVSim.BattleEngine.csproj" Aliases="engine" PrivateAssets="compile" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="SVSim.UnitTests" /> <InternalsVisibleTo Include="SVSim.UnitTests" />

View File

@@ -23,6 +23,18 @@ public sealed class BattleSession
private readonly BattleSessionState _state = new(); private readonly BattleSessionState _state = new();
/// <summary>One authoritative shadow engine per session (design ND2). Fed both clients' frames in
/// pure shadow (N1): it tracks state but emits nothing and changes no route. N2+ flips outbound
/// fields to engine reads. Constructed unconditionally; <see cref="EnsureEngineSetup"/> seats it
/// once both decks are known, and every interaction is guarded so a shadow failure can never break
/// live dispatch (ND6: log, never throw into the relay).</summary>
private readonly Engine.SessionBattleEngine _engine = new();
/// <summary>Setup is attempted exactly once. A shadow engine that can't seat headless in this host
/// (e.g. engine global state not initialized) stays not-ready and the shadow silently no-ops —
/// never retried, never fatal.</summary>
private bool _engineSetupAttempted;
/// <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
@@ -71,7 +83,7 @@ public sealed class BattleSession
new() new()
{ {
A = A, B = B, From = from, Other = ReferenceEquals(from, A) ? B : A, A = A, B = B, From = from, Other = ReferenceEquals(from, A) ? B : A,
Env = env, BattleId = BattleId, State = _state, Env = env, BattleId = BattleId, State = _state, Engine = _engine,
}; };
public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b, public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
@@ -199,6 +211,20 @@ public sealed class BattleSession
/// </summary> /// </summary>
internal IReadOnlyList<DispatchRoute> ComputeFrames(IBattleParticipant from, MsgEnvelope env) internal IReadOnlyList<DispatchRoute> ComputeFrames(IBattleParticipant from, MsgEnvelope env)
{ {
// Shadow engine (N1): seat-once then ingest this frame, fully isolated from dispatch. The
// wire output below is byte-for-byte unchanged — routes still come from the existing handlers;
// the engine only observes (ND1). A shadow failure is logged and swallowed (ND6), never thrown
// into the relay.
try
{
EnsureEngineSetup();
ShadowIngest(from, env);
}
catch (Exception ex)
{
_log.LogWarning(ex, "BattleSession {Bid}: shadow engine error (ignored)", BattleId);
}
if (Handlers.TryGetValue(env.Uri, out var handler)) if (Handlers.TryGetValue(env.Uri, out var handler))
return handler.Handle(BuildContext(from, env)); return handler.Handle(BuildContext(from, env));
@@ -207,4 +233,24 @@ public sealed class BattleSession
return Array.Empty<DispatchRoute>(); return Array.Empty<DispatchRoute>();
} }
/// <summary>Seat the shadow engine once, from the master seed + both deterministically-shuffled
/// decks the node already computed (F-N-5). Attempted a single time; if the host can't seat the
/// engine headless, it stays not-ready and the shadow no-ops for the rest of the battle.</summary>
private void EnsureEngineSetup()
{
if (_engineSetupAttempted) return;
_engineSetupAttempted = true;
_engine.Setup(_state.MasterSeed, _state.GetShuffledDeck(A), _state.GetShuffledDeck(B));
}
private void ShadowIngest(IBattleParticipant from, MsgEnvelope env)
{
if (!_engine.IsReady) return;
bool isPlayerSeat = ReferenceEquals(from, A);
var r = _engine.Receive(env, isPlayerSeat);
if (r.Diverged)
_log.LogInformation("BattleSession {Bid}: shadow engine diverged on {Uri}: {Reason}",
BattleId, env.Uri, r.RejectReason);
}
} }

View File

@@ -17,6 +17,12 @@ internal sealed class FrameDispatchContext
internal required string BattleId { get; init; } internal required string BattleId { get; init; }
internal required BattleSessionState State { get; init; } internal required BattleSessionState State { get; init; }
/// <summary>The session's shadow engine (design ND2/F-N-6). In Phase-2 N1 it is fed in pure shadow
/// and read by no handler; N2+ handlers source opponent-facing fields from it. Always non-null;
/// <see cref="Engine.SessionBattleEngine.IsReady"/> is false until the engine is set up (and stays
/// false if headless setup is unavailable in the host — the shadow then no-ops).</summary>
internal required Engine.SessionBattleEngine Engine { get; init; }
/// <summary>The opponent is an AI-passive (ack-only) bot: it runs no handshake — no /// <summary>The opponent is an AI-passive (ack-only) bot: it runs no handshake — no
/// <see cref="IHasHandshakePhase"/> — and receives no relayed frames (the client drives its own /// <see cref="IHasHandshakePhase"/> — and receives no relayed frames (the client drives its own
/// AI; the server only acks). This is the participant property that replaces the per-handler /// AI; the server only acks). This is the participant property that replaces the per-handler