test(battlenode): node-native battle harness for headless conductor (M-HC-0)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
using NUnit.Framework;
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
using SVSim.BattleNode.Sessions.Dispatch;
|
||||
using SVSim.BattleNode.Sessions.Engine;
|
||||
|
||||
namespace SVSim.UnitTests.BattleNode.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Node-native battle harness for the Headless-Conductor milestones (M-HC-*). It reproduces what
|
||||
/// <c>BattleSession.EnsureEngineSetup</c> does — shuffle each side's deck from a FIXED master seed and
|
||||
/// <c>SessionBattleEngine.Setup</c> the two seats — then exposes the engine + state + participants so
|
||||
/// later milestone tests can drive multi-frame sequences and assert on engine board state.
|
||||
///
|
||||
/// <para>WHY drive the engine directly (not a full <c>BattleSession</c>): the session's <c>_state</c>
|
||||
/// and <c>_engine</c> are private with no fixed-seed injection point, and every milestone assertion is
|
||||
/// on engine board state. The engine (<c>SessionBattleEngine</c>) is the unit under test, so we seat it
|
||||
/// the same way the session does and skip the WS/dispatch scaffolding.</para>
|
||||
///
|
||||
/// <para>The oracle by construction: the node assigns idx = position in the shuffled order
|
||||
/// (<see cref="BattleSessionState.GetShuffledDeck"/>), and the engine's headless draw is lowest-Index
|
||||
/// first, so a FIXED seed makes the engine's draw order reproduce the node's BY CONSTRUCTION.</para>
|
||||
///
|
||||
/// <para>Engine globals (<c>CardMaster</c>, <c>GameMgr</c>, <c>Wizard.Data</c>) are primed by
|
||||
/// <c>SessionBattleEngine.Setup</c> itself (it calls <c>EngineGlobalInit.EnsureInitialized()</c>, which
|
||||
/// loads the full cards.json from <c>AppContext.BaseDirectory/Data/cards.json</c>). The harness adds no
|
||||
/// global init of its own. NOTE: unlike the live session, the harness does NOT acquire
|
||||
/// <c>EngineSessionGate</c> — driving the engine directly bypasses it. One engine-backed battle at a
|
||||
/// time is assumed within a test (the engine's process-global statics can't back two concurrently).</para>
|
||||
/// </summary>
|
||||
internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
{
|
||||
/// <summary>A deterministic master seed so deck shuffles (and the engine RNG stream born from it)
|
||||
/// are reproducible. Matches the value the engine construction tests use.</summary>
|
||||
public const int FixedMasterSeed = 12345;
|
||||
|
||||
/// <summary>Default seat A viewer id — distinct from <see cref="DefaultSeatBViewerId"/> so the two
|
||||
/// sides shuffle independently (the shuffle seed mixes in the viewer id).</summary>
|
||||
public const long DefaultSeatAViewerId = 1001;
|
||||
public const long DefaultSeatBViewerId = 1002;
|
||||
|
||||
/// <summary>Spellboost cost-reducer card (looking ahead to M-HC-3). Present in cards.json.</summary>
|
||||
public const long SpellboostCardId = 101314020;
|
||||
|
||||
/// <summary>A second spellboost card seen in the tk2 capture; present in cards.json.</summary>
|
||||
public const long SpellboostCardIdAlt = 100314020;
|
||||
|
||||
/// <summary>A plain vanilla follower the engine resolution path proved out
|
||||
/// (HeadlessFixture.FollowerId). The bulk of the deterministic deck.</summary>
|
||||
public const long VanillaFollowerId = 100011010;
|
||||
|
||||
public BattleSessionState State { get; }
|
||||
public StubParticipant SeatA { get; }
|
||||
public StubParticipant SeatB { get; }
|
||||
public SessionBattleEngine Engine { get; }
|
||||
|
||||
/// <summary>This side's deck in the node's shuffled order (idx == position + 1).</summary>
|
||||
public IReadOnlyList<long> SeatADeck { get; }
|
||||
public IReadOnlyList<long> SeatBDeck { get; }
|
||||
|
||||
private NodeNativeBattleHarness(
|
||||
BattleSessionState state, StubParticipant a, StubParticipant b, SessionBattleEngine engine,
|
||||
IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck)
|
||||
{
|
||||
State = state;
|
||||
SeatA = a;
|
||||
SeatB = b;
|
||||
Engine = engine;
|
||||
SeatADeck = seatADeck;
|
||||
SeatBDeck = seatBDeck;
|
||||
}
|
||||
|
||||
/// <summary>Build a 30-card deck: mostly the vanilla follower plus a couple of spellboost cards
|
||||
/// (so later milestones have a cost-reducer to play). All ids exist in cards.json.</summary>
|
||||
public static IReadOnlyList<long> DefaultDeck()
|
||||
{
|
||||
var deck = new List<long>(30) { SpellboostCardId, SpellboostCardIdAlt };
|
||||
deck.AddRange(Enumerable.Repeat(VanillaFollowerId, 30 - deck.Count));
|
||||
return deck;
|
||||
}
|
||||
|
||||
/// <summary>Seat the engine exactly as <c>BattleSession.EnsureEngineSetup</c> does: shuffle each
|
||||
/// side's deck from the fixed seed via <see cref="BattleSessionState.GetShuffledDeck"/>, then
|
||||
/// <c>SessionBattleEngine.Setup(seed, deckA, deckB, classA, classB)</c>.</summary>
|
||||
public static NodeNativeBattleHarness Create(
|
||||
IReadOnlyList<long>? seatADeck = null,
|
||||
IReadOnlyList<long>? seatBDeck = null,
|
||||
CardClass seatAClass = CardClass.Forestcraft,
|
||||
CardClass seatBClass = CardClass.Swordcraft,
|
||||
int masterSeed = FixedMasterSeed)
|
||||
{
|
||||
var state = new BattleSessionState(masterSeed);
|
||||
|
||||
var a = new StubParticipant(DefaultSeatAViewerId, MakeCtx(seatADeck ?? DefaultDeck(), seatAClass));
|
||||
var b = new StubParticipant(DefaultSeatBViewerId, MakeCtx(seatBDeck ?? DefaultDeck(), seatBClass));
|
||||
|
||||
var shuffledA = state.GetShuffledDeck(a);
|
||||
var shuffledB = state.GetShuffledDeck(b);
|
||||
|
||||
var engine = new SessionBattleEngine();
|
||||
engine.Setup(state.MasterSeed, shuffledA, shuffledB,
|
||||
(int)a.Context.ClassId, (int)b.Context.ClassId);
|
||||
|
||||
return new NodeNativeBattleHarness(state, a, b, engine, shuffledA, shuffledB);
|
||||
}
|
||||
|
||||
private static MatchContext MakeCtx(IReadOnlyList<long> deck, CardClass cls) => new(
|
||||
SelfDeckCardIds: deck,
|
||||
ClassId: cls, CharaId: ((int)cls).ToString(), CardMasterName: "card_master_node_10015",
|
||||
CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
|
||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||
BattleModeId: BattleModes.TakeTwo);
|
||||
|
||||
// --- engine board-state pass-throughs (seat:true == player A, false == opponent B) ----------
|
||||
|
||||
public bool IsReady => Engine.IsReady;
|
||||
public int LeaderLife(bool playerSeat) => Engine.LeaderLife(playerSeat);
|
||||
public int Pp(bool playerSeat) => Engine.Pp(playerSeat);
|
||||
public int HandCount(bool playerSeat) => Engine.HandCount(playerSeat);
|
||||
public int BoardCount(bool playerSeat) => Engine.BoardCount(playerSeat);
|
||||
|
||||
/// <summary>Build an envelope for <paramref name="body"/> and ingest it into the engine for the
|
||||
/// given seat (player == seat A). Mirrors <c>BattleNodeFlowTests.MakeEnvelopeWith</c> +
|
||||
/// <c>SessionBattleEngine.Receive</c>.</summary>
|
||||
public EngineIngestResult Push(NetworkBattleUri uri, Dictionary<string, object?> body, bool isPlayerSeat)
|
||||
{
|
||||
var seat = isPlayerSeat ? SeatA : SeatB;
|
||||
var env = new MsgEnvelope(
|
||||
uri, ViewerId: seat.ViewerId, Uuid: "udid-test", Bid: null, RetryAttempt: 0,
|
||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
||||
Body: new RawBody(body));
|
||||
return Engine.Receive(env, isPlayerSeat);
|
||||
}
|
||||
|
||||
public void Dispose() { /* engine holds no unmanaged resources; nothing to release. */ }
|
||||
|
||||
/// <summary>Minimal test-only <see cref="IBattleParticipant"/> exposing only the
|
||||
/// <see cref="ViewerId"/> + <see cref="Context"/> that the harness reads. All broker members are
|
||||
/// no-ops — the harness drives the engine directly, never the session relay.</summary>
|
||||
internal sealed class StubParticipant : IBattleParticipant
|
||||
{
|
||||
public long ViewerId { get; }
|
||||
public MatchContext Context { get; }
|
||||
|
||||
public StubParticipant(long viewerId, MatchContext context)
|
||||
{
|
||||
ViewerId = viewerId;
|
||||
Context = context;
|
||||
}
|
||||
|
||||
#pragma warning disable CS0067 // FrameEmitted is part of the interface but the stub never raises it.
|
||||
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||
#pragma warning restore CS0067
|
||||
|
||||
public Task PushAsync(MsgEnvelope envelope, Stock stock, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user