feat(battlenode): target/choice ops resolve on engine state via view-untangle (M-HC-4c)
A targeted hand-play (PLAY_HAND_SELECT, opcode 31) and a choice play (PLAY_HAND +
keyAction type Choice) both resolve headless through the recovery receive conductor
with NO new shim/view fills — the 4a/4b view seeds (DetailPanelControl, _inPlayFrameEffect,
_playerInfoPair, HeadlessConductorVfxMgr) already cover the target/choice surface, because
the recovery path resolves targets/choices from the wire frame without the interactive
select UI, and the damage/token VFX execute through the existing top-level InstantVfx path.
Fixtures (cards.json, full skill mechanics):
- single-target: 100414020 (cost-1 Dragoncraft spell, when_play damage=2 to a selected
enemy unit). Asserts the enemy 1/4 (101411060) drops to life 2 — exact magnitude, survives.
- choice: 127011010 (cost-1 Neutral choice follower, choose 1 of 2 tokens to add to hand).
Asserts the chosen token (B) lands in hand and the un-chosen token (A) does not — decisive
about WHICH branch resolved. Wire keyAction shape cross-checked against a real capture of
this exact card (battle_test/rng/battle-traffic_cl1.ndjson); the receiver consumes a flat
selectCard list (ConvertToListInt).
Drivers: NodeNativeBattleHarness.TargetedPlayBody (reuses the {targetIdx,vid,selectSkillIndex}
target shape proven by AttackBody) + ChoicePlayBody. Zero Engine/*.cs edits (drift clean).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -510,6 +510,122 @@ public class HeadlessConductorTests
|
||||
// it would not exercise GetOpposingCardObjTarget / the select view leaves. Add a real fixture (a
|
||||
// follower whose on_evolve targets/damages an opposing card) once a fuller card-skill dump lands.
|
||||
|
||||
// === M-HC-4c: targeted play resolves headless ================================================
|
||||
|
||||
[Test]
|
||||
public void Targeted_damage_spell_resolves_on_engine_state_headless()
|
||||
{
|
||||
// Seat A plays a single-target when_play DAMAGE spell (deal 2 to a selected enemy follower) at an
|
||||
// enemy follower that seat B revealed onto its board. Assert the engine applied the damage headless:
|
||||
// the enemy follower's life drops by exactly the skill's damage amount (2). Driven entirely through
|
||||
// the receive conductor (Push -> engine.Receive -> RecoveryOperationCollection.PlaySkillSelectHandCardOperation
|
||||
// -> PlayHandCardReflection.PlayAction, target resolved via LookForActionDataToTargetCard).
|
||||
//
|
||||
// Seat A deck: uniformly the cost-1 damage spell so whatever idx the shuffle parked at engine Index 1
|
||||
// (the first dealt card) is unambiguously the spell. Seat A's class is the spell's clan (Dragoncraft=4)
|
||||
// so the leader/clan is consistent. Seat B: the high-life vanilla target at deck idx 1 (revealed).
|
||||
var seatADeck = Enumerable.Repeat(NodeNativeBattleHarness.SingleTargetDamageSpellId, 30).ToList();
|
||||
var seatBDeck = new List<long> { NodeNativeBattleHarness.HighLifeVanillaFollowerId };
|
||||
seatBDeck.AddRange(Enumerable.Repeat(NodeNativeBattleHarness.HighLifeVanillaFollowerId, 29));
|
||||
using var harness = NodeNativeBattleHarness.Create(
|
||||
seatADeck: seatADeck, seatBDeck: seatBDeck,
|
||||
seatAClass: SVSim.BattleNode.Bridge.CardClass.Dragoncraft);
|
||||
|
||||
// --- 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.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd");
|
||||
|
||||
// --- seat B turn 2: reveal the high-life follower onto seat B's board ------------------------
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnStart (B)");
|
||||
Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(0), "seat B board empty before reveal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.PlayActions,
|
||||
RevealPlayBody(idx: 1, cardId: NodeNativeBattleHarness.HighLifeVanillaFollowerId), isPlayerSeat: false).Accepted,
|
||||
Is.True, "seat B reveal-play high-life follower");
|
||||
Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(1), "seat B follower on board after reveal");
|
||||
int targetIdx = harness.InPlayCardIndex(playerSeat: false, boardPos: 0);
|
||||
int targetLifeBefore = harness.InPlayCardLife(playerSeat: false, boardPos: 0);
|
||||
Assert.That(targetLifeBefore, Is.EqualTo(NodeNativeBattleHarness.HighLifeVanillaFollowerLife),
|
||||
"target's base life (4) before the spell");
|
||||
|
||||
// --- back to seat A (turn 3): play the damage spell at the enemy follower --------------------
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnEnd (B)");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn3 TurnStart (A)");
|
||||
|
||||
// Locate the cost-1 damage spell in seat A's hand (uniform deck -> first hand card is the spell).
|
||||
int spellIdx = harness.HandCardIndex(playerSeat: true, handPos: 0);
|
||||
Assert.That(harness.HandCardId(playerSeat: true, handPos: 0),
|
||||
Is.EqualTo((int)NodeNativeBattleHarness.SingleTargetDamageSpellId), "seat A hand card is the damage spell");
|
||||
int handBefore = harness.HandCount(playerSeat: true);
|
||||
int ppBefore = harness.Pp(playerSeat: true);
|
||||
|
||||
var play = harness.Push(
|
||||
NetworkBattleUri.PlayActions,
|
||||
NodeNativeBattleHarness.TargetedPlayBody(spellIdx, targetIdx, targetOnEnemySeat: true),
|
||||
isPlayerSeat: true);
|
||||
|
||||
Assert.That(play.Accepted, Is.True, $"targeted spell play rejected: {play.RejectReason}");
|
||||
// The spell actually resolved: it left the hand and charged its cost (guards against an accepted-
|
||||
// but-silently-no-op resolution that would make the damage assertion vacuous).
|
||||
Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(handBefore - 1),
|
||||
"the played spell must leave seat A's hand");
|
||||
Assert.That(harness.Pp(playerSeat: true), Is.LessThan(ppBefore),
|
||||
"the spell's cost must be charged to seat A's PP");
|
||||
// THE assertion: the enemy follower took exactly the skill's damage (2) -> 1/4 survives at life 2.
|
||||
Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(1),
|
||||
"the 1/4 target survives 2 damage (still on board)");
|
||||
Assert.That(harness.InPlayCardLife(playerSeat: false, boardPos: 0),
|
||||
Is.EqualTo(targetLifeBefore - NodeNativeBattleHarness.SingleTargetDamageAmount),
|
||||
"the enemy follower's life must drop by the spell's damage amount (2)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Choice_play_resolves_chosen_branch_on_engine_state_headless()
|
||||
{
|
||||
// Seat A plays a CHOICE card (id 127011010: "choose ONE of two tokens to add to hand") and selects
|
||||
// token B. Assert the engine resolved the CHOSEN branch headless: seat A's hand gains the chosen
|
||||
// token (the choice card itself leaves the hand; the chosen token is drawn in). Driven through the
|
||||
// receive conductor (Push -> engine.Receive). The wire keyAction shape is taken verbatim from a real
|
||||
// capture of THIS card: data_dumps/captures/battle_test/rng/battle-traffic_cl1.ndjson carries
|
||||
// keyAction:[{"type":1,"cardId":127011010,"selectCard":{"cardId":[121011010],"open":0}}].
|
||||
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: true).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);
|
||||
Assert.That(harness.HandCardId(playerSeat: true, handPos: 0),
|
||||
Is.EqualTo((int)NodeNativeBattleHarness.ChoiceCardId), "seat A hand card is the choice card");
|
||||
|
||||
// Choose token B (distinct from token A so the assertion is decisive about WHICH branch resolved).
|
||||
const long chosen = NodeNativeBattleHarness.ChoiceTokenB;
|
||||
var play = harness.Push(
|
||||
NetworkBattleUri.PlayActions,
|
||||
NodeNativeBattleHarness.ChoicePlayBody(choiceIdx, NodeNativeBattleHarness.ChoiceCardId, chosen),
|
||||
isPlayerSeat: true);
|
||||
|
||||
Assert.That(play.Accepted, Is.True, $"choice play rejected: {play.RejectReason}");
|
||||
// The chosen token landed in seat A's hand (token_draw of the CHOSEN id) -> the chosen branch resolved.
|
||||
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 be added to seat A's hand — proving the chosen choice branch resolved");
|
||||
|
||||
// Non-vacuity / decisiveness: the OTHER branch's token (A) must NOT be in hand — i.e. the engine
|
||||
// resolved the SPECIFIC chosen branch, not "any token" or "both". (Token A != token B by construction.)
|
||||
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 — the engine resolved the specific chosen branch");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deal_seats_three_card_hand_headless()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user