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:
@@ -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;
|
||||
|
||||
/// <summary>True once the in-play follower at <paramref name="boardPos"/> (0-based, leader excluded)
|
||||
/// has evolved (<see cref="UnitBattleCard.IsEvolution"/>, set true inside the engine's own
|
||||
/// <c>UnitBattleCard.Evolution</c> mutation). Only <see cref="UnitBattleCard"/> followers carry the
|
||||
/// flag; a non-follower (or the leader) reads false. The evolve test's decisive engine-state assertion
|
||||
/// (M-HC-4b).</summary>
|
||||
public bool IsEvolved(bool playerSeat, int boardPos) =>
|
||||
(Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1] as UnitBattleCard)?.IsEvolution ?? false;
|
||||
|
||||
/// <summary>The seat's current evolve-point count (<see cref="BattlePlayerBase.CurrentEpCount"/>). An
|
||||
/// evolve spends one EP, so the evolve test asserts this decrements by 1. EP is granted at setup by
|
||||
/// the engine's <c>SetupEvolCount</c> (2 for the game-first seat, 3 for the second) and unlocks once
|
||||
/// <c>EvolveWaitTurnCount</c> has counted down (M-HC-4b).</summary>
|
||||
public int Ep(bool playerSeat) => Seat(playerSeat).CurrentEpCount;
|
||||
|
||||
/// <summary>Turns remaining until <paramref name="playerSeat"/> may evolve
|
||||
/// (<see cref="BattlePlayerBase.EvolveWaitTurnCount"/>); 0 means evolve is unlocked. Lets a test ramp to
|
||||
/// the evolve-enabled turn deterministically (M-HC-4b).</summary>
|
||||
public int EvolveWaitTurnCount(bool playerSeat) => Seat(playerSeat).EvolveWaitTurnCount;
|
||||
|
||||
/// <summary>The engine-RESOLVED play-time cost of the card whose engine <c>Index</c> == <paramref name="idx"/>
|
||||
/// on <paramref name="playerSeat"/> (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
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -171,6 +171,15 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
/// <summary>True while the in-play follower at <paramref name="boardPos"/> can still attack this turn.</summary>
|
||||
public bool InPlayCardAttackable(bool playerSeat, int boardPos) => Engine.InPlayCardAttackable(playerSeat, boardPos);
|
||||
|
||||
/// <summary>True once the in-play follower at <paramref name="boardPos"/> has evolved (M-HC-4b).</summary>
|
||||
public bool IsEvolved(bool playerSeat, int boardPos) => Engine.IsEvolved(playerSeat, boardPos);
|
||||
|
||||
/// <summary>The seat's current evolve-point count (M-HC-4b). An evolve spends one EP.</summary>
|
||||
public int Ep(bool playerSeat) => Engine.Ep(playerSeat);
|
||||
|
||||
/// <summary>Turns remaining until the seat may evolve (0 == unlocked) (M-HC-4b).</summary>
|
||||
public int EvolveWaitTurnCount(bool playerSeat) => Engine.EvolveWaitTurnCount(playerSeat);
|
||||
|
||||
/// <summary>Build an envelope for <paramref name="body"/> and ingest it into the engine for the
|
||||
/// given seat (player == seat A). Mirrors <c>BattleNodeFlowTests.MakeEnvelopeWith</c> +
|
||||
/// <c>SessionBattleEngine.Receive</c>.</summary>
|
||||
@@ -229,6 +238,46 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>The engine's <c>NetworkBattleDefine.PlayActionType.EVOLUTION</c> opcode — confirmed
|
||||
/// <c>= 20</c> in <c>SVSim.BattleEngine/Engine/NetworkBattleDefine.cs</c> (EVOLUTION_SELECT is 21). The
|
||||
/// receiver maps the wire <c>type</c> int straight to the enum; EVOLUTION/EVOLUTION_SELECT route through
|
||||
/// the SAME InPlayAction dispatch arm as ATTACK (NetworkOperationCollection.cs:163-170).</summary>
|
||||
public const int EvolutionOpcode = 20;
|
||||
|
||||
/// <summary>The engine's <c>NetworkBattleDefine.PlayActionType.EVOLUTION_SELECT</c> opcode — confirmed
|
||||
/// <c>= 21</c> in <c>SVSim.BattleEngine/Engine/NetworkBattleDefine.cs</c>.</summary>
|
||||
public const int EvolutionSelectOpcode = 21;
|
||||
|
||||
/// <summary>Build a PlayActions EVOLUTION frame for the in-play follower addressed by its engine
|
||||
/// <c>Index</c> (<paramref name="cardIdx"/> == the wire <c>playIdx</c>). A plain (non-targeted) evolve
|
||||
/// carries no targetList — the dispatch's <c>list</c> stays empty and the engine evolves the card in
|
||||
/// place (InPlayCardReflection.Evol).</summary>
|
||||
public static Dictionary<string, object?> EvolveBody(int cardIdx) => new()
|
||||
{
|
||||
["playIdx"] = cardIdx,
|
||||
["type"] = EvolutionOpcode,
|
||||
};
|
||||
|
||||
/// <summary>Build a PlayActions EVOLUTION_SELECT frame: the follower at engine <c>Index</c>
|
||||
/// <paramref name="cardIdx"/> evolves and targets the card at <paramref name="targetIdx"/>. The target is
|
||||
/// described in the SAME <c>{targetIdx, vid, selectSkillIndex}</c> shape as <see cref="AttackBody"/>
|
||||
/// (the dispatch resolves the target's owner from <c>vid</c> under IsRecovery, not from an isSelf key);
|
||||
/// <paramref name="targetOnEnemySeat"/> selects the vid stamp.</summary>
|
||||
public static Dictionary<string, object?> EvolveSelectBody(int cardIdx, int targetIdx, bool targetOnEnemySeat) => new()
|
||||
{
|
||||
["playIdx"] = cardIdx,
|
||||
["type"] = EvolutionSelectOpcode,
|
||||
["targetList"] = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["targetIdx"] = (long)targetIdx,
|
||||
["vid"] = targetOnEnemySeat ? EnemySeatVid : SelfSeatVid,
|
||||
["selectSkillIndex"] = new List<object?>(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public void Dispose() { /* engine holds no unmanaged resources; nothing to release. */ }
|
||||
|
||||
/// <summary>Minimal test-only <see cref="IBattleParticipant"/> exposing only the
|
||||
|
||||
Reference in New Issue
Block a user