using System.Net.WebSockets;
using Microsoft.AspNetCore.Mvc.Testing;
using Vanguard.Shared.Protocol;
using Xunit.Sdk;
namespace Vanguard.Server.Tests;
///
/// Минимальный клиент протокола для интеграционных тестов:
/// шлёт интенты, догоняет состояние и проверяет кадры.
///
internal sealed class TestGameClient : IAsyncDisposable
{
private readonly WebSocket _socket;
private readonly SemaphoreSlim _sendLock = new(1, 1);
private TestGameClient(WebSocket socket, string playerId)
{
_socket = socket;
PlayerId = playerId;
}
public string PlayerId { get; }
public int Seat { get; private set; }
public long LastSeq { get; private set; }
public MatchView View { get; private set; } = null!;
public static async Task ConnectAsync(
WebApplicationFactory factory,
string matchId,
string ticket,
string playerId,
long resumeFromSeq,
CancellationToken cancellationToken)
{
var webSocketClient = factory.Server.CreateWebSocketClient();
var socket = await webSocketClient.ConnectAsync(
new Uri($"ws://localhost/game"), cancellationToken);
var client = new TestGameClient(socket, playerId);
await client.SendAsync(new ClientHello
{
ProtocolVersion = ProtocolConstants.Version,
Ticket = ticket,
ClientBuild = "tests",
ResumeFromSeq = resumeFromSeq,
}, cancellationToken);
var hello = await client.ReadUntilAsync(cancellationToken);
if (hello.MatchId != matchId)
throw new XunitException($"Ожидался матч {matchId}, пришёл {hello.MatchId}.");
client.Seat = hello.PlayerIndex;
client.LastSeq = hello.EventSeq;
if (hello.Resumed)
{
// после hello сервер присылает события догона и часы
var events = await client.ReadUntilAsync(cancellationToken);
if (events.ToSeq != hello.EventSeq)
throw new XunitException($"Догон дошёл до {events.ToSeq}, ожидалось {hello.EventSeq}.");
}
else
{
var snapshot = await client.ReadUntilAsync(cancellationToken);
client.View = snapshot.View;
client.LastSeq = snapshot.View.EventSeq;
}
// после догона нужен снимок, чтобы получить MatchView
if (client.View is null)
await client.RequestSnapshotAsync(cancellationToken);
return client;
}
public async Task SendIntentAsync(
string intentId,
IntentPayload intent,
CancellationToken cancellationToken)
{
await SendAsync(new ClientIntent
{
IntentId = intentId,
BasedOnSeq = LastSeq,
Intent = intent,
}, cancellationToken);
}
public async Task SubmitAsync(
string intentId,
IntentPayload intent,
CancellationToken cancellationToken)
{
await SendIntentAsync(intentId, intent, cancellationToken);
var result = await ReadUntilAsync(cancellationToken);
if (result.IntentId != intentId)
throw new XunitException($"Ожидался результат {intentId}, пришёл {result.IntentId}.");
return result;
}
public async Task RequestSnapshotAsync(CancellationToken cancellationToken)
{
await SendAsync(new ClientResync
{
FromSeq = LastSeq + 1,
FullSnapshot = true,
}, cancellationToken);
var snapshot = await ReadUntilAsync(cancellationToken);
View = snapshot.View;
LastSeq = snapshot.View.EventSeq;
}
public async Task ReadUntilAsync(CancellationToken cancellationToken)
where T : ServerFrame
{
for (var i = 0; i < 2000; i++)
{
var frame = await ReadFrameAsync(cancellationToken);
if (frame is T typed)
return typed;
if (frame is ServerError error)
throw new XunitException(
$"Сервер ответил ошибкой {error.Code}: {error.Message}");
}
throw new XunitException($"Не дождались кадра {typeof(T).Name}.");
}
public async Task SendAsync(ClientFrame frame, CancellationToken cancellationToken)
{
var bytes = ProtocolSerializer.Encode(frame);
await _sendLock.WaitAsync(cancellationToken);
try
{
await _socket.SendAsync(
bytes.AsMemory(),
WebSocketMessageType.Binary,
endOfMessage: true,
cancellationToken);
}
finally
{
_sendLock.Release();
}
}
private async Task ReadFrameAsync(CancellationToken cancellationToken)
{
var buffer = new byte[16 * 1024];
using var stream = new MemoryStream();
while (true)
{
var result = await _socket.ReceiveAsync(buffer.AsMemory(), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
throw new XunitException(
$"Сокет закрыт: {_socket.CloseStatus} {_socket.CloseStatusDescription}");
if (result.MessageType != WebSocketMessageType.Binary)
throw new XunitException("Сервер прислал текстовый кадр.");
stream.Write(buffer, 0, result.Count);
if (stream.Length > ProtocolConstants.MaxFrameBytes)
throw new XunitException("Кадр сервера слишком велик.");
if (result.EndOfMessage)
break;
}
return ProtocolSerializer.DecodeServer(stream.ToArray());
}
public async ValueTask DisposeAsync()
{
try
{
if (_socket.State == WebSocketState.Open)
await _socket.CloseOutputAsync(
WebSocketCloseStatus.NormalClosure,
"bye",
CancellationToken.None);
}
catch (Exception)
{
// тестовый клиент мог уже отвалиться
}
_socket.Dispose();
_sendLock.Dispose();
}
}