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(); } }