using System.Collections.Generic; using System.Linq; using NUnit.Framework; using SVSim.BattleNode.Protocol; namespace SVSim.UnitTests.BattleNode.Integration; /// /// 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 mgr.IsRecovery = true. Under IsRecovery the engine seeds its /// post-mulligan deck-reshuffle RNG from RecoveryManager.IdxChangeSeed /// (NetworkBattleManagerBase.cs:259-261). The node's RecoveryManager is NullRecoveryManager, /// whose IdxChangeSeed == -1, so the engine runs CreateXorShift(-1, -1). /// - CreateXorShift only builds an XorShift when seed != -1 /// (BattleManagerBase.cs:806-815), and new XorShift(-1) sets IsActive = false /// (BattleManagerBase.cs:48). So both seats' XorShift stay null/inactive. /// - The post-mulligan deck reshuffle + card re-index (AddToDeck gate at BattlePlayerBase.cs:3049 /// queues returned cards; AddToDeckCardIndexChange at 3073-3084 repositions/renumbers them) is /// gated on XorShiftRandom(...) != null && .IsActive && IsMulliganEnd. With the XorShift /// inactive the engine SKIPS the reshuffle the real clients performed (each client used the per-seat /// idxChangeSeed 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 DebugSeedIdxChange hook) ACTIVATES the engine's OWN /// reshuffle, changing the post-mulligan draw order/indices vs the un-seeded run. /// [TestFixture] [NonParallelizable] public class PostMulliganReshuffleRootCauseTests { // --- frame bodies (same wire shapes the node emits; mirror HeadlessConductorTests) ------------- private static List PosIdxList(params (int pos, int idx)[] entries) { var list = new List(entries.Length); foreach (var (pos, idx) in entries) list.Add(new Dictionary { ["pos"] = pos, ["idx"] = idx }); return list; } // Opening deal: top 3 of each shuffled deck (idx 1,2,3). private static Dictionary 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 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 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 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; /// Drive Deal + Swap + Ready + turn-1 TurnStart and return seat A's post-draw hand as /// (Index, CardId) pairs in hand order. 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. 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(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"); } }