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:
gamer147
2026-06-06 15:05:36 -04:00
parent eaa7b4d85c
commit 6740313446
2 changed files with 75 additions and 2 deletions

View File

@@ -2,6 +2,8 @@ extern alias engine;
using System.Reflection;
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;
@@ -28,6 +30,7 @@ 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;
@@ -58,12 +61,61 @@ internal sealed class SessionBattleEngine
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).</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)
=> 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) -----------------------------------