247 lines
8.4 KiB
C#
247 lines
8.4 KiB
C#
using System.Net.WebSockets;
|
||
using Microsoft.AspNetCore.Mvc.Testing;
|
||
using Vanguard.Shared.Protocol;
|
||
using Xunit.Sdk;
|
||
|
||
namespace Vanguard.Server.Tests;
|
||
|
||
/// <summary>
|
||
/// Минимальный клиент протокола для интеграционных тестов:
|
||
/// шлёт интенты, догоняет состояние и проверяет кадры.
|
||
/// </summary>
|
||
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<TestGameClient> ConnectAsync(
|
||
WebApplicationFactory<Program> 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<ServerHello>(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<ServerEvents>(cancellationToken);
|
||
if (events.ToSeq != hello.EventSeq)
|
||
throw new XunitException($"Догон дошёл до {events.ToSeq}, ожидалось {hello.EventSeq}.");
|
||
}
|
||
else
|
||
{
|
||
var snapshot = await client.ReadUntilAsync<ServerSnapshot>(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<ServerIntentResult> SubmitAsync(
|
||
string intentId,
|
||
IntentPayload intent,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
await SendIntentAsync(intentId, intent, cancellationToken);
|
||
var result = await ReadUntilAsync<ServerIntentResult>(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<ServerSnapshot>(cancellationToken);
|
||
View = snapshot.View;
|
||
LastSeq = snapshot.View.EventSeq;
|
||
}
|
||
|
||
/// <summary>Случайный бот: играет легальными интентами до конца партии.</summary>
|
||
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<T> ReadUntilAsync<T>(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<ServerFrame> 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();
|
||
}
|
||
}
|