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>
199 lines
12 KiB
C#
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 && .IsActive && 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");
|
|
}
|
|
}
|