From a306295fe2a99c8627818618fb3f5d628db0e36a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 22:10:17 -0400 Subject: [PATCH] feat(battle-node): BattleSession skeleton with EIO/SIO read pump --- SVSim.BattleNode/Sessions/BattleSession.cs | 119 ++++++++++++++++++ .../Sessions/BattleSessionPhase.cs | 14 +++ 2 files changed, 133 insertions(+) create mode 100644 SVSim.BattleNode/Sessions/BattleSession.cs create mode 100644 SVSim.BattleNode/Sessions/BattleSessionPhase.cs diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs new file mode 100644 index 0000000..a7ef774 --- /dev/null +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -0,0 +1,119 @@ +using System.Net.WebSockets; +using System.Text; +using Microsoft.Extensions.Logging; +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 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); + } + + 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); + } +} diff --git a/SVSim.BattleNode/Sessions/BattleSessionPhase.cs b/SVSim.BattleNode/Sessions/BattleSessionPhase.cs new file mode 100644 index 0000000..8e4abe1 --- /dev/null +++ b/SVSim.BattleNode/Sessions/BattleSessionPhase.cs @@ -0,0 +1,14 @@ +namespace SVSim.BattleNode.Sessions; + +/// +/// Where we are in the v1 scripted lifecycle. Drives which scripted frames the session pushes +/// in response to inbound emits. +/// +public enum BattleSessionPhase +{ + AwaitingInitNetwork, + AwaitingLoaded, + AwaitingSwap, + AfterReady, + Terminal, +}