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