using System.Net.WebSockets; using System.Security.Cryptography; 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 run the read loop until the WS closes. /// 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. // 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; 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) { 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: "node-stub", Bid: null, Try: 0, Cat: EmitCategory.General, PubSeq: null, PlaySeq: null, Body: Gungnir.BuildAlivePushBody()); await PushNoStockAsync(aliveEnv, eventName: "alive"); } 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(ViewerId, ScriptedLifecycle.FakeOpponentViewerId, BattleId), NoStock: false)); Phase = BattleSessionPhase.AwaitingLoaded; break; case NetworkBattleUri.Loaded when Phase == BattleSessionPhase.AwaitingLoaded: result.Add((ScriptedLifecycle.BuildBattleStart(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: // Push a minimal opponent TurnStart so the client transitions to "Opponent's turn…" // display. v1 doesn't simulate the opponent — once this lands, the client sits // there indefinitely (this IS the documented v1 stopping point). result.Add((ScriptedLifecycle.BuildOpponentTurnStart(), NoStock: false)); Phase = BattleSessionPhase.OpponentTurn; 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: "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) { // 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.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 = "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 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, CancellationToken.None); 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, CancellationToken.None); } } private async Task SendSioAckAsync(int ackId, long arg) { // checked() ensures we'd notice if pubSeq ever exceeded int.MaxValue (not realistic but defensive). var ack = SocketIoFrame.AckResponse(ackId, checked((int)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); } }