feat(battlenode): opponent reveal resolves on engine state via ReplaceReceivedCards (M-HC-2)

Drive a node-native battle to seat B's turn, then ingest an opponent
PlayActions reveal frame (knownList[{idx,cardId,to:Field}], isPlayerSeat:false)
matching battle_test_cl2's wire shape. The engine's ReplaceReceivedCard.ReplaceCard
-> CreateActualCard -> CreateBattleCardWithGameObject path resolves headless and
seats the substituted card on seat B's board with the wire cardId. No Engine/ logic
edits and no new view shims were needed — the card-creation view surface is fully
covered by the BackGround/icon-anim/play-queue/hand stubs from Tasks 2/3.

Adds InPlayCardId(seat, boardPos) accessor (SessionBattleEngine + harness) to read a
seated in-play follower's true identity, leader-excluded like BoardCount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-06 20:51:55 -04:00
parent b1d17fb97d
commit 07ffc8906d
3 changed files with 84 additions and 0 deletions

View File

@@ -161,6 +161,14 @@ internal sealed class SessionBattleEngine
/// a card dealt from the seeded deck.</summary>
public int HandCardIndex(bool playerSeat, int handPos) => Seat(playerSeat).HandCardList[handPos].Index;
/// <summary>The real <c>CardId</c> (wire identity) of the in-play follower at <paramref name="boardPos"/>
/// (0-based, skipping the leader/Class card at ClassAndInPlayCardList[0] — same convention as
/// <see cref="BoardCount"/>). 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.</summary>
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);

View File

@@ -92,6 +92,28 @@ public class HeadlessConductorTests
private static Dictionary<string, object?> TurnStartBody() => new() { ["spin"] = 0 };
private static Dictionary<string, object?> 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<string, object?> RevealPlayBody(int idx, long cardId) => new()
{
["playIdx"] = idx,
["type"] = 30,
["knownList"] = new List<object?>
{
new Dictionary<string, object?>
{
["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<long> { 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()
{

View File

@@ -131,6 +131,11 @@ internal sealed class NodeNativeBattleHarness : IDisposable
/// Play frame would carry to play it).</summary>
public int PlayerHandCardIndex(int handPos) => Engine.HandCardIndex(playerSeat: true, handPos);
/// <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>
public int InPlayCardId(bool playerSeat, int boardPos) => Engine.InPlayCardId(playerSeat, boardPos);
/// <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>