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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user