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:
gamer147
2026-06-06 23:34:39 -04:00
parent 2e8f9ab64e
commit 3add58f939
2 changed files with 217 additions and 0 deletions

View File

@@ -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()
{