From ca91fca028a77bd6432084cd4ae63d259988e729 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 6 Jun 2026 19:37:41 -0400 Subject: [PATCH] test(battlenode): node-native battle harness for headless conductor (M-HC-0) Co-Authored-By: Claude Opus 4.8 --- .../Integration/HeadlessConductorTests.cs | 33 ++++ .../Integration/NodeNativeBattleHarness.cs | 160 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs create mode 100644 SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs diff --git a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs new file mode 100644 index 0000000..12794fb --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs @@ -0,0 +1,33 @@ +using NUnit.Framework; + +namespace SVSim.UnitTests.BattleNode.Integration; + +/// +/// 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. +/// +[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"); + } +} diff --git a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs new file mode 100644 index 0000000..d743385 --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs @@ -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; + +/// +/// Node-native battle harness for the Headless-Conductor milestones (M-HC-*). It reproduces what +/// BattleSession.EnsureEngineSetup does — shuffle each side's deck from a FIXED master seed and +/// SessionBattleEngine.Setup the two seats — then exposes the engine + state + participants so +/// later milestone tests can drive multi-frame sequences and assert on engine board state. +/// +/// WHY drive the engine directly (not a full BattleSession): the session's _state +/// and _engine are private with no fixed-seed injection point, and every milestone assertion is +/// on engine board state. The engine (SessionBattleEngine) is the unit under test, so we seat it +/// the same way the session does and skip the WS/dispatch scaffolding. +/// +/// The oracle by construction: the node assigns idx = position in the shuffled order +/// (), 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. +/// +/// Engine globals (CardMaster, GameMgr, Wizard.Data) are primed by +/// SessionBattleEngine.Setup itself (it calls EngineGlobalInit.EnsureInitialized(), which +/// loads the full cards.json from AppContext.BaseDirectory/Data/cards.json). The harness adds no +/// global init of its own. NOTE: unlike the live session, the harness does NOT acquire +/// EngineSessionGate — 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). +/// +internal sealed class NodeNativeBattleHarness : IDisposable +{ + /// 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. + public const int FixedMasterSeed = 12345; + + /// Default seat A viewer id — distinct from so the two + /// sides shuffle independently (the shuffle seed mixes in the viewer id). + public const long DefaultSeatAViewerId = 1001; + public const long DefaultSeatBViewerId = 1002; + + /// Spellboost cost-reducer card (looking ahead to M-HC-3). Present in cards.json. + public const long SpellboostCardId = 101314020; + + /// A second spellboost card seen in the tk2 capture; present in cards.json. + public const long SpellboostCardIdAlt = 100314020; + + /// A plain vanilla follower the engine resolution path proved out + /// (HeadlessFixture.FollowerId). The bulk of the deterministic deck. + public const long VanillaFollowerId = 100011010; + + public BattleSessionState State { get; } + public StubParticipant SeatA { get; } + public StubParticipant SeatB { get; } + public SessionBattleEngine Engine { get; } + + /// This side's deck in the node's shuffled order (idx == position + 1). + public IReadOnlyList SeatADeck { get; } + public IReadOnlyList SeatBDeck { get; } + + private NodeNativeBattleHarness( + BattleSessionState state, StubParticipant a, StubParticipant b, SessionBattleEngine engine, + IReadOnlyList seatADeck, IReadOnlyList seatBDeck) + { + State = state; + SeatA = a; + SeatB = b; + Engine = engine; + SeatADeck = seatADeck; + SeatBDeck = seatBDeck; + } + + /// 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. + public static IReadOnlyList DefaultDeck() + { + var deck = new List(30) { SpellboostCardId, SpellboostCardIdAlt }; + deck.AddRange(Enumerable.Repeat(VanillaFollowerId, 30 - deck.Count)); + return deck; + } + + /// Seat the engine exactly as BattleSession.EnsureEngineSetup does: shuffle each + /// side's deck from the fixed seed via , then + /// SessionBattleEngine.Setup(seed, deckA, deckB, classA, classB). + public static NodeNativeBattleHarness Create( + IReadOnlyList? seatADeck = null, + IReadOnlyList? 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 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); + + /// Build an envelope for and ingest it into the engine for the + /// given seat (player == seat A). Mirrors BattleNodeFlowTests.MakeEnvelopeWith + + /// SessionBattleEngine.Receive. + public EngineIngestResult Push(NetworkBattleUri uri, Dictionary 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. */ } + + /// Minimal test-only exposing only the + /// + that the harness reads. All broker members are + /// no-ops — the harness drives the engine directly, never the session relay. + 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? 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; + } +}