fix(battlenode): shadow engine tracks live PvP wire-truth (full battle, multiple bid regressions)
Six distinct fixes accumulated over live-test iterations against four bids (654473755566, 806245601092, 283192092460, 131549100204, 799755786270) — together they take the shadow engine from "throws on the first non-mulligan play" to "survives a full PvP battle, only weird-edge-case Unity touches still left to whack". 1. Engine StableRandom seed aligned with clients' Matched.seed (BattleSession.EnsureEngineSetup, NodeNativeBattleHarness.Create). Clients seed _stableRandom with BattleSeeds.Stable(masterSeed) (the value the node ships in Matched.seed); we were passing the RAW masterSeed to engine.Setup, so every StableRandom call diverged from call #1 onward — every turn-1+ draw picked a different deck position than the clients. Verified Stable(1184631275)=1543475792 matches the wire on bid 654473755566. 2. SeedDeck advances cardTotalNum to deck.Count+1 + pins BattleStartDeckCardList. Mirrors SBattleLoad.InitPlayer's tail (SBattleLoad.cs:1292). Without it, skill-generated tokens auto-assigned Index 0,1,... and COLLIDED with deck-loaded indices 1..40 — silent until something addressed the deck card with the colliding Index (Hoverboarder at deck idx 1 + a token at engine Index 1 made GetBattleCardIdx's SingleOrDefault throw on bid 806245601092). 3. BattleCardView.GameObject lazily non-null in the shim (ViewUiTouchStubs.cs). The IsRecovery card-create delegate (NetworkBattleManagerBase.cs:379) passes null cardGameObject; Skill_metamorphose.cs:147 in the in-play branch then NRE'd on `metamorphosedCard.BattleCardView.GameObject.transform.rotation = identity`, a purely cosmetic touch with no game-state implication. Bid 283192092460: Petrification on a board follower. 4. TranslateChoiceKeyAction unwraps wrapped selectCard on shadow ingest (SessionBattleEngine.cs, sibling to TranslateTargetOwners). Live sender-send wires Choice plays as selectCard:{cardId:[...], open:0}; engine's ConvertToListInt does `value as List<object>` — a Dict casts to null and foreach NREs. The receiver's swallow-all catch (NetworkBattleReceiver.cs:1255) logs to Debug.LogError + LocalLog — both shimmed/no-op'd headlessly — and returns false, but Receive calls ReceivedMessage with checkBreakData:false so the false isn't propagated. The play continues with choiceIdList=[], the chosen branch never resolves, the played card stays in hand; a later targeted play (A's bounce on B's "board" idx 20) then can't find the target → NRE on null in ActionProcessor.PlayCard:407. Bid 131549100204: B's Resonance + A's bounce. Opponent-relay path is unaffected — node strips selectCard from broadcasts. 5. HeadlessHandViewStub overrides HandUnfocus/HandFocus/FocusRearrangeHandHand to return NullVfx. CreateHandControl returns null in headless; the base methods unconditionally deref `_handControl.SetHandState(...)`. A follower with a when_spell_play Heal trigger fired on its leader for amount 0 — even a 0-heal drives ApplyHealing → CreatePullHandInVfx → HandUnfocus → NRE. Bid 799755786270: two consecutive spell plays both crashed this stack. Added InternalsVisibleTo("SVSim.BattleEngine.Tests") so the shim-level regression tests can pin the no-op contracts directly. Plus the previous-session fixes carried in this same uncommitted state (see docs/superpowers/plans/2026-06-07-shadow-engine-desync-handoff.md): - doesPlayerGoFirst:true + mgr.IsFirst:true (turn-1 draw count correct per seat) - RecoveryOperationCollection.PlayHandCardOperation routes all type:30 through PlaySkillSelectHandCardOperation (skips the two-phase user-select guard that aborts targeted spells in recovery) - ShadowFeed + ToRawBody: server-generated typed bodies (DealBody, etc.) converted to RawBody before engine.Receive (`env.Body as RawBody` returned null for typed bodies) - Ready idxChangeSeed seeds A's XorShift via the receiver; B's seed is injected via SeedOppoIdxChange (BattleSeeds.IdxChange + viewerId) - ReadySpin defaulted to 0 (was 243) — non-zero double-cranks the shadow which ingests BOTH sides' Ready frames on one stream Test counts: SVSim.UnitTests 1054/1054, SVSim.BattleEngine.Tests 34/34. Open: known-residual Unity touches are individual whack-a-mole now (per-card skill edge cases), not the structural divergences fixed here. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Sessions.Dispatch;
|
||||
using SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
@@ -35,6 +37,21 @@ public sealed class BattleSession
|
||||
/// never retried, never fatal.</summary>
|
||||
private bool _engineSetupAttempted;
|
||||
|
||||
/// <summary>Guards: server-generated Deal is fed to the shadow engine exactly once (the first
|
||||
/// occurrence from either LoadedHandler invocation). Deal + Ready are server-generated frames the
|
||||
/// engine needs to drive the mulligan: Deal → StartDeal (cards deck→hand for the player seat,
|
||||
/// _firstDrawList for the opponent), Ready → CompleteMulligan → EnemyChangeCardVfx → opponent
|
||||
/// DrawFirstMulliganCard. Without them the engine's hand stays empty and every play throws
|
||||
/// "Target card was not found in hand cards".</summary>
|
||||
private bool _engineDealFed;
|
||||
|
||||
/// <summary>Guards: server-generated Ready is fed to the shadow engine exactly once (the first
|
||||
/// Ready addressed to participant A). Fed as isPlayerSeat=false so the recovery path's
|
||||
/// OperateMulligan enters the OperateOppoMulligan branch — the only branch that invokes
|
||||
/// ReceiveOpponentMulligan → EnemyChangeCardVfx → DrawFirstMulliganCard. The player's mulligan
|
||||
/// was already processed during the Swap feed.</summary>
|
||||
private bool _engineReadyFed;
|
||||
|
||||
/// <summary>True once this session has acquired the process-wide <see cref="Engine.EngineSessionGate"/>
|
||||
/// (and is therefore the single active engine owner). Drives the matching <c>Release</c> at battle
|
||||
/// end so the next session can take the engine.</summary>
|
||||
@@ -241,13 +258,89 @@ public sealed class BattleSession
|
||||
}
|
||||
|
||||
if (Handlers.TryGetValue(env.Uri, out var handler))
|
||||
return handler.Handle(BuildContext(from, env));
|
||||
{
|
||||
var routes = handler.Handle(BuildContext(from, env));
|
||||
try { ShadowFeedServerFrames(routes); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "BattleSession {Bid}: shadow engine error feeding server frames (ignored)", BattleId);
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in lifecycle={Lifecycle} from vid={Vid}",
|
||||
BattleId, env.Uri, Lifecycle, from.ViewerId);
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
|
||||
/// <summary>Feed server-generated mulligan frames (Deal, Swap response, Ready) into the shadow
|
||||
/// engine. These frames are produced by LoadedHandler/SwapHandler and dispatched only to clients
|
||||
/// — they never enter <see cref="ShadowIngest"/> because they're not client-sent. But the engine
|
||||
/// needs them to drive the mulligan: Deal seats the hand, Ready completes the opponent's hand.
|
||||
/// The test harness (<c>NodeNativeBattleHarness</c>) feeds these directly; this method is the
|
||||
/// live-session equivalent.</summary>
|
||||
private void ShadowFeedServerFrames(IReadOnlyList<DispatchRoute> routes)
|
||||
{
|
||||
if (!_engine.IsReady) return;
|
||||
|
||||
foreach (var (target, frame, _) in routes)
|
||||
{
|
||||
switch (frame.Uri)
|
||||
{
|
||||
case NetworkBattleUri.Deal when !_engineDealFed:
|
||||
_engineDealFed = true;
|
||||
_log.LogWarning("BattleSession {Bid}: DEAL DIAG BEFORE: {Diag}",
|
||||
BattleId, _engine.DiagnoseDealState());
|
||||
ShadowFeed(frame, isPlayerSeat: true, "Deal");
|
||||
_log.LogWarning("BattleSession {Bid}: DEAL DIAG AFTER: {Diag}",
|
||||
BattleId, _engine.DiagnoseDealState());
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.Swap:
|
||||
// The Swap RESPONSE (server-authored, carries post-mulligan self hand as
|
||||
// pos→idx) must go to the engine for the correct seat. The client-sent Swap
|
||||
// ({idxList}) also enters ShadowIngest but is harmless — its selfIdxList
|
||||
// parses to null (no "self" key) so FirstMulliganOperation no-ops.
|
||||
bool swapIsPlayer = ReferenceEquals(target, A);
|
||||
ShadowFeed(frame, swapIsPlayer, $"SwapResponse({(swapIsPlayer ? "A" : "B")})");
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.Ready when !_engineReadyFed && ReferenceEquals(target, A):
|
||||
_engineReadyFed = true;
|
||||
// Feed A's Ready (carries A's idxChangeSeed → receiver seeds _selfXorShiftRandom).
|
||||
ShadowFeed(frame, isPlayerSeat: false, "Ready");
|
||||
// Seed B's XorShift separately — A's Ready doesn't carry B's seed.
|
||||
_engine.SeedOppoIdxChange(BattleSeeds.IdxChange(_state.MasterSeed, B.ViewerId));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ShadowFeed(MsgEnvelope frame, bool isPlayerSeat, string label)
|
||||
{
|
||||
var engineFrame = frame.Body is RawBody ? frame : frame with { Body = ToRawBody(frame.Body) };
|
||||
var r = _engine.Receive(engineFrame, isPlayerSeat);
|
||||
if (r.Diverged)
|
||||
_log.LogWarning("BattleSession {Bid}: shadow engine diverged on {Label} feed: {Reason}",
|
||||
BattleId, label, r.RejectReason);
|
||||
if (frame.Uri is NetworkBattleUri.Deal or NetworkBattleUri.Swap or NetworkBattleUri.Ready)
|
||||
LogEngineHandState(frame.Uri, $"ShadowFeed({label})");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions _bodyJsonOptions = Wire.WireJsonOptions.CamelCase;
|
||||
|
||||
/// <summary>Convert a typed body record (DealBody, SwapResponseBody, ReadyBody, etc.) to the
|
||||
/// <see cref="RawBody"/> the engine receiver expects. Serialize → JsonElement → ToObject (the
|
||||
/// same deep-conversion MsgEnvelope.FromJson uses for incoming wire frames).</summary>
|
||||
private static RawBody ToRawBody(IMsgBody? body)
|
||||
{
|
||||
if (body is null) return new RawBody(new Dictionary<string, object?>());
|
||||
var el = JsonSerializer.SerializeToElement(body, body.GetType(), _bodyJsonOptions);
|
||||
var dict = el.EnumerateObject()
|
||||
.ToDictionary(p => p.Name, p => MsgEnvelope.ToObject(p.Value));
|
||||
return new RawBody(dict);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@@ -268,7 +361,16 @@ public sealed class BattleSession
|
||||
return;
|
||||
}
|
||||
_engineOwned = true;
|
||||
_engine.Setup(_state.MasterSeed,
|
||||
// Seed the engine's StableRandom with BattleSeeds.Stable(MasterSeed) — the SAME value the
|
||||
// Matched frame ships to both clients (InitBattleHandler.cs:28). The clients seed their
|
||||
// System.Random with Matched.seed (BattleManagerBase.cs:721), so the engine's stream must
|
||||
// share that derivation to track. MasterSeed itself is a root only — every wire-facing seed
|
||||
// (Stable, IdxChange, DeckShuffle) is a BattleSeeds.Derive(...) of it; the engine never
|
||||
// consumes the root directly. Live regression: bid 654473755566 had MasterSeed=1184631275
|
||||
// and Stable=1543475792 (the Matched.seed); seeding the engine with the raw root made every
|
||||
// turn-1+ draw pick a different deck position than the clients, so the opponent's first
|
||||
// non-mulligan play addressed a card the engine never drew → HandCardToField threw.
|
||||
_engine.Setup(BattleSeeds.Stable(_state.MasterSeed),
|
||||
_state.GetShuffledDeck(A), _state.GetShuffledDeck(B),
|
||||
(int)A.Context.ClassId, (int)B.Context.ClassId);
|
||||
}
|
||||
@@ -279,8 +381,21 @@ public sealed class BattleSession
|
||||
bool isPlayerSeat = ReferenceEquals(from, A);
|
||||
var r = _engine.Receive(env, isPlayerSeat);
|
||||
if (r.Diverged)
|
||||
_log.LogInformation("BattleSession {Bid}: shadow engine diverged on {Uri}: {Reason}",
|
||||
_log.LogWarning("BattleSession {Bid}: shadow engine diverged on {Uri}: {Reason}",
|
||||
BattleId, env.Uri, r.RejectReason);
|
||||
if (env.Uri is NetworkBattleUri.Swap or NetworkBattleUri.TurnStart or NetworkBattleUri.PlayActions)
|
||||
LogEngineHandState(env.Uri, $"ShadowIngest(seat={(isPlayerSeat ? "A" : "B")})");
|
||||
}
|
||||
|
||||
private void LogEngineHandState(NetworkBattleUri uri, string label)
|
||||
{
|
||||
if (!_engine.IsReady) return;
|
||||
var aIdxs = string.Join(",", Enumerable.Range(0, _engine.HandCount(true))
|
||||
.Select(i => _engine.HandCardIndex(true, i)));
|
||||
var bIdxs = string.Join(",", Enumerable.Range(0, _engine.HandCount(false))
|
||||
.Select(i => _engine.HandCardIndex(false, i)));
|
||||
_log.LogInformation("BattleSession {Bid}: engine hand after {Uri} {Label}: A=[{AHand}] B=[{BHand}]",
|
||||
BattleId, uri, label, aIdxs, bIdxs);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -66,15 +66,43 @@ internal sealed class SessionBattleEngine
|
||||
public void Setup(int masterSeed,
|
||||
IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck,
|
||||
int seatAClass = 1, int seatBClass = 2)
|
||||
=> SetupInternal(masterSeed, seatADeck, seatBDeck, seatAClass, seatBClass, rng: null);
|
||||
|
||||
/// <summary>TEST/DEBUG SEAM (Phase 4 Option-A viability PROBE — NOT a production fix). Identical to
|
||||
/// <see cref="Setup(int, IReadOnlyList{long}, IReadOnlyList{long}, int, int)"/> but installs a logging
|
||||
/// RNG source that, on EVERY <c>StableRandom</c>/<c>StableRandomDouble</c> roll, records a roll entry
|
||||
/// (call index, API, the seat signals readable from mgr state at roll time, and the live call stack).
|
||||
/// Lets a test answer: at roll time, is the ACTING SEAT determinable from mgr state alone, or only from
|
||||
/// the stack? No production path calls this.</summary>
|
||||
internal IReadOnlyList<RollEntry> DebugSetupWithRollLog(int masterSeed,
|
||||
IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck,
|
||||
int seatAClass = 1, int seatBClass = 2)
|
||||
{
|
||||
var log = new List<RollEntry>();
|
||||
// The logger needs the mgr to read seat signals at roll time; the mgr is built inside Setup, so the
|
||||
// logger reads it lazily via a closure populated right after construction.
|
||||
HeadlessNetworkBattleMgr[] mgrBox = { null! };
|
||||
var rng = new RollLoggingRandomSource(new SeededRandomSource(masterSeed), log, () => mgrBox[0]);
|
||||
SetupInternal(masterSeed, seatADeck, seatBDeck, seatAClass, seatBClass, rng, mgrBox);
|
||||
return log;
|
||||
}
|
||||
|
||||
private void SetupInternal(int masterSeed,
|
||||
IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck,
|
||||
int seatAClass, int seatBClass,
|
||||
IRandomSource? rng, HeadlessNetworkBattleMgr[]? mgrBox = null)
|
||||
{
|
||||
// Prime the engine's process-global statics (CardMaster, Wizard.Data, all-8-class Master,
|
||||
// GameMgr/netUser/udid). Idempotent (process-once); makes the LIVE host ready so Setup succeeds
|
||||
// here rather than throwing into the shadow's no-op path (Phase 2 N2, carried-risk A).
|
||||
EngineGlobalInit.EnsureInitialized();
|
||||
|
||||
// rng defaults to SeededRandomSource(masterSeed) inside the mgr — the stream is born aligned
|
||||
// with the seed the node handed both clients (F-N-5; O-N-2 "bit-aligned anyway").
|
||||
var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed));
|
||||
// rng defaults to SeededRandomSource(masterSeed) inside the mgr — masterSeed here is the
|
||||
// engine's StableRandom seed (parameter name preserved for API compatibility; callers pass
|
||||
// BattleSeeds.Stable(rootMasterSeed) so the stream is born aligned with the seed the node
|
||||
// ships to both clients in Matched.seed). F-N-5; O-N-2 "bit-aligned anyway".
|
||||
var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed), rng);
|
||||
if (mgrBox is not null) mgrBox[0] = mgr; // publish for the test roll-logger closure (DebugSetupWithRollLog)
|
||||
// Recovery mode is the engine's OWN headless replay path: the live view/UI touches on the
|
||||
// receive cycle (BattleUIContainer.DisableMenu, turn-control UI, card-view creation, VFX
|
||||
// waits) are all gated `!IsRecovery` (BattleUIContainer.cs:130, BattleManagerBase.cs:1499+),
|
||||
@@ -93,15 +121,20 @@ internal sealed class SessionBattleEngine
|
||||
player.IsSelfTurn = true;
|
||||
enemy.IsSelfTurn = false;
|
||||
|
||||
// Seat the evolve points + evolve-wait-turn counters exactly as the real match-load's
|
||||
// SetupInitialGameState -> SetupEvolCount does (BattleManagerBase.cs:1115/1132). The headless
|
||||
// Setup builds the seats by hand and never runs SetupInitialGameState, so without this both seats'
|
||||
// CurrentEpCount/EvolveWaitTurnCount stay at their field defaults (0/0) and CanEvolution always
|
||||
// fails (CurrentEpCount - GetEp() < 0). doesPlayerGoFirst == false here: seat A (BattlePlayer) is
|
||||
// the SECOND player (IsFirst defaults false; seat A's turn-1 draws 2), so it gets SECOND_PLAYER_EP
|
||||
// (3) + EvolveWaitTurnCount 4, and seat B (BattleEnemy, first) gets FIRST_PLAYER_EP (2) +
|
||||
// EvolveWaitTurnCount 5. TurnEvolveControl (run on each TurnStart receive) counts the wait down.
|
||||
mgr.SetupEvolCount(doesPlayerGoFirst: false);
|
||||
// Participant A always goes first (LoadedHandler gives A TurnState.First). The engine's
|
||||
// BattlePlayer = isPlayer=true = seat A, so doesPlayerGoFirst must be true. This controls:
|
||||
// (1) SetupEvolCount: first player gets FIRST_PLAYER_EP (2) + wait 5,
|
||||
// second player gets SECOND_PLAYER_EP (3) + wait 4
|
||||
// (2) IsFirst → BattlePlayer.IsGameFirst / BattleEnemy.IsGameFirst → turn-1 draw count:
|
||||
// first player draws 1, second draws 2 (BattlePlayerBase.TurnStartDrawCard)
|
||||
mgr.IsFirst = true;
|
||||
mgr.SetupEvolCount(doesPlayerGoFirst: true);
|
||||
|
||||
// The real match-load's SetupInitialGameState(areCardsRandomlyDrawn:true) sets this flag
|
||||
// (BattleManagerBase.cs:1110), routing LotteryRandomDrawCard through seeded StableRandom
|
||||
// instead of top-of-deck. Without it the shadow draws DeckCardList[0] every time while
|
||||
// clients draw seeded-random — desynchronizing the hand and every downstream field.
|
||||
BattleManagerBase.IsRandomDraw = true;
|
||||
|
||||
InitLeaderLife(mgr); // a 0-life leader reads as game-over and silently blocks plays
|
||||
InitCardTemplates(mgr); // play/draw resolution touches the (no-op) card view layer
|
||||
@@ -136,6 +169,7 @@ internal sealed class SessionBattleEngine
|
||||
|
||||
var dict = ToEngineDict((env.Body as RawBody)?.Entries);
|
||||
TranslateTargetOwners(dict, isPlayerSeat);
|
||||
TranslateChoiceKeyAction(dict);
|
||||
var uri = MapUri(env.Uri);
|
||||
|
||||
try
|
||||
@@ -210,6 +244,71 @@ internal sealed class SessionBattleEngine
|
||||
private const string TargetListKey = "targetList";
|
||||
private const string VidKey = "vid";
|
||||
private const string IsSelfKey = "isSelf";
|
||||
private const string KeyActionKey = "keyAction";
|
||||
private const string SelectCardKey = "selectCard";
|
||||
private const string CardIdKey = "cardId";
|
||||
|
||||
// --- live Choice-keyAction shape translation (live PvP ingest fidelity) ------------------------
|
||||
//
|
||||
// THE GAP this closes: a Choice play's wire keyAction entry on the SENDER's send is the wrapped
|
||||
// shape `{type:1, cardId:<choiceCardId>, selectCard:{cardId:[<chosenId>...], open:0|1}}` (verified
|
||||
// in client-send captures, e.g. data_dumps/captures/battle_test/cl1/battle-traffic.ndjson live
|
||||
// Resonance play). The engine's receive parser (NetworkBattleReceiver.cs:1202) reads the
|
||||
// `selectCard` value through `ConvertToListInt`, which does `value as List<object>` — a Dictionary
|
||||
// value casts to null and the inner `foreach (... in null)` throws NRE. The whole
|
||||
// ConvertReceiveDataToMakeData is wrapped in a swallow-catch (NetworkBattleReceiver.cs:1255-1260)
|
||||
// that logs to Debug.LogError + LocalLog — both shimmed/no-op'd headlessly — and returns false.
|
||||
// SessionBattleEngine.Receive calls ReceivedMessage with checkBreakData:false, so the false isn't
|
||||
// surfaced; the engine continues with `choiceIdList=[]`, the choice never resolves, and the played
|
||||
// card never moves from hand to board. Then any LATER frame that addresses the un-resolved card
|
||||
// by Index sees a stale hand entry — silently for a turn or two, until a TARGETED play looks for
|
||||
// it on the board (where it should be per wire) and gets `null` from LookForActionDataToTargetCard
|
||||
// → ActionProcessor.PlayCard:407 NRE on `selectedCard.SelfBattlePlayer`.
|
||||
//
|
||||
// OPPONENT-FACING relay shape is different: the node strips selectCard entirely from the opponent
|
||||
// broadcast (verified: cl2 receives `keyAction:[{type:1, cardId:127011010}]`), so the opponent
|
||||
// never needs this transform. Only the shadow engine — which ingests the SENDER's raw send — does.
|
||||
//
|
||||
// The fix: walk keyAction on the ENGINE's own dict copy (TranslateTargetOwners' pattern) and
|
||||
// unwrap selectCard. `{cardId:[121011010], open:0}` → `[121011010]`. The `open` flag (was this
|
||||
// choice revealed to the opponent) is irrelevant to the engine's resolution. The flat-list shape
|
||||
// is what `ConvertToListInt` consumes successfully, AND what the existing test harness
|
||||
// (NodeNativeBattleHarness.ChoicePlayBody) already supplies — that test passes, proving the rest
|
||||
// of the Choice resolution path works given the right shape. Idempotent: an already-flat list
|
||||
// (no wrapping dict) is left alone, so a future relay frame that happens to carry the flat form
|
||||
// also resolves directly.
|
||||
//
|
||||
// Live regression: bid 131549100204, B's Resonance (127011010) play of idx 20 at error.txt:1642.
|
||||
// Without the unwrap, idx 20 stays in B's hand; later A's 6-cost bounce targets B's "board" idx 20,
|
||||
// engine can't find it on the board, ActionProcessor.PlayCard NRE's at the foreach over a list
|
||||
// containing a null target.
|
||||
private static void TranslateChoiceKeyAction(Dictionary<string, object> dict)
|
||||
{
|
||||
if (!dict.TryGetValue(KeyActionKey, out var raw) || raw is not List<object> entries)
|
||||
return;
|
||||
|
||||
foreach (var e in entries)
|
||||
{
|
||||
if (e is not Dictionary<string, object> entry) continue;
|
||||
if (!entry.TryGetValue(SelectCardKey, out var sel)) continue;
|
||||
// Already-flat (a List): no transform needed. Idempotent guard.
|
||||
if (sel is List<object>) continue;
|
||||
// Wrapped (a Dict): unwrap to the inner cardId list.
|
||||
if (sel is Dictionary<string, object> wrap
|
||||
&& wrap.TryGetValue(CardIdKey, out var inner)
|
||||
&& inner is List<object> flat)
|
||||
{
|
||||
entry[SelectCardKey] = flat;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unrecognized shape — drop the key so the parse doesn't NRE; the play will resolve
|
||||
// with an empty choice list, and the divergence (if any) will surface downstream
|
||||
// rather than crash the receiver.
|
||||
entry.Remove(SelectCardKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The decoded wire value may be a boxed long/int/bool depending on the codec; normalize to int.
|
||||
private static int ToInt(object v) => v switch
|
||||
@@ -457,6 +556,102 @@ internal sealed class SessionBattleEngine
|
||||
return card.Cost;
|
||||
}
|
||||
|
||||
// === TEST/DEBUG SEAMS (Phase 4 root-cause verification — NOT a production fix) =================
|
||||
// These exist solely to PROVE the post-mulligan reshuffle root cause from a test. They read/poke
|
||||
// the engine's XorShift idx-change RNG, which the live recovery path leaves null/inactive (seed -1).
|
||||
// No production code path calls them. Remove (or fold into the real seeding) when the fix lands.
|
||||
|
||||
/// <summary>TEST/DEBUG: is the engine's SELF-seat XorShift idx-change RNG active? Mirrors the gate the
|
||||
/// post-mulligan deck reshuffle/re-index checks (<c>BattleMgr.XorShiftRandom(true) != null &&
|
||||
/// .IsActive</c>, BattlePlayerBase.cs:3049/3073). Under the live recovery setup
|
||||
/// (<c>CreateXorShift(-1,-1)</c> via NullRecoveryManager.IdxChangeSeed == -1) this is FALSE, so the
|
||||
/// engine SKIPS the reshuffle the real clients performed.</summary>
|
||||
internal bool SelfXorShiftActive => (_mgr?.XorShiftRandom(isSelf: true)?.IsActive) ?? false;
|
||||
|
||||
/// <summary>TEST/DEBUG: same as <see cref="SelfXorShiftActive"/> for the OPPONENT seat.</summary>
|
||||
internal bool OppoXorShiftActive => (_mgr?.XorShiftRandom(isSelf: false)?.IsActive) ?? false;
|
||||
|
||||
/// <summary>DIAGNOSTIC: check if OnReceiveDeal is wired and report deck/hand counts.</summary>
|
||||
internal string DiagnoseDealState()
|
||||
{
|
||||
if (_mgr is null) return "mgr=null";
|
||||
var or = _mgr.OperateReceive;
|
||||
bool dealWired = or.OnReceiveDeal != null;
|
||||
var p = _mgr.GetBattlePlayer(true);
|
||||
var e = _mgr.GetBattlePlayer(false);
|
||||
return $"OnReceiveDeal={(dealWired ? "wired" : "NULL")}, " +
|
||||
$"playerDeck={p.DeckCardList.Count}, playerHand={p.HandCardList.Count}, " +
|
||||
$"enemyDeck={e.DeckCardList.Count}, enemyHand={e.HandCardList.Count}";
|
||||
}
|
||||
|
||||
/// <summary>Seed the opponent seat's XorShift for post-mulligan deck reshuffle. The Ready frame's
|
||||
/// <c>idxChangeSeed</c> seeds the self seat (BattlePlayer/A) automatically via the receiver. The
|
||||
/// opponent seat (BattleEnemy/B) needs its seed injected separately because the Ready frame sent
|
||||
/// to A doesn't carry B's seed. Called from <see cref="BattleSession.ShadowFeedServerFrames"/>
|
||||
/// after feeding the Ready.</summary>
|
||||
internal void SeedOppoIdxChange(int oppoSeed)
|
||||
{
|
||||
_mgr?.CreateXorShift(-1, oppoSeed);
|
||||
}
|
||||
|
||||
/// <summary>TEST/DEBUG: inject BOTH per-seat idxChange seeds at once (the verification seam the
|
||||
/// PostMulliganReshuffleRootCauseTests use). Production code uses the Ready frame for the self
|
||||
/// seed + <see cref="SeedOppoIdxChange"/> for the opponent seed.</summary>
|
||||
internal void DebugSeedIdxChange(int selfSeed, int oppoSeed)
|
||||
{
|
||||
if (_mgr is null) throw new InvalidOperationException("DebugSeedIdxChange before Setup.");
|
||||
_mgr.CreateXorShift(selfSeed, oppoSeed);
|
||||
}
|
||||
|
||||
/// <summary>TEST/DEBUG: override the engine's process-global <c>BattleManagerBase.IsRandomDraw</c>
|
||||
/// flag. Production Setup now sets this true (matching the real match-load's
|
||||
/// <c>SetupInitialGameState(areCardsRandomlyDrawn:true)</c>). This seam exists so tests can
|
||||
/// force it false to reproduce the old top-of-deck bug. Static field → set per run under
|
||||
/// [NonParallelizable].</summary>
|
||||
internal void DebugSetRandomDraw(bool value) => BattleManagerBase.IsRandomDraw = value;
|
||||
|
||||
/// <summary>TEST/DEBUG (Phase 4 draw-recompute hypothesis): advance the SHARED <c>_stableRandom</c>
|
||||
/// stream by <paramref name="n"/> draws, exactly as <c>OperateReceive.StartOperate</c> does on a
|
||||
/// received frame carrying <c>spin=n</c> (OperateReceive.cs:80-83 loops <c>StableRandomDouble()</c>
|
||||
/// n times). The live shadow never ingests the Ready frame that carries the wire spin, so its stream
|
||||
/// is offset; this applies the pre-roll at the same point the real client would.</summary>
|
||||
internal void DebugSpinPreroll(int n)
|
||||
{
|
||||
if (_mgr is null) throw new InvalidOperationException("DebugSpinPreroll before Setup.");
|
||||
for (int i = 0; i < n; i++) _mgr.StableRandomDouble();
|
||||
}
|
||||
|
||||
/// <summary>TEST/DEBUG: consume one value from the shared <c>_stableRandom</c> stream and return it.
|
||||
/// Lets a regression test assert engine seed alignment against the wire — the very first
|
||||
/// <c>StableRandom.NextDouble()</c> the engine produces must equal the first <c>NextDouble()</c> of a
|
||||
/// fresh <c>System.Random(BattleSeeds.Stable(masterSeed))</c>, since clients seed
|
||||
/// <c>_stableRandom = new System.Random(Matched.seed)</c> with the SAME value
|
||||
/// (BattleManagerBase.cs:721; Matched.seed == BattleSeeds.Stable(masterSeed),
|
||||
/// InitBattleHandler.cs:28).</summary>
|
||||
internal double DebugStableRandomDouble()
|
||||
{
|
||||
if (_mgr is null) throw new InvalidOperationException("DebugStableRandomDouble before Setup.");
|
||||
return _mgr.StableRandomDouble();
|
||||
}
|
||||
|
||||
/// <summary>TEST/DEBUG: read the per-seat <c>cardTotalNum</c> counter that drives auto-assigned
|
||||
/// Index for skill-generated tokens (BattleManagerBase.SetupCardIndex uses this when
|
||||
/// <c>addIndex == -1</c>). After Setup it must equal <c>deck.Count + 1</c> on both seats (matches
|
||||
/// the real client's <c>SBattleLoad.InitPlayer</c> tail, SBattleLoad.cs:1292), so the FIRST
|
||||
/// generated token gets Index 41 — clear of deck-loaded indices 1..40 — and matches the wire
|
||||
/// <c>add.idx</c>. A stale value of 0 causes tokens to take Index 0, 1, ... and collide.</summary>
|
||||
internal int DebugCardTotalNum(bool playerSeat) =>
|
||||
_mgr is null ? -1 : _mgr.GetBattlePlayer(playerSeat).cardTotalNum;
|
||||
|
||||
/// <summary>TEST/DEBUG: the engine's running <c>StableRandom</c>/<c>StableRandomDouble</c> call count
|
||||
/// (private <c>BattleManagerBase.stableRandomCount</c>), so a divergence dump can report how far the
|
||||
/// shared stream has advanced at the moment of a mismatch.</summary>
|
||||
internal int DebugStableRandomCount =>
|
||||
_mgr is null ? -1
|
||||
: (int)(typeof(BattleManagerBase)
|
||||
.GetField("stableRandomCount", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(_mgr) ?? -1);
|
||||
|
||||
private engine::BattlePlayerBase Seat(bool playerSeat) =>
|
||||
(_mgr ?? throw new InvalidOperationException("read before Setup")).GetBattlePlayer(playerSeat);
|
||||
|
||||
@@ -598,7 +793,18 @@ internal sealed class SessionBattleEngine
|
||||
|
||||
/// <summary>Seat one side's full deck in order (idx == list position + 1). Each card is created
|
||||
/// through the engine's own null-view seam and pushed via AddToDeck — the SeedDeck primitive the
|
||||
/// test harness proved (HeadlessFixture.SeedDeck).</summary>
|
||||
/// test harness proved (HeadlessFixture.SeedDeck).
|
||||
/// <para>Mirrors the real client's <c>SBattleLoad.InitPlayer</c>/<c>InitEnemy</c> tail: after
|
||||
/// loading the 40-card deck at indices 1..40, set <c>cardTotalNum = deck.Count + 1</c> so the
|
||||
/// next skill-generated token gets Index 41 (matches the wire's <c>add.idx</c>). Without this,
|
||||
/// <c>cardTotalNum</c> stays at the property default (0) and the auto-assign path
|
||||
/// (<c>SetupCardIndex(_, -1)</c> in BattleManagerBase.cs:1770) hands tokens Index 0, 1, ...,
|
||||
/// which COLLIDES with deck-loaded cards' Index 1..40. The collision is silent until something
|
||||
/// plays the deck card with the colliding Index (e.g. Hoverboarder at deck idx 1 with a token
|
||||
/// at engine Index 1): <c>GetBattleCardIdx</c>'s <c>SingleOrDefault</c> finds two matches and
|
||||
/// throws "Sequence contains more than one matching element". Also pin
|
||||
/// <c>BattleStartDeckCardList</c> like the real client, so any skill that reads the starting
|
||||
/// deck (e.g. tribe filters) sees the seeded deck instead of an empty list.</para></summary>
|
||||
private static void SeedDeck(BattleManagerBase mgr, IReadOnlyList<long> deck, bool isPlayer)
|
||||
{
|
||||
BattlePlayerBase owner = mgr.GetBattlePlayer(isPlayer);
|
||||
@@ -607,6 +813,8 @@ internal sealed class SessionBattleEngine
|
||||
var card = CreateHeadlessCard(mgr, (int)deck[i], index: i + 1, isPlayer);
|
||||
owner.AddToDeck(card);
|
||||
}
|
||||
owner.cardTotalNum = deck.Count + 1;
|
||||
owner.BattleStartDeckCardList = new List<BattleCardBase>(owner.DeckCardList);
|
||||
}
|
||||
|
||||
private static readonly MethodInfo CreateCardWithoutResources =
|
||||
@@ -697,4 +905,79 @@ internal sealed class SessionBattleEngine
|
||||
t = t.BaseType;
|
||||
}
|
||||
}
|
||||
|
||||
// === TEST/DEBUG: per-roll attribution probe (Phase 4 Option-A viability) =======================
|
||||
// Captures, at the EXACT moment of each StableRandom*/StableRandomDouble roll, the seat signals the
|
||||
// mgr can read from its own state, plus the live call stack. The decisive question: can the acting
|
||||
// seat be attributed from mgr STATE alone (a router could route on it), or only by reading the STACK?
|
||||
|
||||
/// <summary>One recorded RNG roll. <paramref name="SelfIsSelfTurn"/>/<paramref name="OppoIsSelfTurn"/>
|
||||
/// are the mgr-readable seat-turn flags at roll time; <paramref name="Stack"/> is the trimmed call
|
||||
/// stack (the only place the acting seat is sometimes visible).</summary>
|
||||
internal sealed record RollEntry(
|
||||
int Index, string Api, int Arg,
|
||||
bool SelfIsSelfTurn, bool OppoIsSelfTurn,
|
||||
string Stack);
|
||||
|
||||
// A logging IRandomSource: delegates to the real seeded source but records each roll. Reads the mgr's
|
||||
// seat-turn flags (the richest seat signal a mgr-level StableRandom override can see — there is no
|
||||
// "current operating seat" field on the mgr) and the call stack at the call site.
|
||||
private sealed class RollLoggingRandomSource : IRandomSource
|
||||
{
|
||||
private readonly IRandomSource _inner;
|
||||
private readonly List<RollEntry> _log;
|
||||
private readonly Func<HeadlessNetworkBattleMgr?> _mgr;
|
||||
private int _i;
|
||||
|
||||
public RollLoggingRandomSource(IRandomSource inner, List<RollEntry> log, Func<HeadlessNetworkBattleMgr?> mgr)
|
||||
{
|
||||
_inner = inner; _log = log; _mgr = mgr;
|
||||
}
|
||||
|
||||
public double NextUnit() { Record("NextUnit", -1); return _inner.NextUnit(); }
|
||||
public int NextSelf(int max) { Record("NextSelf", max); return _inner.NextSelf(max); }
|
||||
|
||||
private void Record(string api, int arg)
|
||||
{
|
||||
bool selfTurn = false, oppoTurn = false;
|
||||
try
|
||||
{
|
||||
var mgr = _mgr();
|
||||
if (mgr is not null)
|
||||
{
|
||||
selfTurn = mgr.GetBattlePlayer(true).IsSelfTurn;
|
||||
oppoTurn = mgr.GetBattlePlayer(false).IsSelfTurn;
|
||||
}
|
||||
}
|
||||
catch { /* read-only probe; never let a state read abort the roll */ }
|
||||
|
||||
string stack = TrimStack(System.Environment.StackTrace);
|
||||
_log.Add(new RollEntry(_i++, api, arg, selfTurn, oppoTurn, stack));
|
||||
}
|
||||
|
||||
// Keep the frames that reveal WHO is rolling (mulligan lottery vs draw vs filter vs spin pre-roll),
|
||||
// dropping the logger's own frames and System.Environment.
|
||||
private static string TrimStack(string raw)
|
||||
{
|
||||
var lines = (raw ?? "").Split('\n')
|
||||
.Select(s => s.Trim())
|
||||
.Where(s => s.Length > 0
|
||||
&& !s.Contains("RollLoggingRandomSource")
|
||||
&& !s.Contains("Environment.get_StackTrace")
|
||||
&& !s.Contains("Environment.GetStackTrace"))
|
||||
.Select(Shorten)
|
||||
.Take(8);
|
||||
return string.Join(" <- ", lines);
|
||||
}
|
||||
|
||||
// "at Namespace.Type.Method(args) in file:line N" -> "Type.Method" (keep it scannable).
|
||||
private static string Shorten(string frame)
|
||||
{
|
||||
string s = frame.StartsWith("at ") ? frame.Substring(3) : frame;
|
||||
int paren = s.IndexOf('(');
|
||||
if (paren >= 0) s = s.Substring(0, paren);
|
||||
var parts = s.Split('.');
|
||||
return parts.Length >= 2 ? parts[^2] + "." + parts[^1] : s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user