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 SendLeaveAsync(string reason, CancellationToken cancellationToken) =>
await SendAsync(new ClientLeave { Reason = reason }, 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 PlayRandomlyAsync(int seed, CancellationToken cancellationToken)
{
var random = new Random(seed);
var deadline = DateTime.UtcNow.AddSeconds(90);
while (!View.IsFinished)
{
if (DateTime.UtcNow > deadline)
throw new XunitException(
$"Тест не доиграл партию за 90 секунд (seat {Seat}).");
if (View.ActivePlayer == Seat)
{
var choices = View.LegalIntents
.Where(intent => intent is not ConcedeIntent)
.ToList();
if (choices.Count == 0)
throw new XunitException(
$"Нет легальных интентов для seat {Seat}, " +
$"фаза {View.Phase}, шаг {View.Step}.");
var intent = choices[random.Next(choices.Count)];
var intentId = Guid.NewGuid().ToString("N");
var result = await SubmitAsync(intentId, intent, cancellationToken);
if (result.Duplicate)
throw new XunitException($"Неожиданный duplicate для {intentId}.");
if (!result.Accepted)
throw new XunitException(
$"Интент {intent.GetType().Name} отклонён: {result.Code} {result.Reason}");
// держимся ниже лимита 16 интентов в секунду
await Task.Delay(75, cancellationToken);
}
await RequestSnapshotAsync(cancellationToken);
if (!View.IsFinished && View.ActivePlayer != Seat)
await Task.Delay(20, cancellationToken);
}
}
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();
}
}