feat(battlenode): Setup builds two-seat network battle headless (Phase 2 N0)
Mirrors HeadlessFixture.NewNetworkEmitBattle wiring (opponent seating, leader life, card templates, deck seeding) minus the emit-only RealTimeNetworkAgent scaffolding (shadow only receives). Probe passed first run — M13 already filled the network-mgr construction gaps. No Engine/ edits; drift clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,5 +13,19 @@ namespace SVSim.BattleEngine.Tests.SessionEngine
|
||||
var engine = new SessionBattleEngine();
|
||||
Assert.That(engine.IsReady, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Setup_builds_two_seat_network_battle_headless()
|
||||
{
|
||||
HeadlessEngineEnv.EnsureInitialized();
|
||||
// Load every card id the two test decks reference so CardMaster can resolve them.
|
||||
var deckA = Enumerable.Repeat(100011010L, 40).ToList(); // vanilla 1/2 follower x40
|
||||
var deckB = Enumerable.Repeat(100011010L, 40).ToList();
|
||||
HeadlessCardMaster.Load(100011010);
|
||||
|
||||
var engine = new SessionBattleEngine();
|
||||
Assert.DoesNotThrow(() => engine.Setup(masterSeed: 12345, seatADeck: deckA, seatBDeck: deckB));
|
||||
Assert.That(engine.IsReady, Is.True);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
extern alias engine;
|
||||
using System.Reflection;
|
||||
using engine::SVSim.BattleEngine.Rng;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
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;
|
||||
|
||||
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).</summary>
|
||||
/// 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;
|
||||
|
||||
/// <summary>True once Setup has built the two-seat battle.</summary>
|
||||
@@ -19,10 +37,90 @@ internal sealed class SessionBattleEngine
|
||||
/// already computed (BattleSessionState.GetShuffledDeck) and handed each client.</summary>
|
||||
public void Setup(int masterSeed,
|
||||
IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck)
|
||||
=> throw new NotImplementedException("Filled by Task 3 (construction probe).");
|
||||
{
|
||||
// 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));
|
||||
|
||||
// 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
|
||||
|
||||
SeedDeck(mgr, seatADeck, isPlayer: true);
|
||||
SeedDeck(mgr, seatBDeck, isPlayer: false);
|
||||
|
||||
_mgr = mgr;
|
||||
}
|
||||
|
||||
/// <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).</summary>
|
||||
public EngineIngestResult Receive(MsgEnvelope env, bool isPlayerSeat)
|
||||
=> throw new NotImplementedException("Filled by Task 4 (ingest probe).");
|
||||
|
||||
// --- 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();
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user