using System.Collections.Generic; using NUnit.Framework; using SVSim.BattleNode.Protocol; 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. /// [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"] = new List { new Dictionary { ["pos"] = 0, ["idx"] = 1 }, new Dictionary { ["pos"] = 1, ["idx"] = 2 }, new Dictionary { ["pos"] = 2, ["idx"] = 3 }, }, ["oppo"] = new List { new Dictionary { ["pos"] = 0, ["idx"] = 1 }, new Dictionary { ["pos"] = 1, ["idx"] = 2 }, new Dictionary { ["pos"] = 2, ["idx"] = 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)), ["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 }; [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"); // 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 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"); } }