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] `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 с, затем экран «не удалось».
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
| Клиент и лаунчер | не начато |
|
||||
|
|
|
|||
|
|
@ -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`: сервер должен зависеть от него, а не наоборот.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ using Vanguard.Shared.Protocol;
|
|||
|
||||
namespace Vanguard.Server.Tests;
|
||||
|
||||
[Collection("Orleans")]
|
||||
public class OrleansMatchTests
|
||||
{
|
||||
[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