test(battlenode): board-dependent when_evolve_other cost validated headless (M-HC-4d)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-06 23:54:13 -04:00
parent 3285097d1b
commit daaec20afb
2 changed files with 167 additions and 0 deletions

View File

@@ -1048,4 +1048,133 @@ public class HeadlessConductorTests
Assert.That(body.KnownList[0].Spellboost, Is.Not.EqualTo(0),
"non-vacuity: emitted spellboost is NOT 0");
}
// === M-HC-4d: BOARD-DEPENDENT (when_evolve_other) cost validated headless =====================
// The board-dependent cost-reducer 127011020 (base cost 6, neutral 3/3). Its when_evolve_other
// cost_change skill (skill_option set=1, condition turn=self & {me.hand_self.unit.count}>0 &
// target=evolution_card & card_type=unit) SETS this card's cost to a flat 1 once ANOTHER of the
// controller's followers evolves on the controller's turn (with >=1 other unit in hand). Unlike the
// M-HC-3 spellboost reducer (a SELF when_spell_charge modifier), this reduction is driven by a BOARD
// EVENT (an evolve) on a DIFFERENT card — so it could only ever be captured once evolve resolves
// headless (M-HC-4b). Because the node reads opponent-facing cost straight off the resolved engine
// (PlayedCardCost, M-HC-3), the reduction is captured BY CONSTRUCTION — this test proves it.
private const long BoardDependentCostCardId = NodeNativeBattleHarness.BoardDependentCostCardId; // 127011020
private const int BoardDependentCostBase = NodeNativeBattleHarness.BoardDependentCostBase; // 6
private const int BoardDependentCostReduced = NodeNativeBattleHarness.BoardDependentCostReduced; // 1
[Test]
public void Board_dependent_when_evolve_other_cost_validated_headless()
{
// The M-HC-4d payoff. Drive a node-native battle: seat A plays the vanilla follower turn 1, ramps
// to its evolve turn, and EVOLVES it while a board-dependent cost-reducer (127011020) sits in hand.
// The reducer's when_evolve_other cost_change (set=1) fires on the evolve, dropping its resolved cost
// 6 -> 1. We pin the reducer's resolved cost BEFORE the evolve (== base 6) and AFTER (== reduced 1)
// to prove the EVOLVE caused the reduction (causation, not coincidence), then drive the reducer's play
// through PlayActionsHandler and assert the emitted knownList[].cost == the reduced cost. This proves
// the desync the retired wire-derived count->cost calculator would have corrupted is closed by
// construction: a board event modifies a DIFFERENT card's cost, and the engine read carries it.
using var harness = NodeNativeBattleHarness.Create(seatADeck: NodeNativeBattleHarness.BoardDependentCostDeck());
// --- mulligan + open seat A turn 1, play a vanilla follower 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");
// Locate a vanilla follower in the (shuffled) opening hand and play it (cost 1, affordable on PP 1).
int vanillaHandIdx = FindHandIdxByCardId(harness, NodeNativeBattleHarness.VanillaFollowerId);
Assert.That(vanillaHandIdx, Is.GreaterThan(0), "a vanilla follower must be in seat A's opening hand");
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(vanillaHandIdx), isPlayerSeat: true).Accepted,
Is.True, "turn1 vanilla play");
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "the vanilla is on seat A's board");
int evolverIdx = harness.InPlayCardIndex(playerSeat: true, boardPos: 0);
// --- ramp seat A to the turn its evolve unlocks (mirrors Evolve_resolves_on_engine_state_headless) ---
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;
}
Assert.That(harness.EvolveWaitTurnCount(playerSeat: true), Is.EqualTo(0), "evolve unlocked on seat A's turn");
Assert.That(harness.Ep(playerSeat: true), Is.GreaterThanOrEqualTo(1), "seat A must hold >= 1 EP before evolving");
// The reducer must be IN HAND across the evolve (its when_evolve_other skill is scanned off the hand).
int reducerHandIdx = FindHandIdxByCardId(harness, BoardDependentCostCardId);
Assert.That(reducerHandIdx, Is.GreaterThan(0), "the board-dependent cost-reducer must be in seat A's hand at the evolve");
Assert.That(harness.HandCardId(playerSeat: true,
FindHandPosByEngineIdx(harness, reducerHandIdx)), Is.EqualTo((int)BoardDependentCostCardId),
"located the reducer by identity");
// PRE-EVOLVE pin (non-vacuity + causation baseline): the reducer resolves to its BASE cost (6) while
// no follower has evolved yet. Read it WHILE in hand by its engine Index.
Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, reducerHandIdx, fallback: -1),
Is.EqualTo(BoardDependentCostBase),
"reducer resolves to its BASE cost (6) BEFORE any follower evolves");
Assert.That(harness.IsEvolved(playerSeat: true, boardPos: 0), Is.False, "vanilla not yet evolved");
// --- THE evolve: a plain EVOLUTION frame on the vanilla (boardPos 0) — fires when_evolve_other ----
var evolve = harness.Push(
NetworkBattleUri.PlayActions, NodeNativeBattleHarness.EvolveBody(evolverIdx), isPlayerSeat: true);
Assert.That(evolve.Accepted, Is.True, $"evolve rejected: {evolve.RejectReason}");
Assert.That(harness.IsEvolved(playerSeat: true, boardPos: 0), Is.True, "the vanilla must be flagged evolved");
// POST-EVOLVE pin (THE engine-state assertion): the evolve fired the reducer's when_evolve_other
// cost_change (set=1), so the reducer's resolved cost is now the flat REDUCED cost (1) — 6 -> 1 caused
// by the evolve. (Engine-derived: the value is the engine's CostSetModifier, not test-set.)
Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, reducerHandIdx, fallback: -1),
Is.EqualTo(BoardDependentCostReduced),
"the evolve must drop the reducer's resolved cost to the SET value (1) via when_evolve_other");
// --- HANDLER-EMIT proof: the board-reduced cost reaches the opponent-facing knownList[].cost --------
// Ingest the reducer's play into the engine (cost 1 is affordable on seat A's fresh PP), then run
// PlayActionsHandler and assert the emitted cost is the board-reduced 1, not the base 6.
var playBody = HandlerPlayBody(reducerHandIdx);
Assert.That(harness.Push(NetworkBattleUri.PlayActions, playBody, isPlayerSeat: true).Accepted,
Is.True, "board-reduced reducer play ingest");
harness.SeatA.Phase = HandshakePhase.AfterReady;
harness.SeatB.Phase = HandshakePhase.AfterReady;
var env = new MsgEnvelope(
NetworkBattleUri.PlayActions, ViewerId: harness.SeatA.ViewerId, Uuid: "udid-test", Bid: null,
RetryAttempt: 0, Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
Body: new RawBody(playBody));
var ctx = new FrameDispatchContext
{
A = harness.SeatA, B = harness.SeatB, From = harness.SeatA, Other = harness.SeatB,
Env = env, BattleId = "test-battle", State = harness.State, Engine = harness.Engine,
};
var routes = new PlayActionsHandler().Handle(ctx);
Assert.That(routes, Has.Count.EqualTo(1), "one route to the opponent");
var body = routes[0].Frame.Body as PlayActionsBroadcastBody;
Assert.That(body, Is.Not.Null, "frame body is a PlayActionsBroadcastBody");
Assert.That(body!.KnownList, Is.Not.Null.And.Count.EqualTo(1), "one knownList entry (the played reducer)");
Assert.That(body.KnownList![0].CardId, Is.EqualTo(BoardDependentCostCardId), "the reducer's identity");
// THE emit assertion: the opponent-facing cost is the BOARD-REDUCED cost (1), engine-sourced.
Assert.That(body.KnownList[0].Cost, Is.EqualTo(BoardDependentCostReduced),
"knownList[].cost must be the engine-resolved BOARD-reduced cost (1), not the base cost (6)");
// Non-vacuity: the emitted cost must NOT be the un-reduced base — a wire-derived count->cost
// calculator (the retired path) had no signal for a when_evolve_other event and would have shipped 6.
Assert.That(body.KnownList[0].Cost, Is.Not.EqualTo(BoardDependentCostBase),
"non-vacuity: the emitted cost must NOT be the un-reduced base cost (6)");
}
// The hand POSITION (0-based) of the seat-A hand card with the given engine Index, or -1. Lets a test
// re-derive a HandCardId(seat, pos) lookup from an engine Index it already located by identity.
private static int FindHandPosByEngineIdx(NodeNativeBattleHarness harness, int engineIdx)
{
for (int i = 0; i < harness.HandCount(playerSeat: true); i++)
if (harness.HandCardIndex(playerSeat: true, i) == engineIdx)
return i;
return -1;
}
}

