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:
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user