diff --git a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs
index 803aa96..efddbab 100644
--- a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs
+++ b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs
@@ -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;
+ }
}
diff --git a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs
index 61b212b..11bdcd5 100644
--- a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs
+++ b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs
@@ -108,6 +108,30 @@ internal sealed class NodeNativeBattleHarness : IDisposable
/// The second choice option of (token added to hand).
public const long ChoiceTokenB = 120011010;
+ /// 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
+ /// cost_change,rush / skill_timing when_evolve_other,when_change_inplay / skill_option
+ /// set=1,none / skill_condition (cost_change) turn=self&{me.hand_self.unit.count}>0&
+ /// character=me&target=evolution_card&card_type=unit / skill_target character=me&target=self
+ /// &card_type=unit — 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
+ /// (UnitBattleCard non-skill evolve) scans the evolving player's HAND for cards whose skills have
+ /// OnWhenEvolveOtherStart != 0 and registers them via SkillCollectionBase.CreateWhenEvolveOtherInfo;
+ /// Skill_cost_change then applies a CostSetModifier(1) to this card, so its resolved
+ /// Cost drops 6 → 1. Because the node reads opponent-facing cost straight off the resolved engine
+ /// (SessionBattleEngine.PlayedCardCost, 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.
+ public const long BoardDependentCostCardId = 127011020;
+
+ /// Base cost of (6) — the pre-evolve resolved cost.
+ public const int BoardDependentCostBase = 6;
+
+ /// The flat cost resolves to AFTER another follower evolves
+ /// on the controller's turn (skill_option set=1 → CostSetModifier(1)). Independent of how many
+ /// followers evolved (a SET, not an add) — exactly 1.
+ 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;
}
+ /// 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
+ /// (the when_evolve_other set=1 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
+ /// {me.hand_self.unit.count}>0 (another unit in hand) is satisfied because copies of BOTH followers
+ /// remain in hand at the evolve.
+ public static IReadOnlyList BoardDependentCostDeck()
+ {
+ var deck = new List(30);
+ for (int i = 0; i < 15; i++) { deck.Add(VanillaFollowerId); deck.Add(BoardDependentCostCardId); }
+ return deck;
+ }
+
/// Seat the engine exactly as BattleSession.EnsureEngineSetup does: shuffle each
/// side's deck from the fixed seed via , then
/// SessionBattleEngine.Setup(seed, deckA, deckB, classA, classB).