603 lines
38 KiB
C#
603 lines
38 KiB
C#
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;
|
||
}
|
||
|
||
/// <summary>The engine-RESOLVED clan of the card whose engine <c>Index</c> == <paramref name="idx"/> on
|
||
/// <paramref name="playerSeat"/> (M-HC-4e), as the int <c>ClanType</c> ordinal prod sends on the
|
||
/// knownList entry (e.g. <c>clan:8</c> in the tk2 capture). Reads <see cref="BattleCardBase.Clan"/>, whose
|
||
/// getter returns the skill-applied clan (<c>SkillApplyInformation.ClanSkinInfo.Last()</c> when a skill
|
||
/// changed it, else <c>BaseParameter.Clan</c>) — so a <c>change_affiliation</c> is reflected, which is WHY
|
||
/// the engine value (not the static card-master clan) is the faithful one to emit.
|
||
/// <para>Same post-resolution zone search + degrade-to-<paramref name="fallback"/> contract as
|
||
/// <see cref="PlayedCardCost"/>: no engine / no card → fallback, so a non-engine session never crashes.</para></summary>
|
||
public int PlayedCardClan(bool playerSeat, int idx, int fallback = 0)
|
||
{
|
||
if (_mgr is null) return fallback;
|
||
var card = FindByIndex(Seat(playerSeat), idx);
|
||
return card is null ? fallback : (int)card.Clan;
|
||
}
|
||
|
||
/// <summary>The engine-RESOLVED tribe of the card whose engine <c>Index</c> == <paramref name="idx"/> on
|
||
/// <paramref name="playerSeat"/> (M-HC-4e), in the EXACT wire string form prod sends: the comma-joined
|
||
/// int <c>TribeType</c> ordinals (e.g. <c>tribe:"7,16"</c> for MACHINE+SCHOOL in the tk2 capture), and
|
||
/// <c>"0"</c> when the card has no tribe (== <c>TribeType.ALL == 0</c> — prod never sends empty/omitted;
|
||
/// the client reads it via <c>item.Value.ToString()</c>, NetworkBattleReceiver.cs:2382). Reads
|
||
/// <see cref="BattleCardBase.Tribe"/>, whose getter folds in any skill-applied tribe CHANGE/ADD over
|
||
/// <c>BaseParameter.Tribe</c> (and drops ALL when the resolved list has ≥2 entries) — so the wire carries
|
||
/// the LIVE tribe, the faithful value over the static card-master one.
|
||
/// <para>Same post-resolution zone search + degrade-to-<paramref name="fallback"/> contract as
|
||
/// <see cref="PlayedCardClan"/>: no engine / no card → <paramref name="fallback"/> (default <c>"0"</c>, the
|
||
/// prod no-tribe form — NEVER empty, which is wire-illegal: prod always sends tribe as a non-empty string,
|
||
/// the client reads it via <c>item.Value.ToString()</c> at NetworkBattleReceiver.cs:2382). The degrade is
|
||
/// LIVE, not dead: a second concurrent battle that loses the single-active-engine gate has <c>_mgr is null</c>
|
||
/// yet still emits a knownList entry (KnownListBuilder.BuildPlayedCard gates on the deck map, not engine
|
||
/// ownership), so this path must hand back a legal wire value.</para></summary>
|
||
public string PlayedCardTribe(bool playerSeat, int idx, string fallback = "0")
|
||
{
|
||
if (_mgr is null) return fallback;
|
||
var card = FindByIndex(Seat(playerSeat), idx);
|
||
if (card is null) return fallback;
|
||
var tribe = card.Tribe;
|
||
// Prod's no-tribe form is the single "0" (TribeType.ALL == 0), never an empty string; an empty list
|
||
// (defensive) renders the same "0".
|
||
return tribe is null || tribe.Count == 0
|
||
? "0"
|
||
: string.Join(",", tribe.Select(t => (int)t));
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
}
|