diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs index fa07d06..814867e 100644 --- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs +++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs @@ -149,6 +149,8 @@ internal sealed class SessionBattleEngine public int LeaderLife(bool playerSeat) => Seat(playerSeat).Class.Life; public int Pp(bool playerSeat) => Seat(playerSeat).Pp; public int HandCount(bool playerSeat) => Seat(playerSeat).HandCardList.Count; + public int DeckCount(bool playerSeat) => Seat(playerSeat).DeckCardList.Count; + public int Turn(bool playerSeat) => Seat(playerSeat).Turn; /// Followers in play, excluding the leader (the Class card occupies one slot of /// ClassAndInPlayCardList). diff --git a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs index 321d92e..bc8d140 100644 --- a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs @@ -17,6 +17,12 @@ namespace SVSim.UnitTests.BattleNode.Integration; /// 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] @@ -65,6 +71,118 @@ public class HeadlessConductorTests ["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() { diff --git a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs index 8c907f3..f9bd6d9 100644 --- a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs +++ b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs @@ -124,6 +124,8 @@ internal sealed class NodeNativeBattleHarness : IDisposable public int Pp(bool playerSeat) => Engine.Pp(playerSeat); public int HandCount(bool playerSeat) => Engine.HandCount(playerSeat); public int BoardCount(bool playerSeat) => Engine.BoardCount(playerSeat); + public int DeckCount(bool playerSeat) => Engine.DeckCount(playerSeat); + public int Turn(bool playerSeat) => Engine.Turn(playerSeat); /// The engine Index of seat A's hand card at (the playIdx a /// Play frame would carry to play it).