feat(battlenode): Receive ingests a captured PlayActions headless (Phase 2 N0)
Receive feeds the decoded frame into the mgr's own NetworkBattleReceiver (isHaveSequence:true, checkBreakData:false — mirroring the engine's RecoveryDataHandler frame replay), reboxing object?->object for nested data. No engine gaps surfaced; the only fix was a test-harness one (load all deck ids in a single HeadlessCardMaster.Load — per-id calls each replace the master). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,5 +27,26 @@ namespace SVSim.BattleEngine.Tests.SessionEngine
|
|||||||
Assert.DoesNotThrow(() => engine.Setup(masterSeed: 12345, seatADeck: deckA, seatBDeck: deckB));
|
Assert.DoesNotThrow(() => engine.Setup(masterSeed: 12345, seatADeck: deckA, seatBDeck: deckB));
|
||||||
Assert.That(engine.IsReady, Is.True);
|
Assert.That(engine.IsReady, Is.True);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Receive_one_playactions_resolves_headless()
|
||||||
|
{
|
||||||
|
HeadlessEngineEnv.EnsureInitialized();
|
||||||
|
|
||||||
|
var cl1 = CaptureReplay.Load("battle_test_cl1.ndjson");
|
||||||
|
var deck = CaptureReplay.SelfDeckFrom(cl1);
|
||||||
|
// Load ALL deck ids in ONE call: HeadlessCardMaster.Load replaces the static CardMaster each
|
||||||
|
// call, so a per-id loop would leave only the last card resolvable.
|
||||||
|
HeadlessCardMaster.Load(deck.Select(x => (int)x).Distinct().ToArray());
|
||||||
|
|
||||||
|
var engine = new SessionBattleEngine();
|
||||||
|
engine.Setup(CaptureReplay.SeedFrom(cl1), seatADeck: deck, seatBDeck: deck);
|
||||||
|
|
||||||
|
var firstPlay = cl1.First(f => f.Direction == "send" && f.Uri == "PlayActions");
|
||||||
|
var result = engine.Receive(firstPlay.Env, isPlayerSeat: true);
|
||||||
|
|
||||||
|
Assert.That(result.RejectReason, Is.Null, $"ingest threw/rejected: {result.RejectReason}");
|
||||||
|
Assert.That(result.Accepted, Is.True);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ extern alias engine;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using engine::SVSim.BattleEngine.Rng;
|
using engine::SVSim.BattleEngine.Rng;
|
||||||
using SVSim.BattleNode.Protocol;
|
using SVSim.BattleNode.Protocol;
|
||||||
|
using NetworkBattleReceiver = engine::NetworkBattleReceiver;
|
||||||
|
using NetworkBattleDefine = engine::NetworkBattleDefine;
|
||||||
using BattleManagerBase = engine::BattleManagerBase;
|
using BattleManagerBase = engine::BattleManagerBase;
|
||||||
using BattlePlayerBase = engine::BattlePlayerBase;
|
using BattlePlayerBase = engine::BattlePlayerBase;
|
||||||
using BattleCardBase = engine::BattleCardBase;
|
using BattleCardBase = engine::BattleCardBase;
|
||||||
@@ -28,6 +30,7 @@ internal sealed class SessionBattleEngine
|
|||||||
private const int DefaultLeaderLife = 20;
|
private const int DefaultLeaderLife = 20;
|
||||||
|
|
||||||
private HeadlessNetworkBattleMgr? _mgr;
|
private HeadlessNetworkBattleMgr? _mgr;
|
||||||
|
private NetworkBattleReceiver? _receiver;
|
||||||
|
|
||||||
/// <summary>True once Setup has built the two-seat battle.</summary>
|
/// <summary>True once Setup has built the two-seat battle.</summary>
|
||||||
public bool IsReady => _mgr is not null;
|
public bool IsReady => _mgr is not null;
|
||||||
@@ -58,12 +61,61 @@ internal sealed class SessionBattleEngine
|
|||||||
SeedDeck(mgr, seatBDeck, isPlayer: false);
|
SeedDeck(mgr, seatBDeck, isPlayer: false);
|
||||||
|
|
||||||
_mgr = mgr;
|
_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"/>
|
/// <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>
|
/// 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)
|
public EngineIngestResult Receive(MsgEnvelope env, bool isPlayerSeat)
|
||||||
=> throw new NotImplementedException("Filled by Task 4 (ingest probe).");
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return EngineIngestResult.Reject($"{env.Uri} threw: {ex.GetType().Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) -----------------------------------
|
// --- headless wiring (production analogue of HeadlessFixture) -----------------------------------
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user