using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Mvc.Testing; using Vanguard.Server.Api; using Vanguard.Shared.Protocol; using Xunit.Sdk; namespace Vanguard.Server.Tests; public class GameServerE2ETests { [Fact] public async Task Two_Clients_Play_A_Full_Match_Over_WebSocket() { using var factory = NewFactory(); using var http = factory.CreateClient(); var deck = ServerTestData.BuildDeck(); var firstQueue = await EnqueueAsync(http, "p1", deck); var secondQueue = await EnqueueAsync(http, "p2", deck); var firstStatus = await WaitForMatchAsync(http, firstQueue.QueueId, "p1"); var secondStatus = await WaitForMatchAsync(http, secondQueue.QueueId, "p2"); Assert.Equal(firstStatus.MatchId, secondStatus.MatchId); Assert.Equal(0, firstStatus.Seat); Assert.Equal(1, secondStatus.Seat); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); await using var first = await TestGameClient.ConnectAsync( factory, firstStatus.MatchId!, firstStatus.Ticket!, "p1", 0, cts.Token); await using var second = await TestGameClient.ConnectAsync( factory, secondStatus.MatchId!, secondStatus.Ticket!, "p2", 0, cts.Token); await Task.WhenAll( PlayAsync(first, seed: 11, cts.Token), PlayAsync(second, seed: 22, cts.Token)); Assert.True(first.View.IsFinished, "Первый клиент не увидел завершение партии."); Assert.True(second.View.IsFinished, "Второй клиент не увидел завершение партии."); Assert.Equal(first.View.Winner, second.View.Winner); Assert.InRange(first.View.Winner, 0, 1); var loser = 1 - first.View.Winner; Assert.False(first.View.Players[first.View.Winner].IsDefeated); Assert.True(first.View.Players[loser].IsDefeated); var health = await http.GetFromJsonAsync("/health"); Assert.True(health.GetProperty("matches").GetInt32() >= 1); } [Fact] public async Task Client_Can_Reconnect_And_Resume_After_Disconnect() { using var factory = NewFactory(); using var http = factory.CreateClient(); var deck = ServerTestData.BuildDeck(); var firstQueue = await EnqueueAsync(http, "p1", deck); var secondQueue = await EnqueueAsync(http, "p2", deck); var firstStatus = await WaitForMatchAsync(http, firstQueue.QueueId, "p1"); var secondStatus = await WaitForMatchAsync(http, secondQueue.QueueId, "p2"); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); var first = await TestGameClient.ConnectAsync( factory, firstStatus.MatchId!, firstStatus.Ticket!, "p1", 0, cts.Token); await using var second = await TestGameClient.ConnectAsync( factory, secondStatus.MatchId!, secondStatus.Ticket!, "p2", 0, cts.Token); var result = await first.SubmitAsync("mulligan-1", new FinishMulliganIntent(), cts.Token); Assert.True(result.Accepted, result.Reason); var lastSeq = result.EventSeq; await first.DisposeAsync(); var ticketResponse = await http.PostAsJsonAsync( "/api/game/tickets", new TicketRequest("p1", firstStatus.MatchId)); if (!ticketResponse.IsSuccessStatusCode) throw new XunitException(await ticketResponse.Content.ReadAsStringAsync()); var ticket = await ticketResponse.Content.ReadFromJsonAsync(); Assert.NotNull(ticket); await using var reconnected = await TestGameClient.ConnectAsync( factory, firstStatus.MatchId!, ticket!.Ticket, "p1", lastSeq, cts.Token); Assert.True(reconnected.View.EventSeq >= lastSeq); Assert.Equal(0, reconnected.Seat); await second.RequestSnapshotAsync(cts.Token); Assert.Equal(reconnected.View.EventSeq, second.View.EventSeq); Assert.Equal(reconnected.View.ActivePlayer, second.View.ActivePlayer); Assert.False(reconnected.View.IsFinished); } private static WebApplicationFactory NewFactory() => new(); private static async Task EnqueueAsync( HttpClient http, string playerId, List deck) { var response = await http.PostAsJsonAsync( "/api/game/queue", new QueueRequest(playerId, "classic-ranked", deck)); if (!response.IsSuccessStatusCode) throw new XunitException(await response.Content.ReadAsStringAsync()); return (await response.Content.ReadFromJsonAsync())!; } private static async Task WaitForMatchAsync( HttpClient http, Guid queueId, string playerId) { for (var attempt = 0; attempt < 200; attempt++) { var status = await http.GetFromJsonAsync( $"/api/game/queue/{queueId}?playerId={playerId}"); if (status is null) throw new XunitException("Очередь исчезла."); if (status.Status == "matched") return status; if (status.Status is "failed" or "expired" or "cancelled") throw new XunitException($"Очередь в состоянии {status.Status}: {status.Reason}"); await Task.Delay(50); } throw new XunitException("Матч не был найден за 10 секунд."); } private static async Task PlayAsync( TestGameClient client, int seed, CancellationToken cancellationToken) { var random = new Random(seed); var deadline = DateTime.UtcNow.AddSeconds(90); while (!client.View.IsFinished) { if (DateTime.UtcNow > deadline) throw new XunitException( $"Тест не доиграл партию за 90 секунд (seat {client.Seat})."); if (client.View.ActivePlayer == client.Seat) { var choices = client.View.LegalIntents .Where(intent => intent is not ConcedeIntent) .ToList(); if (choices.Count == 0) throw new XunitException( $"Нет легальных интентов для seat {client.Seat}, " + $"фаза {client.View.Phase}, шаг {client.View.Step}."); var intent = choices[random.Next(choices.Count)]; var intentId = Guid.NewGuid().ToString("N"); var result = await client.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 client.RequestSnapshotAsync(cancellationToken); if (!client.View.IsFinished && client.View.ActivePlayer != client.Seat) await Task.Delay(20, cancellationToken); } } }