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; namespace SVSim.BattleNode.Sessions; /// /// One per connected client. Owns the WebSocket + reliability ledgers + lifecycle phase. /// public sealed class BattleSession { private readonly WebSocket _ws; private readonly ILogger _log; public string BattleId { get; } public long ViewerId { get; } public BattleSessionPhase Phase { get; internal set; } = BattleSessionPhase.AwaitingInitNetwork; public InboundTracker Inbound { get; } = new(); public OutboundSequencer Outbound { get; } = new(); public BattleSession(WebSocket ws, string battleId, long viewerId, ILogger log) { _ws = ws; _log = log; BattleId = battleId; ViewerId = viewerId; } /// /// Send the EIO3 open handshake then enter the read loop. Returns when the WS closes. /// Dispatch is a no-op in this task; Task 13 fills it in. /// public async Task RunAsync(CancellationToken cancellation) { await SendEioOpenAsync(cancellation); var buffer = new byte[8192]; var pendingAttachments = new List(); SocketIoFrame? pendingFrame = null; while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested) { var msg = await ReadCompleteMessageAsync(buffer, cancellation); if (msg is null) break; if (msg.Value.IsText) { var text = Encoding.UTF8.GetString(msg.Value.Bytes); if (text.Length == 0) continue; var eio = EngineIoFrame.Parse(text); if (eio.Type == EngineIoPacketType.Ping) { await SendTextAsync("3", cancellation); // EIO3 pong continue; } if (eio.Type != EngineIoPacketType.Message) continue; // SIO inside the message payload. var sio = SocketIoFrame.Parse(eio.Payload); if (sio.AttachmentCount > 0) { pendingFrame = sio; pendingAttachments.Clear(); continue; } DispatchSocketIo(sio); } else { // Binary frame — an attachment for a pending binary event. pendingAttachments.Add(msg.Value.Bytes); if (pendingFrame is not null && pendingAttachments.Count == pendingFrame.AttachmentCount) { var assembled = pendingFrame.WithAttachments(pendingAttachments.ToArray()); pendingFrame = null; DispatchSocketIo(assembled); } } } } private async void DispatchSocketIo(SocketIoFrame frame) { 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) { var sid = Guid.NewGuid().ToString("N").Substring(0, 16); var handshake = new EngineIoHandshake(sid, Array.Empty(), 25000, 60000).ToJson(); await SendTextAsync($"0{handshake}", ct); } private Task SendTextAsync(string text, CancellationToken ct) { var bytes = Encoding.UTF8.GetBytes(text); return _ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct); } private async Task<(byte[] Bytes, bool IsText)?> ReadCompleteMessageAsync(byte[] buffer, CancellationToken ct) { using var ms = new MemoryStream(); WebSocketReceiveResult result; do { try { result = await _ws.ReceiveAsync(buffer, ct); } catch (OperationCanceledException) { return null; } catch (WebSocketException) { return null; } if (result.MessageType == WebSocketMessageType.Close) return null; ms.Write(buffer, 0, result.Count); } while (!result.EndOfMessage); return (ms.ToArray(), result.MessageType == WebSocketMessageType.Text); } }