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");
}
}