diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index a7ef774..7016a01 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -1,6 +1,7 @@ using System.Net.WebSockets; using System.Text; using Microsoft.Extensions.Logging; +using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Reliability; using SVSim.BattleNode.Wire; @@ -82,11 +83,162 @@ public sealed class BattleSession } } - private void DispatchSocketIo(SocketIoFrame frame) + private async void DispatchSocketIo(SocketIoFrame frame) { - // v1 task 11 stub: log and drop. Task 13 wires the lifecycle dispatch in. - _log.LogDebug("BattleSession {Bid}: received SIO {Type} event={Event}", - BattleId, frame.Type, frame.EventName); + if (frame.Type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent) + { + switch (frame.EventName) + { + case "msg" when frame.BinaryAttachments.Count == 1: + await HandleMsgEventAsync(frame); + return; + case "alive" when frame.BinaryAttachments.Count == 1: + await HandleAliveEventAsync(frame); + return; + } + } + // hand / unknown events: log and drop. + _log.LogDebug("BattleSession {Bid}: dropping SIO event={Event}", BattleId, frame.EventName); + } + + private async Task HandleMsgEventAsync(SocketIoFrame frame) + { + MsgEnvelope env; + try { env = MsgPayloadCodec.Decode(frame.BinaryAttachments[0]); } + catch (Exception ex) + { + _log.LogWarning(ex, "BattleSession {Bid}: failed to decode msg envelope", BattleId); + return; + } + + // Ack tracking + dedupe. + bool shouldDispatch = true; + if (env.PubSeq.HasValue) + { + shouldDispatch = Inbound.Observe(env.PubSeq.Value); + if (frame.AckId.HasValue) + { + await SendSioAckAsync(frame.AckId.Value, (int)env.PubSeq.Value); + } + } + if (!shouldDispatch) return; + + // Run the pure-logic decision and drive sends. + var responses = ComputeResponses(env); + foreach (var (responseEnv, noStock) in responses) + { + if (noStock) + await PushNoStockAsync(responseEnv); + else + await PushOrderedAsync(responseEnv); + } + } + + private async Task HandleAliveEventAsync(SocketIoFrame frame) + { + // Client emits Gungnir every 5s with an SIO ack callback expecting just liveness confirmation + // (payload ignored). We ack immediately, then push our own alive back with scs/ocs ONLINE + // placeholders — the only response the client uses to drive its scs/ocs state machine. + if (frame.AckId.HasValue) + { + await SendSioAckAsync(frame.AckId.Value, 0); + } + var aliveEnv = new MsgEnvelope( + Uri: NetworkBattleUri.Gungnir, + ViewerId: ScriptedLifecycle.FakeOpponentViewerId, + Uuid: "node-stub", + Bid: null, + Try: 0, + Cat: EmitCategory.General, + PubSeq: null, + PlaySeq: null, + Body: Gungnir.BuildAlivePushBody()); + await PushNoStockAsync(aliveEnv, eventName: "alive"); + } + + internal IReadOnlyList<(MsgEnvelope Envelope, bool NoStock)> ComputeResponses(MsgEnvelope env) + { + var result = new List<(MsgEnvelope Envelope, bool NoStock)>(); + switch (env.Uri) + { + case NetworkBattleUri.InitNetwork: + result.Add((BuildAckedEnvelope(NetworkBattleUri.InitNetwork), NoStock: true)); + result.Add((ScriptedLifecycle.BuildMatched(ViewerId, ScriptedLifecycle.FakeOpponentViewerId, BattleId), NoStock: false)); + result.Add((ScriptedLifecycle.BuildBattleStart(ViewerId), NoStock: false)); + Phase = BattleSessionPhase.AwaitingLoaded; + break; + case NetworkBattleUri.Loaded: + result.Add((ScriptedLifecycle.BuildDeal(), NoStock: false)); + Phase = BattleSessionPhase.AwaitingSwap; + break; + case NetworkBattleUri.Swap: + result.Add((ScriptedLifecycle.BuildSwapResponse(ExtractIdxList(env)), NoStock: false)); + result.Add((ScriptedLifecycle.BuildReady(), NoStock: false)); + Phase = BattleSessionPhase.AfterReady; + break; + case NetworkBattleUri.Retire: + case NetworkBattleUri.Kill: + result.Add((BuildBattleFinishNoContest(), NoStock: true)); + Phase = BattleSessionPhase.Terminal; + break; + } + return result; + } + + private MsgEnvelope BuildAckedEnvelope(NetworkBattleUri uri) => new( + uri, + ViewerId: ScriptedLifecycle.FakeOpponentViewerId, + Uuid: "node-stub", + Bid: null, + Try: 0, + Cat: EmitCategory.General, + PubSeq: null, + PlaySeq: null, + Body: new Dictionary { ["resultCode"] = 1 }); + + private MsgEnvelope BuildBattleFinishNoContest() => new( + NetworkBattleUri.BattleFinish, + ViewerId: ScriptedLifecycle.FakeOpponentViewerId, + Uuid: "node-stub", + Bid: null, + Try: 0, + Cat: EmitCategory.Battle, + PubSeq: null, + PlaySeq: null, + Body: new Dictionary { ["result"] = 1, ["resultCode"] = 1 }); + + private static IReadOnlyList ExtractIdxList(MsgEnvelope env) + { + if (env.Body.TryGetValue("idxList", out var raw) && raw is List lst) + return lst.OfType().ToList(); + return Array.Empty(); + } + + private Task PushOrderedAsync(MsgEnvelope env, string eventName = "synchronize") => + EncodeAndSendAsync(Outbound.AssignAndArchive(env), eventName); + + private Task PushNoStockAsync(MsgEnvelope env, string eventName = "synchronize") => + EncodeAndSendAsync(Outbound.WrapNoStock(env), eventName); + + private async Task EncodeAndSendAsync(MsgEnvelope env, string eventName) + { + var seq = 0; + var key = NodeCrypto.GenerateKey(() => (seq++ * 11) % 16); + var bytes = MsgPayloadCodec.Encode(env, key); + var sio = SocketIoFrame.BinaryEventWithAttachments(eventName, new[] { bytes }); + var (text, bins) = sio.Encode(); + var eioText = $"{(int)EngineIoPacketType.Message}{text}"; + await SendTextAsync(eioText, CancellationToken.None); + foreach (var bin in bins) + await _ws.SendAsync(bin, WebSocketMessageType.Binary, endOfMessage: true, CancellationToken.None); + } + + private async Task SendSioAckAsync(int ackId, int arg) + { + var ack = SocketIoFrame.AckResponse(ackId, arg); + var (text, _) = ack.Encode(); + var eioText = $"{(int)EngineIoPacketType.Message}{text}"; + await SendTextAsync(eioText, CancellationToken.None); } private async Task SendEioOpenAsync(CancellationToken ct) diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs new file mode 100644 index 0000000..ce49e4f --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -0,0 +1,64 @@ +// SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using SVSim.BattleNode.Protocol; +using SVSim.BattleNode.Sessions; + +namespace SVSim.UnitTests.BattleNode.Sessions; + +[TestFixture] +public class BattleSessionDispatchTests +{ + private static BattleSession NewSession() + { + // ws is unused by ComputeResponses; pass null! and rely on the test never invoking the pump. + return new BattleSession(ws: null!, battleId: "bid-1", viewerId: 1, log: NullLogger.Instance); + } + + private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) => + new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle, + PubSeq: null, PlaySeq: null, Body: new Dictionary()); + + [Test] + public void InitNetwork_PushesAckThenMatchedThenBattleStart() + { + var s = NewSession(); + var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); + Assert.That(responses.Select(r => r.Envelope.Uri), + Is.EqualTo(new[] { NetworkBattleUri.InitNetwork, NetworkBattleUri.Matched, NetworkBattleUri.BattleStart })); + Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded)); + } + + [Test] + public void Loaded_PushesDeal_TransitionsToAwaitingSwap() + { + var s = NewSession(); + s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); // advance phase + var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); + Assert.That(responses.Single().Envelope.Uri, Is.EqualTo(NetworkBattleUri.Deal)); + Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap)); + } + + [Test] + public void Swap_PushesSwapResponseThenReady_TransitionsToAfterReady() + { + var s = NewSession(); + s.ComputeResponses(NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeResponses(NewEnvelope(NetworkBattleUri.Loaded)); + var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Swap)); + Assert.That(responses.Select(r => r.Envelope.Uri), + Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready })); + Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); + } + + [Test] + public void Retire_PushesBattleFinishNoContest_TransitionsToTerminal() + { + var s = NewSession(); + var responses = s.ComputeResponses(NewEnvelope(NetworkBattleUri.Retire)); + var (env, noStock) = responses.Single(); + Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); + Assert.That(noStock, Is.True); + Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); + } +}