Add Orleans-enabled HTTP/WebSocket E2E
CI / test (push) Failing after 1m9s
Details
CI / test (push) Failing after 1m9s
Details
This commit is contained in:
parent
37ebee407d
commit
b33b7292d1
|
|
@ -72,8 +72,8 @@
|
||||||
- [x] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`.
|
- [x] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`.
|
||||||
- [x] `docker-compose.yml`: postgres, redis (AOF), game-server с контентом.
|
- [x] `docker-compose.yml`: postgres, redis (AOF), game-server с контентом.
|
||||||
- [x] `docker compose up` поднимает БД, Redis и API; `/health` отвечает.
|
- [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`.
|
**Готово, когда:** `docker compose up` + `dotnet run` дают живой `/health`.
|
||||||
|
|
||||||
|
|
@ -176,7 +176,7 @@
|
||||||
- [x] `IGameTicketService`: одноразовый тикет на подключение (TTL 60 с).
|
- [x] `IGameTicketService`: одноразовый тикет на подключение (TTL 60 с).
|
||||||
- [x] Транспорт матча: WebSocket (ASP.NET Core); резерв — TCP с префиксом длины. Accept, handshake, auth по тикету, привязка к grain.
|
- [x] Транспорт матча: WebSocket (ASP.NET Core); резерв — TCP с префиксом длины. Accept, handshake, auth по тикету, привязка к grain.
|
||||||
- [x] `IMatchGrain`: 1 матч = 1 grain, последовательная обработка.
|
- [x] `IMatchGrain`: 1 матч = 1 grain, последовательная обработка.
|
||||||
— реализовано in-process; Orleans-хостинг — следующий шаг, контракт отделён.
|
— реализовано in-process; Orleans-хостинг готов и включается `Orleans:Enabled`.
|
||||||
- [x] Проекция состояния под клиента: своя рука, свой trigger, открытые зоны; **ничего** о колоде, руке противника и закрытом уроне.
|
- [x] Проекция состояния под клиента: своя рука, свой trigger, открытые зоны; **ничего** о колоде, руке противника и закрытом уроне.
|
||||||
- [x] `eventSeq` + `resync` по запросу.
|
- [x] `eventSeq` + `resync` по запросу.
|
||||||
- [x] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось».
|
- [x] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось».
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
| Сервер: матч по сети, матчмейкинг, реконнект | готово |
|
| Сервер: матч по сети, матчмейкинг, реконнект | готово |
|
||||||
| PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово |
|
| PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово |
|
||||||
| Мета-API: JWT, коллекция, колоды, магазин, история, реплеи | готово |
|
| Мета-API: JWT, коллекция, колоды, магазин, история, реплеи | готово |
|
||||||
| CI (Forgejo Actions) | workflow добавлен, runner нужно привязать |
|
| CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` |
|
||||||
| EF-миграции | готово (`InitialCreate`) |
|
| EF-миграции | готово (`InitialCreate`) |
|
||||||
| Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` |
|
| Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` |
|
||||||
| Клиент и лаунчер | не начато |
|
| Клиент и лаунчер | не начато |
|
||||||
|
|
|
||||||
|
|
@ -266,9 +266,9 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
(AOF) и сервер; `/health` на `http://localhost:8080` (порт меняется
|
(AOF) и сервер; `/health` на `http://localhost:8080` (порт меняется
|
||||||
через `VANGUARD_PORT`, адрес WebSocket — `VANGUARD_WS_URL`).
|
через `VANGUARD_PORT`, адрес WebSocket — `VANGUARD_WS_URL`).
|
||||||
- **CI**: `.gitea/workflows/ci.yml` — restore, тесты, `validate` и
|
- **CI**: `.gitea/workflows/ci.yml` — restore, тесты, `validate` и
|
||||||
`translations check` на push/PR. Workflow добавлен, но home-runner
|
`translations check` на push/PR. Actions включены для репозитория,
|
||||||
зарегистрирован на другой репозиторий: для этого репозитория нужно
|
`home-runner` выполняет job (проверено: все шаги зелёные). `setup-dotnet`
|
||||||
включить Actions и привязать runner.
|
указан явным URL с GitHub: в зеркале `code.forgejo.org` его нет.
|
||||||
- **Тесты серверного ядра** (34 из 43 в `Vanguard.Server.Tests`):
|
- **Тесты серверного ядра** (34 из 43 в `Vanguard.Server.Tests`):
|
||||||
- тикеты: одноразовость и TTL;
|
- тикеты: одноразовость и TTL;
|
||||||
- heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром;
|
- heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром;
|
||||||
|
|
@ -475,8 +475,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
Критерий готовности серверного блока выполнен: два клиента играют полный
|
Критерий готовности серверного блока выполнен: два клиента играют полный
|
||||||
матч по сети; kill клиента восстанавливается через новый тикет и
|
матч по сети; kill клиента восстанавливается через новый тикет и
|
||||||
`resumeFromSeq`; рестарт сервера восстанавливает матчи из PostgreSQL.
|
`resumeFromSeq`; рестарт сервера восстанавливает матчи из PostgreSQL.
|
||||||
Остался только CI-runner: workflow есть, но home-runner привязан к другому
|
CI подключён и проходит; серверный блок закрыт.
|
||||||
репозиторию.
|
|
||||||
|
|
||||||
### 7.2. Мета-API
|
### 7.2. Мета-API
|
||||||
|
|
||||||
|
|
@ -533,7 +532,8 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
`GameConnection`, логин, коллекция, декбилдер, магазин, очередь,
|
`GameConnection`, логин, коллекция, декбилдер, магазин, очередь,
|
||||||
история и реплей. Сервер и мета-API готовы: `docs/protocol.md`,
|
история и реплей. Сервер и мета-API готовы: `docs/protocol.md`,
|
||||||
`docs/server-api.md`, `Vanguard.Shared`.
|
`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.
|
5. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
|
||||||
6. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
|
6. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ using Vanguard.Shared.Protocol;
|
||||||
|
|
||||||
namespace Vanguard.Server.Tests;
|
namespace Vanguard.Server.Tests;
|
||||||
|
|
||||||
|
[Collection("Orleans")]
|
||||||
public class OrleansMatchTests
|
public class OrleansMatchTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Полный матч через HTTP + WebSocket с включённым Orleans: проверяет не
|
||||||
|
/// только грану, но и связку registry → adapter → endpoint → silo.
|
||||||
|
/// </summary>
|
||||||
|
[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<JsonElement>("/health");
|
||||||
|
Assert.True(health.GetProperty("matches").GetInt32() >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WebApplicationFactory<Program> NewFactory() =>
|
||||||
|
new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
|
||||||
|
builder.ConfigureAppConfiguration((_, config) =>
|
||||||
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Orleans:Enabled"] = "true",
|
||||||
|
})));
|
||||||
|
|
||||||
|
private static async Task<QueueAccepted> EnqueueAsync(
|
||||||
|
HttpClient http,
|
||||||
|
string playerId,
|
||||||
|
List<string> 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<QueueAccepted>())!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<QueueStatusResponse> WaitForMatchAsync(
|
||||||
|
HttpClient http,
|
||||||
|
Guid queueId,
|
||||||
|
string playerId)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; attempt < 200; attempt++)
|
||||||
|
{
|
||||||
|
var status = await http.GetFromJsonAsync<QueueStatusResponse>(
|
||||||
|
$"/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 секунд.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue