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 LeaderLife(bool playerSeat) => Seat(playerSeat).Class.Life;
|
||||||
public int Pp(bool playerSeat) => Seat(playerSeat).Pp;
|
public int Pp(bool playerSeat) => Seat(playerSeat).Pp;
|
||||||
public int HandCount(bool playerSeat) => Seat(playerSeat).HandCardList.Count;
|
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
|
/// <summary>Followers in play, excluding the leader (the Class card occupies one slot of
|
||||||
/// ClassAndInPlayCardList).</summary>
|
/// 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
|
/// 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
|
/// by cost, board reflects the play) — driven through the receive CONDUCTOR, not the
|
||||||
/// direct ActionProcessor path the M2-M12 oracles use.
|
/// 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>
|
/// </summary>
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
[NonParallelizable]
|
[NonParallelizable]
|
||||||
@@ -65,6 +71,118 @@ public class HeadlessConductorTests
|
|||||||
["type"] = 30,
|
["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]
|
[Test]
|
||||||
public void Deal_seats_three_card_hand_headless()
|
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 Pp(bool playerSeat) => Engine.Pp(playerSeat);
|
||||||
public int HandCount(bool playerSeat) => Engine.HandCount(playerSeat);
|
public int HandCount(bool playerSeat) => Engine.HandCount(playerSeat);
|
||||||
public int BoardCount(bool playerSeat) => Engine.BoardCount(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
|
/// <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>
|
/// Play frame would carry to play it).</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user