View File

@@ -108,6 +108,30 @@ internal sealed class NodeNativeBattleHarness : IDisposable
/// <summary>The second choice option of <see cref="ChoiceCardId"/> (token added to hand).</summary>
public const long ChoiceTokenB = 120011010;
/// <summary>A BOARD-DEPENDENT cost-reducer follower (M-HC-4d fixture). cards.json id 127011020:
/// char_type 1 (follower), clan 0 (Neutral — playable under any seat class), base cost 6, 3/3, skill
/// <c>cost_change,rush</c> / skill_timing <c>when_evolve_other,when_change_inplay</c> / skill_option
/// <c>set=1,none</c> / skill_condition (cost_change) <c>turn=self&amp;{me.hand_self.unit.count}&gt;0&amp;
/// character=me&amp;target=evolution_card&amp;card_type=unit</c> / skill_target <c>character=me&amp;target=self
/// &amp;card_type=unit</c> — i.e. "WHILE in hand, when ANOTHER of your followers evolves on your turn (and you
/// hold at least one other unit in hand), SET this card's cost to 1." The engine's evolve path
/// (<c>UnitBattleCard</c> non-skill evolve) scans the evolving player's HAND for cards whose skills have
/// <c>OnWhenEvolveOtherStart != 0</c> and registers them via <c>SkillCollectionBase.CreateWhenEvolveOtherInfo</c>;
/// <c>Skill_cost_change</c> then applies a <c>CostSetModifier(1)</c> to this card, so its resolved
/// <c>Cost</c> drops 6 → 1. Because the node reads opponent-facing cost straight off the resolved engine
/// (<c>SessionBattleEngine.PlayedCardCost</c>, M-HC-3), this board-dependent reduction is captured BY
/// CONSTRUCTION once evolve resolves headless (M-HC-4b) — this card validates that. Present + creatable in
/// cards.json.</summary>
public const long BoardDependentCostCardId = 127011020;
/// <summary>Base cost of <see cref="BoardDependentCostCardId"/> (6) — the pre-evolve resolved cost.</summary>
public const int BoardDependentCostBase = 6;
/// <summary>The flat cost <see cref="BoardDependentCostCardId"/> resolves to AFTER another follower evolves
/// on the controller's turn (skill_option <c>set=1</c> → <c>CostSetModifier(1)</c>). Independent of how many
/// followers evolved (a SET, not an add) — exactly 1.</summary>
public const int BoardDependentCostReduced = 1;
public BattleSessionState State { get; }
public StubParticipant SeatA { get; }
public StubParticipant SeatB { get; }
@@ -138,6 +162,20 @@ internal sealed class NodeNativeBattleHarness : IDisposable
return deck;
}
/// <summary>A deck for the M-HC-4d board-dependent-cost test: an alternating mix of the vanilla
/// follower (to play turn 1 and EVOLVE on seat A's evolve turn) and the <see cref="BoardDependentCostCardId"/>
/// (the <c>when_evolve_other set=1</c> cost-reducer that must sit IN HAND across the evolve). Alternating
/// 15/15 guarantees BOTH identities populate the opening hand + early draws regardless of the fixed shuffle;
/// the test locates each by identity (not a shuffle-dependent position). The cost-reducer's condition
/// <c>{me.hand_self.unit.count}&gt;0</c> (another unit in hand) is satisfied because copies of BOTH followers
/// remain in hand at the evolve.</summary>
public static IReadOnlyList<long> BoardDependentCostDeck()
{
var deck = new List<long>(30);
for (int i = 0; i < 15; i++) { deck.Add(VanillaFollowerId); deck.Add(BoardDependentCostCardId); }
return deck;
}
/// <summary>Seat the engine exactly as <c>BattleSession.EnsureEngineSetup</c> does: shuffle each
/// side's deck from the fixed seed via <see cref="BattleSessionState.GetShuffledDeck"/>, then
/// <c>SessionBattleEngine.Setup(seed, deckA, deckB, classA, classB)</c>.</summary>