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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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&{me.hand_self.unit.count}>0&
|
||||
/// character=me&target=evolution_card&card_type=unit</c> / skill_target <c>character=me&target=self
|
||||
/// &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}>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>
|
||||
|
||||
Reference in New Issue
Block a user