feat(battlenode): evolve resolves on engine state via view-untangle (M-HC-4b)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-06 23:08:59 -04:00
parent 7a02cb3626
commit 2e8f9ab64e
3 changed files with 162 additions and 0 deletions

View File

@@ -427,6 +427,89 @@ public class HeadlessConductorTests
"neither leader takes damage in a follower-vs-follower trade");
}
// === M-HC-4b: evolve resolves headless =======================================================
[Test]
public void Evolve_resolves_on_engine_state_headless()
{
// Seat A plays a vanilla follower (base 1/2, evo 3/4 — a +2/+2 plain evolve, no target), then ramps
// to the turn its EP unlocks and EVOLVES it. Assert the engine-state mutation: the follower is marked
// evolved, its atk/life rise by the card's evolve deltas, and seat A's EP drops by 1. 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. Card
// 100011010: base atk 1 / life 2, evo_atk 3 / evo_life 4 -> evolve delta +2/+2 (read from cards.json).
const int evolvedAtk = 3;
const int evolvedLife = 4;
var deck = Enumerable.Repeat(NodeNativeBattleHarness.VanillaFollowerId, 30).ToList();
using var harness = NodeNativeBattleHarness.Create(seatADeck: deck);
// --- mulligan + open seat A turn 1, play the vanilla onto seat A's board --------------------
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 vanilla play");
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "seat A follower on board after play");
// The follower can't evolve yet — seat A's EvolveWaitTurnCount has not counted down to 0.
Assert.That(harness.EvolveWaitTurnCount(playerSeat: true), Is.GreaterThan(0),
"evolve is locked on seat A's first turn (wait-turn counter not yet 0)");
int attackerIdx = harness.InPlayCardIndex(playerSeat: true, boardPos: 0);
// --- ramp seat A to the turn its evolve unlocks (EvolveWaitTurnCount counts down per seat-A turn) ---
// End turn 1 first (TurnEnd sets NowTurnEvol = true, the other CanEvolution precondition), then
// alternate A/B TurnStart/TurnEnd until seat A's EvolveWaitTurnCount reaches 0, leaving seat A's
// turn OPEN. A guard bounds the loop so a never-unlocking bug fails loud instead of hanging.
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd");
bool seatA = false; // next TurnStart is seat B's
int guard = 0;
while (harness.EvolveWaitTurnCount(playerSeat: true) > 0)
{
Assert.That(++guard, Is.LessThan(20), "evolve never unlocked — EvolveWaitTurnCount stuck > 0");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: seatA).Accepted, Is.True, "ramp TurnStart");
if (seatA && harness.EvolveWaitTurnCount(playerSeat: true) == 0) break; // leave seat A's turn open
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: seatA).Accepted, Is.True, "ramp TurnEnd");
seatA = !seatA;
}
// EP precondition: seat A holds at least 1 evolve point and evolve is now unlocked.
Assert.That(harness.EvolveWaitTurnCount(playerSeat: true), Is.EqualTo(0), "evolve unlocked on seat A's turn");
int epBefore = harness.Ep(playerSeat: true);
Assert.That(epBefore, Is.GreaterThanOrEqualTo(1), "seat A must hold >= 1 EP before evolving");
// Pre-evolve stats: the un-evolved vanilla is 1/2 and not yet flagged evolved.
Assert.That(harness.IsEvolved(playerSeat: true, boardPos: 0), Is.False, "follower not evolved before the evolve");
int atkBefore = harness.InPlayCardAtk(playerSeat: true, boardPos: 0);
int lifeBefore = harness.InPlayCardLife(playerSeat: true, boardPos: 0);
Assert.That(atkBefore, Is.EqualTo(1), "vanilla base atk is 1 before evolve");
Assert.That(lifeBefore, Is.EqualTo(2), "vanilla base life is 2 before evolve");
// --- the evolve: a plain EVOLUTION frame addressing the follower by its in-play Index -------
var evolve = harness.Push(
NetworkBattleUri.PlayActions, NodeNativeBattleHarness.EvolveBody(attackerIdx), isPlayerSeat: true);
Assert.That(evolve.Accepted, Is.True, $"evolve rejected: {evolve.RejectReason}");
Assert.That(harness.IsEvolved(playerSeat: true, boardPos: 0), Is.True,
"the follower must be flagged evolved after the EVOLUTION frame resolves");
Assert.That(harness.InPlayCardAtk(playerSeat: true, boardPos: 0), Is.EqualTo(evolvedAtk),
"evolved atk must equal the card's evo_atk (3) — base 1 + evolve delta +2");
Assert.That(harness.InPlayCardLife(playerSeat: true, boardPos: 0), Is.EqualTo(evolvedLife),
"evolved life must equal the card's evo_life (4) — base 2 + evolve delta +2");
Assert.That(harness.Ep(playerSeat: true), Is.EqualTo(epBefore - 1),
"an evolve must spend exactly one evolve point");
}
// TODO(M-HC-4): EVOLUTION_SELECT target path uncovered — needs an evolve-target fixture card.
// The EVOLUTION_SELECT driver (NodeNativeBattleHarness.EvolveSelectBody, opcode 21) is in place, but
// no card in the current cards.json carries an evo_skill/evo_skill_target (the skill/translation tables
// are placeholders per CLAUDE.md — only base stats + plain evolve deltas are dumped). Driving an
// EVOLUTION_SELECT with no targeting skill degenerates to the plain-evolve path (empty select list), so
// 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.
[Test]
public void Deal_seats_three_card_hand_headless()
{