using System.Net.WebSockets; using System.Security.Cryptography; using System.Text; using Microsoft.Extensions.Logging; using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol.Bodies; 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; /// /// Cancellation token for the session lifetime, captured from 's /// parameter. Read by all send helpers so host shutdown promptly terminates pending /// writes (was CancellationToken.None on every send pre-this-slice). /// /// /// Defaults to when hasn't /// run — e.g. unit tests that drive directly without /// going through the WS pump. None of those tests exercise send code, so the default /// is safe. /// private CancellationToken _sessionCt; public string BattleId { get; } public long ViewerId { get; } public BattleSessionPhase Phase { get; private set; } = BattleSessionPhase.AwaitingInitNetwork; public InboundTracker Inbound { get; } = new(); public OutboundSequencer Outbound { get; } = new(); /// /// Player-side snapshot captured at do_matching time. ScriptedLifecycle reads the player /// half of Matched/BattleStart frames from here; opponent half stays in ScriptedProfiles. /// internal MatchContext Context { get; } public BattleSession(WebSocket ws, string battleId, long viewerId, MatchContext context, ILogger log) { _ws = ws; _log = log; BattleId = battleId; ViewerId = viewerId; Context = context; } /// /// Send the EIO3 open handshake then run the read loop until the WS closes. /// public async Task RunAsync(CancellationToken cancellation) { _sessionCt = 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; } await DispatchSocketIo(sio); } else { // Binary frame — an attachment for a pending binary event. // Engine.IO v3 prefixes binary WS frames with the packet-type byte // (0x04 = Message), analogous to the leading digit on text frames. // Strip it before treating the rest as the Socket.IO attachment payload. var bin = msg.Value.Bytes; if (bin.Length > 0 && bin[0] == (byte)EngineIoPacketType.Message) { bin = bin.AsSpan(1).ToArray(); } pendingAttachments.Add(bin); if (pendingFrame is not null && pendingAttachments.Count == pendingFrame.AttachmentCount) { var assembled = pendingFrame.WithAttachments(pendingAttachments.ToArray()); pendingFrame = null; await DispatchSocketIo(assembled); } } } } private async Task DispatchSocketIo(SocketIoFrame frame) { if (frame.Type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent) { switch (frame.EventName) { case WireConstants.MsgEvent when frame.BinaryAttachments.Count == 1: await HandleMsgEventAsync(frame); return; case WireConstants.AliveEvent 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) { try { 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, 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); } } catch (Exception ex) { _log.LogError(ex, "BattleSession {Bid}: unhandled exception in HandleMsgEventAsync", BattleId); } } private async Task HandleAliveEventAsync(SocketIoFrame frame) { try { // 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: WireConstants.ServerUuid, Bid: null, Try: 0, Cat: EmitCategory.General, PubSeq: null, PlaySeq: null, Body: new AlivePushBody(Scs: WireConstants.OnlineStatus, Ocs: WireConstants.OnlineStatus)); await PushNoStockAsync(aliveEnv, eventName: WireConstants.AliveEvent); } catch (Exception ex) { _log.LogError(ex, "BattleSession {Bid}: unhandled exception in HandleAliveEventAsync", BattleId); } } /// /// Pure-logic lifecycle state machine: given an inbound and the /// current , return the envelopes the session should push back AND /// transition . Extracted as an internal method so unit tests can drive /// the state machine without standing up a real WebSocket. /// /// /// Ordered list of (envelope, no-stock) tuples. NoStock = true means the push is a /// control frame (ack / BattleFinish) and bypasses 's /// playSeq assignment + Resume archive. NoStock = false means the push is part of /// the ordered stream and gets a fresh playSeq. /// internal IReadOnlyList<(MsgEnvelope Envelope, bool NoStock)> ComputeResponses(MsgEnvelope env) { var result = new List<(MsgEnvelope Envelope, bool NoStock)>(); switch (env.Uri) { // The real handshake sequence (MatchingNetworkConnectChecker + Matching.cs): // 1. WS opens. // 2. Client emits InitNetwork (cat=general). // 3. Server replies with InitNetwork ack → _initNetworkSuccess = true. // 4. MatchingInitBattle() runs: status=Connect, emits InitBattle, THEN subscribes // the OnReceivedEvent matching handler. // 5. Server replies with Matched → handler is subscribed, status=Connect → // transitions to StartLoad and StartBattleLoad() loads decks/scene. // 6. Asset load completes → client emits Loaded. // 7. Server replies with BattleStart + Deal → status=Prepared, GotoBattle(). // Pushing Matched in response to InitNetwork (instead of InitBattle) drops it // before the handler is subscribed; the state machine never advances. case NetworkBattleUri.InitNetwork when Phase == BattleSessionPhase.AwaitingInitNetwork: result.Add((BuildAckedEnvelope(NetworkBattleUri.InitNetwork), NoStock: true)); Phase = BattleSessionPhase.AwaitingInitBattle; break; case NetworkBattleUri.InitBattle when Phase == BattleSessionPhase.AwaitingInitBattle: result.Add((ScriptedLifecycle.BuildMatched(Context, ViewerId, ScriptedLifecycle.FakeOpponentViewerId, BattleId), NoStock: false)); Phase = BattleSessionPhase.AwaitingLoaded; break; case NetworkBattleUri.Loaded when Phase == BattleSessionPhase.AwaitingLoaded: result.Add((ScriptedLifecycle.BuildBattleStart(Context, ViewerId), NoStock: false)); result.Add((ScriptedLifecycle.BuildDeal(), NoStock: false)); Phase = BattleSessionPhase.AwaitingSwap; break; case NetworkBattleUri.Swap when Phase == BattleSessionPhase.AwaitingSwap: { // Compute the actual post-mulligan hand: any idx in idxList that's in the initial // 3-card hand gets replaced with a fresh deck idx. Both Swap response AND Ready // need the SAME hand — the client diffs them to compute "drawn cards" and errors // out with "Card swap failed: AbandonCards[...]/DrawCards[]" if they don't agree. var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env)); result.Add((ScriptedLifecycle.BuildSwapResponse(hand), NoStock: false)); result.Add((ScriptedLifecycle.BuildReady(hand), NoStock: false)); Phase = BattleSessionPhase.AfterReady; break; } case NetworkBattleUri.TurnEnd when Phase == BattleSessionPhase.AfterReady: case NetworkBattleUri.TurnEndFinal when Phase == BattleSessionPhase.AfterReady: // Scripted opponent: opens its turn, ends it, then pushes Judge so the // client's JudgeOperation -> ControlTurnStartPlayer fires and the player's // next turn begins. Three-frame burst — see // docs/superpowers/plans/2026-06-01-battle-node-opponent-turn-judge.md. // OpponentTurn is set then cleared within the same call to document intent; // the only externally-observable phase after the call is AfterReady, ready for // the next player TurnEnd to fire the cycle again. Phase = BattleSessionPhase.OpponentTurn; result.Add((ScriptedLifecycle.BuildOpponentTurnStart(), NoStock: false)); result.Add((ScriptedLifecycle.BuildOpponentTurnEnd(), NoStock: false)); result.Add((ScriptedLifecycle.BuildOpponentJudge(), NoStock: false)); Phase = BattleSessionPhase.AfterReady; break; case NetworkBattleUri.Retire: case NetworkBattleUri.Kill: // These always terminate, regardless of phase. result.Add((BuildBattleFinishNoContest(), NoStock: true)); Phase = BattleSessionPhase.Terminal; break; default: // Out-of-order or unknown URI: log and drop (no response). _log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase}", BattleId, env.Uri, Phase); break; } return result; } private MsgEnvelope BuildAckedEnvelope(NetworkBattleUri uri) => new( uri, ViewerId: ScriptedLifecycle.FakeOpponentViewerId, Uuid: WireConstants.ServerUuid, Bid: null, Try: 0, Cat: EmitCategory.General, PubSeq: null, PlaySeq: null, Body: new ResultCodeOnlyBody()); private MsgEnvelope BuildBattleFinishNoContest() => new( NetworkBattleUri.BattleFinish, ViewerId: ScriptedLifecycle.FakeOpponentViewerId, Uuid: WireConstants.ServerUuid, Bid: null, Try: 0, Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: new BattleFinishBody(Result: BattleResult.Win)); private static IReadOnlyList ExtractIdxList(MsgEnvelope env) { // Defensive: accept any IEnumerable carrying any numeric boxing (long/int/double/decimal/ // string). MsgEnvelope.FromJson should box small ints as long, but a parser quirk // anywhere upstream could yield a different boxed type and OfType would silently // drop the entries — that broke the v1 mulligan during smoke. if (env.Body is not RawBody rawBody) return Array.Empty(); if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string) { var result = new List(); foreach (var item in seq) { switch (item) { case long l: result.Add(l); break; case int i: result.Add(i); break; case double d: result.Add((long)d); break; case decimal m: result.Add((long)m); break; case string s when long.TryParse(s, out var p): result.Add(p); break; } } return result; } return Array.Empty(); } private Task PushOrderedAsync(MsgEnvelope env, string eventName = WireConstants.SynchronizeEvent) => EncodeAndSendAsync(Outbound.AssignAndArchive(env), eventName); private Task PushNoStockAsync(MsgEnvelope env, string eventName = WireConstants.SynchronizeEvent) => EncodeAndSendAsync(Outbound.WrapNoStock(env), eventName); private async Task EncodeAndSendAsync(MsgEnvelope env, string eventName) { var key = NodeCrypto.GenerateKey(() => RandomNumberGenerator.GetInt32(0, 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, _sessionCt); foreach (var bin in bins) { // Engine.IO v3 binary frames are prefixed with the packet-type byte // (0x04 = Message), the binary analog of the leading digit on text frames. var prefixed = new byte[bin.Length + 1]; prefixed[0] = (byte)EngineIoPacketType.Message; Buffer.BlockCopy(bin, 0, prefixed, 1, bin.Length); await _ws.SendAsync(prefixed, WebSocketMessageType.Binary, endOfMessage: true, _sessionCt); } } /// /// Clip a long ack arg into the int range Socket.IO v2's typed AckResponse API accepts. /// Logs a warning on clip; the implausibly-large pubSeq case is observationally /// indistinguishable at the client (BestHTTP.SocketIO discards the echoed value), so /// clipping is safer than the prior checked((int)arg) that threw and killed the /// session on overflow. /// internal static int ClipAckArg(long arg, ILogger log, string battleId) { if (arg > int.MaxValue) { log.LogWarning( "BattleSession {Bid}: pubSeq {Seq} exceeds int.MaxValue; clipping ack arg.", battleId, arg); return int.MaxValue; } if (arg < int.MinValue) { log.LogWarning( "BattleSession {Bid}: pubSeq {Seq} below int.MinValue; clipping ack arg.", battleId, arg); return int.MinValue; } return (int)arg; } private async Task SendSioAckAsync(int ackId, long arg) { var ack = SocketIoFrame.AckResponse(ackId, ClipAckArg(arg, _log, BattleId)); var (text, _) = ack.Encode(); var eioText = $"{(int)EngineIoPacketType.Message}{text}"; await SendTextAsync(eioText, _sessionCt); } private async Task SendEioOpenAsync(CancellationToken ct) { // SID format is server-internal; the EIO3 spec suggests a 20-char base64-url id, // but BestHTTP's SocketIO client treats it as an opaque string and never validates // shape. Hex-truncated Guid is fine in practice; revisit if we ever care about // matching prod telemetry. 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); } }