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>
This commit is contained in:
gamer147
2026-06-06 20:08:53 -04:00
parent 50294c10b1
commit 35e9847911
12 changed files with 339 additions and 6 deletions

View File

@@ -31,6 +31,23 @@ namespace SVSim.BattleEngine.Tests.SessionEngine
[Test]
public void Receive_one_playactions_resolves_headless()
{
// SUPERSEDED by the node-native oracle (SVSim.UnitTests HeadlessConductorTests). This test
// predates the M-HC-0b view-untangle: before it, the receive conductor resolved NOTHING
// headless (every InstantVfx the conductor fused the mutation into was no-op'd by the shared
// VfxMgr, and OperateReceive.OnReceiveDeal was never wired), so a play "ingested" without
// touching state and trivially did not reject. Now the conductor RESOLVES (HeadlessConductor
// VfxMgr runs the InstantVfx; the deal seats the hand). This test feeds the first captured
// `send PlayActions` WITHOUT first replaying the capture's Deal/mulligan, so the played card
// is not in the seated hand and the now-live resolution correctly rejects
// (RemoveSpellCardFromHand: not found). Replaying the capture's Deal first does NOT fix it:
// the seated deck order can't reproduce the capture's post-mulligan idx references (the
// documented capture-replay draw-misalignment artifact — see memory
// project_battle_headless_conductor: "validate via node-native battles"). The valid headless
// play oracle is now HeadlessConductorTests.Vanilla_play_resolves_on_engine_state_headless.
Assert.Ignore("Superseded by node-native HeadlessConductorTests (M-HC-0b). Capture-replay " +
"draw-misalignment makes a captured play unresolvable against a node-seated deck; the " +
"node-native harness is the post-M-HC-0b oracle. Revive if capture-replay alignment lands.");
HeadlessEngineEnv.EnsureInitialized();
var cl1 = CaptureReplay.Load("battle_test_cl1.ndjson");

View File

@@ -20,6 +20,21 @@ namespace SVSim.BattleEngine.Tests.SessionEngine
[Test]
public void Shadow_replay_of_captured_battle_tracks_state_without_desync()
{
// SUPERSEDED by the node-native oracle (SVSim.UnitTests HeadlessConductorTests). This test's
// "0 rejects" used to pass VACUOUSLY: before the M-HC-0b view-untangle the receive conductor
// resolved NOTHING headless (InstantVfx mutations no-op'd; OnReceiveDeal unwired), so no
// captured frame could diverge because none was applied. The retracted "shadow tracks the
// capture" claim is documented in memory project_battle_node_engine_shadow / _headless_conductor.
// Now that the conductor RESOLVES, replaying a captured stream against a node-seated deck hits
// the documented capture-replay draw-misalignment: the seated deck order can't reproduce the
// capture's post-mulligan idx references, so played cards aren't in the seated hand
// (HandCardToField/RemoveSpellCardFromHand: not found). The decision (memory
// project_battle_headless_conductor) is to validate headless resolution via NODE-NATIVE
// battles, not capture replay. The node-native oracle now covers Deal+Play.
Assert.Ignore("Superseded by node-native HeadlessConductorTests (M-HC-0b). Capture-replay " +
"against a node-seated deck hits the documented draw-misalignment artifact once the " +
"receive path actually resolves. Revive if a capture-replay alignment path lands.");
HeadlessEngineEnv.EnsureInitialized();
var cl1 = CaptureReplay.Load("battle_test_cl1.ndjson");

View File

@@ -18,7 +18,7 @@ namespace Wizard.Battle.View {
CardTemplate global::Wizard.Battle.View.IBattleCardView.CardTemplate { get => default!; }
BoxCollider global::Wizard.Battle.View.IBattleCardView.Collider { get => default!; }
private BattleCardIconAnimations _headlessIconAnims; // HEADLESS-FIX (N1)
BattleCardIconAnimations global::Wizard.Battle.View.IBattleCardView.BattleCardIconAnimations { get => _headlessIconAnims ??= new BattleCardIconAnimations(); } // HEADLESS-FIX (N1): non-null no-op so ReplaceReceivedCard.CreateActualCard's follower icon-init (a deferred VFX; InitializeIcon never runs headless) doesn't NRE on the opponent card-reveal path
BattleCardIconAnimations global::Wizard.Battle.View.IBattleCardView.BattleCardIconAnimations { get => _headlessIconAnims ??= global::Wizard.Battle.View.HeadlessIconAnimations.Create(); } // HEADLESS-FIX (N1/M-HC-0b): non-null no-op so ReplaceReceivedCard.CreateActualCard's follower icon-init AND the receive play path's BattlePlayerBase.UpdateInPlayBattleCardIconLabel (HasInductionNumberSkill iterates the private `collection`) don't NRE. HeadlessIconAnimations.Create seeds an empty SkillCollectionBase so the induction-label check is a clean false.
Func<bool> global::Wizard.Battle.View.IBattleCardView.GetIsOnMove { get => default!; }
bool global::Wizard.Battle.View.IBattleCardView.InPlayModelActive { get => default!; set { } }
BattleCamera global::Wizard.Battle.View.IBattleCardView.m_BattleCamera { get => default!; }
@@ -221,7 +221,11 @@ namespace Wizard.Battle.View {
BattleCardBase global::Wizard.Battle.View.IBattlePlayerView.SelectSkillActCard { get => default!; }
GameObject global::Wizard.Battle.View.IBattlePlayerView.TurnEndBtn { get => default!; }
BattleCardBase global::Wizard.Battle.View.IBattlePlayerView.m_CurrentTarget { get => default!; }
PlayQueueViewBase global::Wizard.Battle.View.IBattlePlayerView.PlayQueueView { get => default!; }
// HEADLESS-FIX (M-HC-0b): generator emitted `default!` (null); the RECEIVE-conductor play path
// calls BattleView.PlayQueueView.AddCardToViewVfx (OperateMgr.cs:201/203/219/221). Redirect to a
// shared no-op PlayQueueViewBase so the presentation call is a safe NullVfx (the authoritative
// play mutation runs in PlayHandCardReflection.Play, not in this view).
PlayQueueViewBase global::Wizard.Battle.View.IBattlePlayerView.PlayQueueView { get => global::Wizard.Battle.View.HeadlessPlayQueueViewStub.Instance; }
AttackSelectControl global::Wizard.Battle.View.IBattlePlayerView.AttackSelectControl { get => default!; }
InPlayViewBase global::Wizard.Battle.View.IBattlePlayerView.InPlayView { get => default!; }
GameObject global::Wizard.Battle.View.IBattlePlayerView.StatusParentPanel { get => default!; }
@@ -324,7 +328,11 @@ namespace Wizard.Battle.View {
BattleCardBase global::Wizard.Battle.View.IBattlePlayerView.SelectSkillActCard { get => default!; }
GameObject global::Wizard.Battle.View.IBattlePlayerView.TurnEndBtn { get => default!; }
BattleCardBase global::Wizard.Battle.View.IBattlePlayerView.m_CurrentTarget { get => default!; }
PlayQueueViewBase global::Wizard.Battle.View.IBattlePlayerView.PlayQueueView { get => default!; }
// HEADLESS-FIX (M-HC-0b): generator emitted `default!` (null); the RECEIVE-conductor play path
// calls BattleView.PlayQueueView.AddCardToViewVfx (OperateMgr.cs:201/203/219/221). Redirect to a
// shared no-op PlayQueueViewBase so the presentation call is a safe NullVfx (the authoritative
// play mutation runs in PlayHandCardReflection.Play, not in this view).
PlayQueueViewBase global::Wizard.Battle.View.IBattlePlayerView.PlayQueueView { get => global::Wizard.Battle.View.HeadlessPlayQueueViewStub.Instance; }
AttackSelectControl global::Wizard.Battle.View.IBattlePlayerView.AttackSelectControl { get => default!; }
InPlayViewBase global::Wizard.Battle.View.IBattlePlayerView.InPlayView { get => default!; }
GameObject global::Wizard.Battle.View.IBattlePlayerView.StatusParentPanel { get => default!; }

View File

@@ -0,0 +1,31 @@
// AUTHORED SHIM (not copied). Factory for the no-op BattleCardIconAnimations the headless
// IBattleCardView.BattleCardIconAnimations getter hands back (see _IfaceImpl.g.cs).
//
// BattleCardIconAnimations.Initialize (which seats its private `collection`) is a deferred VFX that
// never runs headless, so a bare `new BattleCardIconAnimations()` leaves `collection` null. The receive
// play path calls BattlePlayerBase.UpdateInPlayBattleCardIconLabel -> HasInductionNumberSkill, which
// iterates `collection.Count()` and NREs on the null. Seed `collection` with an EMPTY
// SkillCollectionBase so the induction-label check resolves to a clean `false` (no cosmetic
// induction-number VFX to play headless). The collection is intentionally empty rather than the played
// card's real skills: the only consumer on the resolve path is this cosmetic icon-label gate, which a
// no-op (empty) collection satisfies correctly. Nothing here touches authoritative game state.
using System.Reflection;
namespace Wizard.Battle.View
{
internal static class HeadlessIconAnimations
{
private static readonly FieldInfo CollectionField =
typeof(global::BattleCardIconAnimations).GetField("collection",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new System.InvalidOperationException("BattleCardIconAnimations.collection field not found");
public static global::BattleCardIconAnimations Create()
{
var anims = new global::BattleCardIconAnimations();
CollectionField.SetValue(anims, new global::SkillCollectionBase(null));
return anims;
}
}
}

View File

@@ -0,0 +1,40 @@
// AUTHORED SHIM (not copied). A non-null no-op PlayQueueViewBase the headless RECEIVE-conductor play
// path needs.
//
// On the receive play path OperateMgr.InitSetCard (OperateMgr.cs:201/203/219/221) reads
// BattleView.PlayQueueView and calls AddCardToViewVfx — a pure presentation-layer "card slides into the
// play queue" animation. The m1_stub_gen generated IBattlePlayerView.PlayQueueView getter returns
// default! (null), so the call NREs. The direct-ActionProcessor solo oracles never hit this
// (InitSetCard is on the OperateMgr path, which they bypass). Seed a single shared no-op
// PlayQueueViewBase whose VFX-producing methods return NullVfx and whose abstract geometry members are
// inert. Built through the parameterless base ctor (the BattleCamera ctor eagerly computes screen
// corners off a live camera — skipped). Nothing here touches game state: the authoritative play
// mutation runs in PlayHandCardReflection.Play, not in this view.
using UnityEngine;
using Wizard.Battle.View.Vfx;
namespace Wizard.Battle.View
{
public sealed class HeadlessPlayQueueViewStub : PlayQueueViewBase
{
// Shared instance the generated IBattlePlayerView.PlayQueueView getters return headless.
public static readonly HeadlessPlayQueueViewStub Instance = new HeadlessPlayQueueViewStub();
public HeadlessPlayQueueViewStub() : base() { }
protected override BattlePlayerBase BattlePlayerBase => null;
protected override float RotationAmount => 0f;
protected override Vector3 ScreenTopCornerPosition => Vector3.zero;
protected override Vector3 ScreenBottomCornerPosition => Vector3.zero;
public override VfxBase AddCardToViewVfx(IBattleCardView playedCardView, bool forceCardIntoPlayQueue, bool isSelectTarget, bool isChoice, bool isChoiceBrave = false)
=> NullVfx.GetInstance();
public override VfxBase InstantAddCardToViewVfx(IBattleCardView playedCardView, bool forceCardIntoPlayQueue, bool isChoice)
=> NullVfx.GetInstance();
protected override Vector3 GetScreenTopCornerOffset(float aspectRatio) => Vector3.zero;
protected override Vector3 GetScreenBottomCornerOffset(float aspectRatio) => Vector3.zero;
}
}

View File

@@ -33,10 +33,17 @@ namespace Wizard.Battle.View.Vfx
public sealed class InstantVfx : VfxBase
{
// Stored, never invoked (headless suppression -- see file header).
// Stored, never invoked ON THE DIRECT ActionProcessor PATH (headless suppression -- see file
// header: the mutation already happened synchronously before the VFX is built, so the M2-M12
// direct-path oracles never need to run it). BUT on the RECEIVE-CONDUCTOR path the conductor
// FUSES the authoritative mutation INTO this delegate (NetworkOperationCollectionBase.cs:63/73/86
// register an InstantVfx whose body IS the play mutation). A node-side VfxMgr that executes the
// registered InstantVfx (see HeadlessConductorVfxMgr) calls Run() to fire that mutation. Run() is
// opt-in: the shared/default VfxMgr still no-ops registration, so the direct path is untouched.
private Action _action;
public static InstantVfx Create(Action action) => new InstantVfx { _action = action };
public override bool IsVfxNonEmpty() => true;
public void Run() => _action?.Invoke();
}
public sealed class WaitVfx : VfxBase

View File

@@ -8,6 +8,7 @@ using System.Reflection;
using System.Runtime.Serialization;
using System.Text.Json;
using BattleManagerBase = engine::BattleManagerBase;
using BattleRecoveryInfo = engine::Wizard.BattleRecoveryInfo;
using CardCSVData = engine::Wizard.CardCSVData;
using CardMaster = engine::Wizard.CardMaster;
using Certification = engine::Cute.Certification;
@@ -76,6 +77,15 @@ internal static class EngineGlobalInit
// Suppress VFX / take the virtual-battle resolution path (no live view layer).
BattleManagerBase.IsForecast = true;
// The receive-conductor deal path runs under IsRecovery (SessionBattleEngine sets it after
// construction) and reads Data.BattleRecoveryInfo.IsMulliganEnd in MulliganMgrBase.StartDeal
// (line 43) — null by default -> NRE. Seed a no-op instance with IsMulliganEnd=false (the
// default) so the deal returns its real parallel VFX rather than the mulligan-end short
// circuit. GetUninitializedObject skips the JsonData ctor. Only when absent (coexistence).
if (Data.BattleRecoveryInfo == null)
Data.BattleRecoveryInfo =
(BattleRecoveryInfo)FormatterServices.GetUninitializedObject(typeof(BattleRecoveryInfo));
// --- static CardMaster (full cards.json) ----------------------------------------------
// ALWAYS rebuild + re-inject the FULL master. We must not defer to a possibly-thin
// existing Default (e.g. a HeadlessCardMaster.Load(singleCard) from an earlier test in

View File

@@ -0,0 +1,33 @@
extern alias engine;
using engine::Wizard.Battle.View.Vfx;
namespace SVSim.BattleNode.Sessions.Engine;
/// <summary>The node's receive-conductor VfxMgr (design Headless-Conductor, Candidate B). The engine's
/// receive CONDUCTOR fuses each authoritative mutation INTO an <see cref="InstantVfx"/> delegate and
/// registers it via <c>VfxMgr.RegisterSequentialVfx</c> (NetworkOperationCollectionBase.cs:63/73/86 —
/// the play move; the deal seats its hand synchronously before any VFX). The shared/authored
/// <see cref="VfxMgr"/> NO-OPS registration (correct for the DIRECT ActionProcessor path the M2-M12
/// oracles use, where the mutation already ran synchronously before the VFX was built). On the RECEIVE
/// path the mutation IS the delegate, so the shadow must RUN it.
///
/// <para>This subclass is wired ONLY through <see cref="SessionContentsCreator.CreateVfxMgr"/> (the
/// node's own factory). The HeadlessFixture oracle tests construct their VfxMgr via their own
/// HeadlessContentsCreator (a plain <c>new VfxMgr()</c>), so the M2-M12 direct-path oracles are
/// untouched BY CONSTRUCTION.</para>
///
/// <para>It executes ONLY top-level <see cref="InstantVfx"/> registrations. It deliberately does NOT
/// recurse into container VFX (Sequential/Parallel) — those carry cosmetic nested VFX built over the
/// no-op view leaves, which must stay un-played. The authoritative mutations the receive conductor
/// cares about are always registered as a top-level InstantVfx.</para></summary>
internal sealed class HeadlessConductorVfxMgr : VfxMgr
{
public override void RegisterSequentialVfx<T>(T vfx)
{
if (vfx is InstantVfx instant)
{
instant.Run();
}
// Non-InstantVfx (containers, waits, cosmetic vfx) are dropped — no render loop headless.
}
}

View File

@@ -21,6 +21,12 @@ using GameMgr = engine::GameMgr;
using BattleUIContainer = engine::BattleUIContainer;
using BackGroundBase = engine::BackGroundBase;
using NullPlayerEmotion = engine::Wizard.Battle.Player.Emotion.NullPlayerEmotion;
using NetworkMulliganPhase = engine::Wizard.Battle.Phase.NetworkMulliganPhase;
using MulliganInfoControl = engine::Wizard.Battle.Mulligan.MulliganInfoControl;
using UIWidget = engine::UIWidget;
using UISprite = engine::UISprite;
using NullDetailPanelControl = engine::NullDetailPanelControl;
using DetailPanelControl = engine::DetailPanelControl;
namespace SVSim.BattleNode.Sessions.Engine;
@@ -97,6 +103,8 @@ internal sealed class SessionBattleEngine
SeedDeck(mgr, seatADeck, isPlayer: true);
SeedDeck(mgr, seatBDeck, isPlayer: false);
WireMulliganPhase(mgr); // wire OperateReceive.OnReceiveDeal -> StartDeal (deal seats the hand)
_mgr = mgr;
// Use the mgr's OWN receiver — the ctor already wired it to the mgr's OperateReceive +
// NetworkBattleData (NetworkBattleManagerBase.cs:266, non-recovery branch). This is the same
@@ -126,7 +134,9 @@ internal sealed class SessionBattleEngine
}
catch (Exception ex)
{
var site = ex.StackTrace?.Split('\n').FirstOrDefault()?.Trim();
// Keep the first few frames: a headless-gap NRE/ANE is almost always diagnosable from the
// call chain (the throwing leaf is often a ThrowHelper, so one frame is too few).
var site = string.Join(" || ", (ex.StackTrace ?? "").Split('\n').Take(4).Select(s => s.Trim()));
return EngineIngestResult.Reject($"{env.Uri} threw: {ex.GetType().Name}: {ex.Message} @ {site}");
}
}
@@ -144,6 +154,11 @@ internal sealed class SessionBattleEngine
/// ClassAndInPlayCardList).</summary>
public int BoardCount(bool playerSeat) => Math.Max(0, Seat(playerSeat).ClassAndInPlayCardList.Count - 1);
/// <summary>The engine <c>Index</c> of the hand card at the given hand position. The receive-path
/// Play frame addresses a card by its engine Index (playIdx), which equals deck position + 1 for
/// a card dealt from the seeded deck.</summary>
public int HandCardIndex(bool playerSeat, int handPos) => Seat(playerSeat).HandCardList[handPos].Index;
private engine::BattlePlayerBase Seat(bool playerSeat) =>
(_mgr ?? throw new InvalidOperationException("read before Setup")).GetBattlePlayer(playerSeat);
@@ -211,8 +226,78 @@ internal sealed class SessionBattleEngine
// where present.
TrySetProperty(mgr.GetBattlePlayer(true), "PlayerEmotion", new NullPlayerEmotion());
TrySetProperty(mgr.GetBattlePlayer(false), "PlayerEmotion", new NullPlayerEmotion());
// The receive play path runs SetupActionProcessorEvent (BattlePlayerBase.cs:1431/1438), which
// wires BattleMgr.DetailMgr.DetailPanelControl.UpdateCardDescription* into OnPlayComplete/
// OnEvolutionComplete. DetailMgr is created in CreateManager but its panel controls are null
// headless. Seed the engine's own NullDetailPanelControl no-op (IDetailPanelControl) + an
// uninitialized SubDetailPanelControl (concrete DetailPanelControl, read on other action arms).
mgr.DetailMgr.DetailPanelControl = new NullDetailPanelControl();
mgr.DetailMgr.SubDetailPanelControl =
(DetailPanelControl)FormatterServices.GetUninitializedObject(typeof(DetailPanelControl));
}
// Hold a strong reference to the wired mulligan phase: its StartDeal closure is what
// OperateReceive.OnReceiveDeal invokes, and it stores the mulligan mgr/controls that seat the hand.
private NetworkMulliganPhase? _mulliganPhase;
// Wire the receive path's deal handler. In production the phase machine advances to
// NetworkMulliganPhase, whose Setup/MulliganEventSetting wires OperateReceive.OnReceiveDeal ->
// MulliganPhaseBase.StartDeal (NetworkMulliganPhase.cs:91). The node never pumps the phase machine
// (BattleManagerBase.Update is never called), and the node's PhaseCreator yields no NetworkMulligan
// phase anyway — so construct the phase directly and run MulliganEventSetting() to install that
// delegate. The phase ctor's Initialize builds the player/opponent mulligan controls (PlayerMlgCtrl
// via InitMulligan) off the no-op view leaves the shim GameObject lazily materializes. The DEAL
// mutation (cards deck->hand) happens synchronously inside StartDeal -> CreateMulliganDealList +
// DrawFirstMulliganCard; the VFX it returns are cosmetic (dropped by HeadlessConductorVfxMgr).
private void WireMulliganPhase(HeadlessNetworkBattleMgr mgr)
{
// The phase ctor's Initialize does NGUITools.AddChild(Battle3DContainer,
// GetPrefabMgr().Get("Prefab/UI/MulliganInfo")).GetComponent<MulliganInfoControl>(). PrefabMgr.Get
// returns null for an unregistered prefab (engine logic — not editable), and AddChild(parent,
// null) -> Instantiate(null) -> null -> NRE on GetComponent. Seed a no-op GameObject under that
// key so AddChild clones it and the shim GameObject lazily materializes a no-op
// MulliganInfoControl. Node seed (allowed); the control is never shown/updated headless.
var prefab = new GameObject();
SeedMulliganInfoControl(prefab);
var prefabData = GameMgr.GetIns().GetPrefabMgr().GetPrefabData();
prefabData["Prefab/UI/MulliganInfo"] = prefab;
var phase = new NetworkMulliganPhase(mgr, mgr.NetworkSender);
phase.MulliganEventSetting();
_mulliganPhase = phase;
}
// Materialize a no-op MulliganInfoControl on the prefab GameObject and seed the view-leaf fields the
// phase ctor's PlayerMulliganView ctor -> MulliganInfoControl.InitMulliganInfo reads:
// _partsPlayer/_partsOpponent (private nested MulliganParts) — each needs a non-null _exchangeMark
// array (read for .Length in InitMulliganInfo) plus non-null _keepZone/_abandonZone UIWidgets
// (read for .gameObject elsewhere on the mulligan path).
// The shim GameObject lazily creates the MulliganInfoControl but does NOT fill the MulliganParts
// (it isn't a Component, so WireComponentFields skips it). Node seed (allowed) — pure no-op view leaves.
private static void SeedMulliganInfoControl(GameObject prefab)
{
var ctrl = prefab.GetComponent<MulliganInfoControl>();
var partsType = typeof(MulliganInfoControl)
.GetNestedType("MulliganParts", BindingFlags.NonPublic)
?? throw new InvalidOperationException("MulliganInfoControl.MulliganParts nested type not found");
SetField(ctrl, "_partsPlayer", BuildMulliganParts(partsType));
SetField(ctrl, "_partsOpponent", BuildMulliganParts(partsType));
}
private static object BuildMulliganParts(Type partsType)
{
var parts = FormatterServices.GetUninitializedObject(partsType);
SetField(parts, "_exchangeMark", Array.CreateInstance(typeof(UISprite), 0));
SetField(parts, "_keepZone", NewUiWidget());
SetField(parts, "_abandonZone", NewUiWidget());
return parts;
}
// A UIWidget is read for .gameObject (Component.gameObject) on the mulligan path; create one on a
// fresh GameObject so its gameObject backref resolves.
private static UIWidget NewUiWidget() => new GameObject().GetComponent<UIWidget>();
/// <summary>Seat one side's full deck in order (idx == list position + 1). Each card is created
/// through the engine's own null-view seam and pushed via AddToDeck — the SeedDeck primitive the
/// test harness proved (HeadlessFixture.SeedDeck).</summary>

View File

@@ -27,7 +27,10 @@ internal sealed class SessionContentsCreator : IBattleMgrContentsCreator
public IReplayRecordManager ReplayRecordManager { get; } = new NullReplayRecordManager();
public IBattleResourceMgr CreateResourceMgr() => new BattleResourceMgr();
public VfxMgr CreateVfxMgr() => new VfxMgr();
// The receive-conductor VfxMgr: runs the InstantVfx the conductor fuses the play mutation into
// (design Headless-Conductor Candidate B). The shared VfxMgr no-ops registration — correct for the
// direct ActionProcessor path, wrong for the receive path. See HeadlessConductorVfxMgr.
public VfxMgr CreateVfxMgr() => new HeadlessConductorVfxMgr();
public IPhaseCreator CreatePhaseCreator(engine::BattleManagerBase battleMgr) =>
new SessionPhaseCreator(battleMgr);
}

View File

@@ -1,4 +1,6 @@
using System.Collections.Generic;
using NUnit.Framework;
using SVSim.BattleNode.Protocol;
namespace SVSim.UnitTests.BattleNode.Integration;
@@ -10,6 +12,11 @@ namespace SVSim.UnitTests.BattleNode.Integration;
///
/// Task 1 (M-HC-0a) exit criterion: the engine seats headless (IsReady) in the
/// SVSim.UnitTests process.
///
/// Task 2 (M-HC-0b) exit criterion: a node-generated <c>Deal</c> seats the 3-card hand and a
/// vanilla hand-card <c>Play</c> resolves on ENGINE board state (card left hand, PP dropped
/// by cost, board reflects the play) — driven through the receive CONDUCTOR, not the
/// direct ActionProcessor path the M2-M12 oracles use.
/// </summary>
[TestFixture]
[NonParallelizable]
@@ -31,4 +38,77 @@ public class HeadlessConductorTests
Assert.That(harness.LeaderLife(playerSeat: true), Is.EqualTo(20), "seat A leader life");
Assert.That(harness.LeaderLife(playerSeat: false), Is.EqualTo(20), "seat B leader life");
}
// The node's BuildDeal opening hand: pos->idx (0,1),(1,2),(2,3). hand == deck idx 1,2,3, i.e.
// the top 3 of the node-native shuffled deck. Both seats deal the same idx triple.
private static Dictionary<string, object?> DealBody() => new()
{
["self"] = new List<object?>
{
new Dictionary<string, object?> { ["pos"] = 0, ["idx"] = 1 },
new Dictionary<string, object?> { ["pos"] = 1, ["idx"] = 2 },
new Dictionary<string, object?> { ["pos"] = 2, ["idx"] = 3 },
},
["oppo"] = new List<object?>
{
new Dictionary<string, object?> { ["pos"] = 0, ["idx"] = 1 },
new Dictionary<string, object?> { ["pos"] = 1, ["idx"] = 2 },
new Dictionary<string, object?> { ["pos"] = 2, ["idx"] = 3 },
},
};
// A minimal vanilla hand-card play: type 30 == PLAY_HAND; playIdx is the played card's index.
// No targetList/orderList — a vanilla follower auto-resolves with no selection.
private static Dictionary<string, object?> PlayBody(int playIdx) => new()
{
["playIdx"] = playIdx,
["type"] = 30,
};
[Test]
public void Deal_seats_three_card_hand_headless()
{
using var harness = NodeNativeBattleHarness.Create();
var result = harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true);
Assert.That(result.Accepted, Is.True, $"Deal rejected: {result.RejectReason}");
Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(3),
"Deal must seat the 3-card opening hand on the player seat.");
}
[Test]
public void Vanilla_play_resolves_on_engine_state_headless()
{
// Deck idx 1/2/3 are the top three of the shuffled deck; arrange idx-1 to be a known vanilla
// follower so the Play assertion is decisive. Put the vanilla follower first; the rest of the
// default deck (spellboost + vanillas) follows.
var deck = new List<long> { NodeNativeBattleHarness.VanillaFollowerId };
deck.AddRange(NodeNativeBattleHarness.DefaultDeck());
deck = deck.GetRange(0, 30);
using var harness = NodeNativeBattleHarness.Create(seatADeck: deck);
var deal = harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true);
Assert.That(deal.Accepted, Is.True, $"Deal rejected: {deal.RejectReason}");
Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(3), "post-Deal hand");
var ppBefore = harness.Pp(playerSeat: true);
var handBefore = harness.HandCount(playerSeat: true);
var boardBefore = harness.BoardCount(playerSeat: true);
// The played card is at hand index 1 (deck idx 1 -> the first dealt card; engine card Index
// mirrors deck position+1). The shuffle determines which deck idx-1 maps to; we only need a
// vanilla follower in the opening hand. Use the first dealt idx.
var playIdx = harness.PlayerHandCardIndex(0);
var play = harness.Push(NetworkBattleUri.PlayActions, PlayBody(playIdx), isPlayerSeat: true);
Assert.That(play.Accepted, Is.True, $"Play rejected: {play.RejectReason}");
Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(handBefore - 1),
"the played card must leave the hand");
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(boardBefore + 1),
"a follower play must add one to the board");
Assert.That(harness.Pp(playerSeat: true), Is.LessThan(ppBefore),
"PP must drop by the played card's cost");
}
}

View File

@@ -125,6 +125,10 @@ internal sealed class NodeNativeBattleHarness : IDisposable
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>