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:
gamer147
2026-06-07 19:05:07 -04:00
parent 2a8c44a6d7
commit addeb021d2
22 changed files with 2263 additions and 62 deletions

View File

@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
using SVSim.BattleNode.Sessions;
@@ -169,7 +170,7 @@ public class HeadlessConductorTests
Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted,
Is.True, "Swap");
var ready = harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true);
var ready = harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false);
Assert.That(ready.Accepted, Is.True, $"Ready rejected: {ready.RejectReason}");
// After Ready the mulligan is sealed and the main phase is entered, but no turn has been
@@ -180,32 +181,29 @@ public class HeadlessConductorTests
Assert.That(harness.Turn(playerSeat: true), Is.EqualTo(0), "no turn opened yet after Ready");
// --- turn 1 (seat A active) -------------------------------------------------------------
// Seat A is the engine's player seat and is NOT game-first here, so turn-1 draws TWO cards
// (the standard second-player turn-1 draw). PP ramps to 1.
// Seat A is game-first (doesPlayerGoFirst: true), so turn-1 draws ONE card. PP ramps to 1.
var t1 = harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true);
Assert.That(t1.Accepted, Is.True, $"turn1 TurnStart rejected: {t1.RejectReason}");
Assert.That(harness.Turn(playerSeat: true), Is.EqualTo(1), "seat A turn counter");
Assert.That(harness.Pp(playerSeat: true), Is.EqualTo(1), "turn 1 ramps seat A max PP to 1");
Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(5),
"turn-1 second-player draw is 2 cards (3 -> 5)");
Assert.That(harness.DeckCount(playerSeat: true), Is.EqualTo(25), "seat A deck after draw");
Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(4),
"turn-1 first-player draw is 1 card (3 mulligan + 1 draw)");
Assert.That(harness.DeckCount(playerSeat: true), Is.EqualTo(26), "seat A deck after draw");
// End seat A's turn.
var t1End = harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true);
Assert.That(t1End.Accepted, Is.True, $"turn1 TurnEnd rejected: {t1End.RejectReason}");
// --- turn 2 (seat B active) -------------------------------------------------------------
// Seat B opens its first turn: PP ramps to 1 and it draws its turn-1 card. (Seat B's deck
// started full at 30 because its opening hand is dealt into hidden zones, not its
// HandCardList, until reveal — so its first visible draw moves deck 30 -> 29, hand 0 -> 1.)
// Seat B is second player (doesPlayerGoFirst: true → enemy goes second). Ready's
// isPlayerSeat=false triggers OperateOppoMulligan → DrawFirstMulliganCard, moving 3 dealt
// cards from deck to hand. Turn-1 draws 2 (second player draws 2 on turn 1).
var t2 = harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false);
Assert.That(t2.Accepted, Is.True, $"turn2 TurnStart rejected: {t2.RejectReason}");
Assert.That(harness.Turn(playerSeat: false), Is.EqualTo(1), "seat B turn counter");
Assert.That(harness.Pp(playerSeat: false), Is.EqualTo(1), "turn 2 ramps seat B max PP to 1");
Assert.That(harness.HandCount(playerSeat: false), Is.EqualTo(1), "seat B turn-1 draw");
// Seat B's opening hand was dealt into hidden zones (not HandCardList), so its deck started at 30;
// the single turn-1 draw brings it to 29.
Assert.That(harness.DeckCount(playerSeat: false), Is.EqualTo(29), "seat B deck after turn-1 draw");
Assert.That(harness.HandCount(playerSeat: false), Is.EqualTo(5), "seat B hand: 3 mulligan + 2 turn-1 draws");
Assert.That(harness.DeckCount(playerSeat: false), Is.EqualTo(25), "seat B deck: 30 - 3 mulligan - 2 draws");
// Both leaders untouched (no damage dealt across the two opening turns) — state tracks
// cleanly on BOTH seats at the turn boundary.
@@ -213,6 +211,140 @@ public class HeadlessConductorTests
Assert.That(harness.LeaderLife(playerSeat: false), Is.EqualTo(20), "seat B leader life");
}
[Test]
public void Seat_A_play_after_partial_mulligan_finds_kept_card()
{
// Regression: a partial mulligan (swap 1 of 3) must leave the kept cards in hand.
// Matches live battle 175320039619: A (cl2, Forestcraft) swaps idx 1,2 (keeps 3).
// Includes BOTH client Swaps + server Swap responses (the full live frame stream).
var aDeck = new List<long> { 101121080,102131020,100111010,102121030,101121020,101121110,101114010,100111010,102141010,102121010,101121020,102131030,701141011,100111020,101131050,100111020,100111070,101121010,100111070,101121080,100114010,101121110,101114050,101114050,100114010,100114010,102111060,113011010,102121030,102131010,100111020,101114050,101121080,101121010,101131020,113011010,113011010,101114010,102111060,102121010 };
var bDeck = Enumerable.Repeat(NodeNativeBattleHarness.VanillaFollowerId, 30).ToList();
using var harness = NodeNativeBattleHarness.Create(
seatADeck: aDeck, seatBDeck: bDeck,
seatAClass: CardClass.Forestcraft, seatBClass: CardClass.Runecraft);
harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true);
// Client Swap from A (idxList only — no "self")
harness.Push(NetworkBattleUri.Swap, new Dictionary<string, object?> { ["idxList"] = new List<object?> { 2, 1 } }, isPlayerSeat: true);
// Server Swap response to A
harness.Push(NetworkBattleUri.Swap, new Dictionary<string, object?> { ["self"] = PosIdxList((0, 4), (1, 5), (2, 3)) }, isPlayerSeat: true);
// Client Swap from B (no mulligan)
harness.Push(NetworkBattleUri.Swap, new Dictionary<string, object?> { ["idxList"] = new List<object?>() }, isPlayerSeat: false);
// Server Swap response to B
harness.Push(NetworkBattleUri.Swap, new Dictionary<string, object?> { ["self"] = PosIdxList((0, 1), (1, 2), (2, 3)) }, isPlayerSeat: false);
// Ready (from A's perspective)
harness.Push(NetworkBattleUri.Ready, new Dictionary<string, object?>
{
["self"] = PosIdxList((0, 4), (1, 5), (2, 3)),
["oppo"] = PosIdxList((0, 1), (1, 2), (2, 4)),
["idxChangeSeed"] = 1463392880, ["spin"] = 0,
}, isPlayerSeat: false);
harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true);
var handIdxs = Enumerable.Range(0, harness.HandCount(playerSeat: true))
.Select(i => harness.HandCardIndex(playerSeat: true, i)).ToList();
TestContext.WriteLine($"A hand after T1: [{string.Join(",", handIdxs)}]");
Assert.That(handIdxs, Does.Contain(3), "kept card idx 3 must be in A hand");
var play = harness.Push(NetworkBattleUri.PlayActions, PlayBody(3), isPlayerSeat: true);
Assert.That(play.Accepted, Is.True, $"A play idx 3 rejected: {play.RejectReason}");
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "A board after play");
}
[Test]
public void Seed_deck_advances_cardTotalNum_so_tokens_dont_collide_with_deck_indices()
{
// Regression for the engine-divergence diagnosed 2026-06-07 (bid 806245601092).
//
// The real client's SBattleLoad.InitPlayer (SBattleLoad.cs:1292) loads the 40-card deck at
// indices 1..40 and THEN sets `cardTotalNum = deck.Count + 1` (== 41), so the first
// skill-generated token (via BattleManagerBase.SetupCardIndex with addIndex=-1) gets Index
// 41 — exactly what the wire `add.idx` carries (e.g. `{"add":{"idx":[41,42],...}}`).
//
// The headless SessionBattleEngine.SeedDeck used to omit that tail, leaving `cardTotalNum`
// at the property default (0). The first generated token then got Index 0, the second got
// Index 1, and they COLLIDED with deck-loaded cards at the same indices. The collision was
// silent until something addressed the deck card with the colliding Index: Hoverboarder at
// deck idx 1 made GetBattleCardIdx's SingleOrDefault find TWO Index-1 cards and throw
// "Sequence contains more than one matching element".
//
// The contract verified here: after Setup, `cardTotalNum` MUST equal `deck.Count + 1` on
// both seats. This pins SBattleLoad's tail behavior in the headless engine.
const int deckSize = 30; // NodeNativeBattleHarness.DefaultDeck is 30 cards
using var harness = NodeNativeBattleHarness.Create();
Assert.That(harness.IsReady, Is.True, "engine seats headless");
Assert.Multiple(() =>
{
Assert.That(harness.DebugCardTotalNum(playerSeat: true), Is.EqualTo(deckSize + 1),
"seat A cardTotalNum must be deck.Count+1 after Setup (= next token Index >= deck.Count+1)");
Assert.That(harness.DebugCardTotalNum(playerSeat: false), Is.EqualTo(deckSize + 1),
"seat B cardTotalNum must be deck.Count+1 after Setup");
});
}
[Test]
public void Engine_stableRandom_seed_aligns_with_wire_seed_clients_receive()
{
// Regression for the shadow-engine desync diagnosed 2026-06-07 (bid 654473755566).
//
// CLIENTS seed System.Random with Matched.seed (BattleManagerBase.cs:721), which the node
// sends as BattleSeeds.Stable(MasterSeed) (InitBattleHandler.cs:28). The engine must seed its
// _stableRandom with the SAME value; otherwise the very first NextDouble() returns a different
// number, every turn-1+ StableRandom-driven draw picks a different deck position, and the
// opponent's first non-mulligan play addresses a card the engine never drew → HandCardToField
// throws.
//
// Before the fix, engine.Setup received the raw MasterSeed (1184631275 in the live battle),
// while clients received BattleSeeds.Stable(MasterSeed) (=1543475792). After the fix,
// BattleSession.EnsureEngineSetup + NodeNativeBattleHarness.Create both pass the Stable-derived
// value, so both streams produce the same NextDouble sequence.
const int masterSeed = 1184631275; // the bid 654473755566 master seed
int wireSeed = BattleSeeds.Stable(masterSeed);
// The first NextDouble a fresh client would consume (turn-1 first-player draw is the very
// first _stableRandom consumer — Deal/Swap/Ready don't touch _stableRandom).
double expectedFirstDouble = new System.Random(wireSeed).NextDouble();
using var harness = NodeNativeBattleHarness.Create(masterSeed: masterSeed);
Assert.That(harness.IsReady, Is.True, "engine seats headless");
double engineFirstDouble = harness.DebugStableRandomDouble();
Assert.That(engineFirstDouble, Is.EqualTo(expectedFirstDouble),
$"engine _stableRandom must be seeded with BattleSeeds.Stable({masterSeed})={wireSeed} " +
"(the value Matched.seed ships clients); otherwise turn-1+ draws desync from the clients.");
}
[Test]
public void Seat_B_vanilla_play_resolves_on_engine_state()
{
// Seat B (opponent/enemy) plays a vanilla follower on its first turn. Uses an all-vanilla
// deck so no spell-path interference. Verifies the doesPlayerGoFirst:true seat mapping
// lets B's play resolve through the engine (hand→board mutation).
var allVanilla = Enumerable.Repeat(NodeNativeBattleHarness.VanillaFollowerId, 30).ToList();
using var harness = NodeNativeBattleHarness.Create(seatADeck: allVanilla, seatBDeck: allVanilla);
harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true);
harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true);
harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false);
// A's turn
harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true);
harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true);
// B's turn (second player, draws 2)
harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false);
Assert.That(harness.HandCount(playerSeat: false), Is.EqualTo(5), "B hand: 3 mulligan + 2 draws");
var bPlay = harness.Push(NetworkBattleUri.PlayActions, PlayBody(3), isPlayerSeat: false);
Assert.That(bPlay.Accepted, Is.True, $"B play rejected: {bPlay.RejectReason}");
Assert.That(harness.HandCount(playerSeat: false), Is.EqualTo(4), "B hand after play");
Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(1), "B board after play");
}
[Test]
public void Opponent_reveal_seats_card_on_seat_B_headless()
{
@@ -229,7 +361,7 @@ public class HeadlessConductorTests
Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted,
Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted,
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted,
Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
Is.True, "turn1 TurnStart");
@@ -294,7 +426,7 @@ public class HeadlessConductorTests
Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted,
Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted,
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted,
Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
Is.True, "turn1 TurnStart");
@@ -335,7 +467,7 @@ public class HeadlessConductorTests
// --- mulligan + open seat A turn 1 ------------------------------------------------------------
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
Is.True, "turn1 TurnStart");
@@ -391,7 +523,7 @@ public class HeadlessConductorTests
// --- mulligan + seat A turn 1: play the 1/1 -------------------------------------------------
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted, Is.True, "turn1 play 1/1");
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "seat A 1/1 on board");
@@ -448,7 +580,7 @@ public class HeadlessConductorTests
// --- mulligan + open seat A turn 1, play the vanilla onto seat A's board --------------------
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
Is.True, "turn1 TurnStart");
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted,
@@ -530,7 +662,7 @@ public class HeadlessConductorTests
// --- mulligan + open seat A turn 1, end it (no enemy target yet) -----------------------------
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd");
@@ -634,7 +766,7 @@ public class HeadlessConductorTests
// seat A turn 1: play a 1/2 onto seat A's board.
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart (A)");
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted, Is.True, "turn1 play 1/2 (A)");
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "one seat A follower on board");
@@ -686,7 +818,7 @@ public class HeadlessConductorTests
// seat A turn 1: play a 1/1.
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted, Is.True, "turn1 play 1/1");
int attackerIdx = harness.InPlayCardIndex(playerSeat: true, boardPos: 0);
@@ -729,7 +861,7 @@ public class HeadlessConductorTests
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
int choiceIdx = harness.HandCardIndex(playerSeat: true, handPos: 0);
@@ -767,6 +899,65 @@ public class HeadlessConductorTests
"the UN-chosen token (A) must NOT be added — the engine resolved the specific chosen branch");
}
[Test]
public void Choice_play_resolves_under_wrapped_selectCard_wire_shape()
{
// Regression for the engine silently-dropped Choice play diagnosed 2026-06-07
// (bid 131549100204): the SENDER's live wire wraps selectCard as
// selectCard:{cardId:[<tokenId>], open:0}
// (verified in data_dumps/captures/battle_test/cl1/battle-traffic.ndjson at the Resonance
// play of idx 20). The engine's receive parser reads selectCard via ConvertToListInt
// (NetworkBattleReceiver.cs:1202), which does `value as List<object>` — a Dictionary value
// casts to null and the inner foreach NREs. The surrounding ConvertReceiveDataToMakeData has
// a swallow-all 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 propagated. The play
// continues with choiceIdList=[], never moves the card from hand to board, and any LATER
// targeted play that addresses the un-resolved card by Index (e.g. a bounce spell) crashes
// with a null target.
//
// Fix: SessionBattleEngine.TranslateChoiceKeyAction unwraps the wrapped selectCard on the
// engine's own dict copy before the receiver sees it (sibling to TranslateTargetOwners). The
// unwrap is purely a shadow-ingest shape transformation — production engine code is
// unchanged, and the opponent-facing relay (which never carries selectCard at all) is
// untouched. After the unwrap, the same resolution path that the existing flat-list test
// (Choice_play_resolves_chosen_branch_on_engine_state_headless) exercises must produce the
// same outcome.
var seatADeck = Enumerable.Repeat(NodeNativeBattleHarness.ChoiceCardId, 30).ToList();
using var harness = NodeNativeBattleHarness.Create(seatADeck: seatADeck);
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
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");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
int choiceIdx = harness.HandCardIndex(playerSeat: true, handPos: 0);
int ppBefore = harness.Pp(playerSeat: true);
const long chosen = NodeNativeBattleHarness.ChoiceTokenB;
// Drive the play using the WRAPPED wire shape — the exact form a live client emits.
var play = harness.Push(
NetworkBattleUri.PlayActions,
NodeNativeBattleHarness.ChoicePlayBodyWrapped(choiceIdx, NodeNativeBattleHarness.ChoiceCardId, chosen),
isPlayerSeat: true);
Assert.That(play.Accepted, Is.True, $"wrapped-selectCard choice play rejected: {play.RejectReason}");
Assert.That(harness.Pp(playerSeat: true), Is.LessThan(ppBefore),
"the choice card's cost must charge PP — confirms the play actually resolved, not silently dropped");
bool chosenInHand = false;
for (int i = 0; i < harness.HandCount(playerSeat: true); i++)
if (harness.HandCardId(playerSeat: true, i) == (int)chosen) { chosenInHand = true; break; }
Assert.That(chosenInHand, Is.True,
"the chosen token (B) must land in seat A's hand — proves the CHOSEN branch resolved through the wrapped wire shape");
bool otherInHand = false;
for (int i = 0; i < harness.HandCount(playerSeat: true); i++)
if (harness.HandCardId(playerSeat: true, i) == (int)NodeNativeBattleHarness.ChoiceTokenA) { otherInHand = true; break; }
Assert.That(otherInHand, Is.False,
"the UN-chosen token (A) must NOT be added — decisive that the unwrap forwarded the SPECIFIC chosen id, not a default or both");
}
[Test]
public void Deal_seats_three_card_hand_headless()
{
@@ -845,7 +1036,7 @@ public class HeadlessConductorTests
Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted,
Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted,
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted,
Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
Is.True, "turn1 TurnStart");
@@ -935,7 +1126,7 @@ public class HeadlessConductorTests
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
Is.True, "turn1 TurnStart");
@@ -1049,7 +1240,7 @@ public class HeadlessConductorTests
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
// Ramp to seat A turn 3 (PP 3) so the cost-3 grantor is affordable.
RampToSeatATurn(harness, targetTurn: 3);
@@ -1110,7 +1301,7 @@ public class HeadlessConductorTests
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
RampToSeatATurn(harness, targetTurn: 3);
int reducerIdx = FindHandIdxByCardId(harness, SpellboostReducerId);
@@ -1194,7 +1385,7 @@ public class HeadlessConductorTests
// --- mulligan + open seat A turn 1, play a vanilla follower onto seat A's board -------------
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
Is.True, "turn1 TurnStart");
@@ -1295,7 +1486,7 @@ public class HeadlessConductorTests
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
Is.True, "turn1 TurnStart");
@@ -1322,7 +1513,7 @@ public class HeadlessConductorTests
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
Is.True, "turn1 TurnStart");
@@ -1347,7 +1538,7 @@ public class HeadlessConductorTests
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
Is.True, "turn1 TurnStart");
@@ -1442,7 +1633,7 @@ public class HeadlessConductorTests
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnStart (B)");
@@ -1477,7 +1668,7 @@ public class HeadlessConductorTests
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
int choiceIdx = harness.HandCardIndex(playerSeat: true, handPos: 0);

