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:
gamer147
2026-06-06 22:48:26 -04:00
parent 0d7136787a
commit c5a511e4fe
11 changed files with 291 additions and 9 deletions

View File

@@ -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()
{