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:
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user