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