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:
@@ -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&target=inplay&card_type=unit&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:<played card id>, selectCard:[<tokenId>] }</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
|
||||
|
||||
Reference in New Issue
Block a user