354 lines
20 KiB
C#
354 lines
20 KiB
C#
using System.Collections.Generic;
|
|
using NUnit.Framework;
|
|
using SVSim.BattleNode.Protocol;
|
|
|
|
namespace SVSim.UnitTests.BattleNode.Integration;
|
|
|
|
/// <summary>
|
|
/// Headless-Conductor milestone tests (M-HC-*). The oracle is a node-native battle:
|
|
/// a FIXED master seed + FIXED decks drive the engine's receive path headless, and we
|
|
/// assert on engine board-state. By construction the node assigns idx = position in the
|
|
/// shuffled order, so the engine's headless draw reproduces the node's draw order.
|
|
///
|
|
/// Task 1 (M-HC-0a) exit criterion: the engine seats headless (IsReady) in the
|
|
/// SVSim.UnitTests process.
|
|
///
|
|
/// Task 2 (M-HC-0b) exit criterion: a node-generated <c>Deal</c> seats the 3-card hand and a
|
|
/// 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]
|
|
public class HeadlessConductorTests
|
|
{
|
|
[Test]
|
|
public void Harness_seats_engine_headless_and_is_ready()
|
|
{
|
|
using var harness = NodeNativeBattleHarness.Create();
|
|
|
|
Assert.That(harness.IsReady, Is.True,
|
|
"Engine must seat headless: EngineGlobalInit ran + both decks seeded. " +
|
|
"If false, the most likely cause is a missing cards.json content link in " +
|
|
"SVSim.UnitTests.csproj (EngineGlobalInit reads AppContext.BaseDirectory/Data/cards.json).");
|
|
|
|
// Non-vacuous: a seated engine has live board state for BOTH seats. Reading these off a
|
|
// not-really-set-up engine would throw (Seat() guards on _mgr). Leader life is the headless
|
|
// default (20) before any frame is ingested.
|
|
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");
|
|
}
|
|
|
|
// The node's BuildDeal opening hand: pos->idx (0,1),(1,2),(2,3). hand == deck idx 1,2,3, i.e.
|
|
// the top 3 of the node-native shuffled deck. Both seats deal the same idx triple.
|
|
private static Dictionary<string, object?> DealBody() => new()
|
|
{
|
|
["self"] = PosIdxList((0, 1), (1, 2), (2, 3)),
|
|
["oppo"] = PosIdxList((0, 1), (1, 2), (2, 3)),
|
|
};
|
|
|
|
// A minimal vanilla hand-card play: type 30 == PLAY_HAND; playIdx is the played card's index.
|
|
// No targetList/orderList — a vanilla follower auto-resolves with no selection.
|
|
private static Dictionary<string, object?> PlayBody(int playIdx) => new()
|
|
{
|
|
["playIdx"] = playIdx,
|
|
["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)), // same post-mulligan self hand as SwapBody — Ready re-echoes it
|
|
["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 };
|
|
|
|
// An opponent play that REVEALS the played card. The wire shape is taken verbatim from
|
|
// battle_test_cl2.ndjson's first opponent PlayActions frame:
|
|
// { playIdx, type:30, knownList:[{idx, cardId, to:30, spellboost:0, attachTarget:""}] }
|
|
// type 30 == PLAY_HAND; knownList[].idx == the hidden dummy's engine Index; cardId == the real
|
|
// identity to substitute; to 30 == NetworkCardPlaceState.Field (the card lands in play).
|
|
private static Dictionary<string, object?> RevealPlayBody(int idx, long cardId) => new()
|
|
{
|
|
["playIdx"] = idx,
|
|
["type"] = 30,
|
|
["knownList"] = new List<object?>
|
|
{
|
|
new Dictionary<string, object?>
|
|
{
|
|
["idx"] = idx,
|
|
["cardId"] = cardId,
|
|
["to"] = 30,
|
|
["spellboost"] = 0,
|
|
["attachTarget"] = "",
|
|
},
|
|
},
|
|
};
|
|
|
|
[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");
|
|
// Seat B's opening hand was dealt into hidden zones (not HandCardList), so its deck started at 30;
|
|
// the single turn-1 draw brings it to 29.
|
|
Assert.That(harness.DeckCount(playerSeat: false), Is.EqualTo(29), "seat B deck after 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 Opponent_reveal_seats_card_on_seat_B_headless()
|
|
{
|
|
// Seat B's deck idx 1 is a known vanilla follower, so the reveal's wire cardId maps to a real
|
|
// card the opponent can play to the board. (Seat A's deck is left at default — irrelevant here.)
|
|
var seatBDeck = new List<long> { NodeNativeBattleHarness.VanillaFollowerId };
|
|
seatBDeck.AddRange(NodeNativeBattleHarness.DefaultDeck());
|
|
seatBDeck = seatBDeck.GetRange(0, 30);
|
|
|
|
using var harness = NodeNativeBattleHarness.Create(seatBDeck: seatBDeck);
|
|
|
|
// --- drive to seat B's turn (reuse Task 3's two-turn sequence) ---------------------------
|
|
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");
|
|
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted,
|
|
Is.True, "Ready");
|
|
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
|
|
Is.True, "turn1 TurnStart");
|
|
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted,
|
|
Is.True, "turn1 TurnEnd");
|
|
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted,
|
|
Is.True, "turn2 TurnStart (seat B active)");
|
|
|
|
// Seat B's opening hand is hidden (deck reads full minus its single turn-1 draw); its cards
|
|
// have NOT been disclosed to the relay yet. The dummy at engine Index 1 is whatever card the
|
|
// shuffle seated at that index (shuffledDeck[0]), parked in a hidden zone — NOT on the board.
|
|
// Confirm seat B's board is empty BEFORE the reveal, so the post-reveal +1 is decisively the
|
|
// reveal seating the card. (Node-native, the harness seeds each side's cards with their real id
|
|
// — it knows both decks — so this test's reveal substitution is identity-preserving by choice;
|
|
// CreateActualCard builds the card purely from the wire cardId regardless of which card the
|
|
// shuffle parked at Index 1. The board delta is what proves ReplaceReceivedCard.ReplaceCard ->
|
|
// CreateActualCard resolved the card onto the board headless. The companion test
|
|
// Opponent_reveal_overrides_seeded_identity_headless stresses a MISMATCHED cardId to prove the
|
|
// wire id — not the seeded identity — is what gets seated.)
|
|
var boardBefore = harness.BoardCount(playerSeat: false);
|
|
Assert.That(boardBefore, Is.EqualTo(0), "seat B has no board followers before the reveal");
|
|
|
|
// --- the reveal: an opponent PlayActions frame carrying a knownList that discloses idx 1 ---
|
|
const long revealedCardId = NodeNativeBattleHarness.VanillaFollowerId;
|
|
var reveal = harness.Push(
|
|
NetworkBattleUri.PlayActions, RevealPlayBody(idx: 1, cardId: revealedCardId),
|
|
isPlayerSeat: false);
|
|
|
|
Assert.That(reveal.Accepted, Is.True, $"opponent reveal rejected: {reveal.RejectReason}");
|
|
Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(boardBefore + 1),
|
|
"the revealed follower must seat on seat B's board");
|
|
Assert.That(harness.InPlayCardId(playerSeat: false, boardPos: 0), Is.EqualTo((int)revealedCardId),
|
|
"the seated card's identity must equal the wire cardId from the reveal");
|
|
}
|
|
|
|
[Test]
|
|
public void Opponent_reveal_overrides_seeded_identity_headless()
|
|
{
|
|
// This is the substitution half of M-HC-2: prove the seated card's POST-reveal identity is the
|
|
// WIRE cardId even when it DIFFERS from whatever the shuffle parked at that engine Index.
|
|
// ReplaceReceivedCard.CreateActualCard builds the card purely from cardData.CardId, independent
|
|
// of the seated dummy's id — so a reveal whose cardId mismatches the seed must OVERRIDE it.
|
|
//
|
|
// Z (seeded) vs W (revealed) are DIFFERENT cost-1 vanilla followers, both present + creatable in
|
|
// cards.json:
|
|
// Z = 100011010 — the proven vanilla follower (char_type 1, cost 1). Seat B's deck is made
|
|
// UNIFORMLY of Z, so whichever idx the shuffle parked at Index 1 is unambiguously Z.
|
|
// W = 101211120 — a different cost-1 vanilla follower (char_type 1, cost 1, no skill). Cost 1
|
|
// seats at seat B's first-turn PP (1). The id is NOT in seat B's deck, so the only way it
|
|
// can appear on the board is the reveal substituting it in.
|
|
const long Z = NodeNativeBattleHarness.VanillaFollowerId; // 100011010
|
|
const long W = 101211120;
|
|
Assert.That(W, Is.Not.EqualTo(Z), "Z and W must differ for the substitution to be observable");
|
|
|
|
// Uniform Z deck for seat B (every dummy is Z regardless of shuffle). Seat A left at default.
|
|
var seatBDeck = Enumerable.Repeat(Z, 30).ToList();
|
|
|
|
using var harness = NodeNativeBattleHarness.Create(seatBDeck: seatBDeck);
|
|
|
|
// --- drive to seat B's turn (same two-turn sequence as the sibling reveal test) -------------
|
|
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");
|
|
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted,
|
|
Is.True, "Ready");
|
|
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
|
|
Is.True, "turn1 TurnStart");
|
|
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted,
|
|
Is.True, "turn1 TurnEnd");
|
|
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted,
|
|
Is.True, "turn2 TurnStart (seat B active)");
|
|
|
|
var boardBefore = harness.BoardCount(playerSeat: false);
|
|
Assert.That(boardBefore, Is.EqualTo(0), "seat B has no board followers before the reveal");
|
|
|
|
// The reveal discloses idx 1 (seeded as Z) with the MISMATCHED wire cardId W.
|
|
var reveal = harness.Push(
|
|
NetworkBattleUri.PlayActions, RevealPlayBody(idx: 1, cardId: W), isPlayerSeat: false);
|
|
|
|
Assert.That(reveal.Accepted, Is.True, $"opponent reveal rejected: {reveal.RejectReason}");
|
|
Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(boardBefore + 1),
|
|
"the revealed follower must seat on seat B's board");
|
|
// The decisive assertion: the seated identity is W (the wire cardId), NOT Z (the seeded id).
|
|
// Because the deck is uniformly Z, this can only pass if the reveal OVERRODE the seeded identity.
|
|
Assert.That(harness.InPlayCardId(playerSeat: false, boardPos: 0), Is.EqualTo((int)W),
|
|
"the seated card must be the wire cardId W, overriding the seeded Z identity at that idx");
|
|
}
|
|
|
|
[Test]
|
|
public void Deal_seats_three_card_hand_headless()
|
|
{
|
|
using var harness = NodeNativeBattleHarness.Create();
|
|
|
|
var result = harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true);
|
|
|
|
Assert.That(result.Accepted, Is.True, $"Deal rejected: {result.RejectReason}");
|
|
Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(3),
|
|
"Deal must seat the 3-card opening hand on the player seat.");
|
|
}
|
|
|
|
[Test]
|
|
public void Vanilla_play_resolves_on_engine_state_headless()
|
|
{
|
|
// Deck idx 1/2/3 are the top three of the shuffled deck; arrange idx-1 to be a known vanilla
|
|
// follower so the Play assertion is decisive. Put the vanilla follower first; the rest of the
|
|
// default deck (spellboost + vanillas) follows.
|
|
var deck = new List<long> { NodeNativeBattleHarness.VanillaFollowerId };
|
|
deck.AddRange(NodeNativeBattleHarness.DefaultDeck());
|
|
deck = deck.GetRange(0, 30);
|
|
|
|
using var harness = NodeNativeBattleHarness.Create(seatADeck: deck);
|
|
|
|
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 ppBefore = harness.Pp(playerSeat: true);
|
|
var handBefore = harness.HandCount(playerSeat: true);
|
|
var boardBefore = harness.BoardCount(playerSeat: true);
|
|
|
|
// The played card is at hand index 1 (deck idx 1 -> the first dealt card; engine card Index
|
|
// mirrors deck position+1). The shuffle determines which deck idx-1 maps to; we only need a
|
|
// vanilla follower in the opening hand. Use the first dealt idx.
|
|
var playIdx = harness.PlayerHandCardIndex(0);
|
|
var play = harness.Push(NetworkBattleUri.PlayActions, PlayBody(playIdx), isPlayerSeat: true);
|
|
|
|
Assert.That(play.Accepted, Is.True, $"Play rejected: {play.RejectReason}");
|
|
Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(handBefore - 1),
|
|
"the played card must leave the hand");
|
|
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(boardBefore + 1),
|
|
"a follower play must add one to the board");
|
|
Assert.That(harness.Pp(playerSeat: true), Is.LessThan(ppBefore),
|
|
"PP must drop by the played card's cost");
|
|
}
|
|
}
|