View File

@@ -1,4 +1,5 @@
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions;
using SVSim.BattleNode.Sessions.Dispatch;
@@ -219,7 +220,10 @@ internal sealed class NodeNativeBattleHarness : IDisposable
var shuffledB = state.GetShuffledDeck(b);
var engine = new SessionBattleEngine();
engine.Setup(state.MasterSeed, shuffledA, shuffledB,
// Mirror BattleSession.EnsureEngineSetup: engine's StableRandom is seeded with
// BattleSeeds.Stable(MasterSeed), the value the Matched frame ships to clients
// (InitBattleHandler.cs:28). See BattleSession.cs for the full root-cause comment.
engine.Setup(BattleSeeds.Stable(state.MasterSeed), shuffledA, shuffledB,
(int)a.Context.ClassId, (int)b.Context.ClassId);
return new NodeNativeBattleHarness(state, a, b, engine, shuffledA, shuffledB);
@@ -259,6 +263,17 @@ internal sealed class NodeNativeBattleHarness : IDisposable
/// <summary>The engine Index of the hand card at <paramref name="handPos"/> on the given seat.</summary>
public int HandCardIndex(bool playerSeat, int handPos) => Engine.HandCardIndex(playerSeat, handPos);
/// <summary>TEST/DEBUG: pull one value from the engine's shared <c>_stableRandom</c> stream. Mirrors the
/// engine's <see cref="SessionBattleEngine.DebugStableRandomDouble"/> seam; lets a regression test
/// assert seed alignment with the wire (clients seed their <c>_stableRandom</c> with the Matched.seed,
/// which is <c>BattleSeeds.Stable(masterSeed)</c>).</summary>
public double DebugStableRandomDouble() => Engine.DebugStableRandomDouble();
/// <summary>TEST/DEBUG: read the seat's auto-assign Index counter (<c>cardTotalNum</c>). After
/// Setup it must equal <c>deck.Count + 1</c> so the next skill-generated token gets an Index
/// clear of the deck-loaded 1..40 (= the real client's SBattleLoad behavior).</summary>
public int DebugCardTotalNum(bool playerSeat) => Engine.DebugCardTotalNum(playerSeat);
/// <summary>The real wire <c>CardId</c> of the in-play follower at <paramref name="boardPos"/> on the
/// given seat (0-based, leader excluded). Asserts an opponent reveal seated the substituted identity
/// (M-HC-2).</summary>
@@ -286,6 +301,19 @@ internal sealed class NodeNativeBattleHarness : IDisposable
/// <summary>Turns remaining until the seat may evolve (0 == unlocked) (M-HC-4b).</summary>
public int EvolveWaitTurnCount(bool playerSeat) => Engine.EvolveWaitTurnCount(playerSeat);
// --- TEST/DEBUG seams (Phase 4 root-cause verification: post-mulligan reshuffle) ---------------
/// <summary>TEST/DEBUG: is the engine's SELF-seat XorShift idx-change RNG active (the gate the
/// post-mulligan reshuffle checks)? Live recovery setup leaves it FALSE.</summary>
public bool SelfXorShiftActive => Engine.SelfXorShiftActive;
/// <summary>TEST/DEBUG: opponent-seat XorShift active state.</summary>
public bool OppoXorShiftActive => Engine.OppoXorShiftActive;
/// <summary>TEST/DEBUG: inject the per-seat idxChange seeds (call before the Ready mulligan-end frame
/// to activate the engine's own post-mulligan reshuffle).</summary>
public void DebugSeedIdxChange(int selfSeed, int oppoSeed) => Engine.DebugSeedIdxChange(selfSeed, oppoSeed);
/// <summary>Build an envelope for <paramref name="body"/> and ingest it into the engine for the
/// given seat (player == seat A). Mirrors <c>BattleNodeFlowTests.MakeEnvelopeWith</c> +
/// <c>SessionBattleEngine.Receive</c>.</summary>
@@ -420,6 +448,36 @@ internal sealed class NodeNativeBattleHarness : IDisposable
},
};
/// <summary>VERBATIM CLIENT-SEND Choice play shape — the wrapped form
/// <c>selectCard:{cardId:[&lt;tokenId&gt;], open:&lt;0|1&gt;}</c> the sender's wire actually carries
/// (data_dumps/captures/battle_test/cl1/battle-traffic.ndjson, live bid 131549100204:
/// <c>"selectCard":{"cardId":[121011010],"open":0}</c>). The shadow engine's ingest receives this
/// wrapper directly (the node strips selectCard from the opponent broadcast, so opponent-facing
/// frames never see it); <see cref="Engine.SessionBattleEngine.TranslateChoiceKeyAction"/>
/// unwraps it on the engine's own dict copy before the receiver parses keyAction. This driver
/// exists so a regression test can pin that unwrap end-to-end against the SAME shape the live
/// wire delivers, distinct from <see cref="ChoicePlayBody"/> which fast-paths the flat list.
/// <paramref name="open"/> defaults to 0 (choice hidden from opponent) — the value the live
/// capture carries; flag is dropped by the unwrap and irrelevant to resolution.</summary>
public static Dictionary<string, object?> ChoicePlayBodyWrapped(int playIdx, long playedCardId, long chosenTokenId, int open = 0) => new()
{
["playIdx"] = playIdx,
["type"] = 30,
["keyAction"] = new List<object?>
{
new Dictionary<string, object?>
{
["type"] = "Choice",
["cardId"] = playedCardId,
["selectCard"] = new Dictionary<string, object?>
{
["cardId"] = new List<object?> { chosenTokenId },
["open"] = open,
},
},
},
};
/// <summary>The engine's <c>NetworkBattleDefine.PlayActionType.EVOLUTION</c> opcode — confirmed
/// <c>= 20</c> in <c>SVSim.BattleEngine/Engine/NetworkBattleDefine.cs</c> (EVOLUTION_SELECT is 21). The
/// receiver maps the wire <c>type</c> int straight to the enum; EVOLUTION/EVOLUTION_SELECT route through

