diff --git a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs index 14d3c9c..4d17b89 100644 --- a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs @@ -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 { 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() { diff --git a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs index 4ca78cf..a160df7 100644 --- a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs +++ b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs @@ -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). public const long VanillaOneOneFollowerId = 900011080; + /// 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 damage / skill_timing + /// when_play / skill_target character=op&target=inplay&card_type=unit&select_count=1 + /// / skill_option damage=2 — 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. + public const long SingleTargetDamageSpellId = 100414020; + + /// The flat damage magnitude of (skill_option + /// damage=2). The targeted-play test asserts the enemy follower's life drops by exactly this. + public const int SingleTargetDamageAmount = 2; + + /// 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 (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. + public const long HighLifeVanillaFollowerId = 101411060; + + /// Base life of (4). Pre-damage pin for the target. + public const int HighLifeVanillaFollowerLife = 4; + + /// 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 + /// choice,token_draw / skill_timing when_choice_play,when_play / skill_option + /// card_id=121011010:120011010,... — i.e. "choose ONE of two tokens to add to hand" + /// ( / ). 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. + public const long ChoiceCardId = 127011010; + + /// The first choice option of (token added to hand). + public const long ChoiceTokenA = 121011010; + + /// The second choice option of (token added to hand). + 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 }, }; + /// The engine's NetworkBattleDefine.PlayActionType.PLAY_HAND_SELECT opcode — confirmed + /// = 31 in SVSim.BattleEngine/Engine/NetworkBattleDefine.cs. A TARGETED hand play (a + /// when_play spell/fanfare that selects a target) carries this opcode (the "_SELECT" suffix), as + /// opposed to the plain PLAY_HAND = 30 a vanilla play uses. The recovery receive path branches + /// on it to RecoveryOperationCollection.PlaySkillSelectHandCardOperation → + /// PlayHandCardReflection.PlayAction, which resolves the target from targetList via + /// NetworkBattleGenericTool.LookForActionDataToTargetCard (seat A) before applying the skill. + public const int PlayHandSelectOpcode = 31; + + /// Build a PlayActions PLAY_HAND_SELECT (targeted hand-play) frame. + /// is the played hand card's engine Index (the wire playIdx); the single target is + /// described in targetList in the SAME {targetIdx, vid, selectSkillIndex} shape as + /// / (the receive parse reads it identically — + /// CreateTargetList in NetworkBattleReceiver.cs:2164 — into the seat's TargetDataList, and under + /// IsRecovery resolves the target's owner from vid, not an isSelf key). + /// For a seat-A spell targeting an enemy follower: = the enemy + /// follower's in-play engine Index and = true (vid stamped + /// → isSelf=true → LookForActionDataToTargetCard resolves it on + /// BattleEnemy.ClassAndInPlayCardList). + public static Dictionary TargetedPlayBody(int playIdx, int targetIdx, bool targetOnEnemySeat) => new() + { + ["playIdx"] = playIdx, + ["type"] = PlayHandSelectOpcode, + ["targetList"] = new List + { + new Dictionary + { + ["targetIdx"] = (long)targetIdx, + ["vid"] = targetOnEnemySeat ? EnemySeatVid : SelfSeatVid, + ["selectSkillIndex"] = new List(), + }, + }, + }; + + /// Build a PlayActions CHOICE hand-play frame. A choice play carries the plain + /// PLAY_HAND = 30 opcode plus a keyAction list that the receiver parses + /// (NetworkBattleReceiver.cs:1176-1228) into keyActionType=Choice (→ ReceiveData.IsChoice) + /// and choiceIdList = the chosen token id(s). Each entry is + /// { type:"Choice", cardId:<played card id>, selectCard:[<tokenId>] }. The receiver reads + /// selectCard via ConvertToListInt (NetworkBattleReceiver.cs:1202), i.e. it consumes a + /// FLAT list of the chosen token id(s). (The verbatim CLIENT-SEND capture of THIS card — + /// data_dumps/captures/battle_test/rng/battle-traffic_cl1.ndjson — wraps it as + /// selectCard:{cardId:[121011010],open:0}; that wrapper is unwrapped before the node's + /// server-authored receive frame, which is what the receiver — and this driver — consume.) + /// is the choice card's hand engine Index; + /// its wire id; the selected option. + public static Dictionary 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 + { + new Dictionary + { + ["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 { chosenTokenId }, + }, + }, + }; + /// The engine's NetworkBattleDefine.PlayActionType.EVOLUTION opcode — confirmed /// = 20 in SVSim.BattleEngine/Engine/NetworkBattleDefine.cs (EVOLUTION_SELECT is 21). The /// receiver maps the wire type int straight to the enum; EVOLUTION/EVOLUTION_SELECT route through