using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Sessions; using SVSim.BattleNode.Sessions.Dispatch; using SVSim.BattleNode.Sessions.Engine; using SVSim.BattleNode.Sessions.Participants; 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; /// A SECOND, distinct cost-1 vanilla follower (char_type 1, cost 1, no skill) — present + /// creatable in cards.json. Used by the opponent-reveal substitution test as the WIRE cardId that /// must override a seeded identity (it is deliberately NOT in any harness deck, so its only route /// onto the board is a reveal). Named here so card-id provenance stays traceable as ids accumulate /// (Task-4 review nit promoted in M-HC-3). public const long AltVanillaFollowerId = 101211120; /// A truly skill-less cost-1 vanilla follower with attack >= life (a 1/1), so a mutual /// follower-vs-follower attack is a LETHAL trade (each deals 1, each has 1 life → both die). The /// proven vanillas / are 1/2, so they /// survive a single trade — this id is the one that exercises the death/removal arm of an attack /// (M-HC-4a follower trade). Present + creatable in cards.json (no skill, char_type 1, cost 1, 1/1). public const long VanillaOneOneFollowerId = 900011080; /// A SIMPLE single-target when_play DAMAGE spell (M-HC-4c fixture). cards.json id 100414020: /// char_type 4 (spell), clan 4 (Dragoncraft), cost 1, skill damage / skill_timing /// when_play / skill_target character=op&target=inplay&card_type=unit&select_count=1 /// / skill_option damage=2 — i.e. "deal 2 damage to a selected enemy follower". Concrete sane /// cost (1), no board-state-dependent magnitude, no condition beyond an enemy unit existing — the /// cleanest targeted-play fixture in the current dump. Present + creatable in cards.json. public const long SingleTargetDamageSpellId = 100414020; /// The flat damage magnitude of (skill_option /// damage=2). The targeted-play test asserts the enemy follower's life drops by exactly this. public const int SingleTargetDamageAmount = 2; /// A high-life vanilla follower (M-HC-4c damage TARGET). cards.json id 101411060: char_type 1, /// clan 4, cost 2, 1/4, no skill. A 1/4 body takes (2) and /// SURVIVES at life 2 — so the targeted-damage assertion reads a clean life DROP (not a death/removal, /// which would only prove BoardCount). Present + creatable in cards.json. public const long HighLifeVanillaFollowerId = 101411060; /// Base life of (4). Pre-damage pin for the target. public const int HighLifeVanillaFollowerLife = 4; /// A SIMPLE CHOICE card (M-HC-4c choice fixture). cards.json id 127011010: char_type 1 /// (follower), clan 0 (Neutral — playable under any seat class), cost 1, 1/2, skill /// choice,token_draw / skill_timing when_choice_play,when_play / skill_option /// card_id=121011010:120011010,... — i.e. "choose ONE of two tokens to add to hand" /// ( / ). The choice OUTCOME is directly /// observable: the chosen token lands in the caster's hand, so a test can assert which branch /// resolved by the new hand card's identity. (The token resolves into HAND — confirmed against the /// capture's orderList.add{to:20} hand-zone op — despite the skill_option summon_side=me /// superficially reading like a summon-to-board.) Present + creatable in cards.json. public const long ChoiceCardId = 127011010; /// The first choice option of (token added to hand). public const long ChoiceTokenA = 121011010; /// The second choice option of (token added to hand). public const long ChoiceTokenB = 120011010; /// A BOARD-DEPENDENT cost-reducer follower (M-HC-4d fixture). cards.json id 127011020: /// char_type 1 (follower), clan 0 (Neutral — playable under any seat class), base cost 6, 3/3, skill /// cost_change,rush / skill_timing when_evolve_other,when_change_inplay / skill_option /// set=1,none / skill_condition (cost_change) turn=self&{me.hand_self.unit.count}>0& /// character=me&target=evolution_card&card_type=unit / skill_target character=me&target=self /// &card_type=unit — i.e. "WHILE in hand, when ANOTHER of your followers evolves on your turn (and you /// hold at least one other unit in hand), SET this card's cost to 1." The engine's evolve path /// (UnitBattleCard non-skill evolve) scans the evolving player's HAND for cards whose skills have /// OnWhenEvolveOtherStart != 0 and registers them via SkillCollectionBase.CreateWhenEvolveOtherInfo; /// Skill_cost_change then applies a CostSetModifier(1) to this card, so its resolved /// Cost drops 6 → 1. Because the node reads opponent-facing cost straight off the resolved engine /// (SessionBattleEngine.PlayedCardCost, M-HC-3), this board-dependent reduction is captured BY /// CONSTRUCTION once evolve resolves headless (M-HC-4b) — this card validates that. Present + creatable in /// cards.json. public const long BoardDependentCostCardId = 127011020; /// Base cost of (6) — the pre-evolve resolved cost. public const int BoardDependentCostBase = 6; /// The flat cost resolves to AFTER another follower evolves /// on the controller's turn (skill_option set=1CostSetModifier(1)). Independent of how many /// followers evolved (a SET, not an add) — exactly 1. public const int BoardDependentCostReduced = 1; /// A non-trivial CLAN+TRIBE fixture follower (M-HC-4e). cards.json id 900231030: char_type 1 /// (follower), clan 2 (ROYAL / Swordcraft), tribe 2 (LEGION), cost 0, 2/2. Cost 0 makes it playable on /// turn-1 PP 1; its clan (2) matches so it is legal under a Swordcraft /// seat. Its clan/tribe (2 / "2") are concretely non-zero so the engine-sourced clan/tribe read + /// knownList emit assert REAL values (not the 0/"0" no-tribe default). Verified against cards.json AND the /// prod wire form (comma-joined int TribeType as string: tribe 2 → "2"). Present + creatable in /// cards.json. public const long ClanTribeFollowerId = 900231030; /// The engine-resolved clan of as the wire int (ROYAL == /// ClanType 2). The M-HC-4e knownList emit asserts clan equals this. public const int ClanTribeFollowerClan = 2; /// The engine-resolved tribe of in the EXACT prod wire string /// form (LEGION == TribeType 2 → the single-element comma-join "2"). The M-HC-4e knownList emit asserts /// tribe equals this. public const string ClanTribeFollowerTribe = "2"; 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; } /// A deck for the M-HC-4d board-dependent-cost test: an alternating mix of the vanilla /// follower (to play turn 1 and EVOLVE on seat A's evolve turn) and the /// (the when_evolve_other set=1 cost-reducer that must sit IN HAND across the evolve). Alternating /// 15/15 guarantees BOTH identities populate the opening hand + early draws regardless of the fixed shuffle; /// the test locates each by identity (not a shuffle-dependent position). The cost-reducer's condition /// {me.hand_self.unit.count}>0 (another unit in hand) is satisfied because copies of BOTH followers /// remain in hand at the evolve. public static IReadOnlyList BoardDependentCostDeck() { var deck = new List(30); for (int i = 0; i < 15; i++) { deck.Add(VanillaFollowerId); deck.Add(BoardDependentCostCardId); } return deck; } /// A 30-card deck of the clan+tribe fixture (M-HC-4e). All one /// identity, all cost 0 — so the opening hand reliably holds a copy to play turn 1, regardless of shuffle, /// and the engine-resolved clan/tribe read off the played card is unambiguous. public static IReadOnlyList ClanTribeDeck() => Enumerable.Repeat(ClanTribeFollowerId, 30).ToList(); /// 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-resolved wire cardId of the card at engine on the /// given seat (M-HC-4f). Pass-through to SessionBattleEngine.PlayedCardId — the TRUE id the engine /// seated (deck id / token id / chosen-token id / copied id), the value the handler now sources for the /// opponent-facing knownList instead of the wire-mined map. public long PlayedCardId(bool playerSeat, int idx, long fallback = 0) => Engine.PlayedCardId(playerSeat, idx, fallback); /// 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); /// The wire CardId of the hand card at on the given seat. Lets a /// test find a specific card (e.g. the spellboost reducer) in a shuffled opening hand by identity. public int HandCardId(bool playerSeat, int handPos) => Engine.HandCardId(playerSeat, handPos); /// The engine Index of the hand card at on the given seat. public int HandCardIndex(bool playerSeat, int handPos) => Engine.HandCardIndex(playerSeat, handPos); /// The real wire CardId of the in-play follower at on the /// given seat (0-based, leader excluded). Asserts an opponent reveal seated the substituted identity /// (M-HC-2). public int InPlayCardId(bool playerSeat, int boardPos) => Engine.InPlayCardId(playerSeat, boardPos); /// The engine Index of the in-play follower at — the wire /// playIdx an ATTACK frame carries to address that follower as the attacker (M-HC-4a). public int InPlayCardIndex(bool playerSeat, int boardPos) => Engine.InPlayCardIndex(playerSeat, boardPos); /// The current life/health of the in-play follower at . public int InPlayCardLife(bool playerSeat, int boardPos) => Engine.InPlayCardLife(playerSeat, boardPos); /// The attack stat of the in-play follower at . public int InPlayCardAtk(bool playerSeat, int boardPos) => Engine.InPlayCardAtk(playerSeat, boardPos); /// True while the in-play follower at can still attack this turn. public bool InPlayCardAttackable(bool playerSeat, int boardPos) => Engine.InPlayCardAttackable(playerSeat, boardPos); /// True once the in-play follower at has evolved (M-HC-4b). public bool IsEvolved(bool playerSeat, int boardPos) => Engine.IsEvolved(playerSeat, boardPos); /// The seat's current evolve-point count (M-HC-4b). An evolve spends one EP. public int EpCount(bool playerSeat) => Engine.EpCount(playerSeat); /// Turns remaining until the seat may evolve (0 == unlocked) (M-HC-4b). public int EvolveWaitTurnCount(bool playerSeat) => Engine.EvolveWaitTurnCount(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); } /// The engine's NetworkBattleDefine.PlayActionType.ATTACK opcode — confirmed /// = 10 in SVSim.BattleEngine/Engine/NetworkBattleDefine.cs (NOT 31, which is /// PLAY_HAND_SELECT). The receiver maps the wire type int straight to the enum /// (NetworkBattleReceiver.cs:1093). public const int AttackOpcode = 10; // NOTE (live-fidelity migration): the target-builders below emit the REAL client wire shape — // a sender-relative isSelf flag on each targetList entry — NOT the engine's internal // vid stamp. Real client-sent attack/evolve/targeted-play frames carry // {targetIdx, isSelf, selectSkillIndex} (verified in the client-send captures, e.g. // data_dumps/captures/battle_test/battle-traffic_cl1.ndjson); the previous vid shape was a // harness workaround that masked a missing ingest translation. SessionBattleEngine.Receive now // translates isSelf → the engine vid on the engine's OWN dict copy (the engine's IsRecovery target // parse derives owner from vid != PlayerStaticData.UserViewerID, NetworkBattleReceiver.cs:2186), // so the harness drives the live contract end-to-end. // // isSelf is relative to the FRAME's SENDER: isSelf:1 = the target sits on the sender's own // seat; isSelf:0 = it sits on the OTHER seat. The builders take // (stable signature) and map it to isSelf:0 (true) / isSelf:1 (false), since every // builder is driven by seat A attacking/targeting seat B's card (targetOnEnemySeat:true) or its own // (false). private static long IsSelfFlag(bool targetOnEnemySeat) => targetOnEnemySeat ? 0 : 1; /// Build a PlayActions ATTACK frame in the REAL client wire shape. /// is the attacker's in-play engine Index (the wire playIdx); the target is described in /// targetList as {targetIdx, isSelf, selectSkillIndex} — the sender-relative isSelf /// flag a live client actually sends (see ). /// The dispatch reads (_isPlayer ? PlayerTargetDataList : OpponentTargetDataList) /// (WatchOperationCollection.InPlayActionOperation), and the targetList key populates the seat's /// list matching the ingest's isPlayer — so a seat-A (isPlayer:true) attack correctly fills /// PlayerTargetDataList. The target's OWNER is resolved by /// NetworkBattleGenericTool.LookForActionDataToTargetCard with fixed-seat semantics: /// the engine's IsRecovery parse derives owner from a vid stamp, which /// SessionBattleEngine.TranslateTargetOwners writes on ingest from this isSelf flag — /// so drives the absolute target seat through the live contract. /// For a seat-A attack on seat B's leader: targetIdx = 0 (the leader/Class card is Index 0) /// and targetOnEnemySeat = true. public static Dictionary AttackBody(int attackerIdx, int targetIdx, bool targetOnEnemySeat) => new() { ["playIdx"] = attackerIdx, ["type"] = AttackOpcode, ["targetList"] = new List { new Dictionary { ["targetIdx"] = (long)targetIdx, ["isSelf"] = IsSelfFlag(targetOnEnemySeat), ["selectSkillIndex"] = new List(), }, }, }; /// The engine's NetworkBattleDefine.PlayActionType.PLAY_HAND_SELECT opcode — confirmed /// = 31 in SVSim.BattleEngine/Engine/NetworkBattleDefine.cs. A TARGETED hand play (a /// when_play spell/fanfare that selects a target) carries this opcode (the "_SELECT" suffix), as /// opposed to the plain PLAY_HAND = 30 a vanilla play uses. The recovery receive path branches /// on it to RecoveryOperationCollection.PlaySkillSelectHandCardOperation → /// PlayHandCardReflection.PlayAction, which resolves the target from targetList via /// NetworkBattleGenericTool.LookForActionDataToTargetCard (seat A) before applying the skill. public const int PlayHandSelectOpcode = 31; /// Build a PlayActions PLAY_HAND_SELECT (targeted hand-play) frame. /// is the played hand card's engine Index (the wire playIdx); the single target is /// described in targetList in the SAME real {targetIdx, isSelf, selectSkillIndex} shape as /// / (the receive parse reads it identically — /// CreateTargetList in NetworkBattleReceiver.cs:2164 — into the seat's TargetDataList, and under /// IsRecovery resolves the target's owner from the vid that /// SessionBattleEngine.TranslateTargetOwners derives from this isSelf flag on ingest). /// For a seat-A spell targeting an enemy follower: = the enemy /// follower's in-play engine Index and = true (isSelf:0 /// → translated to the seat-B vid → LookForActionDataToTargetCard resolves it on /// BattleEnemy.ClassAndInPlayCardList). public static Dictionary TargetedPlayBody(int playIdx, int targetIdx, bool targetOnEnemySeat) => new() { ["playIdx"] = playIdx, ["type"] = PlayHandSelectOpcode, ["targetList"] = new List { new Dictionary { ["targetIdx"] = (long)targetIdx, ["isSelf"] = IsSelfFlag(targetOnEnemySeat), ["selectSkillIndex"] = new List(), }, }, }; /// Build a PlayActions CHOICE hand-play frame. A choice play carries the plain /// PLAY_HAND = 30 opcode plus a keyAction list that the receiver parses /// (NetworkBattleReceiver.cs:1176-1228) into keyActionType=Choice (→ ReceiveData.IsChoice) /// and choiceIdList = the chosen token id(s). Each entry is /// { type:"Choice", cardId:<played card id>, selectCard:[<tokenId>] }. The receiver reads /// selectCard via ConvertToListInt (NetworkBattleReceiver.cs:1202), i.e. it consumes a /// FLAT list of the chosen token id(s). (The verbatim CLIENT-SEND capture of THIS card — /// data_dumps/captures/battle_test/rng/battle-traffic_cl1.ndjson — wraps it as /// selectCard:{cardId:[121011010],open:0}; that wrapper is unwrapped before the node's /// server-authored receive frame, which is what the receiver — and this driver — consume.) /// is the choice card's hand engine Index; /// its wire id; the selected option. public static Dictionary ChoicePlayBody(int playIdx, long playedCardId, long chosenTokenId) => new() { ["playIdx"] = playIdx, ["type"] = 30, // PLAY_HAND — choice is signalled via keyAction, not a distinct opcode ["keyAction"] = new List { new Dictionary { // The real capture sends type:1 (int); "Choice" (string) is equivalent — the receiver does // Enum.Parse(KeyActionType, type.ToString()) and KeyActionType.Choice == 1, so the string and // the int both parse to the same enum value. ["type"] = "Choice", ["cardId"] = playedCardId, // The RECEIVE parse reads selectCard via ConvertToListInt (NetworkBattleReceiver.cs:1202), // i.e. a FLAT list of the chosen token id(s). (The verbatim CLIENT-SEND capture wraps it as // {cardId:[...],open:0}, but that wrapper is unwrapped before the node's server-authored // receive frame; the receiver consumes the flat list.) ["selectCard"] = new List { chosenTokenId }, }, }, }; /// The engine's NetworkBattleDefine.PlayActionType.EVOLUTION opcode — confirmed /// = 20 in SVSim.BattleEngine/Engine/NetworkBattleDefine.cs (EVOLUTION_SELECT is 21). The /// receiver maps the wire type int straight to the enum; EVOLUTION/EVOLUTION_SELECT route through /// the SAME InPlayAction dispatch arm as ATTACK (NetworkOperationCollection.cs:163-170). public const int EvolutionOpcode = 20; /// The engine's NetworkBattleDefine.PlayActionType.EVOLUTION_SELECT opcode — confirmed /// = 21 in SVSim.BattleEngine/Engine/NetworkBattleDefine.cs. public const int EvolutionSelectOpcode = 21; /// Build a PlayActions EVOLUTION frame for the in-play follower addressed by its engine /// Index ( == the wire playIdx). A plain (non-targeted) evolve /// carries no targetList — the dispatch's list stays empty and the engine evolves the card in /// place (InPlayCardReflection.Evol). public static Dictionary EvolveBody(int cardIdx) => new() { ["playIdx"] = cardIdx, ["type"] = EvolutionOpcode, }; /// Build a PlayActions EVOLUTION_SELECT frame: the follower at engine Index /// evolves and targets the card at . The target is /// described in the SAME real {targetIdx, isSelf, selectSkillIndex} shape as /// (the dispatch resolves the target's owner from the vid that /// SessionBattleEngine.TranslateTargetOwners derives from this isSelf on ingest); /// selects the isSelf flag. public static Dictionary EvolveSelectBody(int cardIdx, int targetIdx, bool targetOnEnemySeat) => new() { ["playIdx"] = cardIdx, ["type"] = EvolutionSelectOpcode, ["targetList"] = new List { new Dictionary { ["targetIdx"] = (long)targetIdx, ["isSelf"] = IsSelfFlag(targetOnEnemySeat), ["selectSkillIndex"] = new List(), }, }, }; 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, IHasHandshakePhase { public long ViewerId { get; } public MatchContext Context { get; } /// Handshake cursor (M-HC-3a handler-emit test). Implementing /// lets a test build a FrameDispatchContext over two /// StubParticipants and advance both to so /// BothSidesAfterReady() passes (the PvP relay gate). Harness tests that drive the engine /// directly never read this; it defaults to the pre-handshake state and is harmless to them. public HandshakePhase Phase { get; set; } = HandshakePhase.AwaitingInitNetwork; 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; } }