extern alias engine;
using BattleAmbient = engine::SVSim.BattleEngine.Ambient.BattleAmbient;
using BattleAmbientContext = engine::SVSim.BattleEngine.Ambient.BattleAmbientContext;
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;
/// 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).
internal sealed class SessionBattleEngine
{
private const int DefaultLeaderLife = 20;
private readonly BattleAmbientContext _ctx = new() {
ViewerId = EngineGlobalInit.ThisViewerId,
IsForecast = true,
IsRandomDraw = true,
// Per-session BattleRecoveryInfo: the receive-conductor deal path runs under IsRecovery
// (set after mgr construction below) and reads Data.BattleRecoveryInfo.IsMulliganEnd in
// MulliganMgrBase.StartDeal — null reads NRE. Each session owns its own no-op instance with
// IsMulliganEnd=false (the default); GetUninitializedObject skips the JsonData ctor. Each
// SessionBattleEngine carries its own ambient _ctx, so per-session isolation is by construction
// (the EngineGlobalInit fallback only seeded once-per-process and silently fell over for the
// second + later session that entered a fresh ambient — diagnosed Task 7).
RecoveryInfo = (engine::Wizard.BattleRecoveryInfo)FormatterServices
.GetUninitializedObject(typeof(engine::Wizard.BattleRecoveryInfo)),
};
private HeadlessNetworkBattleMgr? _mgr;
private NetworkBattleReceiver? _receiver;
/// True once Setup has built the two-seat battle.
public bool IsReady => _mgr is not null;
/// Construct the two-seat network battle from both decks + the master seed (design F-N-5).
/// / are the per-side deck orders the node
/// already computed (BattleSessionState.GetShuffledDeck) and handed each client.
/// / are each seat's class ordinal (1..8,
/// the CardClass 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.
/// NOTE: GameMgr is now per-session via ; the leader
/// chara ids are set on the SESSION's GameMgr (resolved through the ambient by
/// EngineGlobalInit.WirePerSessionGameMgr), not on a process-wide singleton. This is the Task-7
/// payoff: concurrent sessions each own their own GameMgr + engine state, so the historical
/// single-active-engine gate (deleted EngineSessionGate) is no longer needed.
public void Setup(int masterSeed,
IReadOnlyList seatADeck, IReadOnlyList seatBDeck,
int seatAClass = 1, int seatBClass = 2)
{
using var _ambient = BattleAmbient.Enter(_ctx);
SetupInternal(masterSeed, seatADeck, seatBDeck, seatAClass, seatBClass, rng: null);
}
/// TEST/DEBUG SEAM (Phase 4 Option-A viability PROBE — NOT a production fix). Identical to
/// but installs a logging
/// RNG source that, on EVERY StableRandom/StableRandomDouble roll, records a roll entry
/// (call index, API, the seat signals readable from mgr state at roll time, and the live call stack).
/// Lets a test answer: at roll time, is the ACTING SEAT determinable from mgr state alone, or only from
/// the stack? No production path calls this.
internal IReadOnlyList DebugSetupWithRollLog(int masterSeed,
IReadOnlyList seatADeck, IReadOnlyList seatBDeck,
int seatAClass = 1, int seatBClass = 2)
{
using var _ambient = BattleAmbient.Enter(_ctx);
var log = new List();
// The logger needs the mgr to read seat signals at roll time; the mgr is built inside Setup, so the
// logger reads it lazily via a closure populated right after construction.
HeadlessNetworkBattleMgr[] mgrBox = { null! };
var rng = new RollLoggingRandomSource(new SeededRandomSource(masterSeed), log, () => mgrBox[0]);
SetupInternal(masterSeed, seatADeck, seatBDeck, seatAClass, seatBClass, rng, mgrBox);
return log;
}
private void SetupInternal(int masterSeed,
IReadOnlyList seatADeck, IReadOnlyList seatBDeck,
int seatAClass, int seatBClass,
IRandomSource? rng, HeadlessNetworkBattleMgr[]? mgrBox = null)
{
// 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 — masterSeed here is the
// engine's StableRandom seed (parameter name preserved for API compatibility; callers pass
// BattleSeeds.Stable(rootMasterSeed) so the stream is born aligned with the seed the node
// ships to both clients in Matched.seed). F-N-5; O-N-2 "bit-aligned anyway".
var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed), rng);
if (mgrBox is not null) mgrBox[0] = mgr; // publish for the test roll-logger closure (DebugSetupWithRollLog)
// 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;
// Participant A always goes first (LoadedHandler gives A TurnState.First). The engine's
// BattlePlayer = isPlayer=true = seat A, so doesPlayerGoFirst must be true. This controls:
// (1) SetupEvolCount: first player gets FIRST_PLAYER_EP (2) + wait 5,
// second player gets SECOND_PLAYER_EP (3) + wait 4
// (2) IsFirst → BattlePlayer.IsGameFirst / BattleEnemy.IsGameFirst → turn-1 draw count:
// first player draws 1, second draws 2 (BattlePlayerBase.TurnStartDrawCard)
mgr.IsFirst = true;
mgr.SetupEvolCount(doesPlayerGoFirst: true);
// The real match-load's SetupInitialGameState(areCardsRandomlyDrawn:true) sets this flag
// (BattleManagerBase.cs:1110), routing LotteryRandomDrawCard through seeded StableRandom
// instead of top-of-deck. Without it the shadow draws DeckCardList[0] every time while
// clients draw seeded-random — desynchronizing the hand and every downstream field.
BattleManagerBase.IsRandomDraw = true;
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 the SESSION's GameMgr DataMgr (resolved through the
// ambient — see Setup remarks) resolves each leader's correct class.
SetGameMgrCharaIds(seatAClass, seatBClass);
SeedDeck(mgr, seatADeck, isPlayer: true);
SeedDeck(mgr, seatBDeck, isPlayer: false);
// Publish the mgr on the per-session ambient BEFORE wiring the mulligan phase: that ctor
// chains into MulliganInfoControl.InitMulliganInfo, which reads BattleManagerBase.GetIns()
// (MulliganInfoControl.cs:259). With the fallback gone (Task 8), an unset ambient.Mgr would
// resolve to null and NRE on the very next field read. Set ambient.Mgr here so the wiring
// resolves the per-session mgr cleanly.
_mgr = mgr;
_ctx.Mgr = _mgr;
WireMulliganPhase(mgr); // wire OperateReceive.OnReceiveDeal -> StartDeal (deal seats the hand)
// 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();
}
/// Ingest one client frame into the engine for the given seat.
/// 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.
public EngineIngestResult Receive(MsgEnvelope env, bool isPlayerSeat)
{
using var _ambient = BattleAmbient.Enter(_ctx);
if (_mgr is null || _receiver is null)
throw new InvalidOperationException("Receive before Setup.");
var dict = ToEngineDict((env.Body as RawBody)?.Entries);
TranslateTargetOwners(dict, isPlayerSeat);
TranslateChoiceKeyAction(dict);
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 isSelf -> engine-vid target-owner translation (live PvP ingest fidelity) -------------
//
// THE GAP this closes: real clients send each targetList entry as {targetIdx, isSelf, selectSkillIndex}
// (verified in client-send captures, e.g. data_dumps/captures/battle_test/battle-traffic_cl1.ndjson),
// where `isSelf` is the SENDER's perspective flag (isSelf:1 = target on the sender's own seat;
// isSelf:0 = target on the OTHER seat). But the engine receive path the node drives is IsRecovery, and
// its recovery targetList parse (NetworkBattleReceiver.CreateTargetList, isWatch:true branch,
// NetworkBattleReceiver.cs:2180-2188) derives a target's owner from a `vid` stamp:
// isSelf_engine = (vid != PlayerStaticData.UserViewerID) // UserViewerID == EngineGlobalInit.ThisViewerId
// and the downstream resolver (NetworkBattleGenericTool.LookForActionDataToTargetCard:133) routes
// isSelf_engine == false -> BattlePlayer (engine seat A); isSelf_engine == true -> BattleEnemy (seat B).
// So the engine vid encodes the target's ABSOLUTE seat: seat A == ThisViewerId, seat B != it.
//
// Without a translation a real `isSelf` frame carries no `vid`, so the recovery parse leaves
// isSelf_engine=false (vid defaults 0 != ThisViewerId would even read TRUE, but with no key it's the
// default-0 TargetData) and the target mis-resolves -> a targeted attack/spell/evolution silently
// misses. We translate on the ENGINE's OWN dict copy only (ToEngineDict re-boxed a fresh dict; the
// node's relay/mining read the ORIGINAL env.Body, which KnownListBuilder/RecordTokensFrom consume as
// `isSelf` and must keep), so the node-side isSelf bookkeeping is untouched.
//
// ONLY engine-vid field on the live targeted frames: `targetList[].vid`. The recovery parse reads `vid`
// exclusively in the isWatch:true `targetList` branch (the ONLY `vid` read on the receiver,
// NetworkBattleReceiver.cs:2182); `oppoTargetList` parses `isSelf` directly (isWatch:false) but the node
// never sends it. Non-targeted frames (deal/play/turn/mulligan) carry no targetList and pass through
// unchanged.
//
// The (isPlayerSeat, isSelf) -> vid mapping (oracle: the harness's known-good SelfSeatVid/EnemySeatVid):
// target is on seat A <=> isPlayerSeat == (isSelf == 1) // sender-relative isSelf -> absolute seat
// seat A -> ThisViewerId ; seat B -> ThisViewerId + 1
private static void TranslateTargetOwners(Dictionary dict, bool isPlayerSeat)
{
if (!dict.TryGetValue(TargetListKey, out var raw) || raw is not List