321 lines
12 KiB
C#
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);
|
|
}
|
|
}
|