Files
SVSimServer/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs
gamer147 35e9847911 feat(battlenode): receive conductor resolves self Deal+Play headless via view-untangle (M-HC-0)
The engine's receive CONDUCTOR fuses each authoritative mutation behind a view
call: the play mutation is an InstantVfx registered to VfxMgr, and the deal hand
is seated by MulliganPhaseBase.StartDeal wired to OperateReceive.OnReceiveDeal.
Headless, the shared VfxMgr no-op'd registration (correct for the direct
ActionProcessor path the M2-M12 oracles use) and OnReceiveDeal was never wired,
so the receive path resolved nothing.

Untangle (Candidate B, zero Engine logic edits):
- InstantVfx.Run() opt-in executor (authored shim).
- HeadlessConductorVfxMgr : VfxMgr runs registered InstantVfx; wired only via the
  node's SessionContentsCreator.CreateVfxMgr (verified the receive mgr's VfxMgr
  comes from there — BattleManagerBase.cs:768). M2-M12 use HeadlessContentsCreator,
  so they're isolated by construction.
- WireMulliganPhase: construct NetworkMulliganPhase + MulliganEventSetting() to
  install OnReceiveDeal -> StartDeal (the node never pumps the phase machine).

View no-op surface (the 7 from the probe, minus 1 not hit; +1 emergent):
- Deal wiring (NetworkMulliganPhase) [node seed]
- MulliganInfoControl._partsPlayer/_partsOpponent._exchangeMark/_keepZone/_abandonZone [node seed: prefab + SeedMulliganInfoControl]
- Data.BattleRecoveryInfo (IsMulliganEnd=false) [EngineGlobalInit seed]
- IBattlePlayerView.PlayQueueView -> HeadlessPlayQueueViewStub [_IfaceImpl.g.cs, both getters]
- DetailMgr.DetailPanelControl/SubDetailPanelControl [node seed]
- BattleCardIconAnimations.collection (emergent: UpdateInPlayBattleCardIconLabel) -> HeadlessIconAnimations empty SkillCollectionBase [_IfaceImpl.g.cs]
- BattleMenuBtn (probe item 7): NOT hit on the vanilla path; not seeded.

Oracle (HeadlessConductorTests): node Deal seats 3-card hand; a vanilla
hand-card Play leaves hand (-1), adds board (+1), drops PP by cost.

Regression: 24/24 BattleEngine.Tests oracles (M2-M12) green; 241/241
SVSim.UnitTests BattleNode green. The 2 SessionEngine capture-replay shadow
tests are marked Ignore (superseded): they passed VACUOUSLY when the receive
path resolved nothing; with resolution live they hit the documented
capture-replay draw-misalignment artifact. Node-native battles are the oracle.
Drift: no drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:08:53 -04:00

176 lines
9.4 KiB
C#

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;
/// <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;
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);
/// <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>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);
}
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
{
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<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;
}
}