feat(battlenode): attack resolves on engine state via view-untangle (M-HC-4a)
Drive ATTACK frames through the headless receive conductor and assert on engine board state (node-native harness). Two cases: follower -> enemy leader (leader life drops by atk, attacker spent) and a lethal follower-vs-follower trade (both removed). ATTACK opcode confirmed = 10 (NetworkBattleDefine.PlayActionType). Headless view-untangle (no Engine logic edits; drift clean): - IBattlePlayerView.AttackSelectControl -> non-null HeadlessAttackSelectControl (no-op RegisterAttackPair/ResetCardAfterAttack); IsCardTranslatable left to base. - IBattleCardView.CardInfo -> backing card via BuildInfo (so IsCardTranslatable reads authentic IsClass); class/null view ctors now chain : base(buildInfo). - IBattleCardView._inPlayFrameEffect -> non-null no-op control. - Seed Certification.viewer_id headless so the IsRecovery target parse (vid != UserViewerID) does not throw inside SavedataManager and silently drop the parsed targetList. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -318,6 +318,115 @@ public class HeadlessConductorTests
|
||||
"the seated card must be the wire cardId W, overriding the seeded Z identity at that idx");
|
||||
}
|
||||
|
||||
// === M-HC-4a: attack resolves headless =======================================================
|
||||
|
||||
[Test]
|
||||
public void Attack_on_enemy_leader_resolves_on_engine_state_headless()
|
||||
{
|
||||
// Seat A plays a vanilla follower on turn 1, then on its NEXT turn (past summoning sickness)
|
||||
// attacks seat B's leader. Assert seat B's leader life drops by the follower's attack (1) and the
|
||||
// attacker is spent. Driven entirely through the receive conductor (Push -> engine.Receive).
|
||||
//
|
||||
// Uniform vanilla deck so the card dealt at engine Index 1 is unambiguously the 1/2 vanilla.
|
||||
var deck = Enumerable.Repeat(NodeNativeBattleHarness.VanillaFollowerId, 30).ToList();
|
||||
using var harness = NodeNativeBattleHarness.Create(seatADeck: deck);
|
||||
|
||||
// --- mulligan + open seat A turn 1 ------------------------------------------------------------
|
||||
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");
|
||||
|
||||
// Play the vanilla (engine Index 1, cost 1) onto seat A's board.
|
||||
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted,
|
||||
Is.True, "turn1 vanilla play");
|
||||
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "seat A follower on board after play");
|
||||
|
||||
// The just-played follower has summoning sickness this turn (can't attack yet).
|
||||
Assert.That(harness.InPlayCardAttackable(playerSeat: true, boardPos: 0), Is.False,
|
||||
"a follower has summoning sickness the turn it is played");
|
||||
|
||||
int attackerIdx = harness.InPlayCardIndex(playerSeat: true, boardPos: 0);
|
||||
int attackerAtk = harness.InPlayCardAtk(playerSeat: true, boardPos: 0);
|
||||
Assert.That(attackerAtk, Is.EqualTo(1), "the vanilla follower's attack stat is 1");
|
||||
|
||||
// --- advance to seat A's NEXT turn (turn 3) so the follower is past summoning sickness ---------
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnStart (B)");
|
||||
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.InPlayCardAttackable(playerSeat: true, boardPos: 0), Is.True,
|
||||
"the follower can attack on seat A's next turn (summoning sickness cleared)");
|
||||
|
||||
int leaderLifeBefore = harness.LeaderLife(playerSeat: false);
|
||||
Assert.That(leaderLifeBefore, Is.EqualTo(20), "seat B leader untouched before the attack");
|
||||
|
||||
// --- the attack: seat A follower -> seat B leader (Index 0, on the enemy seat) ----------------
|
||||
var attack = harness.Push(
|
||||
NetworkBattleUri.PlayActions,
|
||||
NodeNativeBattleHarness.AttackBody(attackerIdx, targetIdx: 0, targetOnEnemySeat: true),
|
||||
isPlayerSeat: true);
|
||||
|
||||
Assert.That(attack.Accepted, Is.True, $"attack rejected: {attack.RejectReason}");
|
||||
Assert.That(harness.LeaderLife(playerSeat: false), Is.EqualTo(leaderLifeBefore - attackerAtk),
|
||||
"seat B leader life must drop by the attacker's attack stat");
|
||||
Assert.That(harness.InPlayCardAttackable(playerSeat: true, boardPos: 0), Is.False,
|
||||
"the attacker is spent after attacking (can't attack again this turn)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Follower_vs_follower_attack_is_a_lethal_trade_headless()
|
||||
{
|
||||
// Seat A plays a 1/1 vanilla; seat B reveals a 1/1 vanilla (M-HC-2 reveal pattern). On seat A's
|
||||
// next turn the follower attacks seat B's follower. Each deals 1 to a 1-life body -> a lethal
|
||||
// trade: both followers' life drops and both leave the board.
|
||||
var oneOne = NodeNativeBattleHarness.VanillaOneOneFollowerId;
|
||||
var seatADeck = Enumerable.Repeat(oneOne, 30).ToList();
|
||||
var seatBDeck = Enumerable.Repeat(oneOne, 30).ToList();
|
||||
using var harness = NodeNativeBattleHarness.Create(seatADeck: seatADeck, seatBDeck: seatBDeck);
|
||||
|
||||
// --- mulligan + seat A turn 1: play the 1/1 -------------------------------------------------
|
||||
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.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted, Is.True, "turn1 play 1/1");
|
||||
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "seat A 1/1 on board");
|
||||
int attackerIdx = harness.InPlayCardIndex(playerSeat: true, boardPos: 0);
|
||||
|
||||
// --- seat B turn 2: reveal a 1/1 onto seat B's board ------------------------------------------
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd");
|
||||
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: oneOne), isPlayerSeat: false).Accepted,
|
||||
Is.True, "seat B reveal-play 1/1");
|
||||
Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(1), "seat B 1/1 on board after reveal");
|
||||
int targetIdx = harness.InPlayCardIndex(playerSeat: false, boardPos: 0);
|
||||
|
||||
// --- back to seat A (turn 3): the 1/1 is past summoning sickness ------------------------------
|
||||
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.InPlayCardAttackable(playerSeat: true, boardPos: 0), Is.True, "attacker past summoning sickness");
|
||||
|
||||
Assert.That(harness.InPlayCardLife(playerSeat: true, boardPos: 0), Is.EqualTo(1), "attacker 1/1 full life before trade");
|
||||
Assert.That(harness.InPlayCardLife(playerSeat: false, boardPos: 0), Is.EqualTo(1), "target 1/1 full life before trade");
|
||||
|
||||
// --- attack follower -> follower (target on enemy seat B) ------------------------------------
|
||||
var attack = harness.Push(
|
||||
NetworkBattleUri.PlayActions,
|
||||
NodeNativeBattleHarness.AttackBody(attackerIdx, targetIdx, targetOnEnemySeat: true),
|
||||
isPlayerSeat: true);
|
||||
|
||||
Assert.That(attack.Accepted, Is.True, $"follower trade rejected: {attack.RejectReason}");
|
||||
// 1/1 vs 1/1: each takes 1 -> both at 0 life -> both die and leave the board (lethal trade).
|
||||
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(0), "attacker 1/1 died in the trade");
|
||||
Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(0), "target 1/1 died in the trade");
|
||||
Assert.That(harness.LeaderLife(playerSeat: false), Is.EqualTo(20),
|
||||
"neither leader takes damage in a follower-vs-follower trade");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deal_seats_three_card_hand_headless()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user