VanguardReMaster/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs

321 lines
12 KiB
C#

using System.Net.WebSockets;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Tickets;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Realtime;
/// <summary>
/// Точка входа матчевого WebSocket. Отвечает только за транспорт:
/// handshake, чтение кадров, лимит частоты и закрытие. Вся игра — в grain.
/// </summary>
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<GameOptions>();
var tickets = services.GetRequiredService<GameTicketService>();
var matches = services.GetRequiredService<MatchGrainRegistry>();
var time = services.GetRequiredService<TimeProvider>();
var logger = services.GetRequiredService<ILoggerFactory>().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<ClientFrame?> 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);
}
}