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=1CostSetModifier(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).