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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user