using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using SVSim.BattleNode.Sessions; using SVSim.BattleNode.Sessions.Participants; using SVSim.BattleNode.Wire; namespace SVSim.BattleNode.Hosting; /// /// Validates an incoming WebSocket upgrade request, accepts it, and hands off to a fresh /// . Singleton; no per-request state. /// /// /// The validation chain — cheapest checks first, crypto only after both params are /// present, WS accept only after the store lookup confirms the credentials match an outstanding /// pending battle: /// /// Reject non-WS requests with 400 (someone hit /socket.io/ via plain HTTP). /// Read BattleId and encrypted viewerId from request headers, falling back /// to query string. The real client puts them on headers despite BestHTTP's /// AdditionalQueryParams API name — see project README §Wire-format gotchas. /// Decrypt the viewerId with ; reject on /// parse/decrypt failure. /// Look up the in the store and verify the decrypted viewer /// matches the one the registered. /// AcceptWebSocketAsync, remove the pending entry (it's now an active session), construct /// , await until the WS /// closes. /// /// public sealed class BattleNodeWebSocketHandler { private readonly IBattleSessionStore _store; private readonly ILogger _log; private readonly ILoggerFactory _loggerFactory; public BattleNodeWebSocketHandler(IBattleSessionStore store, ILoggerFactory loggerFactory) { _store = store; _loggerFactory = loggerFactory; _log = loggerFactory.CreateLogger(); } /// /// Endpoint entry point. Sets to 400 on any validation /// failure; otherwise upgrades to a WebSocket and awaits /// until the connection closes. /// public async Task HandleAsync(HttpContext ctx) { // Status code mapping: 400 protocol violations (not WS, missing creds); // 401 credential validation failures (decrypt, viewer mismatch); 404 unknown // BattleId. Log messages carry the diagnostic detail; the wire code gives the // client class of failure. if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = StatusCodes.Status400BadRequest; return; } // BestHTTP's SocketOptions.AdditionalQueryParams puts these on HTTP request HEADERS // for the WebSocket-only transport (not on the URL query string). Real clients // therefore send BattleId/viewerId as headers; the integration test sends them as // query params for convenience. Check headers first, fall back to query. var battleId = ReadCredential(ctx, "BattleId"); var encryptedViewerId = ReadCredential(ctx, "viewerId"); if (string.IsNullOrEmpty(battleId) || string.IsNullOrEmpty(encryptedViewerId)) { _log.LogWarning("WS upgrade missing BattleId or viewerId (header or query)."); ctx.Response.StatusCode = StatusCodes.Status400BadRequest; return; } long viewerId; try { var plain = NodeCrypto.DecryptForNode(encryptedViewerId); viewerId = long.Parse(plain); } catch (Exception ex) { _log.LogWarning(ex, "viewerId failed to decrypt (encryptedLen={Len})", encryptedViewerId.Length); ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } var pending = _store.TryGetPending(battleId); if (pending is null) { _log.LogWarning( "WS upgrade for unknown BattleId={Bid} (decrypted viewerId={Vid}). " + "Bridge may not have minted this battle, or it was already consumed/expired.", battleId, viewerId); ctx.Response.StatusCode = StatusCodes.Status404NotFound; return; } if (pending.P1.ViewerId != viewerId) { _log.LogWarning( "WS upgrade viewer-id mismatch on BattleId={Bid}: bridge expected={Expected}, decrypted={Got}.", battleId, pending.P1.ViewerId, viewerId); ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } var ws = await ctx.WebSockets.AcceptWebSocketAsync(); _store.RemovePending(battleId); // Phase 1: only Scripted is wired; Pvp + Bot land in subsequent phases. if (pending.Type != BattleType.Scripted) { _log.LogWarning( "WS upgrade for BattleId={Bid} with unsupported type={Type} (Phase 1 only handles Scripted).", battleId, pending.Type); return; } var realParticipant = new RealParticipant(ws, viewerId, pending.P1.Context, _loggerFactory.CreateLogger()); var scriptedBot = new ScriptedBotParticipant(); var session = new BattleSessionV2(battleId, pending.Type, realParticipant, scriptedBot, _loggerFactory.CreateLogger()); await session.RunAsync(ctx.RequestAborted); } private static string ReadCredential(HttpContext ctx, string name) { var header = ctx.Request.Headers[name].ToString(); if (!string.IsNullOrEmpty(header)) return header; return ctx.Request.Query[name].ToString(); } }