Add Orleans-enabled HTTP/WebSocket E2E
CI / test (push) Failing after 1m9s Details

This commit is contained in:
MpakobecEx 2026-10-21 16:08:28 +05:00
parent 37ebee407d
commit b33b7292d1
5 changed files with 111 additions and 10 deletions

View File

@ -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 с, затем экран «не удалось».

View File

@ -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` |
| Клиент и лаунчер | не начато |

View File

@ -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`: сервер должен зависеть от него, а не наоборот.

View File

@ -10,6 +10,7 @@ using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Tests;
[Collection("Orleans")]
public class OrleansMatchTests
{
[Fact]

View File

@ -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 секунд.");
}
}