Files
SVSimServer/SVSim.UnitTests/BattleNode/Integration/PostMulliganReshuffleRootCauseTests.cs
gamer147 addeb021d2 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>
2026-06-07 19:05:07 -04:00

199 lines
12 KiB
C#

using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using SVSim.BattleNode.Protocol;
namespace SVSim.UnitTests.BattleNode.Integration;
/// <summary>
/// PHASE 4 STEP 1 — root-cause VERIFICATION (NOT a fix). Tier 1 mechanism proof for the reported PvP
/// spellboost:0 / "Target card was not found in hand cards" desync.
///
/// THE CHAIN (already traced from engine source, re-confirmed here by behavior):
/// - The node runs the engine with <c>mgr.IsRecovery = true</c>. Under IsRecovery the engine seeds its
/// post-mulligan deck-reshuffle RNG from <c>RecoveryManager.IdxChangeSeed</c>
/// (NetworkBattleManagerBase.cs:259-261). The node's RecoveryManager is <c>NullRecoveryManager</c>,
/// whose <c>IdxChangeSeed == -1</c>, so the engine runs <c>CreateXorShift(-1, -1)</c>.
/// - <c>CreateXorShift</c> only builds an <c>XorShift</c> when <c>seed != -1</c>
/// (BattleManagerBase.cs:806-815), and <c>new XorShift(-1)</c> sets <c>IsActive = false</c>
/// (BattleManagerBase.cs:48). So both seats' XorShift stay null/inactive.
/// - The post-mulligan deck reshuffle + card re-index (<c>AddToDeck</c> gate at BattlePlayerBase.cs:3049
/// queues returned cards; <c>AddToDeckCardIndexChange</c> at 3073-3084 repositions/renumbers them) is
/// gated on <c>XorShiftRandom(...) != null &amp;&amp; .IsActive &amp;&amp; IsMulliganEnd</c>. With the XorShift
/// inactive the engine SKIPS the reshuffle the real clients performed (each client used the per-seat
/// <c>idxChangeSeed</c> the node sent in its Ready frame: cl1=1430655717, cl2=661650374).
/// - Result: the engine's post-mulligan deck order + Index values diverge from the clients'. A client
/// play of (e.g.) idx8 finds no Index==8 card in the engine hand -> HandCardToField throws -> the
/// shadow logs "diverged"; downstream Played* reads fall back to 0 -> opponent sees spellboost:0.
///
/// This file proves the MECHANISM headless and deterministically:
/// (1) the live-shaped setup leaves the XorShift inactive (reshuffle skipped);
/// (2) seeding it (the verification-only <c>DebugSeedIdxChange</c> hook) ACTIVATES the engine's OWN
/// reshuffle, changing the post-mulligan draw order/indices vs the un-seeded run.
/// </summary>
[TestFixture]
[NonParallelizable]
public class PostMulliganReshuffleRootCauseTests
{
// --- frame bodies (same wire shapes the node emits; mirror HeadlessConductorTests) -------------
private static List<object?> PosIdxList(params (int pos, int idx)[] entries)
{
var list = new List<object?>(entries.Length);
foreach (var (pos, idx) in entries)
list.Add(new Dictionary<string, object?> { ["pos"] = pos, ["idx"] = idx });
return list;
}
// Opening deal: top 3 of each shuffled deck (idx 1,2,3).
private static Dictionary<string, object?> DealBody() => new()
{
["self"] = PosIdxList((0, 1), (1, 2), (2, 3)),
["oppo"] = PosIdxList((0, 1), (1, 2), (2, 3)),
};
// Mulligan AWAY the pos-2 card (deck idx 3) -> the server hands back the next unused deck idx (4).
// The mulliganed-away card returns to the deck; under an ACTIVE XorShift that return triggers the
// reshuffle/re-index. Under the live (inactive) setup it does not.
private static Dictionary<string, object?> SwapBody() => new()
{
["self"] = PosIdxList((0, 1), (1, 2), (2, 4)),
};
// Ready seals the mulligan (sets IsMulliganEnd) and starts turn 1.
//
// CRUCIAL FIDELITY POINT (the live root cause): the real Ready frame is SERVER-AUTHORED and travels
// server->client (it is a "receive" in every capture; no client SEND frame carries idxChangeSeed).
// The live node's BattleSession.ShadowIngest feeds the engine ONLY inbound participant SENDS — so the
// shadow engine NEVER ingests the Ready frame, and the receiver's idxChangeSeed -> CreateXorShift path
// (NetworkBattleReceiver.cs:1125-1126) NEVER runs for the shadow. We model that here by carrying
// idxChangeSeed = -1 (the "engine never received a real seed" state), then optionally injecting the
// seed out-of-band via DebugSeedIdxChange to prove the seed is what drives the reshuffle.
private static Dictionary<string, object?> ReadyBody() => new()
{
["self"] = PosIdxList((0, 1), (1, 2), (2, 4)),
["oppo"] = PosIdxList((0, 1), (1, 2), (2, 3)),
["idxChangeSeed"] = -1,
["spin"] = 0,
};
private static Dictionary<string, object?> TurnStartBody() => new() { ["spin"] = 0 };
// The fresh smoke-capture per-seat idxChange seeds (battle 907324319325): cl1 = seat A self,
// cl2 = seat B. In the live recovery path only the SELF seed is consumed (oppIdxSeed = -1); we pass
// cl2 as the oppo seed here to also activate seat B's reshuffle for the symmetry check.
private const int Cl1SelfSeed = 1430655717;
private const int Cl2Seed = 661650374;
/// <summary>Drive Deal + Swap + Ready + turn-1 TurnStart and return seat A's post-draw hand as
/// (Index, CardId) pairs in hand order. <paramref name="seedIdxChange"/> injects the idxChange seeds
/// BEFORE the mulligan ops (Swap/Ready), so the engine's own reshuffle is active when the abandoned
/// mulligan card is returned to the deck (MulliganCtrl._ReturnAbandonToDeck -> AddToDeck, whose
/// reshuffle gate checks XorShift active AT RETURN TIME) and re-indexed on the next TurnStart.</summary>
private static (List<(int Index, int CardId)> hand, bool selfActive, bool oppoActive, int deckCount) DriveToTurn1(
bool seedIdxChange)
{
// Deck with DISTINCT card identities across the first ~12 positions so a reshuffle is observable in
// CardId (not just Index). All ids are known-creatable headless (harness constants, sourced from the
// tk2 capture / engine tests). Position 30 is padded with the proven vanilla.
var distinctTop = new long[]
{
NodeNativeBattleHarness.VanillaFollowerId, // idx 1
NodeNativeBattleHarness.AltVanillaFollowerId, // idx 2
NodeNativeBattleHarness.VanillaOneOneFollowerId, // idx 3
NodeNativeBattleHarness.HighLifeVanillaFollowerId, // idx 4
NodeNativeBattleHarness.SpellboostCardId, // idx 5
NodeNativeBattleHarness.SpellboostCardIdAlt, // idx 6
NodeNativeBattleHarness.ClanTribeFollowerId, // idx 7
NodeNativeBattleHarness.ChoiceCardId, // idx 8
NodeNativeBattleHarness.BoardDependentCostCardId,// idx 9
NodeNativeBattleHarness.ChoiceTokenA, // idx 10
NodeNativeBattleHarness.ChoiceTokenB, // idx 11
};
var deck = new List<long>(distinctTop);
deck.AddRange(Enumerable.Repeat(NodeNativeBattleHarness.VanillaFollowerId, 30 - deck.Count));
using var harness = NodeNativeBattleHarness.Create(seatADeck: deck, seatBDeck: deck);
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
// Seed BEFORE the mulligan ops so the XorShift is active when the abandoned card returns to deck.
if (seedIdxChange)
harness.DebugSeedIdxChange(Cl1SelfSeed, Cl2Seed);
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
bool selfActive = harness.SelfXorShiftActive;
bool oppoActive = harness.OppoXorShiftActive;
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
Is.True, "turn1 TurnStart");
int handCount = harness.HandCount(playerSeat: true);
var hand = new List<(int, int)>(handCount);
for (int i = 0; i < handCount; i++)
hand.Add((harness.HandCardIndex(playerSeat: true, i), harness.HandCardId(playerSeat: true, i)));
return (hand, selfActive, oppoActive, harness.DeckCount(playerSeat: true));
}
[Test]
public void Live_recovery_setup_leaves_XorShift_inactive_so_reshuffle_is_skipped()
{
// The harness seats the engine EXACTLY as BattleSession does (IsRecovery=true, no idxChange seed),
// so the XorShift must be inactive on BOTH seats — the live (broken) state.
var (handCurrent, selfActive, oppoActive, _) = DriveToTurn1(seedIdxChange: false);
Assert.Multiple(() =>
{
Assert.That(selfActive, Is.False,
"LIVE BUG: seat A XorShift inactive (CreateXorShift(-1,-1)) -> post-mulligan reshuffle SKIPPED");
Assert.That(oppoActive, Is.False, "seat B XorShift also inactive");
});
TestContext.WriteLine("UN-SEEDED (live) turn-1 hand (Index:CardId): " +
string.Join(", ", handCurrent.Select(h => $"{h.Index}:{h.CardId}")));
}
[Test]
public void Seeding_idxChange_flips_the_reshuffle_gate_from_inactive_to_active()
{
// BEFORE: live setup, XorShift inactive -> the reshuffle gate (XorShiftRandom().IsActive, the EXACT
// predicate BattlePlayerBase.cs:3049/3073 check) is CLOSED.
var (handUnseeded, selfActiveU, _, _) = DriveToTurn1(seedIdxChange: false);
// AFTER: inject the captured per-seat idxChange seeds -> the gate predicate is OPEN on both seats.
var (handSeeded, selfActiveS, oppoActiveS, _) = DriveToTurn1(seedIdxChange: true);
TestContext.WriteLine("UN-SEEDED turn-1 hand (Index:CardId): " +
string.Join(", ", handUnseeded.Select(h => $"{h.Index}:{h.CardId}")));
TestContext.WriteLine("SEEDED turn-1 hand (Index:CardId): " +
string.Join(", ", handSeeded.Select(h => $"{h.Index}:{h.CardId}")));
Assert.Multiple(() =>
{
Assert.That(selfActiveU, Is.False, "un-seeded: seat A reshuffle gate CLOSED (XorShift inactive)");
Assert.That(selfActiveS, Is.True, "seeded: seat A reshuffle gate OPEN (XorShift active)");
Assert.That(oppoActiveS, Is.True, "seeded: seat B reshuffle gate OPEN (oppo seed != -1)");
});
// HEADLESS-PATH NOTE (documented limitation, NOT a contradiction of the root cause): the engine's
// recovery mulligan path does not run the XorShift over the MULLIGAN cards. The reshuffle gate at
// BattlePlayerBase.cs:3049 also requires IsMulliganEnd, and on the recovery path
// (RecoveryOperationCollection.SecondMulliganOperation) the abandoned-card return (AddToDeck) runs
// BEFORE IsMulliganEnd is set, so the mulligan cards are never queued into AddToDeckList. The
// XorShift's GetChangeInt is consumed only by AddToDeckCardIndexChange (3079), i.e. cards added to
// the deck AFTER mulligan-end (mid-battle bounce/shuffle effects). So the seeded vs un-seeded turn-1
// hand is IDENTICAL headless via this flow — the gate flips, but no mulligan card flows through the
// re-index headless. The end-to-end draw-divergence the seed drives is proven against the REAL wire
// in the Tier-2 capture-replay test (CaptureReplayReshuffleRootCauseTests), where the engine draws
// by its own (un-reshuffled) deck order while the capture's plays reference the client's
// (reshuffled) order -> the counted "not found in hand" divergences. We assert the headless
// invariance here so the limitation is pinned, not hidden.
Assert.That(handSeeded.Select(h => (h.Index, h.CardId)),
Is.EqualTo(handUnseeded.Select(h => (h.Index, h.CardId))),
"headless mulligan flow does not route mulligan cards through the XorShift re-index (see note) — " +
"the seed's draw effect is proven end-to-end in the Tier-2 capture-replay test");
}
}