VanguardReMaster/server/tests/Vanguard.Server.Tests/TestGameClient.cs

250 lines
8.6 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 SendLeaveAsync(string reason, CancellationToken cancellationToken) =>
await SendAsync(new ClientLeave { Reason = reason }, 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();
}
}