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

View File

@@ -70,6 +70,42 @@ internal sealed class NodeNativeBattleHarness : IDisposable
/// (M-HC-4a follower trade). Present + creatable in cards.json (no skill, char_type 1, cost 1, 1/1).</summary>
public const long VanillaOneOneFollowerId = 900011080;
/// <summary>A SIMPLE single-target when_play DAMAGE spell (M-HC-4c fixture). cards.json id 100414020:
/// char_type 4 (spell), clan 4 (Dragoncraft), cost 1, skill <c>damage</c> / skill_timing
/// <c>when_play</c> / skill_target <c>character=op&amp;target=inplay&amp;card_type=unit&amp;select_count=1</c>
/// / skill_option <c>damage=2</c> — i.e. "deal 2 damage to a selected enemy follower". Concrete sane
/// cost (1), no board-state-dependent magnitude, no condition beyond an enemy unit existing — the
/// cleanest targeted-play fixture in the current dump. Present + creatable in cards.json.</summary>
public const long SingleTargetDamageSpellId = 100414020;
/// <summary>The flat damage magnitude of <see cref="SingleTargetDamageSpellId"/> (skill_option
/// <c>damage=2</c>). The targeted-play test asserts the enemy follower's life drops by exactly this.</summary>
public const int SingleTargetDamageAmount = 2;
/// <summary>A high-life vanilla follower (M-HC-4c damage TARGET). cards.json id 101411060: char_type 1,
/// clan 4, cost 2, 1/4, no skill. A 1/4 body takes <see cref="SingleTargetDamageAmount"/> (2) and
/// SURVIVES at life 2 — so the targeted-damage assertion reads a clean life DROP (not a death/removal,
/// which would only prove BoardCount). Present + creatable in cards.json.</summary>
public const long HighLifeVanillaFollowerId = 101411060;
/// <summary>Base life of <see cref="HighLifeVanillaFollowerId"/> (4). Pre-damage pin for the target.</summary>
public const int HighLifeVanillaFollowerLife = 4;
/// <summary>A SIMPLE CHOICE card (M-HC-4c choice fixture). cards.json id 127011010: char_type 1
/// (follower), clan 0 (Neutral — playable under any seat class), cost 1, 1/2, skill
/// <c>choice,token_draw</c> / skill_timing <c>when_choice_play,when_play</c> / skill_option
/// <c>card_id=121011010:120011010,...</c> — i.e. "choose ONE of two tokens to add to hand"
/// (<see cref="ChoiceTokenA"/> / <see cref="ChoiceTokenB"/>). The choice OUTCOME is directly
/// observable: the chosen token lands in the caster's hand, so a test can assert which branch
/// resolved by the new hand card's identity. Present + creatable in cards.json.</summary>
public const long ChoiceCardId = 127011010;
/// <summary>The first choice option of <see cref="ChoiceCardId"/> (token added to hand).</summary>
public const long ChoiceTokenA = 121011010;
/// <summary>The second choice option of <see cref="ChoiceCardId"/> (token added to hand).</summary>
public const long ChoiceTokenB = 120011010;
public BattleSessionState State { get; }
public StubParticipant SeatA { get; }
public StubParticipant SeatB { get; }
@@ -238,6 +274,71 @@ internal sealed class NodeNativeBattleHarness : IDisposable
},
};
/// <summary>The engine's <c>NetworkBattleDefine.PlayActionType.PLAY_HAND_SELECT</c> opcode — confirmed
/// <c>= 31</c> in <c>SVSim.BattleEngine/Engine/NetworkBattleDefine.cs</c>. A TARGETED hand play (a
/// when_play spell/fanfare that selects a target) carries this opcode (the "_SELECT" suffix), as
/// opposed to the plain <c>PLAY_HAND = 30</c> a vanilla play uses. The recovery receive path branches
/// on it to <c>RecoveryOperationCollection.PlaySkillSelectHandCardOperation</c> →
/// <c>PlayHandCardReflection.PlayAction</c>, which resolves the target from <c>targetList</c> via
/// <c>NetworkBattleGenericTool.LookForActionDataToTargetCard</c> (seat A) before applying the skill.</summary>
public const int PlayHandSelectOpcode = 31;
/// <summary>Build a PlayActions PLAY_HAND_SELECT (targeted hand-play) frame. <paramref name="playIdx"/>
/// is the played hand card's engine <c>Index</c> (the wire <c>playIdx</c>); the single target is
/// described in <c>targetList</c> in the SAME <c>{targetIdx, vid, selectSkillIndex}</c> shape as
/// <see cref="AttackBody"/>/<see cref="EvolveSelectBody"/> (the receive parse reads it identically —
/// <c>CreateTargetList</c> in NetworkBattleReceiver.cs:2164 — into the seat's TargetDataList, and under
/// IsRecovery resolves the target's owner from <c>vid</c>, not an isSelf key).
/// <para>For a seat-A spell targeting an enemy follower: <paramref name="targetIdx"/> = the enemy
/// follower's in-play engine Index and <paramref name="targetOnEnemySeat"/> = <c>true</c> (vid stamped
/// <see cref="EnemySeatVid"/> → isSelf=true → <c>LookForActionDataToTargetCard</c> resolves it on
/// <c>BattleEnemy.ClassAndInPlayCardList</c>).</para></summary>
public static Dictionary<string, object?> TargetedPlayBody(int playIdx, int targetIdx, bool targetOnEnemySeat) => new()
{
["playIdx"] = playIdx,
["type"] = PlayHandSelectOpcode,
["targetList"] = new List<object?>
{
new Dictionary<string, object?>
{
["targetIdx"] = (long)targetIdx,
["vid"] = targetOnEnemySeat ? EnemySeatVid : SelfSeatVid,
["selectSkillIndex"] = new List<object?>(),
},
},
};
/// <summary>Build a PlayActions CHOICE hand-play frame. A choice play carries the plain
/// <c>PLAY_HAND = 30</c> opcode plus a <c>keyAction</c> list that the receiver parses
/// (NetworkBattleReceiver.cs:1176-1228) into <c>keyActionType=Choice</c> (→ <c>ReceiveData.IsChoice</c>)
/// and <c>choiceIdList</c> = the chosen token id(s). Each entry is
/// <c>{ type:"Choice", cardId:&lt;played card id&gt;, selectCard:[&lt;tokenId&gt;] }</c>. The receiver reads
/// <c>selectCard</c> via <c>ConvertToListInt</c> (NetworkBattleReceiver.cs:1202), i.e. it consumes a
/// FLAT list of the chosen token id(s). (The verbatim CLIENT-SEND capture of THIS card —
/// <c>data_dumps/captures/battle_test/rng/battle-traffic_cl1.ndjson</c> — wraps it as
/// <c>selectCard:{cardId:[121011010],open:0}</c>; that wrapper is unwrapped before the node's
/// server-authored receive frame, which is what the receiver — and this driver — consume.)
/// <paramref name="playIdx"/> is the choice card's hand engine <c>Index</c>; <paramref name="playedCardId"/>
/// its wire id; <paramref name="chosenTokenId"/> the selected option.</summary>
public static Dictionary<string, object?> ChoicePlayBody(int playIdx, long playedCardId, long chosenTokenId) => new()
{
["playIdx"] = playIdx,
["type"] = 30, // PLAY_HAND — choice is signalled via keyAction, not a distinct opcode
["keyAction"] = new List<object?>
{
new Dictionary<string, object?>
{
["type"] = "Choice",
["cardId"] = playedCardId,
// The RECEIVE parse reads selectCard via ConvertToListInt (NetworkBattleReceiver.cs:1202),
// i.e. a FLAT list of the chosen token id(s). (The verbatim CLIENT-SEND capture wraps it as
// {cardId:[...],open:0}, but that wrapper is unwrapped before the node's server-authored
// receive frame; the receiver consumes the flat list.)
["selectCard"] = new List<object?> { chosenTokenId },
},
},
};
/// <summary>The engine's <c>NetworkBattleDefine.PlayActionType.EVOLUTION</c> opcode — confirmed
/// <c>= 20</c> in <c>SVSim.BattleEngine/Engine/NetworkBattleDefine.cs</c> (EVOLUTION_SELECT is 21). The
/// receiver maps the wire <c>type</c> int straight to the enum; EVOLUTION/EVOLUTION_SELECT route through