Files
SVSimServer/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs
2026-06-06 23:08:59 -04:00

560 lines
35 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extern alias engine;
using System.Reflection;
using System.Runtime.Serialization;
using engine::SVSim.BattleEngine.Rng;
using SVSim.BattleNode.Protocol;
using NetworkBattleReceiver = engine::NetworkBattleReceiver;
using NetworkBattleDefine = engine::NetworkBattleDefine;
using BattleManagerBase = engine::BattleManagerBase;
using BattlePlayerBase = engine::BattlePlayerBase;
using BattleCardBase = engine::BattleCardBase;
using UnitBattleCard = engine::UnitBattleCard;
using ClassBattleCardBase = engine::ClassBattleCardBase;
using CardCreatorBase = engine::CardCreatorBase;
using CostAddModifier = engine::CostAddModifier;
using SBattleLoad = engine::SBattleLoad;
using CardTemplate = engine::CardTemplate;
using GameObject = engine::UnityEngine.GameObject;
using RealTimeNetworkAgent = engine::RealTimeNetworkAgent;
using Gungnir = engine::Gungnir;
using NetworkNullLogger = engine::NetworkNullLogger;
using ToolboxGame = engine::Wizard.ToolboxGame;
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;
using BattleLogManager = engine::Wizard.Battle.UI.BattleLogManager;
namespace SVSim.BattleNode.Sessions.Engine;
/// <summary>One authoritative engine per BattleSession, seated as both players (design ND2). A faithful
/// SHADOW: it mirrors each client's resolved play, never overrides/rejects/originates (ND1). Ingest is
/// the engine's own NetworkBattleReceiver.ReceivedMessage (ND4); isPlayer selects the seat (F-N-2).
///
/// The headless wiring here is the production analogue of the test HeadlessFixture
/// (NewNetworkEmitBattle / SeedDeck / InitLeaderLife / InitCardTemplates). It deliberately omits the
/// emit-only RealTimeNetworkAgent scaffolding the test uses for the SEND path — the shadow engine only
/// RECEIVES (F-N-2), so no socket-agent is constructed. The engine's global init (CardMaster, GameMgr,
/// Wizard.Data) is the caller's responsibility (the test does HeadlessEngineEnv.EnsureInitialized;
/// the live node guards Setup in try/catch so an un-initialized host degrades to a no-op shadow).</summary>
internal sealed class SessionBattleEngine
{
private const int DefaultLeaderLife = 20;
private HeadlessNetworkBattleMgr? _mgr;
private NetworkBattleReceiver? _receiver;
/// <summary>True once Setup has built the two-seat battle.</summary>
public bool IsReady => _mgr is not null;
/// <summary>Construct the two-seat network battle from both decks + the master seed (design F-N-5).
/// <paramref name="seatADeck"/>/<paramref name="seatBDeck"/> are the per-side deck orders the node
/// already computed (BattleSessionState.GetShuffledDeck) and handed each client.
/// <paramref name="seatAClass"/>/<paramref name="seatBClass"/> are each seat's class ordinal (1..8,
/// the <c>CardClass</c> int value); they select the leader's class via the all-8-class
/// ClassCharacterList EngineGlobalInit installs (chara_id == class_id for 1..8). The 3-arg overload
/// behavior is preserved by the defaults (1/2), matching the test-harness charaIds.
/// <para>NOTE: GameMgr (the leader chara ids set below) is a PROCESS GLOBAL. Setting per-session
/// chara ids is therefore only safe while exactly one engine-backed battle exists at a time — the
/// invariant <see cref="EngineSessionGate"/> enforces on the caller side.</para></summary>
public void Setup(int masterSeed,
IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck,
int seatAClass = 1, int seatBClass = 2)
{
// Prime the engine's process-global statics (CardMaster, Wizard.Data, all-8-class Master,
// GameMgr/netUser/udid). Idempotent (process-once); makes the LIVE host ready so Setup succeeds
// here rather than throwing into the shadow's no-op path (Phase 2 N2, carried-risk A).
EngineGlobalInit.EnsureInitialized();
// rng defaults to SeededRandomSource(masterSeed) inside the mgr — the stream is born aligned
// with the seed the node handed both clients (F-N-5; O-N-2 "bit-aligned anyway").
var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed));
// Recovery mode is the engine's OWN headless replay path: the live view/UI touches on the
// receive cycle (BattleUIContainer.DisableMenu, turn-control UI, card-view creation, VFX
// waits) are all gated `!IsRecovery` (BattleUIContainer.cs:130, BattleManagerBase.cs:1499+),
// so this collapses them to no-ops without changing authoritative state. Set AFTER construction
// so the ctor still wired the LIVE NetworkBattleReceiver (ND4) rather than the replay receiver.
// Safe for shadow: the only thing !IsRecovery additionally enables is EMIT, which a pure shadow
// never does (it never originates a send).
mgr.IsRecovery = true;
// Seat each player as the other's opponent (private field on BattlePlayerBase, as the real
// match-load does). Mirrors HeadlessFixture.NewNetworkEmitBattle.
BattlePlayerBase player = mgr.GetBattlePlayer(isPlayer: true);
BattlePlayerBase enemy = mgr.GetBattlePlayer(isPlayer: false);
SetField(player, "_opponentBattlePlayer", enemy);
SetField(enemy, "_opponentBattlePlayer", player);
player.IsSelfTurn = true;
enemy.IsSelfTurn = false;
// Seat the evolve points + evolve-wait-turn counters exactly as the real match-load's
// SetupInitialGameState -> SetupEvolCount does (BattleManagerBase.cs:1115/1132). The headless
// Setup builds the seats by hand and never runs SetupInitialGameState, so without this both seats'
// CurrentEpCount/EvolveWaitTurnCount stay at their field defaults (0/0) and CanEvolution always
// fails (CurrentEpCount - GetEp() < 0). doesPlayerGoFirst == false here: seat A (BattlePlayer) is
// the SECOND player (IsFirst defaults false; seat A's turn-1 draws 2), so it gets SECOND_PLAYER_EP
// (3) + EvolveWaitTurnCount 4, and seat B (BattleEnemy, first) gets FIRST_PLAYER_EP (2) +
// EvolveWaitTurnCount 5. TurnEvolveControl (run on each TurnStart receive) counts the wait down.
mgr.SetupEvolCount(doesPlayerGoFirst: false);
InitLeaderLife(mgr); // a 0-life leader reads as game-over and silently blocks plays
InitCardTemplates(mgr); // play/draw resolution touches the (no-op) card view layer
InitHeadlessViews(mgr); // turn/play cycle dereferences UI-container + emotion refs
SeedBattleLogManager(); // per-frame filter cleanup reads BattleLogManager fusion lists
InstallHeadlessNetworkAgent(); // turn-flow resolve reads ToolboxGame.RealTimeNetworkAgent
// Per-session leader class: chara_id == class_id for 1..8 in the all-8-class ClassCharacterList,
// so writing the seats' class ordinals into GameMgr's DataMgr resolves each leader's correct
// class. Process-global — safe only under EngineSessionGate (see method remarks above).
SetGameMgrCharaIds(seatAClass, seatBClass);
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
// receiver the engine's RecoveryDataHandler drives when replaying recorded frames.
_receiver = mgr.GetNetworkBattleReceiver();
}
/// <summary>Ingest one client frame into the engine for the given seat. <paramref name="isPlayerSeat"/>
/// maps the sender to the engine's player(true)/opponent(false) seat (F-N-2). A throw/reject is
/// returned as a detected-desync EVENT (ND6), never silently absorbed.</summary>
public EngineIngestResult Receive(MsgEnvelope env, bool isPlayerSeat)
{
if (_mgr is null || _receiver is null)
throw new InvalidOperationException("Receive before Setup.");
var dict = ToEngineDict((env.Body as RawBody)?.Entries);
var uri = MapUri(env.Uri);
try
{
// Mirror the engine's own recorded-frame replay (RecoveryDataHandler.cs:283): every
// ingested action resolves through the isHaveSequence ConductReceiveData path, and
// checkBreakData:false so a partial/handshake frame is not rejected as a break.
bool accepted = _receiver.ReceivedMessage(
uri, isHaveSequence: true, dict, isPlayerSeat, handler: null, checkBreakData: false);
return accepted ? EngineIngestResult.Ok() : EngineIngestResult.Reject($"receiver rejected {env.Uri}");
}
catch (Exception ex)
{
// 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}");
}
}
// --- live board-state reads (N1 oracle surface; design F-N-4 board-state reads) ----------------
// Each returns LIVE engine state off the seated player, mirroring the Phase-1 oracle reads
// (VanillaFollowerOracleTests: player.Pp, player.HandCardList.Count, ClassAndInPlayCardList,
// leader == the Class card). seat:true == player, false == opponent (F-N-2).
public int LeaderLife(bool playerSeat) => Seat(playerSeat).Class.Life;
public int Pp(bool playerSeat) => Seat(playerSeat).Pp;
public int HandCount(bool playerSeat) => Seat(playerSeat).HandCardList.Count;
public int DeckCount(bool playerSeat) => Seat(playerSeat).DeckCardList.Count;
public int Turn(bool playerSeat) => Seat(playerSeat).Turn;
/// <summary>Followers in play, excluding the leader (the Class card occupies one slot of
/// 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;
/// <summary>The real <c>CardId</c> (wire identity) of the hand card at <paramref name="handPos"/>. Lets a
/// test locate a specific card in a SHUFFLED opening hand by identity (then read its <see cref="HandCardIndex"/>
/// to drive a play), without depending on which shuffled position the card landed at.</summary>
public int HandCardId(bool playerSeat, int handPos) => Seat(playerSeat).HandCardList[handPos].CardId;
/// <summary>The real <c>CardId</c> (wire identity) of the in-play follower at <paramref name="boardPos"/>
/// (0-based, skipping the leader/Class card at ClassAndInPlayCardList[0] — same convention as
/// <see cref="BoardCount"/>). Used to assert an opponent reveal seated the substituted card with its
/// true identity (M-HC-2): before the reveal the slot holds a hidden dummy (cardId 0); after, the
/// engine-resolved actual card carries the wire cardId.</summary>
public int InPlayCardId(bool playerSeat, int boardPos) =>
Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].CardId;
/// <summary>The engine <c>Index</c> of the in-play follower at <paramref name="boardPos"/> (0-based,
/// leader excluded — same convention as <see cref="BoardCount"/>/<see cref="InPlayCardId"/>). An ATTACK
/// frame addresses the attacker by this in-play Index (the wire <c>playIdx</c>), so a test reads it after
/// a follower resolves onto the board to build the attack (M-HC-4a).</summary>
public int InPlayCardIndex(bool playerSeat, int boardPos) =>
Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Index;
/// <summary>The current life/health of the in-play follower at <paramref name="boardPos"/> (0-based,
/// leader excluded). Reads <see cref="BattleCardBase.Life"/> (skill-resolved current health). Lets an
/// attack test assert a follower took the attacker's damage (M-HC-4a follower-vs-follower trade).</summary>
public int InPlayCardLife(bool playerSeat, int boardPos) =>
Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Life;
/// <summary>The attack stat of the in-play follower at <paramref name="boardPos"/> (skill-resolved
/// <see cref="BattleCardBase.Atk"/>). The damage it deals when it attacks.</summary>
public int InPlayCardAtk(bool playerSeat, int boardPos) =>
Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Atk;
/// <summary>True when the in-play follower at <paramref name="boardPos"/> can still attack this turn
/// (<see cref="BattleCardBase.Attackable"/>). After it attacks (consuming its single attack) this reads
/// false — the "attacker is spent" assertion (M-HC-4a).</summary>
public bool InPlayCardAttackable(bool playerSeat, int boardPos) =>
Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].Attackable;
/// <summary>True once the in-play follower at <paramref name="boardPos"/> (0-based, leader excluded)
/// has evolved (<see cref="UnitBattleCard.IsEvolution"/>, set true inside the engine's own
/// <c>UnitBattleCard.Evolution</c> mutation). Only <see cref="UnitBattleCard"/> followers carry the
/// flag; a non-follower (or the leader) reads false. The evolve test's decisive engine-state assertion
/// (M-HC-4b).</summary>
public bool IsEvolved(bool playerSeat, int boardPos) =>
(Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1] as UnitBattleCard)?.IsEvolution ?? false;
/// <summary>The seat's current evolve-point count (<see cref="BattlePlayerBase.CurrentEpCount"/>). An
/// evolve spends one EP, so the evolve test asserts this decrements by 1. EP is granted at setup by
/// the engine's <c>SetupEvolCount</c> (2 for the game-first seat, 3 for the second) and unlocks once
/// <c>EvolveWaitTurnCount</c> has counted down (M-HC-4b).</summary>
public int Ep(bool playerSeat) => Seat(playerSeat).CurrentEpCount;
/// <summary>Turns remaining until <paramref name="playerSeat"/> may evolve
/// (<see cref="BattlePlayerBase.EvolveWaitTurnCount"/>); 0 means evolve is unlocked. Lets a test ramp to
/// the evolve-enabled turn deterministically (M-HC-4b).</summary>
public int EvolveWaitTurnCount(bool playerSeat) => Seat(playerSeat).EvolveWaitTurnCount;
/// <summary>The engine-RESOLVED play-time cost of the card whose engine <c>Index</c> == <paramref name="idx"/>
/// on <paramref name="playerSeat"/> (M-HC-3a). This is the discounted cost the play actually paid —
/// spellboost reduction, board-dependent modifiers and all — read straight off the engine, so the
/// opponent-facing knownList carries the SAME cost the engine charged (closing the spellboost
/// cost-desync BY CONSTRUCTION: no bookkeeping, the engine already knows).
/// <para>READ-MOMENT: the conductor's <c>ShadowIngest</c> runs <c>engine.Receive</c> (→ resolves the
/// play) BEFORE the handler runs, so at read time the played card has LEFT the hand — a follower sits
/// in <c>ClassAndInPlayCardList</c>, a spell in <c>CemeteryList</c>. <see cref="BattleCardBase.PlayCard"/>
/// captures <c>_playedCost = useCost</c> (== the fully-resolved <c>Cost</c> at the moment of play,
/// incl. every CostModifier) onto the card object, which persists after the card leaves the hand —
/// so <see cref="BattleCardBase.PlayedCost"/> is the authoritative play-time discounted cost. We search
/// the seat's post-resolution zones (in-play, cemetery) by <c>Index</c>, then fall back to the hand
/// (a not-yet-resolved card, e.g. a degenerate test path) reading the live <c>Cost</c> there.</para>
/// <para>Degrades to <paramref name="fallback"/> when the engine is not set up (the single-active-engine
/// gate left this session without an owned engine) or the idx resolves to no card — so a non-engine
/// session never crashes and a vanilla play simply emits its base cost via the caller's fallback.</para></summary>
public int PlayedCardCost(bool playerSeat, int idx, int fallback = 0)
{
if (_mgr is null) return fallback;
var card = FindByIndex(Seat(playerSeat), idx);
if (card is null) return fallback;
// PlayedCost is set (>= 0) once PlayCard resolved the play; before that (a card still in hand on a
// degenerate path) read the live Cost, which already folds in any registered CostModifier.
return card.PlayedCost >= 0 ? card.PlayedCost : card.Cost;
}
/// <summary>The engine-RESOLVED spellboost (spell-charge) COUNT of the card whose engine <c>Index</c> ==
/// <paramref name="idx"/> on <paramref name="playerSeat"/> (M-HC-3b). The engine accumulates this count
/// for real on the receive path (each spell play that targets the card runs the card's own
/// <c>Skill_spell_charge.AddSpellChargeCount</c>), so this is the same authoritative count prod sends —
/// emitted on the opponent-facing knownList so the wire stays prod-faithful now that the wire-derived
/// spellboost bookkeeping is retired (cost itself is engine-sourced via <see cref="PlayedCardCost"/>).
/// <para>READ-MOMENT (persist-post-play): <see cref="BattleCardBase.SpellChargeCount"/> is set to 0 only
/// in the ctor (re-init, BattleCardBase.cs:2042) and in <c>ReturnCard</c> (bounce-to-hand,
/// BattleCardBase.cs:2681); <see cref="BattleCardBase.PlayCard"/> never touches it. So the count PERSISTS
/// on the played card object after it leaves the hand (follower in-play, spell in cemetery) — the same
/// persist-after-play property <see cref="BattleCardBase.PlayedCost"/> has. We therefore use the SAME
/// post-resolution zone search (<see cref="FindByIndex"/>: in-play → cemetery → hand) and read
/// <c>SpellChargeCount</c> directly — no separate receive-capture is needed.</para>
/// <para>Degrades to <paramref name="fallback"/> when the engine is not set up or the idx resolves to no
/// card — so a non-engine session never crashes and a vanilla play emits 0 via the caller's fallback.</para></summary>
public int PlayedCardSpellboost(bool playerSeat, int idx, int fallback = 0)
{
if (_mgr is null) return fallback;
var card = FindByIndex(Seat(playerSeat), idx);
return card?.SpellChargeCount ?? fallback;
}
// Locate the card with the given engine Index across the seat's post-resolution zones. Order matters
// only for disambiguation; Index is unique per card so the first hit is the card. In-play (followers)
// and cemetery (spells) are where a just-resolved play lands; hand is the pre-resolution fallback.
private static BattleCardBase? FindByIndex(BattlePlayerBase seat, int idx)
{
foreach (var c in seat.ClassAndInPlayCardList)
if (c.Index == idx) return c;
foreach (var c in seat.CemeteryList)
if (c.Index == idx) return c;
foreach (var c in seat.HandCardList)
if (c.Index == idx) return c;
return null;
}
/// <summary>TEST SEAM (M-HC-3a validation): register a cost-reducing modifier on the hand card at
/// engine <c>Index</c> == <paramref name="idx"/>, mimicking what card 101314020's <c>when_spell_charge</c>
/// <c>cost_change add=ADD_CHARGE_COUNT*-1</c> skill does once it has accumulated <paramref name="charge"/>
/// spellboost charges (each charge adds a <c>CostAddModifier(-1)</c>; the engine's own
/// <see cref="Skill_cost_change"/> builds exactly this). Used to drive the count→cost resolution
/// deterministically headless without pumping the (VFX-coupled) spell-charge skill chain through a
/// real multi-spell sequence — the engine's authentic <see cref="BattleCardBase.Cost"/> getter then
/// resolves the discount, and <see cref="BattleCardBase.PlayCard"/> captures it as PlayedCost on the
/// next play. Returns the resolved hand-card Cost AFTER seeding (base charge) for the caller to pin.
/// No-op-returns -1 if the engine isn't set up or no hand card has that Index.</summary>
internal int SeedHandCardSpellboostCost(bool playerSeat, int idx, int charge)
{
if (_mgr is null) return -1;
BattleCardBase? card = null;
foreach (var c in Seat(playerSeat).HandCardList)
if (c.Index == idx) { card = c; break; }
if (card is null) return -1;
for (int i = 0; i < charge; i++)
card.AddCostModifier(new CostAddModifier(-1), null, eventCall: false);
card.SetSpellChargeCount(charge); // keep the charge count consistent with the modifiers (cosmetic here)
return card.Cost;
}
private engine::BattlePlayerBase Seat(bool playerSeat) =>
(_mgr ?? throw new InvalidOperationException("read before Setup")).GetBattlePlayer(playerSeat);
private static NetworkBattleDefine.NetworkBattleURI MapUri(NetworkBattleUri uri)
=> Enum.Parse<NetworkBattleDefine.NetworkBattleURI>(uri.ToString());
// The receiver reads keys via Enum.IsDefined over NetworkParameter and casts nested values to
// List<object> / Dictionary<string,object>; the node decodes nested data as the nullable
// List<object?> / Dictionary<string,object?>. Rebox to the non-nullable shape, dropping nulls
// (the receiver presence-checks keys, so an absent key is the correct encoding of a null).
private static Dictionary<string, object> ToEngineDict(Dictionary<string, object?>? entries)
{
var result = new Dictionary<string, object>();
if (entries is null) return result;
foreach (var (k, v) in entries)
if (v is not null) result[k] = Rebox(v);
return result;
}
private static object Rebox(object v) => v switch
{
Dictionary<string, object?> d => d.Where(kv => kv.Value is not null)
.ToDictionary(kv => kv.Key, kv => Rebox(kv.Value!)),
List<object?> l => l.Where(x => x is not null).Select(x => Rebox(x!)).ToList(),
_ => v,
};
// --- headless wiring (production analogue of HeadlessFixture) -----------------------------------
private static void InitLeaderLife(BattleManagerBase mgr, int life = DefaultLeaderLife)
{
((ClassBattleCardBase)mgr.GetBattlePlayer(true).Class).InitBaseMaxLife(life);
((ClassBattleCardBase)mgr.GetBattlePlayer(false).Class).InitBaseMaxLife(life);
}
private static void InitCardTemplates(BattleManagerBase mgr)
{
mgr.SBattleLoad = new SBattleLoad
{
UnitCardTemplate = new CardTemplate(),
SpellCardTemplate = new CardTemplate(),
FieldCardTemplate = new CardTemplate(),
};
mgr.Battle3DContainer = new GameObject();
mgr.CardHolder = new GameObject();
mgr.ECardHolder = new GameObject();
mgr.PCardPlace = new GameObject();
mgr.ChoiceCardHolder = new GameObject();
mgr.EvolveCardHolder = new GameObject();
}
// Seed the no-op UI refs the receive/turn cycle dereferences. Under IsRecovery the methods on
// these (e.g. BattleUIContainer.DisableMenu) no-op, but the receiver still CALLS them, so the
// references must be non-null. PlayerEmotion is the engine's own NullPlayerEmotion.
private static void InitHeadlessViews(BattleManagerBase mgr)
{
mgr.BattleUIContainer = (BattleUIContainer)FormatterServices.GetUninitializedObject(typeof(BattleUIContainer));
// Revealed-card creation (ReplaceReceivedCard.CreateActualCard -> CreateBaseCardGameObject)
// clones the card prefab under _backGround.m_Battle3DContainer — a field distinct from
// mgr.Battle3DContainer. Seed a no-op BackGround with a non-null container.
var bg = (BackGroundBase)FormatterServices.GetUninitializedObject(typeof(BackGroundBase));
SetProperty(bg, "m_Battle3DContainer", new GameObject());
SetField(mgr, "_backGround", bg);
// PlayerEmotion is declared on BattlePlayer (the player seat); BattleEnemy has none — set
// 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>(); // Shim GameObject.GetComponent<T>() lazily materialises a no-op component — not a real Unity scene; this is intentional and will not NRE.
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>
private static void SeedDeck(BattleManagerBase mgr, IReadOnlyList<long> deck, bool isPlayer)
{
BattlePlayerBase owner = mgr.GetBattlePlayer(isPlayer);
for (int i = 0; i < deck.Count; i++)
{
var card = CreateHeadlessCard(mgr, (int)deck[i], index: i + 1, isPlayer);
owner.AddToDeck(card);
}
}
private static readonly MethodInfo CreateCardWithoutResources =
typeof(CardCreatorBase).GetMethod("CreateCardWithoutResources",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("CardCreatorBase.CreateCardWithoutResources not found");
private static BattleCardBase CreateHeadlessCard(BattleManagerBase mgr, int cardId, int index, bool isPlayer)
{
var io = mgr.CreatePlayerInnerOptionsBuilder();
var card = (BattleCardBase)CreateCardWithoutResources.Invoke(
null, new object[] { cardId, index, isPlayer, mgr, io })!;
mgr.GetBattlePlayer(isPlayer).SetupCardEvent(card);
return card;
}
// The per-frame skill-filter cleanup (BattleManagerBase.RemoveUnUseCalledFilterDictionary, run on
// EVERY receive) reads BattleLogManager.GetInstance().EnemyFusionCard.Contains(...) when a card with a
// registered CalledCreateFilter is alive — e.g. a follower with a when_play spell_charge/fanfare skill
// (BattleManagerBase.cs:155). The shim BattleLogManager singleton leaves PlayerFusionCard/EnemyFusionCard
// null (no UI ran SetUp), so that .Contains NREs. Seed both to empty lists — a pure no-op view-state
// seed (the fusion log is cosmetic; nothing headless adds to it). Process-global like the other seeds.
private static void SeedBattleLogManager()
{
var log = BattleLogManager.GetInstance();
log.PlayerFusionCard ??= new List<BattleCardBase>();
log.EnemyFusionCard ??= new List<BattleCardBase>();
}
// The turn-flow + emit bookkeeping reads the global ToolboxGame.RealTimeNetworkAgent (e.g.
// RealTimeNetworkAgent.GetIsFirstPlayer/GetTurnState, which delegate to GameMgr's
// NetworkUserInfoData.TurnState; AddActionSequence touches _gungnir). Headless there is no socket
// agent, so seed a no-op one — mirroring HeadlessFixture.NewNetworkEmitBattle. _notEmit short-
// circuits the byte-push before any socket I/O; the shadow engine never originates a send anyway.
// NOTE: this is a process-global; one engine per process is assumed for the shadow (revisit for
// live multi-session — see design O-N status). Idempotent enough for the per-battle setup.
private static void InstallHeadlessNetworkAgent()
{
var agent = (RealTimeNetworkAgent)FormatterServices.GetUninitializedObject(typeof(RealTimeNetworkAgent));
agent.SetCurrentMatchingStatus(RealTimeNetworkAgent.MatchingStatus.Prepared);
SetField(agent, "_gungnir", FormatterServices.GetUninitializedObject(typeof(Gungnir)));
SetProperty(agent, "NetworkLogger", new NetworkNullLogger());
SetField(agent, "_notEmit", true);
ToolboxGame.SetRealTimeNetworkBattle(agent);
}
// Write the two seats' class ordinals into GameMgr's DataMgr leader chara ids. Mirrors the test
// seam HeadlessFixture.cs:202-204 (SetField(dm, "_playerCharaId"/"_enemyCharaId", ...)). chara_id ==
// class_id for 1..8 in EngineGlobalInit's all-8-class ClassCharacterList, so the ordinal selects the
// class. A non-positive ordinal (e.g. CardClass.None == 0) clamps to the default seat (1/2).
// GameMgr is a process global → safe only under EngineSessionGate (one engine-backed battle at a
// time).
private static void SetGameMgrCharaIds(int a, int b)
{
var dm = GameMgr.GetIns().GetDataMgr();
SetField(dm, "_playerCharaId", a <= 0 ? 1 : a);
SetField(dm, "_enemyCharaId", b <= 0 ? 2 : b);
}
private static void SetField(object obj, string name, object value)
{
var f = obj.GetType().GetField(name,
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
?? throw new InvalidOperationException($"{obj.GetType().Name} has no field '{name}'");
f.SetValue(obj, value);
}
private static void SetProperty(object obj, string name, object value)
{
var t = obj.GetType();
PropertyInfo? p = null;
while (t is not null && p is null)
{
p = t.GetProperty(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
t = t.BaseType;
}
(p ?? throw new InvalidOperationException($"{obj.GetType().Name} has no property '{name}'"))
.SetValue(obj, value);
}
private static void TrySetProperty(object obj, string name, object value)
{
var t = obj.GetType();
while (t is not null)
{
var p = t.GetProperty(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (p is not null) { p.SetValue(obj, value); return; }
t = t.BaseType;
}
}
}