diff --git a/SVSim.BattleNode/SVSim.BattleNode.csproj b/SVSim.BattleNode/SVSim.BattleNode.csproj
index 032734e..fe0fa9a 100644
--- a/SVSim.BattleNode/SVSim.BattleNode.csproj
+++ b/SVSim.BattleNode/SVSim.BattleNode.csproj
@@ -11,8 +11,12 @@
-
+ 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. -->
+
diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs
index b70d967..6c02f06 100644
--- a/SVSim.BattleNode/Sessions/BattleSession.cs
+++ b/SVSim.BattleNode/Sessions/BattleSession.cs
@@ -23,6 +23,18 @@ public sealed class BattleSession
private readonly BattleSessionState _state = new();
+ /// 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; 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).
+ private readonly Engine.SessionBattleEngine _engine = new();
+
+ /// 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.
+ private bool _engineSetupAttempted;
+
/// Serializes dispatch. Both participants' read loops raise FrameEmitted on their own
/// threads, and a dispatch ( + the relay PushAsync calls) mutates
/// shared, non-thread-safe state — the 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
///
internal IReadOnlyList 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();
}
+ /// 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.
+ 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);
+ }
+
}
diff --git a/SVSim.BattleNode/Sessions/Dispatch/FrameDispatchContext.cs b/SVSim.BattleNode/Sessions/Dispatch/FrameDispatchContext.cs
index 3cce6e4..809554d 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/FrameDispatchContext.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/FrameDispatchContext.cs
@@ -17,6 +17,12 @@ internal sealed class FrameDispatchContext
internal required string BattleId { get; init; }
internal required BattleSessionState State { get; init; }
+ /// 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;
+ /// is false until the engine is set up (and stays
+ /// false if headless setup is unavailable in the host — the shadow then no-ops).
+ internal required Engine.SessionBattleEngine Engine { get; init; }
+
/// The opponent is an AI-passive (ack-only) bot: it runs no handshake — no
/// — 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