feat(battle-node): BattleSession skeleton with EIO/SIO read pump
This commit is contained in:
119
SVSim.BattleNode/Sessions/BattleSession.cs
Normal file
119
SVSim.BattleNode/Sessions/BattleSession.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// One per connected client. Owns the WebSocket + reliability ledgers + lifecycle phase.
|
||||
/// </summary>
|
||||
public sealed class BattleSession
|
||||
{
|
||||
private readonly WebSocket _ws;
|
||||
private readonly ILogger<BattleSession> _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<BattleSession> log)
|
||||
{
|
||||
_ws = ws;
|
||||
_log = log;
|
||||
BattleId = battleId;
|
||||
ViewerId = viewerId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task RunAsync(CancellationToken cancellation)
|
||||
{
|
||||
await SendEioOpenAsync(cancellation);
|
||||
|
||||
var buffer = new byte[8192];
|
||||
var pendingAttachments = new List<byte[]>();
|
||||
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<string>(), 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);
|
||||
}
|
||||
}
|
||||
14
SVSim.BattleNode/Sessions/BattleSessionPhase.cs
Normal file
14
SVSim.BattleNode/Sessions/BattleSessionPhase.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace SVSim.BattleNode.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Where we are in the v1 scripted lifecycle. Drives which scripted frames the session pushes
|
||||
/// in response to inbound emits.
|
||||
/// </summary>
|
||||
public enum BattleSessionPhase
|
||||
{
|
||||
AwaitingInitNetwork,
|
||||
AwaitingLoaded,
|
||||
AwaitingSwap,
|
||||
AfterReady,
|
||||
Terminal,
|
||||
}
|
||||
Reference in New Issue
Block a user