using System.Collections.Generic; using System.Linq; using NUnit.Framework; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol.Bodies; using SVSim.BattleNode.Sessions; using SVSim.BattleNode.Sessions.Dispatch; using SVSim.BattleNode.Sessions.Dispatch.Handlers; namespace SVSim.UnitTests.BattleNode.Integration; /// /// Headless-Conductor milestone tests (M-HC-*). The oracle is a node-native battle: /// a FIXED master seed + FIXED decks drive the engine's receive path headless, and we /// assert on engine board-state. By construction the node assigns idx = position in the /// shuffled order, so the engine's headless draw reproduces the node's draw order. /// /// Task 1 (M-HC-0a) exit criterion: the engine seats headless (IsReady) in the /// SVSim.UnitTests process. /// /// Task 2 (M-HC-0b) exit criterion: a node-generated Deal seats the 3-card hand and a /// vanilla hand-card Play resolves on ENGINE board state (card left hand, PP dropped /// by cost, board reflects the play) — driven through the receive CONDUCTOR, not the /// direct ActionProcessor path the M2-M12 oracles use. /// /// Task 3 (M-HC-1) exit criterion: the mulligan ops (Swap seats the post-mulligan hand — /// idx-3 swapped for the next unused deck idx-4) and turn ops (Ready/TurnStart/ /// TurnEnd) resolve headless, so two full turns of a node-native battle track on engine /// state (hand/board/PP/deck/turn/leader-life on both seats match the deterministic progression /// at each boundary). All driven through the same receive conductor. /// /// Task 5 (M-HC-3a) exit criterion: the opponent-facing knownList[].cost carries the /// engine-RESOLVED play-time cost (the discounted cost the engine actually charged), closing the /// spellboost cost-desync by construction. Proven both at the engine read (PlayedCardCost off a /// charge-seeded reducer) and the handler emit (PlayActionsHandler -> PlayActionsBroadcastBody). /// NOTE: a BOARD-DEPENDENT cost reducer (e.g. when_evolve_other) is DEFERRED to M-HC-4 — /// evolve does not yet resolve headless. Because cost is read straight off the resolved engine, /// board modifiers are captured by construction once their ops resolve, so no separate emit-site /// change is needed when M-HC-4 lands; only a board-dependent validation case is owed there. /// [TestFixture] [NonParallelizable] public class HeadlessConductorTests { [Test] public void Harness_seats_engine_headless_and_is_ready() { using var harness = NodeNativeBattleHarness.Create(); Assert.That(harness.IsReady, Is.True, "Engine must seat headless: EngineGlobalInit ran + both decks seeded. " + "If false, the most likely cause is a missing cards.json content link in " + "SVSim.UnitTests.csproj (EngineGlobalInit reads AppContext.BaseDirectory/Data/cards.json)."); // Non-vacuous: a seated engine has live board state for BOTH seats. Reading these off a // not-really-set-up engine would throw (Seat() guards on _mgr). Leader life is the headless // default (20) before any frame is ingested. Assert.That(harness.LeaderLife(playerSeat: true), Is.EqualTo(20), "seat A leader life"); Assert.That(harness.LeaderLife(playerSeat: false), Is.EqualTo(20), "seat B leader life"); } // The node's BuildDeal opening hand: pos->idx (0,1),(1,2),(2,3). hand == deck idx 1,2,3, i.e. // the top 3 of the node-native shuffled deck. Both seats deal the same idx triple. private static Dictionary DealBody() => new() { ["self"] = PosIdxList((0, 1), (1, 2), (2, 3)), ["oppo"] = PosIdxList((0, 1), (1, 2), (2, 3)), }; // A minimal vanilla hand-card play: type 30 == PLAY_HAND; playIdx is the played card's index. // No targetList/orderList — a vanilla follower auto-resolves with no selection. private static Dictionary PlayBody(int playIdx) => new() { ["playIdx"] = playIdx, ["type"] = 30, }; // A pos->idx list (the wire shape NetworkParameter.self/oppo carry: an ordered list of // {pos, idx} dicts). The receiver re-sorts by pos into the seat's idx list. private static List PosIdxList(params (int pos, int idx)[] entries) { var list = new List(entries.Length); foreach (var (pos, idx) in entries) list.Add(new Dictionary { ["pos"] = pos, ["idx"] = idx }); return list; } // Server-authored Swap RESPONSE frame (the shadow ingests this, NOT the client's {idxList} // Submit). It carries the POST-mulligan self hand as pos->idx. Swapping the pos-2 card (deck // idx 3) pulls the next unused deck idx (4) — exactly battle_test_cl1's Swap receive frame. private static Dictionary SwapBody() => new() { ["self"] = PosIdxList((0, 1), (1, 2), (2, 4)), }; // Server-authored Ready frame: both hands known + the idxChangeSeed/spin the receiver // consumes to seal the mulligan and start turn 1. Mirrors battle_test_cl1's Ready receive. private static Dictionary ReadyBody() => new() { ["self"] = PosIdxList((0, 1), (1, 2), (2, 4)), // same post-mulligan self hand as SwapBody — Ready re-echoes it ["oppo"] = PosIdxList((0, 1), (1, 2), (2, 3)), ["idxChangeSeed"] = 857671914, ["spin"] = 0, }; private static Dictionary TurnStartBody() => new() { ["spin"] = 0 }; private static Dictionary TurnEndBody() => new() { ["turnState"] = 0 }; // An opponent play that REVEALS the played card. The wire shape is taken verbatim from // battle_test_cl2.ndjson's first opponent PlayActions frame: // { playIdx, type:30, knownList:[{idx, cardId, to:30, spellboost:0, attachTarget:""}] } // type 30 == PLAY_HAND; knownList[].idx == the hidden dummy's engine Index; cardId == the real // identity to substitute; to 30 == NetworkCardPlaceState.Field (the card lands in play). private static Dictionary RevealPlayBody(int idx, long cardId) => new() { ["playIdx"] = idx, ["type"] = 30, ["knownList"] = new List { new Dictionary { ["idx"] = idx, ["cardId"] = cardId, ["to"] = 30, ["spellboost"] = 0, ["attachTarget"] = "", }, }, }; [Test] public void Swap_seats_post_mulligan_hand_headless() { using var harness = NodeNativeBattleHarness.Create(); var deal = harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true); Assert.That(deal.Accepted, Is.True, $"Deal rejected: {deal.RejectReason}"); Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(3), "post-Deal hand"); var swap = harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true); Assert.That(swap.Accepted, Is.True, $"Swap rejected: {swap.RejectReason}"); Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(3), "the swapped slot is replaced, not removed — hand stays at 3"); // The pos-2 card was the deck-idx-3 card; the swap replaces it with the deck-idx-4 card. // The kept cards (idx 1, 2) stay put. Assert the engine hand holds idx {1,2,4}. var handIdxs = new[] { harness.PlayerHandCardIndex(0), harness.PlayerHandCardIndex(1), harness.PlayerHandCardIndex(2), }; Assert.That(handIdxs, Is.EquivalentTo(new[] { 1, 2, 4 }), "post-mulligan hand must hold deck idx 1,2,4 (idx-3 swapped for the next unused idx-4)"); } [Test] public void Two_turns_track_on_engine_state_headless() { // The oracle is the engine's OWN deterministic node-native progression off the fixed seed: // every value below is the engine-resolved state, reproducible by construction. The shadow // ingests the same server-authored frame stream the live node emits (Deal/Swap/Ready then // per-turn TurnStart/TurnEnd — the exact receive frames captured in battle_test_cl1.ndjson). using var harness = NodeNativeBattleHarness.Create(); // --- mulligan barrier: Deal, Swap, Ready ------------------------------------------------- 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"); var ready = harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true); Assert.That(ready.Accepted, Is.True, $"Ready rejected: {ready.RejectReason}"); // After Ready the mulligan is sealed and the main phase is entered, but no turn has been // opened yet (TurnStart does the ramp + draw). Seat A holds its post-mulligan 3-card hand; // the opponent's hand stays hidden until its reveal frames land (Task 4) — node-native, the // opponent's opening hand is never disclosed to the relay before its own turn. Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(3), "seat A hand after Ready"); Assert.That(harness.Turn(playerSeat: true), Is.EqualTo(0), "no turn opened yet after Ready"); // --- turn 1 (seat A active) ------------------------------------------------------------- // Seat A is the engine's player seat and is NOT game-first here, so turn-1 draws TWO cards // (the standard second-player turn-1 draw). PP ramps to 1. var t1 = harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true); Assert.That(t1.Accepted, Is.True, $"turn1 TurnStart rejected: {t1.RejectReason}"); Assert.That(harness.Turn(playerSeat: true), Is.EqualTo(1), "seat A turn counter"); Assert.That(harness.Pp(playerSeat: true), Is.EqualTo(1), "turn 1 ramps seat A max PP to 1"); Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(5), "turn-1 second-player draw is 2 cards (3 -> 5)"); Assert.That(harness.DeckCount(playerSeat: true), Is.EqualTo(25), "seat A deck after draw"); // End seat A's turn. var t1End = harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true); Assert.That(t1End.Accepted, Is.True, $"turn1 TurnEnd rejected: {t1End.RejectReason}"); // --- turn 2 (seat B active) ------------------------------------------------------------- // Seat B opens its first turn: PP ramps to 1 and it draws its turn-1 card. (Seat B's deck // started full at 30 because its opening hand is dealt into hidden zones, not its // HandCardList, until reveal — so its first visible draw moves deck 30 -> 29, hand 0 -> 1.) var t2 = harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false); Assert.That(t2.Accepted, Is.True, $"turn2 TurnStart rejected: {t2.RejectReason}"); Assert.That(harness.Turn(playerSeat: false), Is.EqualTo(1), "seat B turn counter"); Assert.That(harness.Pp(playerSeat: false), Is.EqualTo(1), "turn 2 ramps seat B max PP to 1"); Assert.That(harness.HandCount(playerSeat: false), Is.EqualTo(1), "seat B turn-1 draw"); // Seat B's opening hand was dealt into hidden zones (not HandCardList), so its deck started at 30; // the single turn-1 draw brings it to 29. Assert.That(harness.DeckCount(playerSeat: false), Is.EqualTo(29), "seat B deck after turn-1 draw"); // Both leaders untouched (no damage dealt across the two opening turns) — state tracks // cleanly on BOTH seats at the turn boundary. Assert.That(harness.LeaderLife(playerSeat: true), Is.EqualTo(20), "seat A leader life"); Assert.That(harness.LeaderLife(playerSeat: false), Is.EqualTo(20), "seat B leader life"); } [Test] public void Opponent_reveal_seats_card_on_seat_B_headless() { // Seat B's deck idx 1 is a known vanilla follower, so the reveal's wire cardId maps to a real // card the opponent can play to the board. (Seat A's deck is left at default — irrelevant here.) var seatBDeck = new List { NodeNativeBattleHarness.VanillaFollowerId }; seatBDeck.AddRange(NodeNativeBattleHarness.DefaultDeck()); seatBDeck = seatBDeck.GetRange(0, 30); using var harness = NodeNativeBattleHarness.Create(seatBDeck: seatBDeck); // --- drive to seat B's turn (reuse Task 3's two-turn sequence) --------------------------- 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.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnStart (seat B active)"); // Seat B's opening hand is hidden (deck reads full minus its single turn-1 draw); its cards // have NOT been disclosed to the relay yet. The dummy at engine Index 1 is whatever card the // shuffle seated at that index (shuffledDeck[0]), parked in a hidden zone — NOT on the board. // Confirm seat B's board is empty BEFORE the reveal, so the post-reveal +1 is decisively the // reveal seating the card. (Node-native, the harness seeds each side's cards with their real id // — it knows both decks — so this test's reveal substitution is identity-preserving by choice; // CreateActualCard builds the card purely from the wire cardId regardless of which card the // shuffle parked at Index 1. The board delta is what proves ReplaceReceivedCard.ReplaceCard -> // CreateActualCard resolved the card onto the board headless. The companion test // Opponent_reveal_overrides_seeded_identity_headless stresses a MISMATCHED cardId to prove the // wire id — not the seeded identity — is what gets seated.) var boardBefore = harness.BoardCount(playerSeat: false); Assert.That(boardBefore, Is.EqualTo(0), "seat B has no board followers before the reveal"); // --- the reveal: an opponent PlayActions frame carrying a knownList that discloses idx 1 --- const long revealedCardId = NodeNativeBattleHarness.VanillaFollowerId; var reveal = harness.Push( NetworkBattleUri.PlayActions, RevealPlayBody(idx: 1, cardId: revealedCardId), isPlayerSeat: false); Assert.That(reveal.Accepted, Is.True, $"opponent reveal rejected: {reveal.RejectReason}"); Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(boardBefore + 1), "the revealed follower must seat on seat B's board"); Assert.That(harness.InPlayCardId(playerSeat: false, boardPos: 0), Is.EqualTo((int)revealedCardId), "the seated card's identity must equal the wire cardId from the reveal"); } [Test] public void Opponent_reveal_overrides_seeded_identity_headless() { // This is the substitution half of M-HC-2: prove the seated card's POST-reveal identity is the // WIRE cardId even when it DIFFERS from whatever the shuffle parked at that engine Index. // ReplaceReceivedCard.CreateActualCard builds the card purely from cardData.CardId, independent // of the seated dummy's id — so a reveal whose cardId mismatches the seed must OVERRIDE it. // // Z (seeded) vs W (revealed) are DIFFERENT cost-1 vanilla followers, both present + creatable in // cards.json: // Z = 100011010 — the proven vanilla follower (char_type 1, cost 1). Seat B's deck is made // UNIFORMLY of Z, so whichever idx the shuffle parked at Index 1 is unambiguously Z. // W = 101211120 — a different cost-1 vanilla follower (char_type 1, cost 1, no skill). Cost 1 // seats at seat B's first-turn PP (1). The id is NOT in seat B's deck, so the only way it // can appear on the board is the reveal substituting it in. const long Z = NodeNativeBattleHarness.VanillaFollowerId; // 100011010 const long W = NodeNativeBattleHarness.AltVanillaFollowerId; // 101211120 Assert.That(W, Is.Not.EqualTo(Z), "Z and W must differ for the substitution to be observable"); // Uniform Z deck for seat B (every dummy is Z regardless of shuffle). Seat A left at default. var seatBDeck = Enumerable.Repeat(Z, 30).ToList(); using var harness = NodeNativeBattleHarness.Create(seatBDeck: seatBDeck); // --- drive to seat B's turn (same two-turn sequence as the sibling reveal test) ------------- 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.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnStart (seat B active)"); var boardBefore = harness.BoardCount(playerSeat: false); Assert.That(boardBefore, Is.EqualTo(0), "seat B has no board followers before the reveal"); // The reveal discloses idx 1 (seeded as Z) with the MISMATCHED wire cardId W. var reveal = harness.Push( NetworkBattleUri.PlayActions, RevealPlayBody(idx: 1, cardId: W), isPlayerSeat: false); Assert.That(reveal.Accepted, Is.True, $"opponent reveal rejected: {reveal.RejectReason}"); Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(boardBefore + 1), "the revealed follower must seat on seat B's board"); // The decisive assertion: the seated identity is W (the wire cardId), NOT Z (the seeded id). // Because the deck is uniformly Z, this can only pass if the reveal OVERRODE the seeded identity. Assert.That(harness.InPlayCardId(playerSeat: false, boardPos: 0), Is.EqualTo((int)W), "the seated card must be the wire cardId W, overriding the seeded Z identity at that idx"); } // === M-HC-4a: attack resolves headless ======================================================= [Test] public void Attack_on_enemy_leader_resolves_on_engine_state_headless() { // Seat A plays a vanilla follower on turn 1, then on its NEXT turn (past summoning sickness) // attacks seat B's leader. Assert seat B's leader life drops by the follower's attack (1) and the // attacker is spent. 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. var deck = Enumerable.Repeat(NodeNativeBattleHarness.VanillaFollowerId, 30).ToList(); using var harness = NodeNativeBattleHarness.Create(seatADeck: deck); // --- mulligan + open seat A turn 1 ------------------------------------------------------------ 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"); // Play the vanilla (engine Index 1, cost 1) onto seat A's board. 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 just-played follower has summoning sickness this turn (can't attack yet). Assert.That(harness.InPlayCardAttackable(playerSeat: true, boardPos: 0), Is.False, "a follower has summoning sickness the turn it is played"); int attackerIdx = harness.InPlayCardIndex(playerSeat: true, boardPos: 0); int attackerAtk = harness.InPlayCardAtk(playerSeat: true, boardPos: 0); Assert.That(attackerAtk, Is.EqualTo(1), "the vanilla follower's attack stat is 1"); // --- advance to seat A's NEXT turn (turn 3) so the follower is past summoning sickness --------- Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnStart (B)"); Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnEnd (B)"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn3 TurnStart (A)"); Assert.That(harness.InPlayCardAttackable(playerSeat: true, boardPos: 0), Is.True, "the follower can attack on seat A's next turn (summoning sickness cleared)"); int leaderLifeBefore = harness.LeaderLife(playerSeat: false); Assert.That(leaderLifeBefore, Is.EqualTo(20), "seat B leader untouched before the attack"); // --- the attack: seat A follower -> seat B leader (Index 0, on the enemy seat) ---------------- var attack = harness.Push( NetworkBattleUri.PlayActions, NodeNativeBattleHarness.AttackBody(attackerIdx, targetIdx: 0, targetOnEnemySeat: true), isPlayerSeat: true); Assert.That(attack.Accepted, Is.True, $"attack rejected: {attack.RejectReason}"); Assert.That(harness.LeaderLife(playerSeat: false), Is.EqualTo(leaderLifeBefore - attackerAtk), "seat B leader life must drop by the attacker's attack stat"); Assert.That(harness.InPlayCardAttackable(playerSeat: true, boardPos: 0), Is.False, "the attacker is spent after attacking (can't attack again this turn)"); } [Test] public void Follower_vs_follower_attack_is_a_lethal_trade_headless() { // Seat A plays a 1/1 vanilla; seat B reveals a 1/1 vanilla (M-HC-2 reveal pattern). On seat A's // next turn the follower attacks seat B's follower. Each deals 1 to a 1-life body -> a lethal // trade: both followers' life drops and both leave the board. var oneOne = NodeNativeBattleHarness.VanillaOneOneFollowerId; var seatADeck = Enumerable.Repeat(oneOne, 30).ToList(); var seatBDeck = Enumerable.Repeat(oneOne, 30).ToList(); using var harness = NodeNativeBattleHarness.Create(seatADeck: seatADeck, seatBDeck: seatBDeck); // --- mulligan + seat A turn 1: play the 1/1 ------------------------------------------------- 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 play 1/1"); Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "seat A 1/1 on board"); int attackerIdx = harness.InPlayCardIndex(playerSeat: true, boardPos: 0); // --- seat B turn 2: reveal a 1/1 onto seat B's board ------------------------------------------ Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnStart (B)"); Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(0), "seat B board empty before reveal"); Assert.That(harness.Push(NetworkBattleUri.PlayActions, RevealPlayBody(idx: 1, cardId: oneOne), isPlayerSeat: false).Accepted, Is.True, "seat B reveal-play 1/1"); Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(1), "seat B 1/1 on board after reveal"); int targetIdx = harness.InPlayCardIndex(playerSeat: false, boardPos: 0); // --- back to seat A (turn 3): the 1/1 is past summoning sickness ------------------------------ Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnEnd (B)"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn3 TurnStart (A)"); Assert.That(harness.InPlayCardAttackable(playerSeat: true, boardPos: 0), Is.True, "attacker past summoning sickness"); Assert.That(harness.InPlayCardLife(playerSeat: true, boardPos: 0), Is.EqualTo(1), "attacker 1/1 full life before trade"); Assert.That(harness.InPlayCardLife(playerSeat: false, boardPos: 0), Is.EqualTo(1), "target 1/1 full life before trade"); // --- attack follower -> follower (target on enemy seat B) ------------------------------------ var attack = harness.Push( NetworkBattleUri.PlayActions, NodeNativeBattleHarness.AttackBody(attackerIdx, targetIdx, targetOnEnemySeat: true), isPlayerSeat: true); Assert.That(attack.Accepted, Is.True, $"follower trade rejected: {attack.RejectReason}"); // 1/1 vs 1/1: each takes 1 -> both at 0 life -> both die and leave the board (lethal trade). Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(0), "attacker 1/1 died in the trade"); Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(0), "target 1/1 died in the trade"); Assert.That(harness.LeaderLife(playerSeat: false), Is.EqualTo(20), "neither leader takes damage in a follower-vs-follower trade"); } [Test] public void Deal_seats_three_card_hand_headless() { using var harness = NodeNativeBattleHarness.Create(); var result = harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true); Assert.That(result.Accepted, Is.True, $"Deal rejected: {result.RejectReason}"); Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(3), "Deal must seat the 3-card opening hand on the player seat."); } [Test] public void Vanilla_play_resolves_on_engine_state_headless() { // Deck idx 1/2/3 are the top three of the shuffled deck; arrange idx-1 to be a known vanilla // follower so the Play assertion is decisive. Put the vanilla follower first; the rest of the // default deck (spellboost + vanillas) follows. var deck = new List { NodeNativeBattleHarness.VanillaFollowerId }; deck.AddRange(NodeNativeBattleHarness.DefaultDeck()); deck = deck.GetRange(0, 30); using var harness = NodeNativeBattleHarness.Create(seatADeck: deck); var deal = harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true); Assert.That(deal.Accepted, Is.True, $"Deal rejected: {deal.RejectReason}"); Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(3), "post-Deal hand"); var ppBefore = harness.Pp(playerSeat: true); var handBefore = harness.HandCount(playerSeat: true); var boardBefore = harness.BoardCount(playerSeat: true); // The played card is at hand index 1 (deck idx 1 -> the first dealt card; engine card Index // mirrors deck position+1). The shuffle determines which deck idx-1 maps to; we only need a // vanilla follower in the opening hand. Use the first dealt idx. var playIdx = harness.PlayerHandCardIndex(0); var play = harness.Push(NetworkBattleUri.PlayActions, PlayBody(playIdx), isPlayerSeat: true); Assert.That(play.Accepted, Is.True, $"Play rejected: {play.RejectReason}"); Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(handBefore - 1), "the played card must leave the hand"); Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(boardBefore + 1), "a follower play must add one to the board"); Assert.That(harness.Pp(playerSeat: true), Is.LessThan(ppBefore), "PP must drop by the played card's cost"); } // === M-HC-3a: engine-resolved cost on the knownList ========================================== // The spellboost cost-reducer 101314020 (base cost 5). Its when_spell_charge cost_change skill // (skill_option add=ADD_CHARGE_COUNT*-1) reduces its OWN cost by 1 per accumulated spellboost // charge — so resolved cost == max(0, 5 - charge). The harness seeds the charge directly // (SeedHandCardSpellboostCost registers the same CostAddModifier(-1)/charge the engine's own // Skill_cost_change builds) because pumping real charge needs the VFX-coupled spell-charge chain. private const long SpellboostReducerId = NodeNativeBattleHarness.SpellboostCardId; // 101314020 private const int SpellboostReducerBaseCost = 5; // A deck made UNIFORMLY of the spellboost reducer, so whatever idx the shuffle parks at engine // Index 1 (the first dealt card) is unambiguously the reducer — no need to chase the shuffled // position. (A non-uniform deck would shuffle the reducer off idx 1; the cost read would then be a // vanilla's base 1, masking the discount — that is exactly the first RED this surfaced.) private static IReadOnlyList ReducerDeck() => Enumerable.Repeat(SpellboostReducerId, 30).ToList(); [TestCase(0, SpellboostReducerBaseCost)] // no charge -> base cost (5) [TestCase(4, 1)] // 4 charges -> 5 - 4 = 1 [TestCase(5, 0)] // 5 charges -> max(0, 5 - 5) = 0 public void PlayedCardCost_reads_engine_resolved_discounted_cost(int charge, int expectedCost) { // ENGINE-READ proof (the count->cost resolution off the real Cost getter). Drive a node-native // battle to seat A's turn 1, seed the reducer's spellboost charge, play it, and read the cost the // engine actually charged. expectedCost is base(5) - charge, the engine's authentic resolution — // and the differing values across charge levels are the non-vacuity (a wrong charge -> wrong cost). using var harness = NodeNativeBattleHarness.Create(seatADeck: ReducerDeck()); 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"); // The reducer dealt at engine Index 1 (deck position 0). Seed the charge on it WHILE it is in hand, // then confirm the engine's Cost getter resolved the discount BEFORE the play (pre-play pin). int seededHandCost = harness.Engine.SeedHandCardSpellboostCost(playerSeat: true, idx: 1, charge); Assert.That(seededHandCost, Is.EqualTo(expectedCost), $"engine hand-card Cost must resolve base({SpellboostReducerBaseCost}) - charge({charge})"); // Play it. With max(0,5-charge) <= 1 for charge 4/5, and charge 0 keeping cost 5 (PP 1 can't pay // 5), we only need the cost READ to be correct — but assert acceptance where affordable. var play = harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true); if (expectedCost <= 1) Assert.That(play.Accepted, Is.True, $"affordable reducer play rejected: {play.RejectReason}"); // The PAYOFF read: PlayedCardCost returns the engine-resolved play-time cost. For an affordable // play this is the captured PlayedCost (post-resolution, card now in cemetery — it is a spell); // for the unaffordable charge-0 case the card stays in hand and the live Cost (5) is read. Either // way the value equals the engine's resolved discounted cost. Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, idx: 1), Is.EqualTo(expectedCost), $"PlayedCardCost must equal the engine-resolved cost {expectedCost} at charge {charge}"); } [Test] public void Vanilla_play_PlayedCardCost_is_base_cost() { // A vanilla follower has no cost modifier, so the engine resolves its base cost (1) by // construction — the cost the knownList will carry for a non-boosted play. var deck = new List { NodeNativeBattleHarness.VanillaFollowerId }; deck.AddRange(NodeNativeBattleHarness.DefaultDeck()); deck = deck.GetRange(0, 30); using var harness = NodeNativeBattleHarness.Create(seatADeck: deck); Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True); var playIdx = harness.PlayerHandCardIndex(0); Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(playIdx), isPlayerSeat: true).Accepted, Is.True, "vanilla play"); Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, idx: playIdx), Is.EqualTo(1), "a cost-1 vanilla follower resolves to base cost 1"); } [Test] public void PlayedCardCost_degrades_to_fallback_for_unknown_idx() { // Graceful degradation: an idx with no resolved card returns the fallback (non-engine sessions // and unmapped idxs never crash the handler). using var harness = NodeNativeBattleHarness.Create(); Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, idx: 9999, fallback: 7), Is.EqualTo(7)); } // --- HANDLER-EMIT proof: the cost reaches the opponent-facing knownList[].cost ---------------- // A PlayActions wire frame the HANDLER consumes: it needs an orderList move op for the played idx so // BuildPlayedCard can synthesize the entry (the engine resolves the play from playIdx/type alone, but // the opponent-facing synthesis is driven by the wire orderList). to:30 == Field. private static Dictionary HandlerPlayBody(int playIdx) => new() { ["playIdx"] = playIdx, ["type"] = 30, ["orderList"] = new List { new Dictionary { ["move"] = new Dictionary { ["idx"] = new List { (long)playIdx }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L, }, }, }, }; [Test] public void Handler_emits_engine_resolved_cost_on_knownList() { // The end-to-end payoff: build a FrameDispatchContext over the harness (engine + state + // participants), drive to seat A's turn, seed the reducer's charge, INGEST the play (so the engine // resolves + captures PlayedCost), then run PlayActionsHandler.Handle and inspect the emitted // knownList[0].cost. It must equal the engine-resolved discounted cost (NOT the base cost) — // proving the cost-desync is closed by construction at the emit site. const int charge = 4; const int expectedCost = SpellboostReducerBaseCost - charge; // 1 using var harness = NodeNativeBattleHarness.Create(seatADeck: ReducerDeck()); 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"); // Seed the charge so the engine resolves the reducer at cost 1 (affordable on PP 1). Assert.That(harness.Engine.SeedHandCardSpellboostCost(playerSeat: true, idx: 1, charge), Is.EqualTo(expectedCost), "pre-play resolved hand cost"); // Ingest the play into the engine (seat A == player) so PlayedCost is captured at resolution. var playBody = HandlerPlayBody(1); Assert.That(harness.Push(NetworkBattleUri.PlayActions, playBody, isPlayerSeat: true).Accepted, Is.True, "reducer play ingest"); // Build the dispatch context the way BattleSession.BuildContext does, with both stubs advanced to // AfterReady so the PvP relay gate (BothSidesAfterReady) passes. From == seat A (the sender). 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 card)"); Assert.That(body.KnownList![0].CardId, Is.EqualTo(SpellboostReducerId), "the reducer's identity"); // THE assertion: the emitted cost is the engine-resolved DISCOUNTED cost (1), not the base (5). Assert.That(body.KnownList[0].Cost, Is.EqualTo(expectedCost), "knownList[].cost must be the engine-resolved discounted cost, not the base cost"); Assert.That(body.KnownList[0].Cost, Is.Not.EqualTo(SpellboostReducerBaseCost), "non-vacuity: the emitted cost must NOT be the un-discounted base cost"); } // === M-HC-3b: REAL spell-charge accumulation (no seam) ======================================= // The spellboost GRANTOR 118311030: a cost-3 follower whose when_play spell_charge skill // (add_charge=1, target character=me&target=hand&card_type=all) adds +1 spell-charge to EVERY card in // the caster's hand on each play. Drives the reducer's charge for real headless — no SeedHandCardSpellboostCost // seam. (Its authored SECOND charge skill, add_charge=5, does NOT fire headless — only +1 lands per play; // recorded as a known fidelity follow-up, irrelevant to this regression which needs only the +1.) private const long SpellboostGrantorId = 118311030; // A deck of alternating reducers + grantors so both reliably populate the opening hand and early draws // (a single front-loaded reducer would shuffle out of reach). 15 of each = 30. private static IReadOnlyList ReducerAndGrantorDeck() { var deck = new List(30); for (int i = 0; i < 15; i++) { deck.Add(SpellboostReducerId); deck.Add(SpellboostGrantorId); } return deck; } // Find the engine Index of the first hand card on seat A with the given wire cardId (the hand is // shuffled, so we locate by identity, not position). -1 if not present. private static int FindHandIdxByCardId(NodeNativeBattleHarness harness, long cardId) { for (int i = 0; i < harness.HandCount(playerSeat: true); i++) if (harness.HandCardId(playerSeat: true, i) == (int)cardId) return harness.HandCardIndex(playerSeat: true, i); return -1; } // Ramp seat A to its turn `targetTurn` by alternating TurnStart/TurnEnd A/B; leaves seat A's turn OPEN. private void RampToSeatATurn(NodeNativeBattleHarness harness, int targetTurn) { bool seatA = true; while (true) { Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: seatA).Accepted, Is.True, "TurnStart"); if (seatA && harness.Turn(playerSeat: true) == targetTurn) return; Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: seatA).Accepted, Is.True, "TurnEnd"); seatA = !seatA; } } [Test] public void Real_spell_charge_drops_engine_cost_and_count_no_seam() { // The committed M-HC-3b closure guard: drive a REAL spell-charge sequence headless (NO // SeedHandCardSpellboostCost seam) and assert the engine-sourced COST and SPELLBOOST COUNT the node // now emits are both correct by construction. Proves the retired wire-derived bookkeeping is // redundant: the engine accumulates the charge itself (each grantor play runs the reducer's own // AddSpellChargeCount) and resolves the discount. using var harness = NodeNativeBattleHarness.Create(seatADeck: ReducerAndGrantorDeck()); 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"); // Ramp to seat A turn 3 (PP 3) so the cost-3 grantor is affordable. RampToSeatATurn(harness, targetTurn: 3); Assert.That(harness.Pp(playerSeat: true), Is.EqualTo(3), "seat A PP at turn 3"); // Locate a reducer + a grantor in the (shuffled) hand by identity. int reducerIdx = FindHandIdxByCardId(harness, SpellboostReducerId); int grantorIdx = FindHandIdxByCardId(harness, SpellboostGrantorId); Assert.That(reducerIdx, Is.GreaterThan(0), "a reducer must be in seat A's opening hand"); Assert.That(grantorIdx, Is.GreaterThan(0), "a grantor must be in seat A's opening hand"); // PRE-CHARGE non-vacuity: the reducer resolves to its BASE cost (5) and 0 charge BEFORE any grant. Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, reducerIdx, fallback: -1), Is.EqualTo(SpellboostReducerBaseCost), "reducer cost is base (5) before any charge"); Assert.That(harness.Engine.PlayedCardSpellboost(playerSeat: true, reducerIdx, fallback: -1), Is.EqualTo(0), "reducer spell-charge is 0 before any grant"); // Play the grantor (cost 3). Its when_play spell_charge adds +1 to every hand card — REAL engine // resolution, no seam. This runs through the receive conductor (Push -> engine.Receive). Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(grantorIdx), isPlayerSeat: true).Accepted, Is.True, "grantor play"); // THE engine-read assertions: the reducer (still in hand) now reads charge 1 and cost 4 (5 - 1) — // accumulated for real by the engine, not seeded. Assert.That(harness.Engine.PlayedCardSpellboost(playerSeat: true, reducerIdx, fallback: -1), Is.EqualTo(1), "one grantor play accumulates +1 real spell-charge on the reducer"); Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, reducerIdx, fallback: -1), Is.EqualTo(SpellboostReducerBaseCost - 1), "the engine resolves the reducer's cost down to 4 (base 5 - 1 charge), no seam"); // PERSIST-POST-PLAY proof (the read-moment this milestone chose): advance to seat A's next turn // (fresh PP 4, affording the cost-4 reducer), play the reducer (a spell -> cemetery), and confirm // PlayedCardSpellboost/PlayedCardCost STILL read 1/4 AFTER the card left the hand — i.e. the zone // search reads the persisted count off the resolved card, no receive-capture needed. Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True); Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: false).Accepted, Is.True); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True); Assert.That(harness.Pp(playerSeat: true), Is.GreaterThanOrEqualTo(4), "seat A fresh PP affords cost-4 reducer"); // The reducer's engine Index is stable across turns; play it now. Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(reducerIdx), isPlayerSeat: true).Accepted, Is.True, "charged reducer play"); Assert.That(harness.Engine.PlayedCardSpellboost(playerSeat: true, reducerIdx, fallback: -1), Is.EqualTo(1), "spell-charge persists on the played reducer (now in cemetery)"); Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, reducerIdx, fallback: -1), Is.EqualTo(SpellboostReducerBaseCost - 1), "PlayedCost captured the discounted cost (4) at play time and persists post-play"); } [Test] public void Handler_emits_real_engine_spellboost_and_cost_on_knownList() { // The end-to-end emit payoff for M-HC-3b: a REAL-charged reducer played through the conductor, then // PlayActionsHandler.Handle, with BOTH knownList[].cost AND knownList[].spellboost read straight off // the engine (no wire-derived bookkeeping). Cost 4 (discounted) + count 1 (real charge). using var harness = NodeNativeBattleHarness.Create(seatADeck: ReducerAndGrantorDeck()); 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"); RampToSeatATurn(harness, targetTurn: 3); int reducerIdx = FindHandIdxByCardId(harness, SpellboostReducerId); int grantorIdx = FindHandIdxByCardId(harness, SpellboostGrantorId); Assert.That(reducerIdx, Is.GreaterThan(0), "reducer in hand"); Assert.That(grantorIdx, Is.GreaterThan(0), "grantor in hand"); // Charge the reducer for real (one grantor play -> +1), then advance to a fresh seat A turn that // affords the discounted reducer. Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(grantorIdx), isPlayerSeat: true).Accepted, Is.True, "grantor play"); Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True); Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: false).Accepted, Is.True); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True); // Ingest the reducer play into the engine (so PlayedCost/SpellChargeCount are captured at resolution). var playBody = HandlerPlayBody(reducerIdx); Assert.That(harness.Push(NetworkBattleUri.PlayActions, playBody, isPlayerSeat: true).Accepted, Is.True, "charged reducer play ingest"); // Build the dispatch context the way BattleSession.BuildContext does; From == seat A (the sender). 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(SpellboostReducerId), "the reducer's identity"); // THE assertions: cost is the engine-resolved DISCOUNTED cost (4), spellboost is the REAL count (1). Assert.That(body.KnownList[0].Cost, Is.EqualTo(SpellboostReducerBaseCost - 1), "knownList[].cost must be the engine-resolved discounted cost (4), not base (5)"); Assert.That(body.KnownList[0].Spellboost, Is.EqualTo(1), "knownList[].spellboost must be the REAL engine-accumulated charge count (1), engine-sourced"); // Non-vacuity: neither field is the un-charged default. Assert.That(body.KnownList[0].Cost, Is.Not.EqualTo(SpellboostReducerBaseCost), "non-vacuity: emitted cost is NOT the un-discounted base cost"); Assert.That(body.KnownList[0].Spellboost, Is.Not.EqualTo(0), "non-vacuity: emitted spellboost is NOT 0"); } }