diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs index aa9069a..27692e7 100644 --- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs +++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs @@ -8,6 +8,7 @@ using NetworkBattleDefine = engine::NetworkBattleDefine; using BattleManagerBase = engine::BattleManagerBase; using BattlePlayerBase = engine::BattlePlayerBase; using BattleCardBase = engine::BattleCardBase; +using UnitBattleCard = engine::UnitBattleCard; using ClassBattleCardBase = engine::ClassBattleCardBase; using CardCreatorBase = engine::CardCreatorBase; using CostAddModifier = engine::CostAddModifier; @@ -92,6 +93,16 @@ internal sealed class SessionBattleEngine player.IsSelfTurn = true; enemy.IsSelfTurn = false; + // Seat the evolve points + evolve-wait-turn counters exactly as the real match-load's + // SetupInitialGameState -> SetupEvolCount does (BattleManagerBase.cs:1115/1132). The headless + // Setup builds the seats by hand and never runs SetupInitialGameState, so without this both seats' + // CurrentEpCount/EvolveWaitTurnCount stay at their field defaults (0/0) and CanEvolution always + // fails (CurrentEpCount - GetEp() < 0). doesPlayerGoFirst == false here: seat A (BattlePlayer) is + // the SECOND player (IsFirst defaults false; seat A's turn-1 draws 2), so it gets SECOND_PLAYER_EP + // (3) + EvolveWaitTurnCount 4, and seat B (BattleEnemy, first) gets FIRST_PLAYER_EP (2) + + // EvolveWaitTurnCount 5. TurnEvolveControl (run on each TurnStart receive) counts the wait down. + mgr.SetupEvolCount(doesPlayerGoFirst: false); + InitLeaderLife(mgr); // a 0-life leader reads as game-over and silently blocks plays InitCardTemplates(mgr); // play/draw resolution touches the (no-op) card view layer InitHeadlessViews(mgr); // turn/play cycle dereferences UI-container + emotion refs @@ -201,6 +212,25 @@ internal sealed class SessionBattleEngine public bool InPlayCardAttackable(bool playerSeat, int boardPos) => Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Attackable; + /// True once the in-play follower at (0-based, leader excluded) + /// has evolved (, set true inside the engine's own + /// UnitBattleCard.Evolution mutation). Only followers carry the + /// flag; a non-follower (or the leader) reads false. The evolve test's decisive engine-state assertion + /// (M-HC-4b). + public bool IsEvolved(bool playerSeat, int boardPos) => + (Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1] as UnitBattleCard)?.IsEvolution ?? false; + + /// The seat's current evolve-point count (). An + /// evolve spends one EP, so the evolve test asserts this decrements by 1. EP is granted at setup by + /// the engine's SetupEvolCount (2 for the game-first seat, 3 for the second) and unlocks once + /// EvolveWaitTurnCount has counted down (M-HC-4b). + public int Ep(bool playerSeat) => Seat(playerSeat).CurrentEpCount; + + /// Turns remaining until may evolve + /// (); 0 means evolve is unlocked. Lets a test ramp to + /// the evolve-enabled turn deterministically (M-HC-4b). + public int EvolveWaitTurnCount(bool playerSeat) => Seat(playerSeat).EvolveWaitTurnCount; + /// The engine-RESOLVED play-time cost of the card whose engine Index == /// on (M-HC-3a). This is the discounted cost the play actually paid — /// spellboost reduction, board-dependent modifiers and all — read straight off the engine, so the diff --git a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs index 4be9a1d..14d3c9c 100644 --- a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs @@ -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() { diff --git a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs index b489557..4ca78cf 100644 --- a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs +++ b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs @@ -171,6 +171,15 @@ internal sealed class NodeNativeBattleHarness : IDisposable /// True while the in-play follower at can still attack this turn. public bool InPlayCardAttackable(bool playerSeat, int boardPos) => Engine.InPlayCardAttackable(playerSeat, boardPos); + /// True once the in-play follower at has evolved (M-HC-4b). + public bool IsEvolved(bool playerSeat, int boardPos) => Engine.IsEvolved(playerSeat, boardPos); + + /// The seat's current evolve-point count (M-HC-4b). An evolve spends one EP. + public int Ep(bool playerSeat) => Engine.Ep(playerSeat); + + /// Turns remaining until the seat may evolve (0 == unlocked) (M-HC-4b). + public int EvolveWaitTurnCount(bool playerSeat) => Engine.EvolveWaitTurnCount(playerSeat); + /// Build an envelope for and ingest it into the engine for the /// given seat (player == seat A). Mirrors BattleNodeFlowTests.MakeEnvelopeWith + /// SessionBattleEngine.Receive. @@ -229,6 +238,46 @@ internal sealed class NodeNativeBattleHarness : IDisposable }, }; + /// The engine's NetworkBattleDefine.PlayActionType.EVOLUTION opcode — confirmed + /// = 20 in SVSim.BattleEngine/Engine/NetworkBattleDefine.cs (EVOLUTION_SELECT is 21). The + /// receiver maps the wire type int straight to the enum; EVOLUTION/EVOLUTION_SELECT route through + /// the SAME InPlayAction dispatch arm as ATTACK (NetworkOperationCollection.cs:163-170). + public const int EvolutionOpcode = 20; + + /// The engine's NetworkBattleDefine.PlayActionType.EVOLUTION_SELECT opcode — confirmed + /// = 21 in SVSim.BattleEngine/Engine/NetworkBattleDefine.cs. + public const int EvolutionSelectOpcode = 21; + + /// Build a PlayActions EVOLUTION frame for the in-play follower addressed by its engine + /// Index ( == the wire playIdx). A plain (non-targeted) evolve + /// carries no targetList — the dispatch's list stays empty and the engine evolves the card in + /// place (InPlayCardReflection.Evol). + public static Dictionary EvolveBody(int cardIdx) => new() + { + ["playIdx"] = cardIdx, + ["type"] = EvolutionOpcode, + }; + + /// Build a PlayActions EVOLUTION_SELECT frame: the follower at engine Index + /// evolves and targets the card at . The target is + /// described in the SAME {targetIdx, vid, selectSkillIndex} shape as + /// (the dispatch resolves the target's owner from vid under IsRecovery, not from an isSelf key); + /// selects the vid stamp. + public static Dictionary EvolveSelectBody(int cardIdx, int targetIdx, bool targetOnEnemySeat) => new() + { + ["playIdx"] = cardIdx, + ["type"] = EvolutionSelectOpcode, + ["targetList"] = new List + { + new Dictionary + { + ["targetIdx"] = (long)targetIdx, + ["vid"] = targetOnEnemySeat ? EnemySeatVid : SelfSeatVid, + ["selectSkillIndex"] = new List(), + }, + }, + }; + public void Dispose() { /* engine holds no unmanaged resources; nothing to release. */ } /// Minimal test-only exposing only the