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