diff --git a/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs b/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs index f0454b7..b89611c 100644 --- a/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs +++ b/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Sessions; using SVSim.BattleNode.Sessions.Participants; using SVSim.BattleNode.Wire; @@ -31,12 +32,20 @@ namespace SVSim.BattleNode.Hosting; public sealed class BattleNodeWebSocketHandler { private readonly IBattleSessionStore _store; + private readonly IWaitingRoom _waitingRoom; + private readonly BattleNodeOptions _options; private readonly ILogger _log; private readonly ILoggerFactory _loggerFactory; - public BattleNodeWebSocketHandler(IBattleSessionStore store, ILoggerFactory loggerFactory) + public BattleNodeWebSocketHandler( + IBattleSessionStore store, + IWaitingRoom waitingRoom, + BattleNodeOptions options, + ILoggerFactory loggerFactory) { _store = store; + _waitingRoom = waitingRoom; + _options = options; _loggerFactory = loggerFactory; _log = loggerFactory.CreateLogger(); } @@ -94,33 +103,104 @@ public sealed class BattleNodeWebSocketHandler ctx.Response.StatusCode = StatusCodes.Status404NotFound; return; } - if (pending.P1.ViewerId != viewerId) + var isP1 = viewerId == pending.P1.ViewerId; + var isP2 = pending.P2 is not null && viewerId == pending.P2.ViewerId; + if (!isP1 && !isP2) { _log.LogWarning( - "WS upgrade viewer-id mismatch on BattleId={Bid}: bridge expected={Expected}, decrypted={Got}.", - battleId, pending.P1.ViewerId, viewerId); + "WS upgrade viewer-id mismatch on BattleId={Bid}: bridge expected={P1}/{P2}, decrypted={Got}.", + battleId, pending.P1.ViewerId, pending.P2?.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) + switch (pending.Type) { - _log.LogWarning( - "WS upgrade for BattleId={Bid} with unsupported type={Type} (Phase 1 only handles Scripted).", - battleId, pending.Type); - return; - } + case BattleType.Scripted: + { + _store.RemovePending(battleId); + 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); + break; + } - 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); + case BattleType.Pvp: + { + // Pick this connection's MatchContext (P1's if isP1, P2's if isP2). + var selfCtx = isP1 ? pending.P1.Context : pending.P2!.Context; + var self = new RealParticipant(ws, viewerId, selfCtx, + _loggerFactory.CreateLogger()); + + var firstArriver = _waitingRoom.Pair(battleId, self); + + if (firstArriver is not null) + { + // We are the SECOND arriver. Construct and drive the session. + _store.RemovePending(battleId); + var session = new BattleSession( + battleId, BattleType.Pvp, firstArriver, self, + _loggerFactory.CreateLogger()); + try + { + await session.RunAsync(ctx.RequestAborted); + } + finally + { + firstArriver.MarkSessionFinished(); + } + } + else + { + // We are the FIRST arriver. Park; ParkAsync returns the second arriver + // on pairing, null on timeout / cancellation / TryAdd race. + var second = await _waitingRoom.ParkAsync( + battleId, self, _options.WaitingRoomTimeout, ctx.RequestAborted); + + if (second is null) + { + // Either timeout (most common) or Park/Park race. Retry Pair once. + second = _waitingRoom.Pair(battleId, self); + if (second is null) + { + _log.LogWarning( + "PvP waiting-room timeout or race on BattleId={Bid}; first arriver disconnected.", + battleId); + _store.RemovePending(battleId); + return; + } + // Retry succeeded — we're the de-facto second arriver now. Own the session. + _store.RemovePending(battleId); + var raceSession = new BattleSession( + battleId, BattleType.Pvp, second, self, + _loggerFactory.CreateLogger()); + try { await raceSession.RunAsync(ctx.RequestAborted); } + finally { second.MarkSessionFinished(); } + return; + } + + // Normal first-arriver path: session is being constructed/driven by the + // second arriver. Hold this HTTP request open until they signal completion. + // Do NOT call self.RunAsync — the session already does. + await self.AwaitSessionFinishedAsync(ctx.RequestAborted); + } + break; + } + + case BattleType.Bot: + // Phase 3 deliverable. + _log.LogWarning("BattleType.Bot not yet supported (Phase 3); BattleId={Bid}", battleId); + return; + + default: + _log.LogError("Unknown BattleType={Type} for BattleId={Bid}; closing WS", pending.Type, battleId); + return; + } } private static string ReadCredential(HttpContext ctx, string name)