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

@@ -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