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 BattleSession(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();
}
}