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:
@@ -11,8 +11,12 @@
|
||||
<ItemGroup>
|
||||
<!-- Aliased: the copied decompiled engine puts a huge type surface in the GLOBAL namespace
|
||||
(BattlePlayer, MessagePackSerializer, ...) which would otherwise leak into and collide
|
||||
with every node file. The extern alias confines it; only Sessions/Engine/* opts in. -->
|
||||
<ProjectReference Include="..\SVSim.BattleEngine\SVSim.BattleEngine.csproj" Aliases="engine" />
|
||||
with every node file. The extern alias confines it; only Sessions/Engine/* opts in.
|
||||
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>
|
||||
<InternalsVisibleTo Include="SVSim.UnitTests" />
|
||||
|
||||
@@ -23,6 +23,18 @@ public sealed class BattleSession
|
||||
|
||||
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
|
||||
/// 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
|
||||
@@ -71,7 +83,7 @@ public sealed class BattleSession
|
||||
new()
|
||||
{
|
||||
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,
|
||||
@@ -199,6 +211,20 @@ public sealed class BattleSession
|
||||
/// </summary>
|
||||
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))
|
||||
return handler.Handle(BuildContext(from, env));
|
||||
|
||||
@@ -207,4 +233,24 @@ public sealed class BattleSession
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ internal sealed class FrameDispatchContext
|
||||
internal required string BattleId { 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
|
||||
/// <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
|
||||
|
||||
Reference in New Issue
Block a user