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. /// [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, }; [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"); } }