diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs index 814867e..947295f 100644 --- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs +++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs @@ -161,6 +161,14 @@ internal sealed class SessionBattleEngine /// a card dealt from the seeded deck. public int HandCardIndex(bool playerSeat, int handPos) => Seat(playerSeat).HandCardList[handPos].Index; + /// The real CardId (wire identity) of the in-play follower at + /// (0-based, skipping the leader/Class card at ClassAndInPlayCardList[0] — same convention as + /// ). Used to assert an opponent reveal seated the substituted card with its + /// true identity (M-HC-2): before the reveal the slot holds a hidden dummy (cardId 0); after, the + /// engine-resolved actual card carries the wire cardId. + public int InPlayCardId(bool playerSeat, int boardPos) => + Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].CardId; + private engine::BattlePlayerBase Seat(bool playerSeat) => (_mgr ?? throw new InvalidOperationException("read before Setup")).GetBattlePlayer(playerSeat); diff --git a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs index 28c891a..fed1845 100644 --- a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs @@ -92,6 +92,28 @@ public class HeadlessConductorTests private static Dictionary TurnStartBody() => new() { ["spin"] = 0 }; private static Dictionary TurnEndBody() => new() { ["turnState"] = 0 }; + // An opponent play that REVEALS the played card. The wire shape is taken verbatim from + // battle_test_cl2.ndjson's first opponent PlayActions frame: + // { playIdx, type:30, knownList:[{idx, cardId, to:30, spellboost:0, attachTarget:""}] } + // type 30 == PLAY_HAND; knownList[].idx == the hidden dummy's engine Index; cardId == the real + // identity to substitute; to 30 == NetworkCardPlaceState.Field (the card lands in play). + private static Dictionary RevealPlayBody(int idx, long cardId) => new() + { + ["playIdx"] = idx, + ["type"] = 30, + ["knownList"] = new List + { + new Dictionary + { + ["idx"] = idx, + ["cardId"] = cardId, + ["to"] = 30, + ["spellboost"] = 0, + ["attachTarget"] = "", + }, + }, + }; + [Test] public void Swap_seats_post_mulligan_hand_headless() { @@ -176,6 +198,55 @@ public class HeadlessConductorTests Assert.That(harness.LeaderLife(playerSeat: false), Is.EqualTo(20), "seat B leader life"); } + [Test] + public void Opponent_reveal_seats_card_on_seat_B_headless() + { + // Seat B's deck idx 1 is a known vanilla follower, so the reveal's wire cardId maps to a real + // card the opponent can play to the board. (Seat A's deck is left at default — irrelevant here.) + var seatBDeck = new List { NodeNativeBattleHarness.VanillaFollowerId }; + seatBDeck.AddRange(NodeNativeBattleHarness.DefaultDeck()); + seatBDeck = seatBDeck.GetRange(0, 30); + + using var harness = NodeNativeBattleHarness.Create(seatBDeck: seatBDeck); + + // --- drive to seat B's turn (reuse Task 3's two-turn sequence) --------------------------- + 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.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 (seat B active)"); + + // Seat B's opening hand is hidden (deck reads full minus its single turn-1 draw); its cards + // have NOT been disclosed to the relay yet. The dummy at engine Index 1 is the deck-idx-1 card + // (the vanilla follower), seated in a hidden zone — NOT on the board. Confirm seat B's board is + // empty BEFORE the reveal, so the post-reveal +1 is decisively the reveal seating the card. + // (Node-native, the harness seeds each side's cards with their real id rather than cardId-0 + // dummies — it knows both decks — so the reveal substitution is identity-preserving here; the + // board delta is what proves ReplaceReceivedCard.ReplaceCard -> CreateActualCard resolved the + // card onto the board headless. M-HC-5 exercises a true cardId-0 -> cardId substitution.) + var boardBefore = harness.BoardCount(playerSeat: false); + Assert.That(boardBefore, Is.EqualTo(0), "seat B has no board followers before the reveal"); + + // --- the reveal: an opponent PlayActions frame carrying a knownList that discloses idx 1 --- + const long revealedCardId = NodeNativeBattleHarness.VanillaFollowerId; + var reveal = harness.Push( + NetworkBattleUri.PlayActions, RevealPlayBody(idx: 1, cardId: revealedCardId), + isPlayerSeat: false); + + Assert.That(reveal.Accepted, Is.True, $"opponent reveal rejected: {reveal.RejectReason}"); + Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(boardBefore + 1), + "the revealed follower must seat on seat B's board"); + Assert.That(harness.InPlayCardId(playerSeat: false, boardPos: 0), Is.EqualTo((int)revealedCardId), + "the seated card's identity must equal the wire cardId from the reveal"); + } + [Test] public void Deal_seats_three_card_hand_headless() { diff --git a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs index f9bd6d9..1e261e9 100644 --- a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs +++ b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs @@ -131,6 +131,11 @@ internal sealed class NodeNativeBattleHarness : IDisposable /// Play frame would carry to play it). public int PlayerHandCardIndex(int handPos) => Engine.HandCardIndex(playerSeat: true, handPos); + /// The real wire CardId of the in-play follower at on the + /// given seat (0-based, leader excluded). Asserts an opponent reveal seated the substituted identity + /// (M-HC-2). + public int InPlayCardId(bool playerSeat, int boardPos) => Engine.InPlayCardId(playerSeat, boardPos); + /// Build an envelope for and ingest it into the engine for the /// given seat (player == seat A). Mirrors BattleNodeFlowTests.MakeEnvelopeWith + /// SessionBattleEngine.Receive.