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