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:
gamer147
2026-06-06 23:46:59 -04:00
parent 3add58f939
commit 3285097d1b
2 changed files with 71 additions and 22 deletions

View File

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

View File

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