using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Lifecycle;
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. Per-battle state is isolated via the engine's per-session
/// BattleAmbientContext (Task 7 of multi-instancing migration), so the historical
/// single-active-engine gate is gone — concurrent harnesses + sessions are now safe.
///
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=1 → CostSetModifier(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();
// Mirror BattleSession.EnsureEngineSetup: engine's StableRandom is seeded with
// BattleSeeds.Stable(MasterSeed), the value the Matched frame ships to clients
// (InitBattleHandler.cs:28). See BattleSession.cs for the full root-cause comment.
engine.Setup(BattleSeeds.Stable(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);
/// TEST/DEBUG: pull one value from the engine's shared _stableRandom stream. Mirrors the
/// engine's seam; lets a regression test
/// assert seed alignment with the wire (clients seed their _stableRandom with the Matched.seed,
/// which is BattleSeeds.Stable(masterSeed)).
public double DebugStableRandomDouble() => Engine.DebugStableRandomDouble();
/// TEST/DEBUG: read the seat's auto-assign Index counter (cardTotalNum). After
/// Setup it must equal deck.Count + 1 so the next skill-generated token gets an Index
/// clear of the deck-loaded 1..40 (= the real client's SBattleLoad behavior).
public int DebugCardTotalNum(bool playerSeat) => Engine.DebugCardTotalNum(playerSeat);
/// 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);
// --- TEST/DEBUG seams (Phase 4 root-cause verification: post-mulligan reshuffle) ---------------
/// TEST/DEBUG: is the engine's SELF-seat XorShift idx-change RNG active (the gate the
/// post-mulligan reshuffle checks)? Live recovery setup leaves it FALSE.
public bool SelfXorShiftActive => Engine.SelfXorShiftActive;
/// TEST/DEBUG: opponent-seat XorShift active state.
public bool OppoXorShiftActive => Engine.OppoXorShiftActive;
/// TEST/DEBUG: inject the per-seat idxChange seeds (call before the Ready mulligan-end frame
/// to activate the engine's own post-mulligan reshuffle).
public void DebugSeedIdxChange(int selfSeed, int oppoSeed) => Engine.DebugSeedIdxChange(selfSeed, oppoSeed);
/// 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