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>
|
/// a card dealt from the seeded deck.</summary>
|
||||||
public int HandCardIndex(bool playerSeat, int handPos) => Seat(playerSeat).HandCardList[handPos].Index;
|
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) =>
|
private engine::BattlePlayerBase Seat(bool playerSeat) =>
|
||||||
(_mgr ?? throw new InvalidOperationException("read before Setup")).GetBattlePlayer(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?> TurnStartBody() => new() { ["spin"] = 0 };
|
||||||
private static Dictionary<string, object?> TurnEndBody() => new() { ["turnState"] = 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]
|
[Test]
|
||||||
public void Swap_seats_post_mulligan_hand_headless()
|
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");
|
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]
|
[Test]
|
||||||
public void Deal_seats_three_card_hand_headless()
|
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>
|
/// Play frame would carry to play it).</summary>
|
||||||
public int PlayerHandCardIndex(int handPos) => Engine.HandCardIndex(playerSeat: true, handPos);
|
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
|
/// <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> +
|
/// given seat (player == seat A). Mirrors <c>BattleNodeFlowTests.MakeEnvelopeWith</c> +
|
||||||
/// <c>SessionBattleEngine.Receive</c>.</summary>
|
/// <c>SessionBattleEngine.Receive</c>.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user