319 lines
19 KiB
C#
319 lines
19 KiB
C#
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;
|
|
|
|
/// <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). 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.</summary>
|
|
public const long SpellboostCardId = 101314020;
|
|
|
|
/// <summary>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.</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. 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.</summary>
|
|
public const long VanillaFollowerId = 100011010;
|
|
|
|
/// <summary>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).</summary>
|
|
public const long AltVanillaFollowerId = 101211120;
|
|
|
|
/// <summary>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 <see cref="VanillaFollowerId"/>/<see cref="AltVanillaFollowerId"/> 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).</summary>
|
|
public const long VanillaOneOneFollowerId = 900011080;
|
|
|
|
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);
|
|
public int DeckCount(bool playerSeat) => Engine.DeckCount(playerSeat);
|
|
public int Turn(bool playerSeat) => Engine.Turn(playerSeat);
|
|
|
|
/// <summary>The engine Index of seat A's hand card at <paramref name="handPos"/> (the playIdx a
|
|
/// Play frame would carry to play it).</summary>
|
|
public int PlayerHandCardIndex(int handPos) => Engine.HandCardIndex(playerSeat: true, handPos);
|
|
|
|
/// <summary>The wire CardId of the hand card at <paramref name="handPos"/> on the given seat. Lets a
|
|
/// test find a specific card (e.g. the spellboost reducer) in a shuffled opening hand by identity.</summary>
|
|
public int HandCardId(bool playerSeat, int handPos) => Engine.HandCardId(playerSeat, handPos);
|
|
|
|
/// <summary>The engine Index of the hand card at <paramref name="handPos"/> on the given seat.</summary>
|
|
public int HandCardIndex(bool playerSeat, int handPos) => Engine.HandCardIndex(playerSeat, handPos);
|
|
|
|
/// <summary>The real wire <c>CardId</c> of the in-play follower at <paramref name="boardPos"/> on the
|
|
/// given seat (0-based, leader excluded). Asserts an opponent reveal seated the substituted identity
|
|
/// (M-HC-2).</summary>
|
|
public int InPlayCardId(bool playerSeat, int boardPos) => Engine.InPlayCardId(playerSeat, boardPos);
|
|
|
|
/// <summary>The engine <c>Index</c> of the in-play follower at <paramref name="boardPos"/> — the wire
|
|
/// <c>playIdx</c> an ATTACK frame carries to address that follower as the attacker (M-HC-4a).</summary>
|
|
public int InPlayCardIndex(bool playerSeat, int boardPos) => Engine.InPlayCardIndex(playerSeat, boardPos);
|
|
|
|
/// <summary>The current life/health of the in-play follower at <paramref name="boardPos"/>.</summary>
|
|
public int InPlayCardLife(bool playerSeat, int boardPos) => Engine.InPlayCardLife(playerSeat, boardPos);
|
|
|
|
/// <summary>The attack stat of the in-play follower at <paramref name="boardPos"/>.</summary>
|
|
public int InPlayCardAtk(bool playerSeat, int boardPos) => Engine.InPlayCardAtk(playerSeat, boardPos);
|
|
|
|
/// <summary>True while the in-play follower at <paramref name="boardPos"/> can still attack this turn.</summary>
|
|
public bool InPlayCardAttackable(bool playerSeat, int boardPos) => Engine.InPlayCardAttackable(playerSeat, boardPos);
|
|
|
|
/// <summary>True once the in-play follower at <paramref name="boardPos"/> has evolved (M-HC-4b).</summary>
|
|
public bool IsEvolved(bool playerSeat, int boardPos) => Engine.IsEvolved(playerSeat, boardPos);
|
|
|
|
/// <summary>The seat's current evolve-point count (M-HC-4b). An evolve spends one EP.</summary>
|
|
public int Ep(bool playerSeat) => Engine.Ep(playerSeat);
|
|
|
|
/// <summary>Turns remaining until the seat may evolve (0 == unlocked) (M-HC-4b).</summary>
|
|
public int EvolveWaitTurnCount(bool playerSeat) => Engine.EvolveWaitTurnCount(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);
|
|
}
|
|
|
|
/// <summary>The engine's <c>NetworkBattleDefine.PlayActionType.ATTACK</c> opcode — confirmed
|
|
/// <c>= 10</c> in <c>SVSim.BattleEngine/Engine/NetworkBattleDefine.cs</c> (NOT 31, which is
|
|
/// PLAY_HAND_SELECT). The receiver maps the wire <c>type</c> int straight to the enum
|
|
/// (NetworkBattleReceiver.cs:1093).</summary>
|
|
public const int AttackOpcode = 10;
|
|
|
|
/// <summary>The engine's "self" viewer id (== <c>Certification.viewer_id</c> seeded by EngineGlobalInit).
|
|
/// The IsRecovery target parse derives a target's owner from <c>vid != PlayerStaticData.UserViewerID</c>
|
|
/// (== this value) — NOT from the <c>isSelf</c> key (that key is only read on the live, non-recovery
|
|
/// parse). So a target vid == this resolves on BattlePlayer (engine seat A); vid != this on BattleEnemy
|
|
/// (seat B).</summary>
|
|
private const long SelfSeatVid = EngineGlobalInit.ThisViewerId;
|
|
|
|
/// <summary>A viewer id distinct from <see cref="SelfSeatVid"/>, stamped when the target sits on the
|
|
/// engine's ENEMY seat (so the recovery parse marks it isSelf=true → BattleEnemy).</summary>
|
|
private const long EnemySeatVid = EngineGlobalInit.ThisViewerId + 1;
|
|
|
|
/// <summary>Build a PlayActions ATTACK frame. <paramref name="attackerIdx"/> is the attacker's in-play
|
|
/// engine <c>Index</c> (the wire <c>playIdx</c>); the target is described in <c>targetList</c> as
|
|
/// <c>{targetIdx, vid, selectSkillIndex}</c>.
|
|
/// <para>The dispatch reads <c>(_isPlayer ? PlayerTargetDataList : OpponentTargetDataList)</c>
|
|
/// (WatchOperationCollection.InPlayActionOperation), and the <c>targetList</c> key populates the seat's
|
|
/// list matching the ingest's <c>isPlayer</c> — so a seat-A (<c>isPlayer:true</c>) attack correctly fills
|
|
/// <c>PlayerTargetDataList</c>. The target's OWNER is then resolved by
|
|
/// <c>NetworkBattleGenericTool.LookForActionDataToTargetCard</c> with fixed-seat semantics:
|
|
/// <c>isSelf == false</c> → <c>BattlePlayer</c> (engine seat A); <c>isSelf == true</c> → <c>BattleEnemy</c>
|
|
/// (engine seat B). Under IsRecovery, <c>isSelf</c> is computed from <c>vid</c> (see
|
|
/// <see cref="EnemySeatVid"/>), so <paramref name="targetOnEnemySeat"/> selects the vid stamp.</para>
|
|
/// <para>For a seat-A attack on seat B's leader: <c>targetIdx = 0</c> (the leader/Class card is Index 0)
|
|
/// and <c>targetOnEnemySeat = true</c>.</para></summary>
|
|
public static Dictionary<string, object?> AttackBody(int attackerIdx, int targetIdx, bool targetOnEnemySeat) => new()
|
|
{
|
|
["playIdx"] = attackerIdx,
|
|
["type"] = AttackOpcode,
|
|
["targetList"] = new List<object?>
|
|
{
|
|
new Dictionary<string, object?>
|
|
{
|
|
["targetIdx"] = (long)targetIdx,
|
|
["vid"] = targetOnEnemySeat ? EnemySeatVid : SelfSeatVid,
|
|
["selectSkillIndex"] = new List<object?>(),
|
|
},
|
|
},
|
|
};
|
|
|
|
/// <summary>The engine's <c>NetworkBattleDefine.PlayActionType.EVOLUTION</c> opcode — confirmed
|
|
/// <c>= 20</c> in <c>SVSim.BattleEngine/Engine/NetworkBattleDefine.cs</c> (EVOLUTION_SELECT is 21). The
|
|
/// receiver maps the wire <c>type</c> int straight to the enum; EVOLUTION/EVOLUTION_SELECT route through
|
|
/// the SAME InPlayAction dispatch arm as ATTACK (NetworkOperationCollection.cs:163-170).</summary>
|
|
public const int EvolutionOpcode = 20;
|
|
|
|
/// <summary>The engine's <c>NetworkBattleDefine.PlayActionType.EVOLUTION_SELECT</c> opcode — confirmed
|
|
/// <c>= 21</c> in <c>SVSim.BattleEngine/Engine/NetworkBattleDefine.cs</c>.</summary>
|
|
public const int EvolutionSelectOpcode = 21;
|
|
|
|
/// <summary>Build a PlayActions EVOLUTION frame for the in-play follower addressed by its engine
|
|
/// <c>Index</c> (<paramref name="cardIdx"/> == the wire <c>playIdx</c>). A plain (non-targeted) evolve
|
|
/// carries no targetList — the dispatch's <c>list</c> stays empty and the engine evolves the card in
|
|
/// place (InPlayCardReflection.Evol).</summary>
|
|
public static Dictionary<string, object?> EvolveBody(int cardIdx) => new()
|
|
{
|
|
["playIdx"] = cardIdx,
|
|
["type"] = EvolutionOpcode,
|
|
};
|
|
|
|
/// <summary>Build a PlayActions EVOLUTION_SELECT frame: the follower at engine <c>Index</c>
|
|
/// <paramref name="cardIdx"/> evolves and targets the card at <paramref name="targetIdx"/>. The target is
|
|
/// described in the SAME <c>{targetIdx, vid, selectSkillIndex}</c> shape as <see cref="AttackBody"/>
|
|
/// (the dispatch resolves the target's owner from <c>vid</c> under IsRecovery, not from an isSelf key);
|
|
/// <paramref name="targetOnEnemySeat"/> selects the vid stamp.</summary>
|
|
public static Dictionary<string, object?> EvolveSelectBody(int cardIdx, int targetIdx, bool targetOnEnemySeat) => new()
|
|
{
|
|
["playIdx"] = cardIdx,
|
|
["type"] = EvolutionSelectOpcode,
|
|
["targetList"] = new List<object?>
|
|
{
|
|
new Dictionary<string, object?>
|
|
{
|
|
["targetIdx"] = (long)targetIdx,
|
|
["vid"] = targetOnEnemySeat ? EnemySeatVid : SelfSeatVid,
|
|
["selectSkillIndex"] = new List<object?>(),
|
|
},
|
|
},
|
|
};
|
|
|
|
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. Broker members
|
|
/// (<c>PushAsync</c>, <c>RunAsync</c>, <c>TerminateAsync</c>) throw <see cref="NotSupportedException"/>
|
|
/// — 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.</summary>
|
|
internal sealed class StubParticipant : IBattleParticipant, IHasHandshakePhase
|
|
{
|
|
public long ViewerId { get; }
|
|
public MatchContext Context { get; }
|
|
|
|
/// <summary>Handshake cursor (M-HC-3a handler-emit test). Implementing
|
|
/// <see cref="IHasHandshakePhase"/> lets a test build a <c>FrameDispatchContext</c> over two
|
|
/// StubParticipants and advance both to <see cref="HandshakePhase.AfterReady"/> so
|
|
/// <c>BothSidesAfterReady()</c> 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.</summary>
|
|
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<MsgEnvelope, CancellationToken, Task>? 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;
|
|
}
|
|
}
|