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).