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:
@@ -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
|
||||
|
||||
33
SVSim.BattleNode/Sessions/Engine/HeadlessConductorVfxMgr.cs
Normal file
33
SVSim.BattleNode/Sessions/Engine/HeadlessConductorVfxMgr.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user