using System.Net.WebSockets; using Vanguard.Server.Configuration; using Vanguard.Server.Matches; using Vanguard.Server.Tickets; using Vanguard.Shared.Protocol; namespace Vanguard.Server.Realtime; /// /// Точка входа матчевого WebSocket. Отвечает только за транспорт: /// handshake, чтение кадров, лимит частоты и закрытие. Вся игра — в grain. /// public static class GameWebSocketEndpoint { public static async Task HandleAsync(HttpContext context) { if (!context.WebSockets.IsWebSocketRequest) { context.Response.StatusCode = StatusCodes.Status400BadRequest; return; } var services = context.RequestServices; var options = services.GetRequiredService(); var tickets = services.GetRequiredService(); var matches = services.GetRequiredService(); var time = services.GetRequiredService(); var logger = services.GetRequiredService().CreateLogger("GameSocket"); using var socket = await context.WebSockets.AcceptWebSocketAsync(); var connection = new WebSocketMatchConnection(socket); IMatchGrain? match = null; var seat = -1; HeartbeatState? heartbeat = null; CancellationTokenSource? heartbeatCts = null; Task? heartbeatTask = null; try { ClientFrame? first; try { first = await ReadWithTimeoutAsync( socket, TimeSpan.FromSeconds(options.ConnectTimeoutSeconds), context.RequestAborted); } catch (OperationCanceledException) when (!context.RequestAborted.IsCancellationRequested) { await FailAsync(connection, ProtocolErrorCode.MalformedFrame, "Handshake не получен вовремя.", 4400, "handshake timeout"); return; } catch (ProtocolFormatException exception) { logger.LogDebug(exception, "Bad handshake frame."); await FailAsync(connection, ProtocolErrorCode.MalformedFrame, "Первым кадром должен быть ClientHello.", 4400, "invalid handshake"); return; } if (first is not ClientHello hello) { await FailAsync(connection, ProtocolErrorCode.MalformedFrame, "Первым кадром должен быть ClientHello.", 4400, "invalid handshake"); return; } if (hello.ProtocolVersion != ProtocolConstants.Version) { await FailAsync(connection, ProtocolErrorCode.UnsupportedVersion, $"Поддерживается версия {ProtocolConstants.Version}.", 4410, "unsupported version"); return; } var ticket = tickets.Redeem(hello.Ticket); if (ticket is null) { await FailAsync(connection, ProtocolErrorCode.InvalidTicket, "Тикет неизвестен, использован или просрочен.", 4401, "invalid ticket"); return; } if (!matches.TryGet(ticket.MatchId, out match)) { await FailAsync(connection, ProtocolErrorCode.MatchNotFound, "Матч не найден.", 4401, "match not found"); return; } seat = ticket.Seat; if (!await match.AttachAsync(seat, ticket.PlayerId, connection, hello.ResumeFromSeq)) { await FailAsync(connection, ProtocolErrorCode.Forbidden, "Место в матче не принадлежит игроку.", 4403, "forbidden"); return; } logger.LogInformation( "Socket {ConnectionId}: {PlayerId} connected to {MatchId} seat {Seat}.", connection.Id, ticket.PlayerId, ticket.MatchId, seat); heartbeat = new HeartbeatState(time.GetUtcNow()); heartbeatCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); heartbeatTask = HeartbeatLoopAsync(connection, heartbeat, time, options, heartbeatCts.Token); var limiter = new IntentRateLimiter(ProtocolConstants.MaxIntentsPerSecond, time); while (!context.RequestAborted.IsCancellationRequested && socket.State == WebSocketState.Open) { ClientFrame? frame; try { frame = await ProtocolFrameReader.ReadAsync(socket, context.RequestAborted); } catch (OperationCanceledException) { break; } catch (WebSocketException) { break; } catch (ProtocolFormatException exception) { logger.LogDebug(exception, "Match {MatchId}: bad frame.", match.MatchId); await FailAsync(connection, ProtocolErrorCode.MalformedFrame, "Некорректный кадр.", 4400, "malformed frame", match); break; } if (frame is null) break; heartbeat.Touch(time.GetUtcNow()); try { switch (frame) { case ClientIntent intent: if (!limiter.TryAcquire()) { await connection.SendAsync(new ServerError { Code = ProtocolErrorCode.RateLimited, Message = "Слишком много интентов.", Fatal = false, EventSeq = match.Summary.EventSeq, }); break; } await match.SubmitAsync(seat, intent); break; case ClientResync resync: await match.ResyncAsync(seat, resync.FromSeq, resync.FullSnapshot); break; case ClientPing ping: await connection.SendAsync(new ServerPong { Nonce = ping.Nonce, ServerTimeMs = time.GetUtcNow().ToUnixTimeMilliseconds(), }); break; case ClientPong: break; case ClientLeave: return; default: await FailAsync(connection, ProtocolErrorCode.MalformedFrame, "Неожиданный кадр после handshake.", 4400, "unexpected frame", match); return; } } catch (OperationCanceledException) { break; } catch (Exception exception) { logger.LogError(exception, "Match {MatchId}: intent processing failed.", match.MatchId); await FailAsync(connection, ProtocolErrorCode.Internal, "Внутренняя ошибка сервера.", 4500, "internal error", match); break; } } } catch (WebSocketException exception) { logger.LogDebug(exception, "Socket {ConnectionId} failed.", connection.Id); } catch (OperationCanceledException) { // штатное завершение запроса } finally { if (heartbeatCts is not null) { heartbeatCts.Cancel(); if (heartbeatTask is not null) { try { await heartbeatTask; } catch (Exception) { // heartbeat уже завершился или соединение умерло } } heartbeatCts.Dispose(); } if (match is not null && (seat == 0 || seat == 1)) { try { await match.DetachAsync(seat, connection); } catch (Exception exception) { logger.LogDebug(exception, "Socket {ConnectionId}: detach failed.", connection.Id); } } await connection.CloseAsync(1000, "bye"); } } private static async Task ReadWithTimeoutAsync( WebSocket socket, TimeSpan timeout, CancellationToken cancellationToken) { using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); linked.CancelAfter(timeout); return await ProtocolFrameReader.ReadAsync(socket, linked.Token); } private static async Task HeartbeatLoopAsync( IMatchConnection connection, HeartbeatState state, TimeProvider time, GameOptions options, CancellationToken cancellationToken) { var check = TimeSpan.FromSeconds(Math.Max(1, options.HeartbeatCheckSeconds)); var idle = TimeSpan.FromSeconds(options.HeartbeatIdleSeconds); var timeout = TimeSpan.FromSeconds(options.HeartbeatTimeoutSeconds); using var timer = new PeriodicTimer(check); try { while (await timer.WaitForNextTickAsync(cancellationToken)) { var action = state.Evaluate(time.GetUtcNow(), idle, timeout); if (action.Kind == HeartbeatActionKind.Ping) { await connection.SendAsync(new ServerPing { Nonce = action.Nonce, ServerTimeMs = time.GetUtcNow().ToUnixTimeMilliseconds(), }, cancellationToken); } else if (action.Kind == HeartbeatActionKind.Timeout) { await connection.CloseAsync(4408, "heartbeat timeout"); return; } } } catch (OperationCanceledException) { // штатное завершение соединения } catch (Exception) { // отправка не удалась — соединение закроет основной цикл чтения } } private static async Task FailAsync( IMatchConnection connection, ProtocolErrorCode code, string message, int closeCode, string closeReason, IMatchGrain? match = null) { try { await connection.SendAsync(new ServerError { Code = code, Message = message, Fatal = true, EventSeq = match?.Summary.EventSeq ?? 0, }); } catch (Exception) { // клиент мог уже отвалиться } await connection.CloseAsync(closeCode, closeReason); } }