diff --git a/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineConstructionTests.cs b/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineConstructionTests.cs index 85c8c73..9c75de8 100644 --- a/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineConstructionTests.cs +++ b/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineConstructionTests.cs @@ -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"); diff --git a/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineShadowReplayTests.cs b/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineShadowReplayTests.cs index 36eaf61..f142174 100644 --- a/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineShadowReplayTests.cs +++ b/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineShadowReplayTests.cs @@ -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"); diff --git a/SVSim.BattleEngine/Shim/Generated/_IfaceImpl.g.cs b/SVSim.BattleEngine/Shim/Generated/_IfaceImpl.g.cs index 3bd85c4..dfb9c27 100644 --- a/SVSim.BattleEngine/Shim/Generated/_IfaceImpl.g.cs +++ b/SVSim.BattleEngine/Shim/Generated/_IfaceImpl.g.cs @@ -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 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!; } diff --git a/SVSim.BattleEngine/Shim/View/HeadlessIconAnimations.cs b/SVSim.BattleEngine/Shim/View/HeadlessIconAnimations.cs new file mode 100644 index 0000000..c36d8e8 --- /dev/null +++ b/SVSim.BattleEngine/Shim/View/HeadlessIconAnimations.cs @@ -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; + } + } +} diff --git a/SVSim.BattleEngine/Shim/View/HeadlessPlayQueueViewStub.cs b/SVSim.BattleEngine/Shim/View/HeadlessPlayQueueViewStub.cs new file mode 100644 index 0000000..49279dc --- /dev/null +++ b/SVSim.BattleEngine/Shim/View/HeadlessPlayQueueViewStub.cs @@ -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; + } +} diff --git a/SVSim.BattleEngine/Shim/View/VfxShim.cs b/SVSim.BattleEngine/Shim/View/VfxShim.cs index 7fa2aea..5a54cdb 100644 --- a/SVSim.BattleEngine/Shim/View/VfxShim.cs +++ b/SVSim.BattleEngine/Shim/View/VfxShim.cs @@ -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 diff --git a/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs b/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs index b9b08ec..854fc8a 100644 --- a/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs +++ b/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs @@ -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 diff --git a/SVSim.BattleNode/Sessions/Engine/HeadlessConductorVfxMgr.cs b/SVSim.BattleNode/Sessions/Engine/HeadlessConductorVfxMgr.cs new file mode 100644 index 0000000..f38bd6d --- /dev/null +++ b/SVSim.BattleNode/Sessions/Engine/HeadlessConductorVfxMgr.cs @@ -0,0 +1,33 @@ +extern alias engine; +using engine::Wizard.Battle.View.Vfx; + +namespace SVSim.BattleNode.Sessions.Engine; + +/// The node's receive-conductor VfxMgr (design Headless-Conductor, Candidate B). The engine's +/// receive CONDUCTOR fuses each authoritative mutation INTO an delegate and +/// registers it via VfxMgr.RegisterSequentialVfx (NetworkOperationCollectionBase.cs:63/73/86 — +/// the play move; the deal seats its hand synchronously before any VFX). The shared/authored +/// 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. +/// +/// This subclass is wired ONLY through (the +/// node's own factory). The HeadlessFixture oracle tests construct their VfxMgr via their own +/// HeadlessContentsCreator (a plain new VfxMgr()), so the M2-M12 direct-path oracles are +/// untouched BY CONSTRUCTION. +/// +/// It executes ONLY top-level 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. +internal sealed class HeadlessConductorVfxMgr : VfxMgr +{ + public override void RegisterSequentialVfx(T vfx) + { + if (vfx is InstantVfx instant) + { + instant.Run(); + } + // Non-InstantVfx (containers, waits, cosmetic vfx) are dropped — no render loop headless. + } +} diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs index 239fe72..2bd82de 100644 --- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs +++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs @@ -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). public int BoardCount(bool playerSeat) => Math.Max(0, Seat(playerSeat).ClassAndInPlayCardList.Count - 1); + /// The engine Index 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. + 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(). 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(); + 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(); + /// 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). diff --git a/SVSim.BattleNode/Sessions/Engine/SessionContentsCreator.cs b/SVSim.BattleNode/Sessions/Engine/SessionContentsCreator.cs index 900f46a..5fcce07 100644 --- a/SVSim.BattleNode/Sessions/Engine/SessionContentsCreator.cs +++ b/SVSim.BattleNode/Sessions/Engine/SessionContentsCreator.cs @@ -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); } diff --git a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs index d0342b2..321d92e 100644 --- a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs @@ -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 Deal seats the 3-card hand and a +/// vanilla hand-card Play 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. /// [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 DealBody() => new() + { + ["self"] = new List + { + new Dictionary { ["pos"] = 0, ["idx"] = 1 }, + new Dictionary { ["pos"] = 1, ["idx"] = 2 }, + new Dictionary { ["pos"] = 2, ["idx"] = 3 }, + }, + ["oppo"] = new List + { + new Dictionary { ["pos"] = 0, ["idx"] = 1 }, + new Dictionary { ["pos"] = 1, ["idx"] = 2 }, + new Dictionary { ["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 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 { 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"); + } } diff --git a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs index 3811415..8c907f3 100644 --- a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs +++ b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs @@ -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); + /// The engine Index of seat A's hand card at (the playIdx a + /// Play frame would carry to play it). + public int PlayerHandCardIndex(int handPos) => Engine.HandCardIndex(playerSeat: true, handPos); + /// Build an envelope for and ingest it into the engine for the /// given seat (player == seat A). Mirrors BattleNodeFlowTests.MakeEnvelopeWith + /// SessionBattleEngine.Receive.