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