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:
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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!; }
|
||||
|
||||
31
SVSim.BattleEngine/Shim/View/HeadlessIconAnimations.cs
Normal file
31
SVSim.BattleEngine/Shim/View/HeadlessIconAnimations.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
SVSim.BattleEngine/Shim/View/HeadlessPlayQueueViewStub.cs
Normal file
40
SVSim.BattleEngine/Shim/View/HeadlessPlayQueueViewStub.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user