test(battlenode): target-discriminating + documented choice shape (M-HC-4c review)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -515,18 +515,21 @@ public class HeadlessConductorTests
|
||||
[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
|
||||
// Seat A plays a single-target when_play DAMAGE spell (deal 2 to a selected enemy follower) at ONE of
|
||||
// TWO enemy followers seat B revealed onto its board. Assert the engine applied the damage headless to
|
||||
// the WIRE-SPECIFIED target and ONLY it: the targeted follower's life drops by exactly the skill's
|
||||
// damage amount (2) AND the other follower is untouched (full life). Two targets makes the assertion
|
||||
// itself prove the resolution honored the wire-specified target idx (with a single follower, "auto-pick
|
||||
// the only legal target" would be indistinguishable from honoring the wire target). 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).
|
||||
// so the leader/clan is consistent. Seat B: uniformly the 1/4 high-life vanilla (both reveal targets are
|
||||
// 1/4, so both SURVIVE 2 damage — the non-targeted one reads a clean "untouched" life of 4).
|
||||
var seatADeck = Enumerable.Repeat(NodeNativeBattleHarness.SingleTargetDamageSpellId, 30).ToList();
|
||||
var seatBDeck = new List<long> { NodeNativeBattleHarness.HighLifeVanillaFollowerId };
|
||||
seatBDeck.AddRange(Enumerable.Repeat(NodeNativeBattleHarness.HighLifeVanillaFollowerId, 29));
|
||||
var seatBDeck = Enumerable.Repeat(NodeNativeBattleHarness.HighLifeVanillaFollowerId, 30).ToList();
|
||||
using var harness = NodeNativeBattleHarness.Create(
|
||||
seatADeck: seatADeck, seatBDeck: seatBDeck,
|
||||
seatAClass: SVSim.BattleNode.Bridge.CardClass.Dragoncraft);
|
||||
@@ -538,21 +541,48 @@ public class HeadlessConductorTests
|
||||
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 ------------------------
|
||||
// --- reveal TWO high-life followers onto seat B's board (one per seat-B turn) ----------------
|
||||
// A reveal substitutes identity onto a card seat B holds IN HAND (BattlePlayerBase.HandCardToField),
|
||||
// and seat B's opening hand is dealt into hidden zones — only its per-turn DRAW is a revealable hand
|
||||
// card. So each seat-B turn yields exactly one revealable card: reveal follower #1 on seat B turn 2,
|
||||
// then advance to seat B turn 4 and reveal follower #2. (The reveal frame is server-authored, so it
|
||||
// seats regardless of seat B's PP — turn-2 PP 1 vs the 1/4's cost 2 just drives PP negative, which is
|
||||
// immaterial to seat A's later spell.) Each reveal addresses seat B's current hand card by its live
|
||||
// engine Index so we don't hard-code a shuffle-dependent idx.
|
||||
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.BoardCount(playerSeat: false), Is.EqualTo(0), "seat B board empty before reveals");
|
||||
int revealIdx1 = harness.HandCardIndex(playerSeat: false, handPos: 0);
|
||||
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");
|
||||
RevealPlayBody(idx: revealIdx1, cardId: NodeNativeBattleHarness.HighLifeVanillaFollowerId), isPlayerSeat: false).Accepted,
|
||||
Is.True, "seat B reveal-play follower #1");
|
||||
Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(1), "one seat B follower after reveal #1");
|
||||
|
||||
// --- back to seat A (turn 3): play the damage spell at the enemy follower --------------------
|
||||
// seat A turn 3 (no play) -> seat B turn 4 (draws a second revealable card).
|
||||
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)");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn3 TurnEnd (A)");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True, "turn4 TurnStart (B)");
|
||||
int revealIdx2 = harness.HandCardIndex(playerSeat: false, handPos: 0);
|
||||
Assert.That(revealIdx2, Is.Not.EqualTo(revealIdx1), "the two revealed hand cards must be distinct engine Indices");
|
||||
Assert.That(harness.Push(NetworkBattleUri.PlayActions,
|
||||
RevealPlayBody(idx: revealIdx2, cardId: NodeNativeBattleHarness.HighLifeVanillaFollowerId), isPlayerSeat: false).Accepted,
|
||||
Is.True, "seat B reveal-play follower #2");
|
||||
Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(2), "two seat B followers on board after reveals");
|
||||
|
||||
// The spell will target board position 0; assert board position 1 (the OTHER follower) is left whole.
|
||||
int targetIdx = harness.InPlayCardIndex(playerSeat: false, boardPos: 0);
|
||||
int otherIdx = harness.InPlayCardIndex(playerSeat: false, boardPos: 1);
|
||||
Assert.That(otherIdx, Is.Not.EqualTo(targetIdx), "the two revealed followers must have distinct engine Indices");
|
||||
int targetLifeBefore = harness.InPlayCardLife(playerSeat: false, boardPos: 0);
|
||||
int otherLifeBefore = harness.InPlayCardLife(playerSeat: false, boardPos: 1);
|
||||
Assert.That(targetLifeBefore, Is.EqualTo(NodeNativeBattleHarness.HighLifeVanillaFollowerLife),
|
||||
"target's base life (4) before the spell");
|
||||
Assert.That(otherLifeBefore, Is.EqualTo(NodeNativeBattleHarness.HighLifeVanillaFollowerLife),
|
||||
"non-target's base life (4) before the spell");
|
||||
|
||||
// --- back to seat A (turn 5): play the damage spell at the FIRST enemy follower -------------
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: false).Accepted, Is.True, "turn4 TurnEnd (B)");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn5 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);
|
||||
@@ -573,12 +603,19 @@ public class HeadlessConductorTests
|
||||
"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)");
|
||||
// Both 1/4 targets survive 2 damage, so the board still holds two followers (the test reads life, not
|
||||
// removal). Board positions are stable across this play: pos 0 is the targeted follower, pos 1 the other.
|
||||
Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(2),
|
||||
"both 1/4 followers survive (the spell hits only one, for 2 < 4)");
|
||||
// THE target-discriminating assertion: the WIRE-targeted follower took exactly the skill's damage (2)
|
||||
// -> 1/4 drops to life 2, while the OTHER (non-targeted) follower is UNTOUCHED at full life (4). This
|
||||
// pair proves resolution honored the wire-specified target idx, not "auto-pick the only legal target".
|
||||
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)");
|
||||
"the WIRE-TARGETED follower's life must drop by the spell's damage amount (2)");
|
||||
Assert.That(harness.InPlayCardLife(playerSeat: false, boardPos: 1),
|
||||
Is.EqualTo(otherLifeBefore),
|
||||
"the NON-targeted follower must be UNTOUCHED (full life) — proves the wire target was honored");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -602,6 +639,11 @@ public class HeadlessConductorTests
|
||||
Assert.That(harness.HandCardId(playerSeat: true, handPos: 0),
|
||||
Is.EqualTo((int)NodeNativeBattleHarness.ChoiceCardId), "seat A hand card is the choice card");
|
||||
|
||||
// PP-charged no-op guard (symmetry with the targeted test): the choice card is cost 1 and turn-1 PP
|
||||
// is 1, so a real resolution must drop PP. An accept-but-no-op resolution (chosen branch never ran)
|
||||
// would leave PP unchanged — this catches it.
|
||||
int ppBefore = harness.Pp(playerSeat: true);
|
||||
|
||||
// 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(
|
||||
@@ -610,6 +652,8 @@ public class HeadlessConductorTests
|
||||
isPlayerSeat: true);
|
||||
|
||||
Assert.That(play.Accepted, Is.True, $"choice play rejected: {play.RejectReason}");
|
||||
Assert.That(harness.Pp(playerSeat: true), Is.LessThan(ppBefore),
|
||||
"the choice card's cost (1) must be charged to seat A's PP");
|
||||
// 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++)
|
||||
|
||||
@@ -97,7 +97,9 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
/// <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>
|
||||
/// resolved by the new hand card's identity. (The token resolves into HAND — confirmed against the
|
||||
/// capture's <c>orderList.add{to:20}</c> hand-zone op — despite the skill_option <c>summon_side=me</c>
|
||||
/// superficially reading like a summon-to-board.) 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>
|
||||
@@ -328,6 +330,9 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
// The real capture sends type:1 (int); "Choice" (string) is equivalent — the receiver does
|
||||
// Enum.Parse(KeyActionType, type.ToString()) and KeyActionType.Choice == 1, so the string and
|
||||
// the int both parse to the same enum value.
|
||||
["type"] = "Choice",
|
||||
["cardId"] = playedCardId,
|
||||
// The RECEIVE parse reads selectCard via ConvertToListInt (NetworkBattleReceiver.cs:1202),
|
||||
|
||||
Reference in New Issue
Block a user