Adds SessionBattleEngine.PlayedCardSpellboost + PeekPlayedCardSpellboost (pre-resolve read of the acting seat's hand card by Index==playIdx) and a CaptureReplay.InterleavedSends helper. The non-circular capture oracle (engine-derived spellboost vs prod's independent emission to cl2: idx2->1, idx14->2) is added but [Ignore]'d: the headless receive path does not apply the wire's authoritative orderList (Deal/Swap don't seat the mulligan hand, draws follow the seeded deck top instead of the wire move ops, plays never remove the card, alter spellboost never accumulates), so the engine cannot yet DERIVE the count. Closing this needs an Engine/*.cs + VfxMgr-execution logic change (escalation per the N2 playbook), not a mechanical no-op fill. Read surface, node + engine builds, drift, and the rest of the SessionEngine suite are green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
336 lines
18 KiB
C#
336 lines
18 KiB
C#
extern alias engine;
|
|
using System.Reflection;
|
|
using System.Runtime.Serialization;
|
|
using engine::SVSim.BattleEngine.Rng;
|
|
using SVSim.BattleNode.Protocol;
|
|
using SVSim.BattleNode.Sessions.Dispatch;
|
|
using NetworkBattleReceiver = engine::NetworkBattleReceiver;
|
|
using NetworkBattleDefine = engine::NetworkBattleDefine;
|
|
using BattleManagerBase = engine::BattleManagerBase;
|
|
using BattlePlayerBase = engine::BattlePlayerBase;
|
|
using BattleCardBase = engine::BattleCardBase;
|
|
using ClassBattleCardBase = engine::ClassBattleCardBase;
|
|
using CardCreatorBase = engine::CardCreatorBase;
|
|
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;
|
|
|
|
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;
|
|
private int _lastPlayedSpellboost;
|
|
|
|
/// <summary>True once Setup has built the two-seat battle.</summary>
|
|
public bool IsReady => _mgr is not null;
|
|
|
|
/// <summary>The spellboost (spell-charge) COUNT of the card the most-recently-ingested PlayActions
|
|
/// frame played, read from the acting seat's hand BEFORE the frame resolved (the count is fixed as the
|
|
/// card leaves hand; a play that grants spellboost targets the REST of the hand, not the card just
|
|
/// played). 0 for a non-play frame, a token/unmapped idx, or a card not in hand. PlayActionsHandler
|
|
/// reads this right after Receive — the BattleSession _dispatchGate serializes Receive→Handle, so this
|
|
/// is unambiguously this frame's value.</summary>
|
|
public int PlayedCardSpellboost => _lastPlayedSpellboost;
|
|
|
|
/// <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;
|
|
|
|
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
|
|
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);
|
|
|
|
_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);
|
|
|
|
// Peek the played card's accumulated spellboost count BEFORE resolution: the count is fixed as
|
|
// the card leaves hand, so it must be read while the card is still in hand. 0 for any non-play.
|
|
_lastPlayedSpellboost = uri == NetworkBattleDefine.NetworkBattleURI.PlayActions
|
|
? PeekPlayedCardSpellboost(env, isPlayerSeat)
|
|
: 0;
|
|
|
|
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)
|
|
{
|
|
var site = ex.StackTrace?.Split('\n').FirstOrDefault()?.Trim();
|
|
return EngineIngestResult.Reject($"{env.Uri} threw: {ex.GetType().Name}: {ex.Message} @ {site}");
|
|
}
|
|
}
|
|
|
|
/// <summary>Read the played card's accumulated spellboost count off the acting seat's hand, matching
|
|
/// the card by Index == wire playIdx. Returns 0 when the body has no playIdx, or no hand card matches
|
|
/// (a token/unmapped idx, or a card already gone from hand). Pre-resolve read (see <see
|
|
/// cref="PlayedCardSpellboost"/>).</summary>
|
|
private int PeekPlayedCardSpellboost(MsgEnvelope env, bool isPlayerSeat)
|
|
{
|
|
if (_mgr is null) return 0;
|
|
var entries = (env.Body as RawBody)?.Entries;
|
|
if (entries is null) return 0;
|
|
int playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault(WireKeys.PlayIdx));
|
|
foreach (var card in _mgr.GetBattlePlayer(isPlayerSeat).HandCardList)
|
|
if (card.Index == playIdx) return card.SpellChargeCount;
|
|
return 0;
|
|
}
|
|
|
|
// --- 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;
|
|
|
|
/// <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);
|
|
|
|
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());
|
|
}
|
|
|
|
/// <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 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;
|
|
}
|
|
}
|
|
}
|