View File

@@ -0,0 +1,198 @@
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");
}
}

View File

@@ -141,7 +141,7 @@ public class ServerBattleFramesTests
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, idxChangeSeed: 555_000);
var body = (ReadyBody)env.Body;
Assert.That(body.IdxChangeSeed, Is.EqualTo(555_000));
Assert.That(body.Spin, Is.EqualTo(243));
Assert.That(body.Spin, Is.EqualTo(0));
Assert.That(body.Self[1].Idx, Is.EqualTo(4));
}

View File

@@ -143,7 +143,7 @@ public class TypedBodyWireShapeTests
var node = JsonNode.Parse(json)!.AsObject();
Assert.That(node["idxChangeSeed"]!.GetValue<int>(), Is.EqualTo(771_335_280));
Assert.That(node["spin"]!.GetValue<int>(), Is.EqualTo(243));
Assert.That(node["spin"]!.GetValue<int>(), Is.EqualTo(0));
Assert.That(node["self"]!.AsArray().Count, Is.EqualTo(3));
Assert.That(node["oppo"]!.AsArray().Count, Is.EqualTo(3));
}

View File

@@ -44,12 +44,12 @@ public class HandBodiesTests
Self: new[] { new PosIdx(0, 1) },
Oppo: new[] { new PosIdx(0, 1) },
IdxChangeSeed: 771_335_280,
Spin: 243);
Spin: 0);
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
Assert.That(node["idxChangeSeed"]!.GetValue<int>(), Is.EqualTo(771_335_280));
Assert.That(node["spin"]!.GetValue<int>(), Is.EqualTo(243));
Assert.That(node["spin"]!.GetValue<int>(), Is.EqualTo(0));
Assert.That(node["resultCode"]!.GetValue<int>(), Is.EqualTo(1));
}
}