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). Known id present in cards.json /// (sourced from tk2 battle capture / existing engine tests); a cards.json regeneration that drops /// it will produce a traceable failure here. public const long SpellboostCardId = 101314020; /// A second spellboost card seen in the tk2 capture. Known id present in cards.json /// (sourced from tk2 battle capture / existing engine tests); a cards.json regeneration that drops /// it will produce a traceable failure here. public const long SpellboostCardIdAlt = 100314020; /// A plain vanilla follower the engine resolution path proved out /// (HeadlessFixture.FollowerId). The bulk of the deterministic deck. Known id present in cards.json /// (sourced from tk2 battle capture / existing engine tests); a cards.json regeneration that drops /// it will produce a traceable failure here. 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); public int DeckCount(bool playerSeat) => Engine.DeckCount(playerSeat); public int Turn(bool playerSeat) => Engine.Turn(playerSeat); /// The engine Index of seat A's hand card at (the playIdx a /// Play frame would carry to play it). public int PlayerHandCardIndex(int handPos) => Engine.HandCardIndex(playerSeat: true, handPos); /// 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. Broker members /// (PushAsync, RunAsync, TerminateAsync) throw /// — the harness drives the engine directly, so a frame must never reach the participant relay. /// Silent no-ops would let a misrouted push pass undetected. 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) => throw new NotSupportedException("StubParticipant.PushAsync — harness drives the engine directly; a frame must not reach the participant relay."); public Task RunAsync(CancellationToken ct) => throw new NotSupportedException("StubParticipant.RunAsync should not be called in harness tests."); public Task TerminateAsync(BattleFinishReason reason) => throw new NotSupportedException("StubParticipant.TerminateAsync should not be called in harness tests."); public ValueTask DisposeAsync() => ValueTask.CompletedTask; } }