From 67403134468ac919ccd5ac0988524f6fd9d5e970 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 6 Jun 2026 15:05:36 -0400 Subject: [PATCH] feat(battlenode): Receive ingests a captured PlayActions headless (Phase 2 N0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../SessionEngineConstructionTests.cs | 21 +++++++ .../Sessions/Engine/SessionBattleEngine.cs | 56 ++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineConstructionTests.cs b/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineConstructionTests.cs index 60432c6..85c8c73 100644 --- a/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineConstructionTests.cs +++ b/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineConstructionTests.cs @@ -27,5 +27,26 @@ namespace SVSim.BattleEngine.Tests.SessionEngine Assert.DoesNotThrow(() => engine.Setup(masterSeed: 12345, seatADeck: deckA, seatBDeck: deckB)); 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); + } } } diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs index 4fa2430..c94b1ef 100644 --- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs +++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs @@ -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; /// True once Setup has built the two-seat battle. 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(); } /// 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). + /// 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) - => 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(uri.ToString()); + + // The receiver reads keys via Enum.IsDefined over NetworkParameter and casts nested values to + // List / Dictionary; the node decodes nested data as the nullable + // List / Dictionary. 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 ToEngineDict(Dictionary? entries) + { + var result = new Dictionary(); + 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 d => d.Where(kv => kv.Value is not null) + .ToDictionary(kv => kv.Key, kv => Rebox(kv.Value!)), + List l => l.Where(x => x is not null).Select(x => Rebox(x!)).ToList(), + _ => v, + }; // --- headless wiring (production analogue of HeadlessFixture) -----------------------------------