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:
@@ -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