diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index c46c19c..bb4e3eb 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -72,8 +72,8 @@ - [x] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`. - [x] `docker-compose.yml`: postgres, redis (AOF), game-server с контентом. - [x] `docker compose up` поднимает БД, Redis и API; `/health` отвечает. -- [~] CI: workflow `.gitea/workflows/ci.yml` добавлен; home-runner пока - привязан к другому репозиторию. +- [x] CI: `.gitea/workflows/ci.yml` проходит на `home-runner`; Actions + включены для репозитория. **Готово, когда:** `docker compose up` + `dotnet run` дают живой `/health`. @@ -176,7 +176,7 @@ - [x] `IGameTicketService`: одноразовый тикет на подключение (TTL 60 с). - [x] Транспорт матча: WebSocket (ASP.NET Core); резерв — TCP с префиксом длины. Accept, handshake, auth по тикету, привязка к grain. - [x] `IMatchGrain`: 1 матч = 1 grain, последовательная обработка. - — реализовано in-process; Orleans-хостинг — следующий шаг, контракт отделён. + — реализовано in-process; Orleans-хостинг готов и включается `Orleans:Enabled`. - [x] Проекция состояния под клиента: своя рука, свой trigger, открытые зоны; **ничего** о колоде, руке противника и закрытом уроне. - [x] `eventSeq` + `resync` по запросу. - [x] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось». diff --git a/README.md b/README.md index 879eb18..c2302af 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ | Сервер: матч по сети, матчмейкинг, реконнект | готово | | PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово | | Мета-API: JWT, коллекция, колоды, магазин, история, реплеи | готово | -| CI (Forgejo Actions) | workflow добавлен, runner нужно привязать | +| CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` | | EF-миграции | готово (`InitialCreate`) | | Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` | | Клиент и лаунчер | не начато | diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 1fe5015..6680d62 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -266,9 +266,9 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. (AOF) и сервер; `/health` на `http://localhost:8080` (порт меняется через `VANGUARD_PORT`, адрес WebSocket — `VANGUARD_WS_URL`). - **CI**: `.gitea/workflows/ci.yml` — restore, тесты, `validate` и - `translations check` на push/PR. Workflow добавлен, но home-runner - зарегистрирован на другой репозиторий: для этого репозитория нужно - включить Actions и привязать runner. + `translations check` на push/PR. Actions включены для репозитория, + `home-runner` выполняет job (проверено: все шаги зелёные). `setup-dotnet` + указан явным URL с GitHub: в зеркале `code.forgejo.org` его нет. - **Тесты серверного ядра** (34 из 43 в `Vanguard.Server.Tests`): - тикеты: одноразовость и TTL; - heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром; @@ -475,8 +475,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. Критерий готовности серверного блока выполнен: два клиента играют полный матч по сети; kill клиента восстанавливается через новый тикет и `resumeFromSeq`; рестарт сервера восстанавливает матчи из PostgreSQL. -Остался только CI-runner: workflow есть, но home-runner привязан к другому -репозиторию. +CI подключён и проходит; серверный блок закрыт. ### 7.2. Мета-API @@ -533,7 +532,8 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. `GameConnection`, логин, коллекция, декбилдер, магазин, очередь, история и реплей. Сервер и мета-API готовы: `docs/protocol.md`, `docs/server-api.md`, `Vanguard.Shared`. -4. CI-runner — отдельный серверный долг, не блокирует клиент; Orleans уже готов. +4. CI и Orleans закрыты; следующий блок — клиент. Перед началом: проверить + `docs/protocol.md`, `docs/server-api.md`, `Vanguard.Shared`. 5. После каждого блока: тесты, `validate`, `translations check`, коммит, push. 6. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот. diff --git a/server/tests/Vanguard.Server.Tests/OrleansMatchTests.cs b/server/tests/Vanguard.Server.Tests/OrleansMatchTests.cs index 1b74fd9..546567f 100644 --- a/server/tests/Vanguard.Server.Tests/OrleansMatchTests.cs +++ b/server/tests/Vanguard.Server.Tests/OrleansMatchTests.cs @@ -10,6 +10,7 @@ using Vanguard.Shared.Protocol; namespace Vanguard.Server.Tests; +[Collection("Orleans")] public class OrleansMatchTests { [Fact] diff --git a/server/tests/Vanguard.Server.Tests/OrleansServerE2ETests.cs b/server/tests/Vanguard.Server.Tests/OrleansServerE2ETests.cs new file mode 100644 index 0000000..b3631e5 --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/OrleansServerE2ETests.cs @@ -0,0 +1,100 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Vanguard.Server.Api; +using Xunit.Sdk; + +namespace Vanguard.Server.Tests; + +/// +/// Полный матч через HTTP + WebSocket с включённым Orleans: проверяет не +/// только грану, но и связку registry → adapter → endpoint → silo. +/// +[Collection("Orleans")] +public class OrleansServerE2ETests +{ + [Fact] + public async Task Two_Clients_Play_A_Full_Match_With_Orleans_Enabled() + { + 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); + + 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( + first.PlayRandomlyAsync(seed: 11, cts.Token), + second.PlayRandomlyAsync(seed: 22, cts.Token)); + + Assert.True(first.View.IsFinished, "Первый клиент не увидел завершение партии."); + Assert.True(second.View.IsFinished, "Второй клиент не увидел завершение партии."); + Assert.Equal(first.View.Winner, second.View.Winner); + + var health = await http.GetFromJsonAsync("/health"); + Assert.True(health.GetProperty("matches").GetInt32() >= 1); + } + + private static WebApplicationFactory NewFactory() => + new WebApplicationFactory().WithWebHostBuilder(builder => + builder.ConfigureAppConfiguration((_, config) => + config.AddInMemoryCollection(new Dictionary + { + ["Orleans:Enabled"] = "true", + }))); + + 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 секунд."); + } +}