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