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

@@ -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);
}
}