feat(battlenode): mulligan+turn ops track on engine state (M-HC-1)

Task 2's WireMulliganPhase already installed the full mulligan delegate
set (Swap/Ready, not just Deal) via MulliganEventSetting, and the
mulligan + turn-draw mutations flow through VfxMgr.RegisterSequentialVfx
— which HeadlessConductorVfxMgr runs for InstantVfx. So Swap/Ready/
TurnStart/TurnEnd resolve headless with ZERO new shim/seed/view fills.

Adds the M-HC-1 milestone assertions: a mulligan-swap test (post-swap
hand holds deck idx 1,2,4 — idx-3 swapped for the next unused idx) and a
two-turn test (Deal->Swap->Ready->TurnStart/TurnEnd x2) asserting the
engine's deterministic node-native progression on both seats
(hand/deck/PP/turn/leader-life) at each boundary. Frame shapes mirror the
captured battle_test_cl1 receive stream (self/oppo pos-idx lists, spin).

Harness/node: +DeckCount/Turn board-state pass-throughs (test reads).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-06 20:30:39 -04:00
parent e96cc3363c
commit f0977ab45c
3 changed files with 122 additions and 0 deletions

View File

@@ -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;
/// <summary>Followers in play, excluding the leader (the Class card occupies one slot of
/// ClassAndInPlayCardList).</summary>

View File

@@ -17,6 +17,12 @@ namespace SVSim.UnitTests.BattleNode.Integration;
/// vanilla hand-card <c>Play</c> 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 (<c>Swap</c> seats the post-mulligan hand —
/// idx-3 swapped for the next unused deck idx-4) and turn ops (<c>Ready</c>/<c>TurnStart</c>/
/// <c>TurnEnd</c>) 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.
/// </summary>
[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<object?> PosIdxList(params (int pos, int idx)[] entries)
{
var list = new List<object?>(entries.Length);
foreach (var (pos, idx) in entries)
list.Add(new Dictionary<string, object?> { ["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<string, object?> 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<string, object?> 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<string, object?> TurnStartBody() => new() { ["spin"] = 0 };
private static Dictionary<string, object?> 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()
{

View File

@@ -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);
/// <summary>The engine Index of seat A's hand card at <paramref name="handPos"/> (the playIdx a
/// Play frame would carry to play it).</summary>