Add a playable WebSocket match server with matchmaking and reconnect

This commit is contained in:
MpakobecEx 2026-10-21 14:31:17 +05:00
parent d4e6e9d8c0
commit 99d5b95000
40 changed files with 3905 additions and 38 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
.git
.gitignore
**/bin
**/obj
client
launcher
tools
docs
server/tests

View File

@ -70,8 +70,9 @@
/tools/import_cards.py
```
- [x] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`.
- [ ] `docker-compose.yml`: postgres, redis (AOF), meta-api, game-silo, nginx.
- [~] `docker-compose.yml`: сейчас только game-server; postgres, redis (AOF), meta-api, nginx — со следующим блоком.
- [ ] `docker compose up` поднимает БД и пустой API; healthcheck отвечает.
— сервер уже поднимается и отвечает `/health`, БД ещё нет.
- [ ] CI: сборка solution + `dotnet test` на push.
**Готово, когда:** `docker compose up` + `dotnet run` дают живой `/health`.
@ -172,18 +173,22 @@
- [x] Спецификация протокола в `docs/protocol.md`: кадры, handshake, версия, ping/pong, reconnect.
- [x] `Vanguard.Shared`: DTO интентов и событий, MessagePack-резолвер.
- [ ] `IGameTicketService`: одноразовый тикет на подключение (TTL 60 с).
- [ ] Транспорт матча: WebSocket (ASP.NET Core); резерв — TCP с префиксом длины. Accept, handshake, auth по тикету, привязка к grain.
- [ ] `IMatchGrain`: 1 матч = 1 grain, последовательная обработка.
- [x] `IGameTicketService`: одноразовый тикет на подключение (TTL 60 с).
- [x] Транспорт матча: WebSocket (ASP.NET Core); резерв — TCP с префиксом длины. Accept, handshake, auth по тикету, привязка к grain.
- [x] `IMatchGrain`: 1 матч = 1 grain, последовательная обработка.
— реализовано in-process; Orleans-хостинг — следующий шаг, контракт отделён.
- [x] Проекция состояния под клиента: своя рука, свой trigger, открытые зоны; **ничего** о колоде, руке противника и закрытом уроне.
- [~] `eventSeq` + `resync` по запросу.
- [~] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось».
- [~] Политика отсутствия: 5 минут → авто-поражение.
- [~] Таймер хода: 90 с + банк 5 мин.
- [x] `eventSeq` + `resync` по запросу.
- [x] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось».
- [x] Политика отсутствия: 5 минут → авто-поражение.
- [x] Таймер хода: 90 с + банк 5 мин.
- [ ] Снапшот матча в PostgreSQL + восстановление grain после рестарта.
- [~] Idempotency по `intentId`.
- [x] Idempotency по `intentId`.
- [x] HTTP-очередь и тикеты: `docs/server-api.md`.
- [x] E2E: два клиента играют полный матч по WebSocket; обрыв и возврат проверены.
**Готово, когда:** два клиента играют полный матч по сети; kill процесса клиента на любом шаге восстанавливается.
— выполнено в `Vanguard.Server.Tests`; рестарт сервера пока теряет матчи (ждёт PostgreSQL).
---

View File

@ -13,7 +13,9 @@
| Контент-пайплайн и валидатор | готово |
| Игровое ядро (правила) | каркас: фазы, райд, призыв, бой, драйв, урон |
| Протокол и контракты (`docs/protocol.md`, `Vanguard.Shared`) | готово |
| Сервер, клиент, лаунчер | не начаты |
| Сервер: матч по сети, матчмейкинг, реконнект | готово (in-memory) |
| Персистентность (PostgreSQL, Redis) и Orleans | не начато |
| Клиент и лаунчер | не начато |
## Требования
@ -36,6 +38,16 @@ dotnet run --project server/src/Vanguard.Cli -- stress 1000
dotnet test server/Vanguard.slnx
```
Запуск игрового сервера:
```powershell
dotnet run --project server/src/Vanguard.Server --urls http://localhost:5080
# проверка: http://localhost:5080/health
```
Контейнер: `docker compose up --build`, затем `http://localhost:8080/health`.
HTTP-контракт очереди и тикетов — `docs/server-api.md`.
Ожидаемый вывод валидатора:
```
@ -50,13 +62,15 @@ OK: ошибок 0, предупреждений 0
/server/src/Vanguard.Rules ядро правил: карты, колоды, состояние игры
/server/src/Vanguard.Content загрузка и проверка YAML-контента
/server/src/Vanguard.Shared сетевые контракты (netstandard2.1 для Unity + net10.0)
/server/src/Vanguard.Server ASP.NET Core: WebSocket, очередь, тикеты, матчи
/server/src/Vanguard.Cli консольные команды
/server/tests тесты правил и контента
/server/tests тесты правил, контрактов и сервера (включая E2E)
/content/sets карты в YAML
/content/keywords.yaml ключевые слова и иконки
/content/translations/ru.yaml русские тексты
/tools импорт и сид переводов
/docs правила перевода и прочие документы
/docs протокол, API, правила перевода
/docker-compose.yml сервер в контейнере
```
`Vanguard.Rules` — чистая библиотека без Orleans, БД и сети. Это позволяет прогонять
@ -116,4 +130,5 @@ dotnet run --project server/src/Vanguard.Cli -- translations check
- `IMPLEMENTATION_CHECKLIST.md` — план реализации по блокам и гейтам.
- `docs/HANDOFF.md` — состояние на конец сессии и порядок следующих шагов.
- `docs/protocol.md` — протокол матча: кадры, интенты, события, таймеры, реконнект.
- `docs/server-api.md` — HTTP-очередь, тикеты и `/health`.
- `content/cards-mvp.md` — каталог карт с разбором состава сетов.

11
docker-compose.yml Normal file
View File

@ -0,0 +1,11 @@
services:
server:
build:
context: .
dockerfile: server/src/Vanguard.Server/Dockerfile
ports:
- "8080:8080"
environment:
ASPNETCORE_URLS: http://+:8080
Game__PublicWsUrl: ws://localhost:8080/game
restart: unless-stopped

View File

@ -38,13 +38,14 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
| Проверка | Результат |
|---|---|
| Тесты | **129/129** |
| Тесты | **158/158** (131 правила/контракты + 27 сервер) |
| `validate` | 0 ошибок, 0 предупреждений |
| `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок |
| Карты | **94/94** (66 с текстом + 28 ванильных) |
| Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы |
| Сервер / клиент / лаунчер | не начаты |
| Сервер | полный матч по сети, матчмейкинг, реконнект (in-memory) |
| Клиент / лаунчер | не начаты |
Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям:
боты доигрывают 1000 партий, инварианты держатся, реплей воспроизводится
@ -68,12 +69,20 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000
Ожидаемо:
```
Пройден! : не пройдено 0, пройдено 129
Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests)
Пройден! : не пройдено 0, пройдено 27 (Vanguard.Server.Tests)
OK: ошибок 0, предупреждений 0
Переведено: 94/94
Партий: 1000, завершено: 1000, ошибок: 0
```
Сервер локально:
```powershell
dotnet run --project server\src\Vanguard.Server --urls http://localhost:5080
# GET http://localhost:5080/health
```
Solution — **`server/Vanguard.slnx`**, не `.sln`.
---
@ -190,6 +199,44 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
- Проекция вызывается для наблюдателя без побочных эффектов и не трогает
`LegalCommands()` чужого игрока (иначе утекала бы рука решающего).
### 4.8. Сервер
`server/src/Vanguard.Server` — ASP.NET Core 10, один процесс, состояние в памяти.
- **HTTP**: `POST /api/game/queue`, `GET /api/game/queue/{id}`,
`DELETE /api/game/queue/{id}`, `POST /api/game/tickets`, `GET /health`.
Контракт — `docs/server-api.md`.
- **Матчмейкинг**: две очереди (Classic Ranked / Casual), скрытый MMR,
окно ±100 с расширением на 50 каждые 5 с до 2000, выбор ближайшего MMR.
Ранкед меняет MMR по Эло (K = 32), casual — нет.
- **Тикеты**: одноразовые, TTL 60 с, привязаны к `(playerId, matchId, seat)`;
для реконнекта выдаётся новый тикет.
- **`IMatchGrain`**: 1 матч = 1 экземпляр, все входы (интенты, подключения,
тики) проходят через один семафор; внутри нет ни ASP.NET Core, ни WebSocket —
только `IMatchConnection`. Orleans-хостинг подключается поверх контракта.
- **WebSocket** `/game`: handshake, MessagePack, `intentId`, idempotency,
`eventSeq`, `resync`, heartbeat (20 с простоя → ping, ещё 20 с без ответа
→ `4408`), реконнект, захват сессии (`4409`), rate limit 16 интентов/с.
- **Таймеры**: 90 с на решение + банк 300 с, отсутствие 300 с → поражение;
`ServerClock` каждые 5 с.
- **Результат**: сдача, таймер и отсутствие завершают партию через
`GameEngine.Finish(winner)`; событие `GameFinished` уходит обоим,
результат пишется в профили.
- **Проекция**: своя рука, свой выбор, свои легальные интенты; у противника
только количества и открытые зоны. `seed` и RNG не покидают сервер.
- **Docker**: `Dockerfile` + `docker-compose.yml` поднимают сервер с контентом
на `http://localhost:8080`.
- **Тесты** (`Vanguard.Server.Tests`, 27):
- тикеты: одноразовость и TTL;
- heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром;
- grain: attach, чужое место, duplicate intent, чужой ход, сдача,
команды после финала, банк времени, отсутствие, старт часов,
reconnect с `eventSeq`, захват сессии;
- матчмейкинг: подбор, разные очереди, окно, плохая колода,
отсутствие стартового Авангарда, отмена, замена очереди, истечение, MMR;
- E2E: два клиента играют полный матч по WebSocket и сходятся в победителе;
обрыв и возврат по новому тикету с догоном состояния.
---
## 5. Структура и ключевые файлы
@ -215,12 +262,24 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
/server/src/Vanguard.Shared
/Protocol/*.cs кадры, интенты, события, view, кодек
/Projection/*.cs проекция состояния, фильтр событий, маппинг
/server/tests/Vanguard.Rules.Tests 129 тестов
/server/src/Vanguard.Server
/Program.cs ASP.NET Core, DI, WebSocket endpoint
/Api/ очередь, тикеты, /health
/Matchmaking/ две очереди, MMR, расширяющееся окно
/Matches/ IMatchGrain, таймеры, тикер, регистр
/Realtime/ WebSocket-обвязка, чтение кадров
/Tickets/ одноразовые тикеты
/Profiles/ скрытый MMR и статистика
/Dockerfile
/server/tests/Vanguard.Rules.Tests 131 тест
/server/tests/Vanguard.Server.Tests 27 тестов, включая E2E по WebSocket
/content/sets карты
/content/translations/ru.yaml переводы
/docs/translation-rules.md канон перевода
/docs/protocol.md протокол матча, версия 1
/docs/server-api.md HTTP-очередь, тикеты, /health
/docs/HANDOFF.md этот файл
/docker-compose.yml сервер в контейнере
```
---
@ -240,6 +299,16 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
- Бустер: 5 карт = 4 C + 1 R+; веса R 70 / RR 22 / RRR 7 / SP 1.
- Gold за матч: 30/15/20 (победа/поражение/ничья); Gold = XP / 5.
- Таймер хода 90 с + банк 5 мин.
- Транспорт матча — **WebSocket** + MessagePack; TCP с префиксом длины —
резерв для Unity, если WebSocket не подойдёт.
- Тикет на подключение — одноразовый, TTL 60 с, передаётся в `ClientHello`,
не в URL; reconnect получает новый тикет.
- `playerId` в HTTP-API — dev-заглушка до JWT. Выставлять сервер в интернет
в таком виде нельзя.
- Состояние сервера пока in-memory: `IMatchGrain` отделён от транспорта,
Orleans и PostgreSQL подключаются поверх.
- Rate limit — 16 интентов в секунду на соединение.
- Скрытый MMR: старт 1000, Эло K = 32, casual рейтинг не меняет.
- Порядок триггеров выбирает игрок; опциональные способности спрашиваются.
- Непрерывные эффекты пересчитываются при каждом запросе.
- Поиск в колоде: 0 — уведомление и перемешивание, 1 — сразу, 2+ — выбор.
@ -261,28 +330,28 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
2. **[x] `Vanguard.Shared`**:
DTO интентов и событий, MessagePack-резолвер, проекция состояния
на каждого игрока, фильтрация скрытых событий, маппинг интентов.
3. **[x] `Vanguard.Server`** — сетевой матч работает:
ASP.NET Core + WebSocket, тикеты, `IMatchGrain` (in-process),
матчмейкинг, MMR, идемпотентность, таймеры, реконнект, `/health`,
Dockerfile и compose. Контракт HTTP — `docs/server-api.md`.
4. **[x] Проекция состояния** подключена к грану и проверена E2E-тестом:
своя рука и свой выбор полностью, у противника только количества
и открытые зоны, seed и RNG не уходят.
5. **[x] `Docker Compose`** — сервер с контентом на `http://localhost:8080`.
Осталось:
Осталось в серверном направлении:
3. **`Vanguard.Server`**:
- ASP.NET Core + WebSocket;
- `IGameTicketService` (одноразовый тикет, TTL 60 с);
- `IMatchGrain` — 1 матч = 1 grain, последовательная обработка;
- матчмейкинг: 2 очереди (Classic Ranked / Casual);
- скрытый MMR, расширяющееся окно;
- идемпотентность по `intentId`.
4. **Проекция состояния** — контракты готовы, подключить к грану:
- своя рука и свой триггер — полностью;
- открытые зоны — полностью;
- колода, рука противника, закрытый урон — только количество;
- seed и RNG не уходят.
5. **Персистентность**:
- PostgreSQL — снапшот матча, восстановление grain;
6. **Orleans** — заменить in-process регистр на настоящий silo и граны.
`IMatchGrain` уже отделён от транспорта и ASP.NET Core.
7. **Персистентность**:
- PostgreSQL — снапшот матча, восстановление grain после рестарта;
- Redis — тикеты, очереди, кэш.
6. **Docker Compose** + `/health`.
8. **JWT и мета-API** — `playerId` перестанет быть dev-заглушкой.
Критерий готовности: два клиента играют полный матч по сети; kill клиента
на любом шаге восстанавливается.
Критерий готовности серверного блока выполнен: два клиента играют полный
матч по сети; kill клиента на любом шаге восстанавливается через новый
тикет и `resumeFromSeq`. Рестарт **сервера** пока теряет матчи: это
закрывается пунктом 7.
### 7.2. Мета-API
@ -324,8 +393,10 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
1. Прочитать `docs/HANDOFF.md`, `IMPLEMENTATION_CHECKLIST.md`, `README.md`.
2. Прогнать проверки из раздела 3.
3. Продолжить с `Vanguard.Server`: ASP.NET Core + WebSocket, тикеты,
`IMatchGrain`, матчмейкинг. `docs/protocol.md` и `Vanguard.Shared` уже готовы.
3. Следующий блок — персистентность и Orleans: PostgreSQL-снапшоты,
Redis для тикетов/очередей, замена in-process регистра на silo.
Сетевой матч уже работает: `docs/protocol.md`, `Vanguard.Shared`,
`Vanguard.Server`, `docs/server-api.md`.
4. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
5. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
@ -345,6 +416,20 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
`net10.0`; в `netstandard2.1` (Unity) ссылки на `Vanguard.Rules` нет.
- **Видимость.** `LegalCommands()` описывает решающего игрока. Вызывать его
для наблюдателя нельзя — в списке есть printingId из чужой руки.
- **Сервер: rate limit.** 16 интентов в секунду на соединение. Быстрый бот
в E2E специально притормаживается; живой UI в лимит не упирается.
- **Сервер: in-memory.** Очереди, тикеты, матчи и профили живут в памяти.
Рестарт сервера теряет матчи; kill клиента и реконнект переживаются.
- **Сервер: тесты.** E2E поднимает `WebApplicationFactory<Program>`,
играет полный матч по WebSocket и проверяет, что победитель совпал.
Случайные партии завершаются за ~350 действий; таймаут теста — 120 с.
- **Сервер: таймеры.** Тик раз в секунду. После 90 с банк списывается
по фактическому превышению; `TurnDeadlineMs` в этот момент сдвигается,
поэтому UI должен показывать ещё и `BankMs`.
- **Сервер: часы.** Отсчёт стартует только когда подключились оба места.
До этого `ClockView.IsRunning = false`, но таймер отсутствия уже идёт.
- **Сервер: проекция.** `ServerSnapshot` и `ServerEvents` собираются
отдельно на каждое место; не переиспользовать один `MatchView` на двоих.
- **Порядок фаз.** Вход в Основную — `EnterMainPhase()` с `OnPhaseStart`.
- **Отложенные продолжения.** `State.Continuation` — что выполнить после паузы;
`State.PendingQueue` + `_resolvingDepth` запрещают вложенность эффектов.

View File

@ -8,6 +8,7 @@
- `docs/HANDOFF.md` — состояние проекта и порядок блоков;
- `IMPLEMENTATION_CHECKLIST.md`, раздел 5 — план сетевого блока;
- `docs/server-api.md` — HTTP-очередь, тикеты и `/health`;
- `server/src/Vanguard.Shared` — DTO, MessagePack-резолвер и проекция состояния.
---
@ -454,6 +455,7 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
| `MalformedFrame` | мусор, пустой кадр, превышен размер |
| `RateLimited` | слишком много интентов |
| `MatchFinished` | партия закончена, соединение больше не нужно |
| `Forbidden` | тикет валиден, но место в матче чужое |
| `Internal` | непредвиденная ошибка сервера |
Коды закрытия WebSocket (диапазон приложения 4000–4999):
@ -462,6 +464,7 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
|---|---|
| `4400` | нарушение протокола (handshake, битый кадр) |
| `4401` | не авторизован (тикет) |
| `4403` | место в матче не принадлежит игроку |
| `4408` | heartbeat не получен |
| `4409` | соединение заменено новым |
| `4410` | неподдерживаемая версия протокола |

223
docs/server-api.md Normal file
View File

@ -0,0 +1,223 @@
# HTTP API сервера
Служебный HTTP-слой матчевого блока: очередь, тикеты на подключение и `/health`.
После подключения клиент переходит на WebSocket и работает по `docs/protocol.md`.
Базовая часть стека: **ASP.NET Core 10**, один процесс, состояние в памяти.
---
## 1. Запуск
```powershell
$env:PATH = "$env:LOCALAPPDATA\dotnet;$env:PATH"
dotnet run --project server\src\Vanguard.Server --urls http://localhost:5080
```
Проверка:
```powershell
Invoke-RestMethod http://localhost:5080/health
```
Контейнер:
```powershell
docker compose up --build
# http://localhost:8080/health
```
Каталог карт ищется в `content/sets` вверх от каталога сборки. Если контент
лежит в другом месте, задайте `Content:Path` (или `Content__Path` в окружении).
---
## 2. Авторизация
Пока это **dev-заглушка**: игрок представляется строкой `playerId`.
Никаких токенов и паролей нет, поэтому сервер нельзя выставлять в интернет
как есть.
Когда появится мета-API, `playerId` будет браться из JWT, а контракты очереди
и тикетов не изменятся. Ограничения на формат: 1–64 символа, буквы, цифры,
`_`, `.`, `-`.
---
## 3. Очередь
### `POST /api/game/queue`
Поставить игрока в очередь.
```json
{
"playerId": "p1",
"mode": "classic-ranked",
"deck": ["BT01/001EN", "BT01/002EN", "..."]
}
```
- `mode`: `classic-ranked` или `classic-casual`.
- `deck`: ровно 50 `printingId`. Проверяются те же правила, что и в декбилдере:
50 карт, ≤4 по `nameId`, ≤4 Sentinel, ≤4 Heal, известные принты и хотя бы
одна карта grade 0 без триггера для стартового Авангарда.
Ответ `200`:
```json
{
"queueId": "5b5c7a4e-9d5a-4f7b-a0d8-6f1b2c3d4e5f",
"status": "queued",
"mode": "classic-ranked"
}
```
Ответ `400` при плохой колоде:
```json
{
"error": "deck.invalid",
"message": "Колода не проходит проверку.",
"violations": [
{ "code": "deck.size", "message": "В колоде 49 карт, требуется 50." }
]
}
```
Повторный запрос от того же `playerId` отменяет его предыдущую очередь
в любом режиме: один игрок — одна активная очередь.
### `GET /api/game/queue/{queueId}?playerId=p1`
Статус очереди. Клиент опрашивает, пока не получит `matched`.
```json
{
"queueId": "5b5c7a4e-9d5a-4f7b-a0d8-6f1b2c3d4e5f",
"status": "matched",
"mode": "classic-ranked",
"waitedSeconds": 7,
"window": 100,
"matchId": "9f2c...",
"seat": 0,
"ticket": "c2VjcmV0...",
"opponent": "p2",
"reason": null,
"wsUrl": "ws://localhost:5080/game"
}
```
`status`:
| Значение | Смысл |
|---|---|
| `queued` | ждёт противника, `window` — текущее окно MMR |
| `matched` | матч найден, `ticket` — одноразовый тикет |
| `cancelled` | игрок отменил очередь или встал в новую |
| `expired` | очередь истекла (10 минут) |
| `failed` | не удалось создать матч |
`wsUrl` заполняется только для `matched`. Он строится из `Game:PublicWsUrl`
или из текущего запроса. Тикет живёт 60 секунд: если клиент не успел
подключиться, он запрашивает новый через `POST /api/game/tickets` с `matchId`.
Подбор:
- две независимые очереди — ranked и casual;
- скрытый MMR, стартовое окно ±100;
- окно расширяется на 50 каждые 5 секунд, максимум 2000;
- пара выбирается по ближайшему MMR в пределах взаимных окон;
- кто встал раньше — садится за место 0.
MMR наружу не отдаётся. В ранкеде после матча он меняется по Эло (K = 32),
в casual — нет.
### `DELETE /api/game/queue/{queueId}?playerId=p1`
Отменить очередь. `204` — отменена, `409` — уже не активна.
---
## 4. Тикеты
### `POST /api/game/tickets`
Выдать новый одноразовый тикет на подключение. Нужен для переподключения
после обрыва: старый тикет уже израсходован.
```json
{ "playerId": "p1", "matchId": "9f2c..." }
```
Ответ `200`:
```json
{
"ticket": "c2VjcmV0...",
"matchId": "9f2c...",
"seat": 0,
"wsUrl": "ws://localhost:5080/game",
"expiresAt": "2026-10-21T12:01:00+00:00"
}
```
- TTL — 60 секунд, тикет одноразовый.
- Тикет привязан к `(playerId, matchId, seat)`.
- Если матч уже завершён, тикет всё равно выдаётся: клиент может подключиться
и посмотреть итоговое состояние.
- `404` — матч не найден, `403` — игрок не сидит в этом матче.
---
## 5. Health
### `GET /health`
```json
{
"status": "ok",
"matches": 3,
"activeMatches": 2,
"queued": 4,
"serverTimeMs": 1760000000000
}
```
`matches` включает завершённые матчи, которые ещё живут в памяти
(10 минут после финала — для реконнекта и просмотра результата).
---
## 6. Полный сценарий
```
POST /api/game/queue → queueId
GET /api/game/queue/{id} → matched + ticket + wsUrl
WS /game → ClientHello(ticket)
← ServerHello + ServerSnapshot
→ ClientIntent(...)
← ServerIntentResult + ServerEvents
...
обрыв
POST /api/game/tickets → новый ticket
WS /game → ClientHello(ticket, resumeFromSeq)
← ServerHello + ServerEvents (догон)
```
Формат WebSocket-кадров, интентов, событий, таймеров и кодов ошибок —
`docs/protocol.md`.
---
## 7. Ограничения текущего этапа
- Всё состояние (очереди, тикеты, матчи, профили) — в памяти процесса.
Рестарт сервера теряет матчи; клиентский kill и реконнект переживаются.
- Один процесс: горизонтальное масштабирование и Orleans-хостинг ещё впереди.
- `IMatchGrain` уже отделён от ASP.NET Core и WebSocket: замена на Orleans
grain не затронет транспорт и правила.
- Нет PostgreSQL/Redis, JWT, коллекций и наград — это следующие блоки.
- Rate limit: 16 интентов в секунду с одного соединения. Живой UI в него
не упирается; тестовый бот специально притормаживается.

View File

@ -3,9 +3,11 @@
<Project Path="src/Vanguard.Cli/Vanguard.Cli.csproj" />
<Project Path="src/Vanguard.Content/Vanguard.Content.csproj" />
<Project Path="src/Vanguard.Rules/Vanguard.Rules.csproj" />
<Project Path="src/Vanguard.Server/Vanguard.Server.csproj" />
<Project Path="src/Vanguard.Shared/Vanguard.Shared.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Vanguard.Rules.Tests/Vanguard.Rules.Tests.csproj" />
<Project Path="tests/Vanguard.Server.Tests/Vanguard.Server.Tests.csproj" />
</Folder>
</Solution>

View File

@ -47,7 +47,9 @@ public sealed class GameEngine
ulong seed,
IReadOnlyList<string> deck0,
IReadOnlyList<string> deck1,
IReadOnlyDictionary<string, CardScript>? scripts = null)
IReadOnlyDictionary<string, CardScript>? scripts = null,
string player0Name = "Player 1",
string player1Name = "Player 2")
{
var violations = DeckValidator.Validate(catalog, deck0.ToList())
.Concat(DeckValidator.Validate(catalog, deck1.ToList()))
@ -58,7 +60,7 @@ public sealed class GameEngine
"Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message)));
_scripts = scripts ?? CardScripts.Default;
State = new GameState(catalog, seed, "Player 1", "Player 2");
State = new GameState(catalog, seed, player0Name, player1Name);
FillDeck(0, deck0);
FillDeck(1, deck1);
}
@ -1595,6 +1597,9 @@ public sealed class GameEngine
public CommandResult Execute(GameCommand command)
{
if (State.IsFinished)
return CommandResult.Reject("Партия завершена.");
// пока идёт разрешение способности или триггера, посторонние команды запрещены
if (State.Step is Step.Trigger or Step.Ability or Step.Choice)
{
@ -2607,4 +2612,28 @@ public sealed class GameEngine
State.Winner = 1 - player;
_events.Add(new GameFinishedEvent(State.Winner.Value));
}
/// <summary>
/// Завершить партию извне: сдача, истечение времени, отсутствие игрока.
/// Победа достаётся <paramref name="winner"/>, событие пишется в общий журнал.
/// </summary>
public void Finish(int winner)
{
if (winner is not (0 or 1))
throw new ArgumentOutOfRangeException(nameof(winner), winner, "Победитель — 0 или 1.");
if (State.IsFinished)
return;
Defeat(1 - winner);
// незавершённые выборы больше не имеют смысла: партия закончена
State.PendingChoice = null;
State.PendingTrigger = null;
State.PendingAbility = null;
State.PendingOrder = null;
State.PendingQueue = null;
State.Continuation = null;
State.Step = Step.None;
}
}

View File

@ -0,0 +1,34 @@
using Vanguard.Rules.Decks;
namespace Vanguard.Server.Api;
public sealed record QueueRequest(string? PlayerId, string? Mode, List<string>? Deck);
public sealed record QueueAccepted(Guid QueueId, string Status, string Mode);
public sealed record QueueStatusResponse(
Guid QueueId,
string Status,
string Mode,
int WaitedSeconds,
int Window,
string? MatchId,
int Seat,
string? Ticket,
string? Opponent,
string? Reason,
string? WsUrl);
public sealed record TicketRequest(string? PlayerId, string? MatchId);
public sealed record TicketResponse(
string Ticket,
string MatchId,
int Seat,
string WsUrl,
DateTimeOffset ExpiresAt);
public sealed record ApiError(
string Error,
string Message,
IReadOnlyList<DeckViolation>? Violations = null);

View File

@ -0,0 +1,153 @@
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Matchmaking;
using Vanguard.Server.Tickets;
namespace Vanguard.Server.Api;
/// <summary>
/// HTTP-часть матчевого блока: очередь, тикеты, здоровье.
/// Авторизация здесь ещё dev-заглушка: игрок называется playerId.
/// JWT из мета-API встанет на это место без изменения контрактов очереди.
/// </summary>
public static class ApiEndpoints
{
public static void MapGameApi(this WebApplication app)
{
app.MapPost("/api/game/queue", Enqueue);
app.MapGet("/api/game/queue/{queueId:guid}", PollQueue);
app.MapDelete("/api/game/queue/{queueId:guid}", CancelQueue);
app.MapPost("/api/game/tickets", IssueTicket);
app.MapGet("/health", Health);
}
private static IResult Enqueue(
QueueRequest request,
MatchmakingService matchmaking)
{
if (!IsValidPlayerId(request.PlayerId))
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен: 1–64 символа, буквы, цифры, _ . -"));
if (!GameModes.TryParse(request.Mode, out var mode))
return Results.BadRequest(new ApiError("mode.invalid", $"Режим: {GameModes.Ranked} или {GameModes.Casual}."));
if (request.Deck is not { Count: > 0 })
return Results.BadRequest(new ApiError("deck.missing", "Нужен список printingId на 50 карт."));
try
{
var ticket = matchmaking.Enqueue(request.PlayerId!, request.Deck, mode);
return Results.Ok(new QueueAccepted(ticket.QueueId, "queued", ticket.Mode));
}
catch (DeckRejectedException exception)
{
return Results.BadRequest(new ApiError(
"deck.invalid",
"Колода не проходит проверку.",
exception.Violations));
}
}
private static IResult PollQueue(
Guid queueId,
string? playerId,
MatchmakingService matchmaking,
HttpRequest http,
GameOptions options)
{
if (!IsValidPlayerId(playerId))
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен."));
var snapshot = matchmaking.Poll(queueId, playerId!);
if (snapshot is null)
return Results.NotFound(new ApiError("queue.not-found", "Очередь не найдена."));
return Results.Ok(new QueueStatusResponse(
snapshot.QueueId,
snapshot.Status,
snapshot.Mode,
snapshot.WaitedSeconds,
snapshot.Window,
snapshot.MatchId,
snapshot.Seat,
snapshot.Ticket,
snapshot.Opponent,
snapshot.Reason,
snapshot.MatchId is null ? null : BuildWsUrl(http, options)));
}
private static IResult CancelQueue(
Guid queueId,
string? playerId,
MatchmakingService matchmaking)
{
if (!IsValidPlayerId(playerId))
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен."));
return matchmaking.Cancel(queueId, playerId!)
? Results.NoContent()
: Results.Conflict(new ApiError("queue.not-cancellable", "Очередь уже не активна."));
}
private static IResult IssueTicket(
TicketRequest request,
MatchGrainRegistry matches,
GameTicketService tickets,
HttpRequest http,
GameOptions options)
{
if (!IsValidPlayerId(request.PlayerId))
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен."));
if (string.IsNullOrWhiteSpace(request.MatchId))
return Results.BadRequest(new ApiError("match.missing", "matchId обязателен."));
if (!matches.TryGet(request.MatchId!, out var match))
return Results.NotFound(new ApiError("match.not-found", "Матч не найден."));
var summary = match.Summary;
var seat = summary.Player0Id == request.PlayerId
? 0
: summary.Player1Id == request.PlayerId
? 1
: -1;
if (seat < 0)
return Results.Json(
new ApiError("match.forbidden", "Этот игрок не участвует в матче."),
statusCode: StatusCodes.Status403Forbidden);
var ticket = tickets.Issue(request.PlayerId!, request.MatchId!, seat);
return Results.Ok(new TicketResponse(
ticket.Token,
ticket.MatchId,
ticket.Seat,
BuildWsUrl(http, options),
ticket.ExpiresAt));
}
private static IResult Health(MatchGrainRegistry matches, MatchmakingService matchmaking) =>
Results.Ok(new
{
status = "ok",
matches = matches.Count,
activeMatches = matches.ActiveCount,
queued = matchmaking.QueuedCount,
serverTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
private static string BuildWsUrl(HttpRequest request, GameOptions options)
{
if (!string.IsNullOrWhiteSpace(options.PublicWsUrl))
return options.PublicWsUrl;
var scheme = request.IsHttps ? "wss" : "ws";
return $"{scheme}://{request.Host}/game";
}
private static bool IsValidPlayerId(string? playerId) =>
!string.IsNullOrWhiteSpace(playerId)
&& playerId.Length <= 64
&& playerId.All(ch => char.IsLetterOrDigit(ch) || ch is '_' or '-' or '.');
}

View File

@ -0,0 +1,36 @@
namespace Vanguard.Server.Configuration;
/// <summary>
/// Ищет каталог content/sets: сначала явный путь из конфигурации,
/// затем вверх от каталога сборки и текущего каталога.
/// </summary>
public static class ContentPaths
{
public static string Resolve(string? configured)
{
if (!string.IsNullOrWhiteSpace(configured))
{
var full = Path.GetFullPath(configured);
if (!Directory.Exists(full))
throw new DirectoryNotFoundException($"Каталог контента не найден: {full}");
return full;
}
foreach (var start in new[] { AppContext.BaseDirectory, Directory.GetCurrentDirectory() })
{
var directory = new DirectoryInfo(start);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, "content", "sets");
if (Directory.Exists(candidate))
return candidate;
directory = directory.Parent;
}
}
throw new DirectoryNotFoundException(
"Не найден content/sets. Укажите Content:Path в конфигурации.");
}
}

View File

@ -0,0 +1,37 @@
namespace Vanguard.Server.Configuration;
public enum GameMode
{
ClassicRanked = 0,
ClassicCasual = 1,
}
/// <summary>Стабильные имена режимов на проводе. Меняются только вместе с API.</summary>
public static class GameModes
{
public const string Ranked = "classic-ranked";
public const string Casual = "classic-casual";
public static bool TryParse(string? value, out GameMode mode)
{
switch (value)
{
case Ranked:
mode = GameMode.ClassicRanked;
return true;
case Casual:
mode = GameMode.ClassicCasual;
return true;
default:
mode = default;
return false;
}
}
public static string ToWire(GameMode mode) => mode switch
{
GameMode.ClassicRanked => Ranked,
GameMode.ClassicCasual => Casual,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
};
}

View File

@ -0,0 +1,40 @@
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Configuration;
/// <summary>
/// Настройки матча. Значения по умолчанию совпадают с протоколом,
/// но тесты и локальный стенд могут их уменьшить.
/// </summary>
public sealed class GameOptions
{
public int TurnSeconds { get; set; } = ProtocolConstants.TurnSeconds;
public int BankSeconds { get; set; } = ProtocolConstants.TurnBankSeconds;
public int AbsenceSeconds { get; set; } = ProtocolConstants.AbsenceLossSeconds;
public int ClockSyncSeconds { get; set; } = 5;
public int TicketTtlSeconds { get; set; } = ProtocolConstants.TicketTtlSeconds;
public int ConnectTimeoutSeconds { get; set; } = 5;
public int HeartbeatIdleSeconds { get; set; } = 20;
public int HeartbeatTimeoutSeconds { get; set; } = 20;
public int HeartbeatCheckSeconds { get; set; } = 5;
public int CompletedMatchTtlSeconds { get; set; } = 600;
public int QueueExpirySeconds { get; set; } = 600;
/// <summary>Стартовое окно подбора по скрытому MMR.</summary>
public int MmrInitialWindow { get; set; } = 100;
/// <summary>Насколько окно расширяется за интервал ожидания.</summary>
public int MmrWindowStep { get; set; } = 50;
public int MmrWindowIntervalSeconds { get; set; } = 5;
public int MmrMaxWindow { get; set; } = 2000;
/// <summary>Внешний адрес WebSocket. Пусто — строится из запроса.</summary>
public string PublicWsUrl { get; set; } = string.Empty;
}
/// <summary>Где лежит контент: YAML сетов.</summary>
public sealed class ContentOptions
{
/// <summary>Путь к каталогу с BT01.yaml, TD01.yaml, TD02.yaml. Пусто — искать вверх по дереву.</summary>
public string Path { get; set; } = string.Empty;
}

View File

@ -0,0 +1,19 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY global.json ./
COPY server/ ./server/
COPY content/ ./content/
RUN dotnet publish server/src/Vanguard.Server/Vanguard.Server.csproj -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app ./
COPY --from=build /src/content ./content
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "Vanguard.Server.dll"]

View File

@ -0,0 +1,87 @@
using Vanguard.Server.Configuration;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Matches;
/// <summary>Канал доставки кадров одному месту в матче.</summary>
public interface IMatchConnection
{
string Id { get; }
Task SendAsync(ServerFrame frame, CancellationToken cancellationToken = default);
Task CloseAsync(int code, string reason);
}
/// <summary>Одно место в матче: игрок и его колода.</summary>
public sealed record MatchSeatSetup(string PlayerId, IReadOnlyList<string> Deck, int Mmr);
/// <summary>Всё, что нужно для создания матча.</summary>
public sealed record MatchSetup(
string MatchId,
GameMode Mode,
MatchSeatSetup Seat0,
MatchSeatSetup Seat1);
/// <summary>Снимок и итог матча для регистратуры, health и записи результата.</summary>
public sealed record MatchSummary(
string MatchId,
GameMode Mode,
string Player0Id,
string Player1Id,
bool IsFinished,
int Winner,
long EventSeq,
DateTimeOffset CreatedAt,
DateTimeOffset? FinishedAt);
/// <summary>Результат для записи в профили. MMR меняется только в ранкеде.</summary>
public sealed record MatchResult(
string MatchId,
GameMode Mode,
string Player0Id,
string Player1Id,
int WinnerSeat)
{
public string WinnerPlayerId => WinnerSeat == 0 ? Player0Id : Player1Id;
}
public interface IMatchResultSink
{
Task RecordAsync(MatchResult result);
}
/// <summary>
/// Контракт матча: 1 матч = 1 grain. Все методы, меняющие состояние,
/// выполняются последовательно внутри реализации.
///
/// Сейчас grain живёт в памяти процесса. Orleans-хостинг подключается
/// поверх этого же контракта: сетевая часть и правила от него не зависят.
/// </summary>
public interface IMatchGrain
{
string MatchId { get; }
MatchSummary Summary { get; }
bool HasPlayer(string playerId);
/// <summary>Подключить место. false — место не принадлежит игроку.</summary>
Task<bool> AttachAsync(
int seat,
string playerId,
IMatchConnection connection,
long resumeFromSeq);
/// <summary>Отключить место. Отсчёт отсутствия начинается здесь.</summary>
Task DetachAsync(int seat, IMatchConnection connection);
/// <summary>Обработать интент и отправить результат и события в соединения.</summary>
Task SubmitAsync(int seat, ClientIntent intent);
/// <summary>Догнать состояние: журналом или полным снимком.</summary>
Task ResyncAsync(int seat, long fromSeq, bool fullSnapshot);
/// <summary>Один тик часов и проверки отсутствия.</summary>
Task TickAsync(DateTimeOffset now);
}

View File

@ -0,0 +1,575 @@
using Vanguard.Rules.Cards;
using Vanguard.Rules.Engine;
using Vanguard.Server.Configuration;
using Vanguard.Shared.Projection;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Matches;
/// <summary>
/// Один матч. Все входы — интенты, подключения и тики — проходят через
/// один семафор, поэтому состояние движка меняется строго последовательно.
/// Ничего из ASP.NET Core и WebSocket внутри не лежит: только IMatchConnection.
/// </summary>
public sealed class MatchGrain : IMatchGrain
{
private readonly record struct IntentRecord(
bool Accepted,
IntentRejectCode Code,
string? Reason,
long EventSeq);
private sealed class Seat
{
public required string PlayerId { get; init; }
public required IReadOnlyList<string> Deck { get; init; }
public required int Mmr { get; init; }
public IMatchConnection? Connection { get; set; }
public DateTimeOffset? AbsentSince { get; set; }
public bool EverConnected { get; set; }
public long SentSeq { get; set; }
public long BankMs { get; set; }
}
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly Dictionary<string, IntentRecord> _intents = new(StringComparer.Ordinal);
private readonly GameEngine _engine;
private readonly Seat[] _seats;
private readonly GameOptions _options;
private readonly TimeProvider _time;
private readonly IMatchResultSink _results;
private readonly ILogger<MatchGrain> _logger;
private readonly string _matchId;
private readonly GameMode _mode;
private readonly DateTimeOffset _createdAt;
private volatile MatchSummary _summary = null!;
private bool _started;
private DateTimeOffset _turnDeadline;
private DateTimeOffset _lastClockSent;
private DateTimeOffset? _finishedAt;
private bool _resultReported;
public MatchGrain(
MatchSetup setup,
CardCatalog catalog,
GameOptions options,
TimeProvider time,
IMatchResultSink results,
ILogger<MatchGrain> logger)
{
_matchId = setup.MatchId;
_mode = setup.Mode;
_options = options;
_time = time;
_results = results;
_logger = logger;
_createdAt = time.GetUtcNow();
_seats = new[]
{
new Seat
{
PlayerId = setup.Seat0.PlayerId,
Deck = setup.Seat0.Deck,
Mmr = setup.Seat0.Mmr,
BankMs = options.BankSeconds * 1000L,
AbsentSince = _createdAt,
},
new Seat
{
PlayerId = setup.Seat1.PlayerId,
Deck = setup.Seat1.Deck,
Mmr = setup.Seat1.Mmr,
BankMs = options.BankSeconds * 1000L,
AbsentSince = _createdAt,
},
};
var seed = unchecked((ulong)Random.Shared.NextInt64());
_engine = new GameEngine(
catalog,
seed,
setup.Seat0.Deck.ToList(),
setup.Seat1.Deck.ToList(),
player0Name: setup.Seat0.PlayerId,
player1Name: setup.Seat1.PlayerId);
_engine.Start();
RefreshSummary();
}
public string MatchId => _matchId;
public MatchSummary Summary => _summary;
public bool HasPlayer(string playerId) =>
_seats[0].PlayerId == playerId || _seats[1].PlayerId == playerId;
public async Task<bool> AttachAsync(
int seat,
string playerId,
IMatchConnection connection,
long resumeFromSeq)
{
if (seat is not (0 or 1) || _seats[seat].PlayerId != playerId)
{
_logger.LogWarning(
"Match {MatchId}: player {PlayerId} tried to take seat {Seat}.",
_matchId, playerId, seat);
return false;
}
await _gate.WaitAsync();
try
{
var state = _seats[seat];
var now = _time.GetUtcNow();
if (state.Connection is { } old && !ReferenceEquals(old, connection))
{
_logger.LogInformation(
"Match {MatchId}: seat {Seat} replaced a live connection.",
_matchId, seat);
try
{
await old.CloseAsync(4409, "replaced");
}
catch (Exception exception)
{
_logger.LogDebug(exception, "Match {MatchId}: closing old connection failed.", _matchId);
}
}
state.Connection = connection;
state.AbsentSince = null;
state.EverConnected = true;
if (!_started && _seats[0].EverConnected && _seats[1].EverConnected)
StartClock(now);
var to = _engine.Events.Count;
var resumed = resumeFromSeq > 0 && resumeFromSeq <= to;
await connection.SendAsync(new ServerHello
{
ProtocolVersion = ProtocolConstants.Version,
MatchId = _matchId,
PlayerIndex = seat,
Resumed = resumed,
EventSeq = to,
ServerTimeMs = now.ToUnixTimeMilliseconds(),
});
if (resumed)
await SendEventsAsync(seat, resumeFromSeq + 1);
else
await SendSnapshotAsync(seat, now);
await SendClockAsync(seat, now);
_logger.LogInformation(
"Match {MatchId}: seat {Seat} attached (resume from {Seq}, now {Now}).",
_matchId, seat, resumeFromSeq, to);
return true;
}
catch (Exception exception)
{
_logger.LogDebug(exception, "Match {MatchId}: attach for seat {Seat} failed.", _matchId, seat);
_seats[seat].Connection = null;
_seats[seat].AbsentSince ??= _time.GetUtcNow();
return false;
}
finally
{
_gate.Release();
}
}
public async Task DetachAsync(int seat, IMatchConnection connection)
{
if (seat is not (0 or 1))
return;
await _gate.WaitAsync();
try
{
var state = _seats[seat];
if (!ReferenceEquals(state.Connection, connection))
return; // место уже занято новым соединением
state.Connection = null;
state.AbsentSince = _time.GetUtcNow();
_logger.LogInformation("Match {MatchId}: seat {Seat} detached.", _matchId, seat);
}
finally
{
_gate.Release();
}
}
public async Task SubmitAsync(int seat, ClientIntent intent)
{
if (seat is not (0 or 1))
return;
await _gate.WaitAsync();
try
{
var intentId = intent.IntentId ?? string.Empty;
var now = _time.GetUtcNow();
if (intentId.Length is 0 or > 64 || intent.Intent is null)
{
await SendResultAsync(
seat, intentId, accepted: false, IntentRejectCode.Malformed,
"Некорректный intentId или полезная нагрузка.", _engine.Events.Count, duplicate: false);
return;
}
if (_intents.TryGetValue(intentId, out var seen))
{
await SendResultAsync(
seat, intentId, seen.Accepted, seen.Code, seen.Reason, seen.EventSeq, duplicate: true);
return;
}
if (_engine.State.IsFinished)
{
await RejectAsync(seat, intentId, IntentRejectCode.MatchFinished, "Партия завершена.");
return;
}
var isConcede = intent.Intent is ConcedeIntent;
if (!isConcede && seat != _engine.State.ActivePlayer)
{
await RejectAsync(seat, intentId, IntentRejectCode.NotYourTurn, "Сейчас решает другой игрок.");
return;
}
var decisionBefore = _engine.State.ActivePlayer;
CommandResult result;
if (isConcede)
{
_engine.Finish(1 - seat);
result = CommandResult.Ok();
}
else
{
var command = IntentMapping.ToCommand(intent.Intent);
if (command is null)
{
await RejectAsync(seat, intentId, IntentRejectCode.NotSupported, "Интент не поддержан.");
return;
}
result = _engine.Execute(command);
}
var code = result.Accepted ? IntentRejectCode.None : IntentRejectCode.Illegal;
var record = new IntentRecord(result.Accepted, code, result.Reason, _engine.Events.Count);
_intents[intentId] = record;
RefreshSummary();
await SendResultAsync(
seat, intentId, record.Accepted, record.Code, record.Reason, record.EventSeq, duplicate: false);
if (!record.Accepted)
return;
if (!_engine.State.IsFinished && _engine.State.ActivePlayer != decisionBefore)
ResetTurnClock(now);
await PushEventsAsync(0);
await PushEventsAsync(1);
await SendClockToAllAsync(now);
if (_engine.State.IsFinished)
await OnFinishedAsync(now);
}
finally
{
_gate.Release();
}
}
public async Task ResyncAsync(int seat, long fromSeq, bool fullSnapshot)
{
if (seat is not (0 or 1))
return;
await _gate.WaitAsync();
try
{
if (_seats[seat].Connection is null)
return;
var now = _time.GetUtcNow();
var to = _engine.Events.Count;
if (fullSnapshot || fromSeq < 1 || fromSeq > to + 1)
await SendSnapshotAsync(seat, now);
else
await SendEventsAsync(seat, fromSeq);
await SendClockAsync(seat, now);
}
finally
{
_gate.Release();
}
}
public async Task TickAsync(DateTimeOffset now)
{
await _gate.WaitAsync();
try
{
if (_engine.State.IsFinished)
return;
for (var seat = 0; seat < _seats.Length; seat++)
{
var state = _seats[seat];
if (state.Connection is null && state.AbsentSince is { } since)
{
if (now - since < TimeSpan.FromSeconds(_options.AbsenceSeconds))
continue;
_logger.LogInformation(
"Match {MatchId}: seat {Seat} absent for too long, opponent wins.",
_matchId, seat);
await FinishFromServerAsync(1 - seat, now);
return;
}
}
if (!_started)
return;
var active = _engine.State.ActivePlayer;
if (now > _turnDeadline)
{
var overMs = (long)(now - _turnDeadline).TotalMilliseconds;
var remaining = _seats[active].BankMs - overMs;
if (remaining <= 0)
{
_seats[active].BankMs = 0;
_logger.LogInformation(
"Match {MatchId}: seat {Seat} ran out of time.", _matchId, active);
await FinishFromServerAsync(1 - active, now);
return;
}
_seats[active].BankMs = remaining;
_turnDeadline = now;
}
if (now - _lastClockSent >= TimeSpan.FromSeconds(_options.ClockSyncSeconds))
await SendClockToAllAsync(now);
}
finally
{
_gate.Release();
}
}
private async Task RejectAsync(
int seat,
string intentId,
IntentRejectCode code,
string reason)
{
_intents[intentId] = new IntentRecord(false, code, reason, _engine.Events.Count);
await SendResultAsync(
seat, intentId, accepted: false, code, reason, _engine.Events.Count, duplicate: false);
}
private async Task SendResultAsync(
int seat,
string intentId,
bool accepted,
IntentRejectCode code,
string? reason,
long eventSeq,
bool duplicate)
{
await SendToSeatAsync(seat, new ServerIntentResult
{
IntentId = intentId,
Accepted = accepted,
Code = code,
Reason = reason,
EventSeq = eventSeq,
Duplicate = duplicate,
});
}
private async Task PushEventsAsync(int seat)
{
if (_seats[seat].Connection is null)
return;
var from = _seats[seat].SentSeq + 1;
var to = _engine.Events.Count;
if (from > to)
return;
await SendEventsAsync(seat, from);
}
private async Task SendEventsAsync(int seat, long fromSeq)
{
var to = _engine.Events.Count;
var from = Math.Max(1, fromSeq);
var events = EventProjection.ProjectRange(_engine.Events, seat, from);
_seats[seat].SentSeq = to;
await SendToSeatAsync(seat, new ServerEvents
{
FromSeq = from,
ToSeq = to,
Events = events,
});
}
private async Task SendSnapshotAsync(int seat, DateTimeOffset now)
{
var to = _engine.Events.Count;
var view = MatchProjection.ForViewer(_engine, seat, _matchId, to, BuildClock(now));
_seats[seat].SentSeq = to;
await SendToSeatAsync(seat, new ServerSnapshot { View = view });
}
private async Task SendClockToAllAsync(DateTimeOffset now)
{
_lastClockSent = now;
var clock = BuildClock(now);
var eventSeq = _engine.Events.Count;
for (var seat = 0; seat < _seats.Length; seat++)
{
if (_seats[seat].Connection is not null)
await SendToSeatAsync(seat, new ServerClock { Clock = clock, EventSeq = eventSeq });
}
}
private async Task SendClockAsync(int seat, DateTimeOffset now) =>
await SendToSeatAsync(seat, new ServerClock
{
Clock = BuildClock(now),
EventSeq = _engine.Events.Count,
});
private async Task<bool> SendToSeatAsync(int seat, ServerFrame frame)
{
var state = _seats[seat];
if (state.Connection is not { } connection)
return false;
try
{
await connection.SendAsync(frame);
return true;
}
catch (Exception exception)
{
_logger.LogDebug(exception, "Match {MatchId}: send to seat {Seat} failed.", _matchId, seat);
state.Connection = null;
state.AbsentSince ??= _time.GetUtcNow();
return false;
}
}
private async Task FinishFromServerAsync(int winner, DateTimeOffset now)
{
_engine.Finish(winner);
RefreshSummary();
await PushEventsAsync(0);
await PushEventsAsync(1);
await SendClockToAllAsync(now);
await OnFinishedAsync(now);
}
private async Task OnFinishedAsync(DateTimeOffset now)
{
if (_resultReported)
return;
_resultReported = true;
_finishedAt = now;
RefreshSummary();
var winner = _engine.State.Winner ?? -1;
if (winner is not (0 or 1))
return;
try
{
await _results.RecordAsync(new MatchResult(
_matchId,
_mode,
_seats[0].PlayerId,
_seats[1].PlayerId,
winner));
}
catch (Exception exception)
{
_logger.LogError(exception, "Match {MatchId}: recording the result failed.", _matchId);
}
}
private void StartClock(DateTimeOffset now)
{
_started = true;
_turnDeadline = now.AddSeconds(_options.TurnSeconds);
_lastClockSent = now.AddSeconds(-_options.ClockSyncSeconds);
}
private void ResetTurnClock(DateTimeOffset now)
{
if (!_started)
return;
_turnDeadline = now.AddSeconds(_options.TurnSeconds);
}
private ClockView BuildClock(DateTimeOffset now) => new()
{
DecisionPlayer = _engine.State.ActivePlayer,
TurnDeadlineMs = _started && !_engine.State.IsFinished
? _turnDeadline.ToUnixTimeMilliseconds()
: 0,
Bank0Ms = _seats[0].BankMs,
Bank1Ms = _seats[1].BankMs,
IsRunning = _started && !_engine.State.IsFinished,
ServerTimeMs = now.ToUnixTimeMilliseconds(),
};
private void RefreshSummary()
{
_summary = new MatchSummary(
_matchId,
_mode,
_seats[0].PlayerId,
_seats[1].PlayerId,
_engine.State.IsFinished,
_engine.State.Winner ?? -1,
_engine.Events.Count,
_createdAt,
_finishedAt);
}
}

View File

@ -0,0 +1,65 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Vanguard.Server.Configuration;
namespace Vanguard.Server.Matches;
/// <summary>
/// Регистр матчей: ровно один экземпляр grain на matchId.
/// В Orleans эту роль играет активация грана; контракт совпадает.
/// </summary>
public sealed class MatchGrainRegistry
{
private readonly ConcurrentDictionary<string, IMatchGrain> _matches = new(StringComparer.Ordinal);
private readonly IServiceProvider _services;
private readonly GameOptions _options;
private readonly ILogger<MatchGrainRegistry> _logger;
public MatchGrainRegistry(
IServiceProvider services,
GameOptions options,
ILogger<MatchGrainRegistry> logger)
{
_services = services;
_options = options;
_logger = logger;
}
public IMatchGrain Create(MatchSetup setup)
{
var grain = ActivatorUtilities.CreateInstance<MatchGrain>(_services, setup);
if (!_matches.TryAdd(setup.MatchId, grain))
throw new InvalidOperationException($"Матч уже существует: {setup.MatchId}");
_logger.LogInformation(
"Match {MatchId} created: {Mode}, {Player0} vs {Player1}.",
setup.MatchId, setup.Mode, setup.Seat0.PlayerId, setup.Seat1.PlayerId);
return grain;
}
public bool TryGet(string matchId, [NotNullWhen(true)] out IMatchGrain? match) =>
_matches.TryGetValue(matchId, out match);
public ICollection<IMatchGrain> All => _matches.Values;
public int Count => _matches.Count;
public int ActiveCount => _matches.Values.Count(match => !match.Summary.IsFinished);
public void RemoveExpired(DateTimeOffset now)
{
var ttl = TimeSpan.FromSeconds(_options.CompletedMatchTtlSeconds);
foreach (var (matchId, match) in _matches)
{
var summary = match.Summary;
if (summary.IsFinished && summary.FinishedAt is { } finishedAt && now - finishedAt >= ttl)
{
_matches.TryRemove(matchId, out _);
_logger.LogInformation("Match {MatchId} removed after completion.", matchId);
}
}
}
}

View File

@ -0,0 +1,48 @@
using Vanguard.Server.Configuration;
namespace Vanguard.Server.Matches;
/// <summary>
/// Один общий тикер на процесс: часы и проверки отсутствия.
/// Тик раз в секунду, отправка часов клиентам — по своему интервалу.
/// </summary>
public sealed class MatchTicker : BackgroundService
{
private readonly MatchGrainRegistry _registry;
private readonly TimeProvider _time;
private readonly ILogger<MatchTicker> _logger;
public MatchTicker(
MatchGrainRegistry registry,
TimeProvider time,
ILogger<MatchTicker> logger)
{
_registry = registry;
_time = time;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
var now = _time.GetUtcNow();
foreach (var match in _registry.All)
{
try
{
await match.TickAsync(now);
}
catch (Exception exception)
{
_logger.LogError(exception, "Match {MatchId}: tick failed.", match.MatchId);
}
}
_registry.RemoveExpired(now);
}
}
}

View File

@ -0,0 +1,362 @@
using Vanguard.Rules.Cards;
using Vanguard.Rules.Decks;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Profiles;
using Vanguard.Server.Tickets;
namespace Vanguard.Server.Matchmaking;
public enum QueueEntryState
{
Queued,
Matched,
Cancelled,
Expired,
Failed,
}
public sealed record QueueTicket(Guid QueueId, string Mode);
public sealed record QueueSnapshot(
Guid QueueId,
string Status,
string Mode,
int WaitedSeconds,
int Window,
string? MatchId,
int Seat,
string? Ticket,
string? Opponent,
string? Reason);
/// <summary>Колода не прошла правила декбилдера.</summary>
public sealed class DeckRejectedException : Exception
{
public DeckRejectedException(IReadOnlyList<DeckViolation> violations)
: base("Колода не проходит проверку.")
{
Violations = violations;
}
public IReadOnlyList<DeckViolation> Violations { get; }
}
/// <summary>
/// Две очереди, скрытый MMR и расширяющееся окно.
///
/// Состояние в памяти: до Redis этого достаточно для одного процесса.
/// Наружу MMR и колоды не отдаются — только статус, тикет и имя противника.
/// </summary>
public sealed class MatchmakingService
{
private sealed class Entry
{
public required Guid QueueId { get; init; }
public required string PlayerId { get; init; }
public required IReadOnlyList<string> Deck { get; init; }
public required GameMode Mode { get; init; }
public required int Mmr { get; init; }
public required DateTimeOffset EnqueuedAt { get; init; }
public required long Sequence { get; init; }
public QueueEntryState State { get; set; } = QueueEntryState.Queued;
public string? MatchId { get; set; }
public int Seat { get; set; } = -1;
public string? Ticket { get; set; }
public string? Opponent { get; set; }
public string? Reason { get; set; }
}
private readonly object _gate = new();
private readonly Dictionary<Guid, Entry> _entries = new();
private readonly Dictionary<GameMode, List<Guid>> _queues = new()
{
[GameMode.ClassicRanked] = new List<Guid>(),
[GameMode.ClassicCasual] = new List<Guid>(),
};
private readonly CardCatalog _catalog;
private readonly MatchGrainRegistry _matches;
private readonly GameTicketService _tickets;
private readonly PlayerProfileStore _profiles;
private readonly GameOptions _options;
private readonly TimeProvider _time;
private readonly ILogger<MatchmakingService> _logger;
private long _sequence;
public MatchmakingService(
CardCatalog catalog,
MatchGrainRegistry matches,
GameTicketService tickets,
PlayerProfileStore profiles,
GameOptions options,
TimeProvider time,
ILogger<MatchmakingService> logger)
{
_catalog = catalog;
_matches = matches;
_tickets = tickets;
_profiles = profiles;
_options = options;
_time = time;
_logger = logger;
}
public int QueuedCount
{
get
{
lock (_gate)
return _entries.Values.Count(entry => entry.State == QueueEntryState.Queued);
}
}
public QueueTicket Enqueue(string playerId, IReadOnlyList<string> deck, GameMode mode)
{
var violations = DeckValidator.Validate(_catalog, deck.ToList()).ToList();
// колода без grade 0 без триггера не может выставить стартовый Авангард
if (violations.Count == 0
&& !deck.Any(printingId => _catalog.CardOf(printingId).IsStartingVanguardCandidate))
{
violations.Add(new DeckViolation(
"deck.starting-vanguard",
"Нужна карта grade 0 без триггера для стартового Авангарда."));
}
if (violations.Count > 0)
throw new DeckRejectedException(violations);
lock (_gate)
{
// один игрок — одна активная очередь
foreach (var existing in _entries.Values
.Where(entry => entry.PlayerId == playerId
&& entry.State == QueueEntryState.Queued)
.ToList())
{
existing.State = QueueEntryState.Cancelled;
existing.Reason = "Заменено новым запросом в очередь.";
RemoveFromQueue(existing);
}
var entry = new Entry
{
QueueId = Guid.NewGuid(),
PlayerId = playerId,
Deck = deck.ToList(),
Mode = mode,
Mmr = _profiles.GetMmr(playerId),
EnqueuedAt = _time.GetUtcNow(),
Sequence = ++_sequence,
};
_entries[entry.QueueId] = entry;
_queues[mode].Add(entry.QueueId);
_logger.LogInformation(
"Queue: {PlayerId} joined {Mode} (queue {QueueId}).",
playerId, mode, entry.QueueId);
return new QueueTicket(entry.QueueId, GameModes.ToWire(mode));
}
}
public QueueSnapshot? Poll(Guid queueId, string playerId)
{
lock (_gate)
{
if (!_entries.TryGetValue(queueId, out var entry) || entry.PlayerId != playerId)
return null;
return Snapshot(entry, _time.GetUtcNow());
}
}
public bool Cancel(Guid queueId, string playerId)
{
lock (_gate)
{
if (!_entries.TryGetValue(queueId, out var entry) || entry.PlayerId != playerId)
return false;
if (entry.State != QueueEntryState.Queued)
return false;
entry.State = QueueEntryState.Cancelled;
entry.Reason = "Игрок отменил очередь.";
RemoveFromQueue(entry);
_logger.LogInformation("Queue: {PlayerId} cancelled {QueueId}.", playerId, queueId);
return true;
}
}
/// <summary>Один проход подбора. Вызывается фоновым воркером раз в секунду.</summary>
public void Matchmake(DateTimeOffset now)
{
lock (_gate)
{
Expire(now);
foreach (var mode in new[] { GameMode.ClassicRanked, GameMode.ClassicCasual })
MatchmakeMode(mode, now);
}
}
private void MatchmakeMode(GameMode mode, DateTimeOffset now)
{
var entries = _queues[mode]
.Select(id => _entries[id])
.Where(entry => entry.State == QueueEntryState.Queued)
.OrderBy(entry => entry.Sequence)
.ToList();
var used = new HashSet<Guid>();
for (var i = 0; i < entries.Count; i++)
{
var first = entries[i];
if (used.Contains(first.QueueId))
continue;
Entry? best = null;
var bestDiff = int.MaxValue;
for (var j = 0; j < entries.Count; j++)
{
if (i == j)
continue;
var other = entries[j];
if (used.Contains(other.QueueId) || other.PlayerId == first.PlayerId)
continue;
var diff = Math.Abs(first.Mmr - other.Mmr);
var window = Math.Min(Window(first, now), Window(other, now));
if (diff > window)
continue;
if (diff < bestDiff
|| (diff == bestDiff && best is not null && other.Sequence < best.Sequence))
{
best = other;
bestDiff = diff;
}
}
if (best is null)
continue;
used.Add(first.QueueId);
used.Add(best.QueueId);
TryPair(first, best);
}
}
private void TryPair(Entry a, Entry b)
{
var first = a.Sequence < b.Sequence ? a : b;
var second = ReferenceEquals(first, a) ? b : a;
var matchId = Guid.NewGuid().ToString("N");
try
{
_matches.Create(new MatchSetup(
matchId,
first.Mode,
new MatchSeatSetup(first.PlayerId, first.Deck, first.Mmr),
new MatchSeatSetup(second.PlayerId, second.Deck, second.Mmr)));
MarkMatched(first, matchId, 0, second.PlayerId);
MarkMatched(second, matchId, 1, first.PlayerId);
_logger.LogInformation(
"Queue: matched {Player0} vs {Player1} in {MatchId} ({Mode}).",
first.PlayerId, second.PlayerId, matchId, first.Mode);
}
catch (Exception exception)
{
_logger.LogError(exception, "Queue: creating match {MatchId} failed.", matchId);
MarkFailed(first, "Не удалось создать матч.");
MarkFailed(second, "Не удалось создать матч.");
}
}
private void MarkMatched(Entry entry, string matchId, int seat, string opponent)
{
entry.State = QueueEntryState.Matched;
entry.MatchId = matchId;
entry.Seat = seat;
entry.Ticket = _tickets.Issue(entry.PlayerId, matchId, seat).Token;
entry.Opponent = opponent;
RemoveFromQueue(entry);
}
private void MarkFailed(Entry entry, string reason)
{
entry.State = QueueEntryState.Failed;
entry.Reason = reason;
entry.MatchId = null;
entry.Ticket = null;
entry.Seat = -1;
RemoveFromQueue(entry);
}
private void RemoveFromQueue(Entry entry) => _queues[entry.Mode].Remove(entry.QueueId);
private int Window(Entry entry, DateTimeOffset now)
{
var interval = Math.Max(1, _options.MmrWindowIntervalSeconds);
var waited = Math.Max(0, (now - entry.EnqueuedAt).TotalSeconds);
var steps = (int)(waited / interval);
return Math.Min(
_options.MmrMaxWindow,
_options.MmrInitialWindow + steps * _options.MmrWindowStep);
}
private void Expire(DateTimeOffset now)
{
var ttl = TimeSpan.FromSeconds(_options.QueueExpirySeconds);
var keepFinished = TimeSpan.FromSeconds(_options.QueueExpirySeconds * 2.0);
foreach (var entry in _entries.Values.ToList())
{
if (entry.State == QueueEntryState.Queued && now - entry.EnqueuedAt >= ttl)
{
entry.State = QueueEntryState.Expired;
entry.Reason = "Очередь истекла.";
RemoveFromQueue(entry);
_logger.LogInformation("Queue: {QueueId} expired.", entry.QueueId);
continue;
}
if (entry.State != QueueEntryState.Queued && now - entry.EnqueuedAt >= keepFinished)
_entries.Remove(entry.QueueId);
}
}
private QueueSnapshot Snapshot(Entry entry, DateTimeOffset now) => new(
entry.QueueId,
entry.State switch
{
QueueEntryState.Queued => "queued",
QueueEntryState.Matched => "matched",
QueueEntryState.Cancelled => "cancelled",
QueueEntryState.Expired => "expired",
QueueEntryState.Failed => "failed",
_ => "unknown",
},
GameModes.ToWire(entry.Mode),
Math.Max(0, (int)(now - entry.EnqueuedAt).TotalSeconds),
entry.State == QueueEntryState.Queued ? Window(entry, now) : 0,
entry.MatchId,
entry.Seat,
entry.Ticket,
entry.Opponent,
entry.Reason);
}

View File

@ -0,0 +1,36 @@
namespace Vanguard.Server.Matchmaking;
/// <summary>Раз в секунду двигает подбор очередей.</summary>
public sealed class MatchmakingWorker : BackgroundService
{
private readonly MatchmakingService _matchmaking;
private readonly TimeProvider _time;
private readonly ILogger<MatchmakingWorker> _logger;
public MatchmakingWorker(
MatchmakingService matchmaking,
TimeProvider time,
ILogger<MatchmakingWorker> logger)
{
_matchmaking = matchmaking;
_time = time;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
_matchmaking.Matchmake(_time.GetUtcNow());
}
catch (Exception exception)
{
_logger.LogError(exception, "Matchmaking pass failed.");
}
}
}
}

View File

@ -0,0 +1,76 @@
using System.Collections.Concurrent;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
namespace Vanguard.Server.Profiles;
/// <summary>
/// Скрытый MMR и простая статистика. Наружу MMR не отдаётся:
/// клиент видит только результат матча.
///
/// Хранилище в памяти — до появления PostgreSQL. Контракт уже отделён,
/// чтобы заменить реализацию, не трогая матч.
/// </summary>
public sealed class PlayerProfileStore : IMatchResultSink
{
private const int InitialMmr = 1000;
private const int EloK = 32;
private readonly object _gate = new();
private readonly Dictionary<string, Profile> _profiles = new(StringComparer.Ordinal);
public int GetMmr(string playerId)
{
lock (_gate)
{
return _profiles.TryGetValue(playerId, out var profile) ? profile.Mmr : InitialMmr;
}
}
public Task RecordAsync(MatchResult result)
{
lock (_gate)
{
var winnerId = result.WinnerPlayerId;
var loserId = winnerId == result.Player0Id ? result.Player1Id : result.Player0Id;
var winner = GetOrCreate(winnerId);
var loser = GetOrCreate(loserId);
winner.Matches++;
winner.Wins++;
loser.Matches++;
// Casual не двигает скрытый рейтинг: он остаётся только у ранкеда.
if (result.Mode == GameMode.ClassicRanked)
{
var expected = 1.0 / (1.0 + Math.Pow(10.0, (loser.Mmr - winner.Mmr) / 400.0));
var delta = (int)Math.Round(EloK * (1.0 - expected));
winner.Mmr = Math.Max(0, winner.Mmr + delta);
loser.Mmr = Math.Max(0, loser.Mmr - delta);
}
}
return Task.CompletedTask;
}
private Profile GetOrCreate(string playerId)
{
if (!_profiles.TryGetValue(playerId, out var profile))
{
profile = new Profile(playerId);
_profiles[playerId] = profile;
}
return profile;
}
private sealed class Profile
{
public Profile(string playerId) => PlayerId = playerId;
public string PlayerId { get; }
public int Mmr { get; set; } = InitialMmr;
public int Matches { get; set; }
public int Wins { get; set; }
}
}

View File

@ -0,0 +1,54 @@
using Microsoft.Extensions.Options;
using Vanguard.Content;
using Vanguard.Rules.Cards;
using Vanguard.Server.Api;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Matchmaking;
using Vanguard.Server.Profiles;
using Vanguard.Server.Realtime;
using Vanguard.Server.Tickets;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<GameOptions>(builder.Configuration.GetSection("Game"));
builder.Services.Configure<ContentOptions>(builder.Configuration.GetSection("Content"));
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<GameOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<ContentOptions>>().Value);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton(sp =>
{
var content = sp.GetRequiredService<ContentOptions>();
var directory = ContentPaths.Resolve(content.Path);
return CatalogLoader.LoadDirectory(directory);
});
builder.Services.AddSingleton<GameTicketService>();
builder.Services.AddSingleton<PlayerProfileStore>();
builder.Services.AddSingleton<IMatchResultSink>(sp => sp.GetRequiredService<PlayerProfileStore>());
builder.Services.AddSingleton<MatchGrainRegistry>();
builder.Services.AddSingleton<MatchmakingService>();
builder.Services.AddHostedService<MatchTicker>();
builder.Services.AddHostedService<MatchmakingWorker>();
var app = builder.Build();
// каталог грузим сразу: сервер без карт бесполезен, лучше упасть на старте
app.Services.GetRequiredService<CardCatalog>();
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30),
});
app.MapGameApi();
app.Map("/game", GameWebSocketEndpoint.HandleAsync);
app.Run();
/// <summary>Точка входа доступна интеграционным тестам через WebApplicationFactory.</summary>
public partial class Program
{
}

View File

@ -0,0 +1,320 @@
using System.Net.WebSockets;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Tickets;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Realtime;
/// <summary>
/// Точка входа матчевого WebSocket. Отвечает только за транспорт:
/// handshake, чтение кадров, лимит частоты и закрытие. Вся игра — в grain.
/// </summary>
public static class GameWebSocketEndpoint
{
public static async Task HandleAsync(HttpContext context)
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
var services = context.RequestServices;
var options = services.GetRequiredService<GameOptions>();
var tickets = services.GetRequiredService<GameTicketService>();
var matches = services.GetRequiredService<MatchGrainRegistry>();
var time = services.GetRequiredService<TimeProvider>();
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("GameSocket");
using var socket = await context.WebSockets.AcceptWebSocketAsync();
var connection = new WebSocketMatchConnection(socket);
IMatchGrain? match = null;
var seat = -1;
HeartbeatState? heartbeat = null;
CancellationTokenSource? heartbeatCts = null;
Task? heartbeatTask = null;
try
{
ClientFrame? first;
try
{
first = await ReadWithTimeoutAsync(
socket,
TimeSpan.FromSeconds(options.ConnectTimeoutSeconds),
context.RequestAborted);
}
catch (OperationCanceledException) when (!context.RequestAborted.IsCancellationRequested)
{
await FailAsync(connection, ProtocolErrorCode.MalformedFrame,
"Handshake не получен вовремя.", 4400, "handshake timeout");
return;
}
catch (ProtocolFormatException exception)
{
logger.LogDebug(exception, "Bad handshake frame.");
await FailAsync(connection, ProtocolErrorCode.MalformedFrame,
"Первым кадром должен быть ClientHello.", 4400, "invalid handshake");
return;
}
if (first is not ClientHello hello)
{
await FailAsync(connection, ProtocolErrorCode.MalformedFrame,
"Первым кадром должен быть ClientHello.", 4400, "invalid handshake");
return;
}
if (hello.ProtocolVersion != ProtocolConstants.Version)
{
await FailAsync(connection, ProtocolErrorCode.UnsupportedVersion,
$"Поддерживается версия {ProtocolConstants.Version}.", 4410, "unsupported version");
return;
}
var ticket = tickets.Redeem(hello.Ticket);
if (ticket is null)
{
await FailAsync(connection, ProtocolErrorCode.InvalidTicket,
"Тикет неизвестен, использован или просрочен.", 4401, "invalid ticket");
return;
}
if (!matches.TryGet(ticket.MatchId, out match))
{
await FailAsync(connection, ProtocolErrorCode.MatchNotFound,
"Матч не найден.", 4401, "match not found");
return;
}
seat = ticket.Seat;
if (!await match.AttachAsync(seat, ticket.PlayerId, connection, hello.ResumeFromSeq))
{
await FailAsync(connection, ProtocolErrorCode.Forbidden,
"Место в матче не принадлежит игроку.", 4403, "forbidden");
return;
}
logger.LogInformation(
"Socket {ConnectionId}: {PlayerId} connected to {MatchId} seat {Seat}.",
connection.Id, ticket.PlayerId, ticket.MatchId, seat);
heartbeat = new HeartbeatState(time.GetUtcNow());
heartbeatCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
heartbeatTask = HeartbeatLoopAsync(connection, heartbeat, time, options, heartbeatCts.Token);
var limiter = new IntentRateLimiter(ProtocolConstants.MaxIntentsPerSecond, time);
while (!context.RequestAborted.IsCancellationRequested
&& socket.State == WebSocketState.Open)
{
ClientFrame? frame;
try
{
frame = await ProtocolFrameReader.ReadAsync(socket, context.RequestAborted);
}
catch (OperationCanceledException)
{
break;
}
catch (WebSocketException)
{
break;
}
catch (ProtocolFormatException exception)
{
logger.LogDebug(exception, "Match {MatchId}: bad frame.", match.MatchId);
await FailAsync(connection, ProtocolErrorCode.MalformedFrame,
"Некорректный кадр.", 4400, "malformed frame", match);
break;
}
if (frame is null)
break;
heartbeat.Touch(time.GetUtcNow());
try
{
switch (frame)
{
case ClientIntent intent:
if (!limiter.TryAcquire())
{
await connection.SendAsync(new ServerError
{
Code = ProtocolErrorCode.RateLimited,
Message = "Слишком много интентов.",
Fatal = false,
EventSeq = match.Summary.EventSeq,
});
break;
}
await match.SubmitAsync(seat, intent);
break;
case ClientResync resync:
await match.ResyncAsync(seat, resync.FromSeq, resync.FullSnapshot);
break;
case ClientPing ping:
await connection.SendAsync(new ServerPong
{
Nonce = ping.Nonce,
ServerTimeMs = time.GetUtcNow().ToUnixTimeMilliseconds(),
});
break;
case ClientPong:
break;
case ClientLeave:
return;
default:
await FailAsync(connection, ProtocolErrorCode.MalformedFrame,
"Неожиданный кадр после handshake.", 4400, "unexpected frame", match);
return;
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "Match {MatchId}: intent processing failed.", match.MatchId);
await FailAsync(connection, ProtocolErrorCode.Internal,
"Внутренняя ошибка сервера.", 4500, "internal error", match);
break;
}
}
}
catch (WebSocketException exception)
{
logger.LogDebug(exception, "Socket {ConnectionId} failed.", connection.Id);
}
catch (OperationCanceledException)
{
// штатное завершение запроса
}
finally
{
if (heartbeatCts is not null)
{
heartbeatCts.Cancel();
if (heartbeatTask is not null)
{
try
{
await heartbeatTask;
}
catch (Exception)
{
// heartbeat уже завершился или соединение умерло
}
}
heartbeatCts.Dispose();
}
if (match is not null && (seat == 0 || seat == 1))
{
try
{
await match.DetachAsync(seat, connection);
}
catch (Exception exception)
{
logger.LogDebug(exception, "Socket {ConnectionId}: detach failed.", connection.Id);
}
}
await connection.CloseAsync(1000, "bye");
}
}
private static async Task<ClientFrame?> ReadWithTimeoutAsync(
WebSocket socket,
TimeSpan timeout,
CancellationToken cancellationToken)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
linked.CancelAfter(timeout);
return await ProtocolFrameReader.ReadAsync(socket, linked.Token);
}
private static async Task HeartbeatLoopAsync(
IMatchConnection connection,
HeartbeatState state,
TimeProvider time,
GameOptions options,
CancellationToken cancellationToken)
{
var check = TimeSpan.FromSeconds(Math.Max(1, options.HeartbeatCheckSeconds));
var idle = TimeSpan.FromSeconds(options.HeartbeatIdleSeconds);
var timeout = TimeSpan.FromSeconds(options.HeartbeatTimeoutSeconds);
using var timer = new PeriodicTimer(check);
try
{
while (await timer.WaitForNextTickAsync(cancellationToken))
{
var action = state.Evaluate(time.GetUtcNow(), idle, timeout);
if (action.Kind == HeartbeatActionKind.Ping)
{
await connection.SendAsync(new ServerPing
{
Nonce = action.Nonce,
ServerTimeMs = time.GetUtcNow().ToUnixTimeMilliseconds(),
}, cancellationToken);
}
else if (action.Kind == HeartbeatActionKind.Timeout)
{
await connection.CloseAsync(4408, "heartbeat timeout");
return;
}
}
}
catch (OperationCanceledException)
{
// штатное завершение соединения
}
catch (Exception)
{
// отправка не удалась — соединение закроет основной цикл чтения
}
}
private static async Task FailAsync(
IMatchConnection connection,
ProtocolErrorCode code,
string message,
int closeCode,
string closeReason,
IMatchGrain? match = null)
{
try
{
await connection.SendAsync(new ServerError
{
Code = code,
Message = message,
Fatal = true,
EventSeq = match?.Summary.EventSeq ?? 0,
});
}
catch (Exception)
{
// клиент мог уже отвалиться
}
await connection.CloseAsync(closeCode, closeReason);
}
}

View File

@ -0,0 +1,56 @@
namespace Vanguard.Server.Realtime;
internal enum HeartbeatActionKind
{
None,
Ping,
Timeout,
}
internal readonly record struct HeartbeatAction(HeartbeatActionKind Kind, long Nonce = 0);
/// <summary>
/// Состояние heartbeat одного соединения. Любой кадр клиента считается
/// активностью; если активности нет — сервер шлёт ping, а без ответа
/// закрывает соединение.
/// </summary>
internal sealed class HeartbeatState
{
private readonly object _gate = new();
private DateTimeOffset _lastReceived;
private DateTimeOffset? _pingSent;
private long _nextNonce;
public HeartbeatState(DateTimeOffset now) => _lastReceived = now;
public void Touch(DateTimeOffset now)
{
lock (_gate)
{
_lastReceived = now;
_pingSent = null;
}
}
public HeartbeatAction Evaluate(
DateTimeOffset now,
TimeSpan idle,
TimeSpan timeout)
{
lock (_gate)
{
if (_pingSent is { } sent)
{
return now - sent >= timeout
? new HeartbeatAction(HeartbeatActionKind.Timeout)
: new HeartbeatAction(HeartbeatActionKind.None);
}
if (now - _lastReceived < idle)
return new HeartbeatAction(HeartbeatActionKind.None);
_pingSent = now;
return new HeartbeatAction(HeartbeatActionKind.Ping, ++_nextNonce);
}
}
}

View File

@ -0,0 +1,64 @@
using System.Net.WebSockets;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Realtime;
/// <summary>Чтение одного кадра протокола из бинарных сообщений WebSocket.</summary>
internal static class ProtocolFrameReader
{
public static async Task<ClientFrame?> ReadAsync(WebSocket socket, CancellationToken cancellationToken)
{
var buffer = new byte[16 * 1024];
using var stream = new MemoryStream();
while (true)
{
var result = await socket.ReceiveAsync(buffer.AsMemory(), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
return null;
if (result.MessageType != WebSocketMessageType.Binary)
throw new ProtocolFormatException("Ожидался бинарный кадр.");
stream.Write(buffer, 0, result.Count);
if (stream.Length > ProtocolConstants.MaxFrameBytes)
throw new ProtocolFormatException(
$"Кадр больше {ProtocolConstants.MaxFrameBytes} байт.");
if (result.EndOfMessage)
break;
}
return ProtocolSerializer.DecodeClient(stream.ToArray());
}
}
/// <summary>Не больше N интентов в секунду с одного соединения.</summary>
internal sealed class IntentRateLimiter
{
private readonly int _limit;
private readonly TimeProvider _time;
private DateTimeOffset _windowStart;
private int _count;
public IntentRateLimiter(int limit, TimeProvider time)
{
_limit = limit;
_time = time;
_windowStart = time.GetUtcNow();
}
public bool TryAcquire()
{
var now = _time.GetUtcNow();
if (now - _windowStart >= TimeSpan.FromSeconds(1))
{
_windowStart = now;
_count = 0;
}
return ++_count <= _limit;
}
}

View File

@ -0,0 +1,70 @@
using System.Net.WebSockets;
using Vanguard.Server.Matches;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Realtime;
/// <summary>
/// Обёртка над WebSocket: один писатель, аккуратное закрытие и защита
/// от отправки в уже закрытый сокет.
/// </summary>
public sealed class WebSocketMatchConnection : IMatchConnection
{
private readonly WebSocket _socket;
private readonly SemaphoreSlim _sendLock = new(1, 1);
private int _closed;
public WebSocketMatchConnection(WebSocket socket)
{
_socket = socket;
Id = Guid.NewGuid().ToString("N");
}
public string Id { get; }
public async Task SendAsync(ServerFrame frame, CancellationToken cancellationToken = default)
{
if (Volatile.Read(ref _closed) != 0)
return;
var bytes = ProtocolSerializer.Encode(frame);
await _sendLock.WaitAsync(cancellationToken);
try
{
if (_socket.State != WebSocketState.Open)
return;
await _socket.SendAsync(
bytes.AsMemory(),
WebSocketMessageType.Binary,
endOfMessage: true,
cancellationToken);
}
finally
{
_sendLock.Release();
}
}
public async Task CloseAsync(int code, string reason)
{
if (Interlocked.Exchange(ref _closed, 1) != 0)
return;
await _sendLock.WaitAsync();
try
{
if (_socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
await _socket.CloseOutputAsync((WebSocketCloseStatus)code, reason, CancellationToken.None);
}
catch (Exception)
{
// соединение уже могло умереть — закрывать нечего
}
finally
{
_sendLock.Release();
}
}
}

View File

@ -0,0 +1,83 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using Vanguard.Server.Configuration;
namespace Vanguard.Server.Tickets;
/// <summary>Одноразовый тикет на подключение к конкретному месту в матче.</summary>
public sealed record GameTicket(
string Token,
string PlayerId,
string MatchId,
int Seat,
DateTimeOffset ExpiresAt);
/// <summary>
/// Тикеты живут в памяти: TTL короткий, переживать рестарт им не нужно.
/// При переезде на нескольких инстансов сервис заменяется на Redis-реализацию.
/// </summary>
public sealed class GameTicketService
{
private readonly ConcurrentDictionary<string, GameTicket> _tickets = new(StringComparer.Ordinal);
private readonly GameOptions _options;
private readonly TimeProvider _time;
public GameTicketService(GameOptions options, TimeProvider time)
{
_options = options;
_time = time;
}
public GameTicket Issue(string playerId, string matchId, int seat)
{
if (seat is not (0 or 1))
throw new ArgumentOutOfRangeException(nameof(seat), seat, "Место — 0 или 1.");
var now = _time.GetUtcNow();
var ticket = new GameTicket(
NewToken(),
playerId,
matchId,
seat,
now.AddSeconds(_options.TicketTtlSeconds));
_tickets[ticket.Token] = ticket;
RemoveExpired(now);
return ticket;
}
/// <summary>
/// Забирает тикет. null — неизвестный, уже использованный или просроченный.
/// Тикет одноразовый: повторный Redeem того же токена не сработает.
/// </summary>
public GameTicket? Redeem(string? token)
{
if (string.IsNullOrWhiteSpace(token) || token.Length > 128)
return null;
if (!_tickets.TryRemove(token, out var ticket))
return null;
return ticket.ExpiresAt > _time.GetUtcNow() ? ticket : null;
}
public int Count => _tickets.Count;
private void RemoveExpired(DateTimeOffset now)
{
foreach (var (token, ticket) in _tickets)
{
if (ticket.ExpiresAt <= now)
_tickets.TryRemove(token, out _);
}
}
private static string NewToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
}

View File

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Vanguard.Server</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Vanguard.Content\Vanguard.Content.csproj" />
<ProjectReference Include="..\Vanguard.Rules\Vanguard.Rules.csproj" />
<ProjectReference Include="..\Vanguard.Shared\Vanguard.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Vanguard.Server.Tests" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,30 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Game": {
"TurnSeconds": 90,
"BankSeconds": 300,
"AbsenceSeconds": 300,
"ClockSyncSeconds": 5,
"TicketTtlSeconds": 60,
"ConnectTimeoutSeconds": 5,
"HeartbeatIdleSeconds": 20,
"HeartbeatTimeoutSeconds": 20,
"HeartbeatCheckSeconds": 5,
"CompletedMatchTtlSeconds": 600,
"QueueExpirySeconds": 600,
"MmrInitialWindow": 100,
"MmrWindowStep": 50,
"MmrWindowIntervalSeconds": 5,
"MmrMaxWindow": 2000,
"PublicWsUrl": ""
},
"Content": {
"Path": ""
}
}

View File

@ -115,4 +115,7 @@ public enum ProtocolErrorCode
/// <summary>Внутренняя ошибка сервера.</summary>
Internal = 7,
/// <summary>Тикет валиден, но место в матче не принадлежит этому игроку.</summary>
Forbidden = 8,
}

View File

@ -1720,6 +1720,37 @@ public class GameEngineTests
}
}
[Fact]
public void Finish_Awards_Winner_And_Rejects_Later_Commands()
{
var engine = InMainPhase();
engine.Finish(1);
Assert.True(engine.State.IsFinished);
Assert.Equal(1, engine.State.Winner);
Assert.True(engine.State.Players[0].IsDefeated);
Assert.IsType<GameFinishedEvent>(engine.Events[^1]);
var result = engine.Execute(new EndTurnCommand());
Assert.False(result.Accepted);
Assert.Equal("Партия завершена.", result.Reason);
Assert.Empty(engine.LegalCommands());
}
[Fact]
public void Finish_Is_Idempotent()
{
var engine = InMainPhase();
var before = engine.Events.Count;
engine.Finish(0);
engine.Finish(1);
Assert.Equal(0, engine.State.Winner);
Assert.Equal(before + 1, engine.Events.Count);
}
private static void AssertInvariants(GameState state, string context)
{
var ids = new HashSet<int>();

View File

@ -0,0 +1,191 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Vanguard.Server.Api;
using Vanguard.Shared.Protocol;
using Xunit.Sdk;
namespace Vanguard.Server.Tests;
public class GameServerE2ETests
{
[Fact]
public async Task Two_Clients_Play_A_Full_Match_Over_WebSocket()
{
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);
Assert.Equal(0, firstStatus.Seat);
Assert.Equal(1, secondStatus.Seat);
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(
PlayAsync(first, seed: 11, cts.Token),
PlayAsync(second, seed: 22, cts.Token));
Assert.True(first.View.IsFinished, "Первый клиент не увидел завершение партии.");
Assert.True(second.View.IsFinished, "Второй клиент не увидел завершение партии.");
Assert.Equal(first.View.Winner, second.View.Winner);
Assert.InRange(first.View.Winner, 0, 1);
var loser = 1 - first.View.Winner;
Assert.False(first.View.Players[first.View.Winner].IsDefeated);
Assert.True(first.View.Players[loser].IsDefeated);
var health = await http.GetFromJsonAsync<JsonElement>("/health");
Assert.True(health.GetProperty("matches").GetInt32() >= 1);
}
[Fact]
public async Task Client_Can_Reconnect_And_Resume_After_Disconnect()
{
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");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
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);
var result = await first.SubmitAsync("mulligan-1", new FinishMulliganIntent(), cts.Token);
Assert.True(result.Accepted, result.Reason);
var lastSeq = result.EventSeq;
await first.DisposeAsync();
var ticketResponse = await http.PostAsJsonAsync(
"/api/game/tickets",
new TicketRequest("p1", firstStatus.MatchId));
if (!ticketResponse.IsSuccessStatusCode)
throw new XunitException(await ticketResponse.Content.ReadAsStringAsync());
var ticket = await ticketResponse.Content.ReadFromJsonAsync<TicketResponse>();
Assert.NotNull(ticket);
await using var reconnected = await TestGameClient.ConnectAsync(
factory, firstStatus.MatchId!, ticket!.Ticket, "p1", lastSeq, cts.Token);
Assert.True(reconnected.View.EventSeq >= lastSeq);
Assert.Equal(0, reconnected.Seat);
await second.RequestSnapshotAsync(cts.Token);
Assert.Equal(reconnected.View.EventSeq, second.View.EventSeq);
Assert.Equal(reconnected.View.ActivePlayer, second.View.ActivePlayer);
Assert.False(reconnected.View.IsFinished);
}
private static WebApplicationFactory<Program> NewFactory() => new();
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 секунд.");
}
private static async Task PlayAsync(
TestGameClient client,
int seed,
CancellationToken cancellationToken)
{
var random = new Random(seed);
var deadline = DateTime.UtcNow.AddSeconds(90);
while (!client.View.IsFinished)
{
if (DateTime.UtcNow > deadline)
throw new XunitException(
$"Тест не доиграл партию за 90 секунд (seat {client.Seat}).");
if (client.View.ActivePlayer == client.Seat)
{
var choices = client.View.LegalIntents
.Where(intent => intent is not ConcedeIntent)
.ToList();
if (choices.Count == 0)
throw new XunitException(
$"Нет легальных интентов для seat {client.Seat}, " +
$"фаза {client.View.Phase}, шаг {client.View.Step}.");
var intent = choices[random.Next(choices.Count)];
var intentId = Guid.NewGuid().ToString("N");
var result = await client.SubmitAsync(intentId, intent, cancellationToken);
if (result.Duplicate)
throw new XunitException($"Неожиданный duplicate для {intentId}.");
if (!result.Accepted)
throw new XunitException(
$"Интент {intent.GetType().Name} отклонён: {result.Code} {result.Reason}");
// держимся ниже лимита 16 интентов в секунду
await Task.Delay(75, cancellationToken);
}
await client.RequestSnapshotAsync(cancellationToken);
if (!client.View.IsFinished && client.View.ActivePlayer != client.Seat)
await Task.Delay(20, cancellationToken);
}
}
}

View File

@ -0,0 +1,51 @@
using Vanguard.Server.Tickets;
namespace Vanguard.Server.Tests;
public class GameTicketServiceTests
{
[Fact]
public void Ticket_Is_Redeemed_Only_Once()
{
var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
var service = new GameTicketService(ServerTestData.SmallOptions(), time);
var ticket = service.Issue("p1", "m1", 0);
var first = service.Redeem(ticket.Token);
var second = service.Redeem(ticket.Token);
Assert.NotNull(first);
Assert.Equal("p1", first!.PlayerId);
Assert.Equal("m1", first.MatchId);
Assert.Equal(0, first.Seat);
Assert.Null(second);
}
[Fact]
public void Ticket_Expires_After_Ttl()
{
var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
var options = ServerTestData.SmallOptions();
options.TicketTtlSeconds = 60;
var service = new GameTicketService(options, time);
var ticket = service.Issue("p1", "m1", 1);
time.Advance(TimeSpan.FromSeconds(61));
Assert.Null(service.Redeem(ticket.Token));
}
[Fact]
public void Unknown_Token_Is_Rejected()
{
var service = new GameTicketService(
ServerTestData.SmallOptions(),
new FakeTimeProvider(DateTimeOffset.UnixEpoch));
Assert.Null(service.Redeem(null));
Assert.Null(service.Redeem(""));
Assert.Null(service.Redeem("no-such-ticket"));
}
}

View File

@ -0,0 +1,46 @@
using Vanguard.Server.Realtime;
namespace Vanguard.Server.Tests;
public class HeartbeatStateTests
{
private static readonly DateTimeOffset Start = new(2026, 10, 21, 12, 0, 0, TimeSpan.Zero);
private static readonly TimeSpan Idle = TimeSpan.FromSeconds(20);
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(20);
[Fact]
public void Server_Pings_After_Idle_And_Closes_After_Timeout()
{
var state = new HeartbeatState(Start);
Assert.Equal(HeartbeatActionKind.None, state.Evaluate(Start, Idle, Timeout).Kind);
Assert.Equal(HeartbeatActionKind.None,
state.Evaluate(Start.AddSeconds(19), Idle, Timeout).Kind);
var ping = state.Evaluate(Start.AddSeconds(20), Idle, Timeout);
Assert.Equal(HeartbeatActionKind.Ping, ping.Kind);
Assert.Equal(1, ping.Nonce);
Assert.Equal(HeartbeatActionKind.None,
state.Evaluate(Start.AddSeconds(30), Idle, Timeout).Kind);
Assert.Equal(HeartbeatActionKind.Timeout,
state.Evaluate(Start.AddSeconds(40), Idle, Timeout).Kind);
}
[Fact]
public void Any_Frame_Resets_The_Idle_Timer()
{
var state = new HeartbeatState(Start);
state.Evaluate(Start.AddSeconds(20), Idle, Timeout); // ping отправлен
state.Touch(Start.AddSeconds(25)); // клиент ответил
Assert.Equal(HeartbeatActionKind.None,
state.Evaluate(Start.AddSeconds(44), Idle, Timeout).Kind);
var next = state.Evaluate(Start.AddSeconds(45), Idle, Timeout);
Assert.Equal(HeartbeatActionKind.Ping, next.Kind);
Assert.Equal(2, next.Nonce);
}
}

View File

@ -0,0 +1,309 @@
using Microsoft.Extensions.Logging.Abstractions;
using Vanguard.Rules.Engine;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Tests;
public class MatchGrainTests
{
private static readonly DateTimeOffset Start = new(2026, 10, 21, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Attach_Sends_Hello_Snapshot_And_Hides_Opponent_Hand()
{
var time = new FakeTimeProvider(Start);
var grain = NewGrain(time);
var first = new FakeMatchConnection();
var second = new FakeMatchConnection();
Assert.True(await grain.AttachAsync(0, "p1", first, 0));
Assert.True(await grain.AttachAsync(1, "p2", second, 0));
var hello = first.All<ServerHello>().Single();
Assert.Equal(0, hello.PlayerIndex);
Assert.False(hello.Resumed);
var snapshot = first.All<ServerSnapshot>().Single().View;
Assert.Equal(5, snapshot.Players[0].Hand.Count);
Assert.Equal(5, snapshot.Players[1].HandCount);
Assert.Empty(snapshot.Players[1].Hand);
var clock = second.Last<ServerClock>().Clock;
Assert.True(clock.IsRunning);
Assert.Equal(0, clock.DecisionPlayer);
}
[Fact]
public async Task Wrong_Player_Cannot_Take_Seat()
{
var grain = NewGrain(new FakeTimeProvider(Start));
Assert.False(await grain.AttachAsync(0, "intruder", new FakeMatchConnection(), 0));
Assert.False(await grain.AttachAsync(2, "p1", new FakeMatchConnection(), 0));
}
[Fact]
public async Task Duplicate_Intent_Is_Not_Applied_Twice()
{
var time = new FakeTimeProvider(Start);
var grain = NewGrain(time);
var first = await Attach(grain, 0, "p1");
var second = await Attach(grain, 1, "p2");
var intent = new ClientIntent
{
IntentId = "intent-1",
BasedOnSeq = grain.Summary.EventSeq,
Intent = new FinishMulliganIntent(),
};
await grain.SubmitAsync(0, intent);
var accepted = first.Last<ServerIntentResult>();
Assert.True(accepted.Accepted);
Assert.False(accepted.Duplicate);
var seqAfterFirst = grain.Summary.EventSeq;
var eventsAfterFirst = first.All<ServerEvents>().Count;
await grain.SubmitAsync(0, intent);
var duplicate = first.Last<ServerIntentResult>();
Assert.True(duplicate.Accepted);
Assert.True(duplicate.Duplicate);
Assert.Equal(seqAfterFirst, grain.Summary.EventSeq);
Assert.Equal(eventsAfterFirst, first.All<ServerEvents>().Count);
Assert.True(second.All<ServerIntentResult>().Count == 0);
}
[Fact]
public async Task Non_Active_Player_Is_Rejected()
{
var grain = NewGrain(new FakeTimeProvider(Start));
var first = await Attach(grain, 0, "p1");
var second = await Attach(grain, 1, "p2");
var seqBefore = grain.Summary.EventSeq;
await grain.SubmitAsync(1, new ClientIntent
{
IntentId = "p2-1",
BasedOnSeq = grain.Summary.EventSeq,
Intent = new FinishMulliganIntent(),
});
var result = second.Last<ServerIntentResult>();
Assert.False(result.Accepted);
Assert.Equal(IntentRejectCode.NotYourTurn, result.Code);
Assert.Equal(seqBefore, grain.Summary.EventSeq);
}
[Fact]
public async Task Concede_Works_Regardless_Of_Turn_And_Finishes_Match()
{
var sink = new RecordingResultSink();
var grain = NewGrain(new FakeTimeProvider(Start), sink);
var first = await Attach(grain, 0, "p1");
var second = await Attach(grain, 1, "p2");
// сейчас маллиган игрока 0 — сдаётся игрок 1, он не активен
await grain.SubmitAsync(1, new ClientIntent
{
IntentId = "concede",
BasedOnSeq = grain.Summary.EventSeq,
Intent = new ConcedeIntent(),
});
var result = second.Last<ServerIntentResult>();
Assert.True(result.Accepted);
Assert.True(grain.Summary.IsFinished);
Assert.Equal(0, grain.Summary.Winner);
Assert.Contains(first.All<ServerEvents>().SelectMany(e => e.Events),
e => e.Event is GameFinishedView { Winner: 0 });
var recorded = Assert.Single(sink.Results);
Assert.Equal("p1", recorded.WinnerPlayerId);
var loserId = recorded.Player0Id == recorded.WinnerPlayerId
? recorded.Player1Id
: recorded.Player0Id;
Assert.Equal("p2", loserId);
}
[Fact]
public async Task Intents_After_Finish_Are_Rejected()
{
var grain = NewGrain(new FakeTimeProvider(Start));
var first = await Attach(grain, 0, "p1");
await Attach(grain, 1, "p2");
await grain.SubmitAsync(1, new ClientIntent
{
IntentId = "concede",
BasedOnSeq = 1,
Intent = new ConcedeIntent(),
});
await grain.SubmitAsync(0, new ClientIntent
{
IntentId = "after-finish",
BasedOnSeq = grain.Summary.EventSeq,
Intent = new FinishMulliganIntent(),
});
var result = first.Last<ServerIntentResult>();
Assert.False(result.Accepted);
Assert.Equal(IntentRejectCode.MatchFinished, result.Code);
}
[Fact]
public async Task Turn_Clock_And_Bank_Drain_To_Defeat()
{
var time = new FakeTimeProvider(Start);
var options = ServerTestData.SmallOptions();
options.TurnSeconds = 10;
options.BankSeconds = 20;
var grain = NewGrain(time, options: options);
var first = await Attach(grain, 0, "p1");
await Attach(grain, 1, "p2");
// 10 секунд бесплатно + 15 из банка: банк ещё жив
time.Advance(TimeSpan.FromSeconds(25));
await grain.TickAsync(time.GetUtcNow());
Assert.False(grain.Summary.IsFinished);
Assert.Equal(5_000, first.Last<ServerClock>().Clock.Bank0Ms);
// ещё 6 секунд: банк кончился, игрок 0 проигрывает
time.Advance(TimeSpan.FromSeconds(6));
await grain.TickAsync(time.GetUtcNow());
Assert.True(grain.Summary.IsFinished);
Assert.Equal(1, grain.Summary.Winner);
Assert.Contains(first.All<ServerEvents>().SelectMany(e => e.Events),
e => e.Event is GameFinishedView { Winner: 1 });
}
[Fact]
public async Task Absence_Of_Opponent_Ends_Match()
{
var time = new FakeTimeProvider(Start);
var options = ServerTestData.SmallOptions();
options.AbsenceSeconds = 30;
var grain = NewGrain(time, options: options);
var first = await Attach(grain, 0, "p1");
time.Advance(TimeSpan.FromSeconds(31));
await grain.TickAsync(time.GetUtcNow());
Assert.True(grain.Summary.IsFinished);
Assert.Equal(0, grain.Summary.Winner);
Assert.Contains(first.All<ServerEvents>().SelectMany(e => e.Events),
e => e.Event is GameFinishedView { Winner: 0 });
}
[Fact]
public async Task Clock_Does_Not_Start_Until_Both_Seats_Connected()
{
var time = new FakeTimeProvider(Start);
var grain = NewGrain(time);
var first = await Attach(grain, 0, "p1");
time.Advance(TimeSpan.FromSeconds(20));
await grain.TickAsync(time.GetUtcNow());
Assert.False(first.Last<ServerClock>().Clock.IsRunning);
Assert.False(grain.Summary.IsFinished);
var second = await Attach(grain, 1, "p2");
time.Advance(TimeSpan.FromSeconds(1));
await grain.TickAsync(time.GetUtcNow());
Assert.True(first.Last<ServerClock>().Clock.IsRunning);
Assert.True(second.Last<ServerClock>().Clock.IsRunning);
}
[Fact]
public async Task Reconnect_Resumes_From_Event_Seq()
{
var time = new FakeTimeProvider(Start);
var grain = NewGrain(time);
var first = await Attach(grain, 0, "p1");
var second = await Attach(grain, 1, "p2");
await grain.SubmitAsync(0, new ClientIntent
{
IntentId = "finish-0",
BasedOnSeq = grain.Summary.EventSeq,
Intent = new FinishMulliganIntent(),
});
var seq = grain.Summary.EventSeq;
await grain.DetachAsync(0, first);
var reconnected = new FakeMatchConnection();
Assert.True(await grain.AttachAsync(0, "p1", reconnected, seq));
var hello = reconnected.All<ServerHello>().Single();
Assert.True(hello.Resumed);
Assert.Equal(seq, hello.EventSeq);
var events = reconnected.All<ServerEvents>().Single();
Assert.Equal(seq + 1, events.FromSeq);
Assert.Equal(seq, events.ToSeq);
Assert.Empty(events.Events);
Assert.Empty(reconnected.All<ServerSnapshot>());
// без resumeFromSeq приходит полный снимок
var fresh = new FakeMatchConnection();
Assert.True(await grain.AttachAsync(1, "p2", fresh, 0));
Assert.False(fresh.All<ServerHello>().Single().Resumed);
Assert.Single(fresh.All<ServerSnapshot>());
Assert.NotEmpty(second.All<ServerSnapshot>());
}
[Fact]
public async Task New_Connection_Replaces_The_Old_One()
{
var grain = NewGrain(new FakeTimeProvider(Start));
var old = await Attach(grain, 0, "p1");
await Attach(grain, 1, "p2");
var replacement = new FakeMatchConnection();
Assert.True(await grain.AttachAsync(0, "p1", replacement, grain.Summary.EventSeq));
Assert.True(old.IsClosed);
Assert.Equal(4409, old.CloseCode);
Assert.Single(replacement.All<ServerHello>());
}
private static MatchGrain NewGrain(
FakeTimeProvider time,
RecordingResultSink? sink = null,
GameOptions? options = null)
{
var setup = new MatchSetup(
"match-1",
GameMode.ClassicRanked,
new MatchSeatSetup("p1", ServerTestData.BuildDeck(), 1000),
new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000));
return new MatchGrain(
setup,
ServerTestData.Catalog,
options ?? ServerTestData.SmallOptions(),
time,
sink ?? new RecordingResultSink(),
NullLogger<MatchGrain>.Instance);
}
private static async Task<FakeMatchConnection> Attach(MatchGrain grain, int seat, string playerId)
{
var connection = new FakeMatchConnection();
Assert.True(await grain.AttachAsync(seat, playerId, connection, 0));
return connection;
}
}

View File

@ -0,0 +1,221 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Matchmaking;
using Vanguard.Server.Profiles;
using Vanguard.Server.Tickets;
namespace Vanguard.Server.Tests;
public class MatchmakingServiceTests
{
[Fact]
public void Two_Players_Are_Matched_And_Get_One_Time_Tickets()
{
var harness = NewHarness();
var first = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
var second = harness.Matchmaking.Enqueue("p2", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
harness.Matchmaking.Matchmake(harness.Time.GetUtcNow());
var firstStatus = harness.Matchmaking.Poll(first.QueueId, "p1");
var secondStatus = harness.Matchmaking.Poll(second.QueueId, "p2");
Assert.NotNull(firstStatus);
Assert.NotNull(secondStatus);
Assert.Equal("matched", firstStatus!.Status);
Assert.Equal("matched", secondStatus!.Status);
Assert.Equal(firstStatus.MatchId, secondStatus.MatchId);
Assert.Equal(0, firstStatus.Seat);
Assert.Equal(1, secondStatus.Seat);
Assert.Equal("p2", firstStatus.Opponent);
Assert.Equal("p1", secondStatus.Opponent);
Assert.NotNull(firstStatus.Ticket);
Assert.NotNull(secondStatus.Ticket);
var firstTicket = harness.Tickets.Redeem(firstStatus.Ticket);
var secondTicket = harness.Tickets.Redeem(secondStatus.Ticket);
Assert.Equal(0, firstTicket!.Seat);
Assert.Equal(1, secondTicket!.Seat);
Assert.Equal(firstStatus.MatchId, firstTicket.MatchId);
Assert.Equal(1, harness.Registry.Count);
Assert.Equal(1, harness.Registry.ActiveCount);
Assert.Equal(0, harness.Matchmaking.QueuedCount);
}
[Fact]
public void Ranked_And_Casual_Queues_Are_Separate()
{
var harness = NewHarness();
harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
var casual = harness.Matchmaking.Enqueue("p2", ServerTestData.BuildDeck(), GameMode.ClassicCasual);
harness.Matchmaking.Matchmake(harness.Time.GetUtcNow());
Assert.Equal("queued", harness.Matchmaking.Poll(casual.QueueId, "p2")!.Status);
Assert.Equal(0, harness.Registry.Count);
Assert.Equal(2, harness.Matchmaking.QueuedCount);
}
[Fact]
public void Window_Expands_While_Waiting()
{
var harness = NewHarness();
var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
var initial = harness.Matchmaking.Poll(ticket.QueueId, "p1")!;
Assert.Equal(100, initial.Window);
harness.Time.Advance(TimeSpan.FromSeconds(20));
var later = harness.Matchmaking.Poll(ticket.QueueId, "p1")!;
Assert.Equal(300, later.Window); // 100 + 4 * 50
harness.Time.Advance(TimeSpan.FromSeconds(200));
var capped = harness.Matchmaking.Poll(ticket.QueueId, "p1")!;
Assert.Equal(2000, capped.Window);
}
[Fact]
public void Invalid_Deck_Is_Rejected_At_Enqueue()
{
var harness = NewHarness();
var deck = ServerTestData.BuildDeck();
deck.RemoveAt(0);
var exception = Assert.Throws<DeckRejectedException>(
() => harness.Matchmaking.Enqueue("p1", deck, GameMode.ClassicRanked));
Assert.Contains(exception.Violations, violation => violation.Code == "deck.size");
}
[Fact]
public void Deck_Without_Starting_Vanguard_Is_Rejected()
{
var harness = NewHarness();
var deck = new List<string>();
foreach (var printing in ServerTestData.Catalog.Printings
.Where(printing => !printing.IsParallel)
.OrderBy(printing => printing.PrintingId, StringComparer.Ordinal))
{
if (deck.Count >= 50)
break;
var card = ServerTestData.Catalog.CardOf(printing.PrintingId);
if (card.Grade == 0 || card.IsSentinel || card.Trigger == Vanguard.Rules.Cards.TriggerType.Heal)
continue;
for (var i = 0; i < 4 && deck.Count < 50; i++)
deck.Add(printing.PrintingId);
}
Assert.Equal(50, deck.Count);
var exception = Assert.Throws<DeckRejectedException>(
() => harness.Matchmaking.Enqueue("p1", deck, GameMode.ClassicRanked));
Assert.Contains(exception.Violations,
violation => violation.Code == "deck.starting-vanguard");
}
[Fact]
public void Cancel_Removes_From_Queue()
{
var harness = NewHarness();
var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
Assert.True(harness.Matchmaking.Cancel(ticket.QueueId, "p1"));
var status = harness.Matchmaking.Poll(ticket.QueueId, "p1")!;
Assert.Equal("cancelled", status.Status);
Assert.Equal(0, harness.Matchmaking.QueuedCount);
Assert.False(harness.Matchmaking.Cancel(ticket.QueueId, "p1"));
}
[Fact]
public void Second_Queue_Request_Replaces_The_First()
{
var harness = NewHarness();
var old = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
var current = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
Assert.Equal("cancelled", harness.Matchmaking.Poll(old.QueueId, "p1")!.Status);
Assert.Equal("queued", harness.Matchmaking.Poll(current.QueueId, "p1")!.Status);
Assert.Equal(1, harness.Matchmaking.QueuedCount);
}
[Fact]
public void Queue_Expires_After_Timeout()
{
var harness = NewHarness();
var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
harness.Time.Advance(TimeSpan.FromSeconds(harness.Options.QueueExpirySeconds + 1));
harness.Matchmaking.Matchmake(harness.Time.GetUtcNow());
Assert.Equal("expired", harness.Matchmaking.Poll(ticket.QueueId, "p1")!.Status);
Assert.Equal(0, harness.Matchmaking.QueuedCount);
}
[Fact]
public async Task Ranked_Result_Changes_Hidden_Mmr_Casual_Does_Not()
{
var profiles = new PlayerProfileStore();
await profiles.RecordAsync(new MatchResult(
"m1", GameMode.ClassicRanked, "winner", "loser", 0));
Assert.True(profiles.GetMmr("winner") > 1000);
Assert.True(profiles.GetMmr("loser") < 1000);
var winnerAfterRanked = profiles.GetMmr("winner");
var loserAfterRanked = profiles.GetMmr("loser");
await profiles.RecordAsync(new MatchResult(
"m2", GameMode.ClassicCasual, "winner", "loser", 0));
Assert.Equal(winnerAfterRanked, profiles.GetMmr("winner"));
Assert.Equal(loserAfterRanked, profiles.GetMmr("loser"));
}
private static Harness NewHarness()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 10, 21, 12, 0, 0, TimeSpan.Zero));
var options = ServerTestData.SmallOptions();
var profiles = new PlayerProfileStore();
var tickets = new GameTicketService(options, time);
var services = new ServiceCollection();
services.AddSingleton(ServerTestData.Catalog);
services.AddSingleton(options);
services.AddSingleton<TimeProvider>(time);
services.AddSingleton<IMatchResultSink>(profiles);
services.AddLogging();
var provider = services.BuildServiceProvider();
var registry = new MatchGrainRegistry(provider, options, NullLogger<MatchGrainRegistry>.Instance);
var matchmaking = new MatchmakingService(
ServerTestData.Catalog,
registry,
tickets,
profiles,
options,
time,
NullLogger<MatchmakingService>.Instance);
return new Harness(time, options, profiles, tickets, registry, matchmaking);
}
private sealed record Harness(
FakeTimeProvider Time,
GameOptions Options,
PlayerProfileStore Profiles,
GameTicketService Tickets,
MatchGrainRegistry Registry,
MatchmakingService Matchmaking);
}

View File

@ -0,0 +1,141 @@
using Vanguard.Content;
using Vanguard.Rules.Cards;
using Vanguard.Server.Configuration;
namespace Vanguard.Server.Tests;
internal static class ServerTestData
{
private static readonly Lazy<CardCatalog> LazyCatalog = new(
() => CatalogLoader.LoadDirectory(ContentPaths.Resolve(null)),
isThreadSafe: true);
public static CardCatalog Catalog => LazyCatalog.Value;
/// <summary>Колода без Sentinel и Heal, ровно 50 карт, до 4 копий на nameId.</summary>
public static List<string> BuildDeck()
{
var deck = new List<string>();
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var printing in Catalog.Printings
.Where(p => !p.IsParallel)
.OrderBy(p => p.PrintingId, StringComparer.Ordinal))
{
if (deck.Count >= 50)
break;
var card = Catalog.CardOf(printing.PrintingId);
if (card.IsSentinel || card.Trigger == TriggerType.Heal)
continue;
counts.TryGetValue(card.NameId, out var used);
var room = Math.Min(4 - used, 50 - deck.Count);
for (var i = 0; i < room; i++)
deck.Add(printing.PrintingId);
counts[card.NameId] = used + room;
}
Assert.Equal(50, deck.Count);
return deck;
}
public static GameOptions SmallOptions() => new()
{
TurnSeconds = 90,
BankSeconds = 300,
AbsenceSeconds = 300,
ClockSyncSeconds = 5,
TicketTtlSeconds = 60,
ConnectTimeoutSeconds = 5,
CompletedMatchTtlSeconds = 600,
QueueExpirySeconds = 600,
MmrInitialWindow = 100,
MmrWindowStep = 50,
MmrWindowIntervalSeconds = 5,
MmrMaxWindow = 2000,
};
}
internal sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now += delta;
}
internal sealed class FakeMatchConnection : Matches.IMatchConnection
{
private readonly object _gate = new();
private readonly List<Vanguard.Shared.Protocol.ServerFrame> _frames = new();
public string Id { get; } = Guid.NewGuid().ToString("N");
public bool IsClosed { get; private set; }
public int CloseCode { get; private set; }
public IReadOnlyList<Vanguard.Shared.Protocol.ServerFrame> Frames
{
get
{
lock (_gate)
return _frames.ToList();
}
}
public Task SendAsync(
Vanguard.Shared.Protocol.ServerFrame frame,
CancellationToken cancellationToken = default)
{
lock (_gate)
_frames.Add(frame);
return Task.CompletedTask;
}
public Task CloseAsync(int code, string reason)
{
IsClosed = true;
CloseCode = code;
return Task.CompletedTask;
}
public T Last<T>() where T : Vanguard.Shared.Protocol.ServerFrame
{
lock (_gate)
return _frames.OfType<T>().Last();
}
public IReadOnlyList<T> All<T>() where T : Vanguard.Shared.Protocol.ServerFrame
{
lock (_gate)
return _frames.OfType<T>().ToList();
}
}
internal sealed class RecordingResultSink : Matches.IMatchResultSink
{
private readonly List<Matches.MatchResult> _results = new();
public IReadOnlyList<Matches.MatchResult> Results
{
get
{
lock (_results)
return _results.ToList();
}
}
public Task RecordAsync(Matches.MatchResult result)
{
lock (_results)
_results.Add(result);
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,201 @@
using System.Net.WebSockets;
using Microsoft.AspNetCore.Mvc.Testing;
using Vanguard.Shared.Protocol;
using Xunit.Sdk;
namespace Vanguard.Server.Tests;
/// <summary>
/// Минимальный клиент протокола для интеграционных тестов:
/// шлёт интенты, догоняет состояние и проверяет кадры.
/// </summary>
internal sealed class TestGameClient : IAsyncDisposable
{
private readonly WebSocket _socket;
private readonly SemaphoreSlim _sendLock = new(1, 1);
private TestGameClient(WebSocket socket, string playerId)
{
_socket = socket;
PlayerId = playerId;
}
public string PlayerId { get; }
public int Seat { get; private set; }
public long LastSeq { get; private set; }
public MatchView View { get; private set; } = null!;
public static async Task<TestGameClient> ConnectAsync(
WebApplicationFactory<Program> factory,
string matchId,
string ticket,
string playerId,
long resumeFromSeq,
CancellationToken cancellationToken)
{
var webSocketClient = factory.Server.CreateWebSocketClient();
var socket = await webSocketClient.ConnectAsync(
new Uri($"ws://localhost/game"), cancellationToken);
var client = new TestGameClient(socket, playerId);
await client.SendAsync(new ClientHello
{
ProtocolVersion = ProtocolConstants.Version,
Ticket = ticket,
ClientBuild = "tests",
ResumeFromSeq = resumeFromSeq,
}, cancellationToken);
var hello = await client.ReadUntilAsync<ServerHello>(cancellationToken);
if (hello.MatchId != matchId)
throw new XunitException($"Ожидался матч {matchId}, пришёл {hello.MatchId}.");
client.Seat = hello.PlayerIndex;
client.LastSeq = hello.EventSeq;
if (hello.Resumed)
{
// после hello сервер присылает события догона и часы
var events = await client.ReadUntilAsync<ServerEvents>(cancellationToken);
if (events.ToSeq != hello.EventSeq)
throw new XunitException($"Догон дошёл до {events.ToSeq}, ожидалось {hello.EventSeq}.");
}
else
{
var snapshot = await client.ReadUntilAsync<ServerSnapshot>(cancellationToken);
client.View = snapshot.View;
client.LastSeq = snapshot.View.EventSeq;
}
// после догона нужен снимок, чтобы получить MatchView
if (client.View is null)
await client.RequestSnapshotAsync(cancellationToken);
return client;
}
public async Task SendIntentAsync(
string intentId,
IntentPayload intent,
CancellationToken cancellationToken)
{
await SendAsync(new ClientIntent
{
IntentId = intentId,
BasedOnSeq = LastSeq,
Intent = intent,
}, cancellationToken);
}
public async Task<ServerIntentResult> SubmitAsync(
string intentId,
IntentPayload intent,
CancellationToken cancellationToken)
{
await SendIntentAsync(intentId, intent, cancellationToken);
var result = await ReadUntilAsync<ServerIntentResult>(cancellationToken);
if (result.IntentId != intentId)
throw new XunitException($"Ожидался результат {intentId}, пришёл {result.IntentId}.");
return result;
}
public async Task RequestSnapshotAsync(CancellationToken cancellationToken)
{
await SendAsync(new ClientResync
{
FromSeq = LastSeq + 1,
FullSnapshot = true,
}, cancellationToken);
var snapshot = await ReadUntilAsync<ServerSnapshot>(cancellationToken);
View = snapshot.View;
LastSeq = snapshot.View.EventSeq;
}
public async Task<T> ReadUntilAsync<T>(CancellationToken cancellationToken)
where T : ServerFrame
{
for (var i = 0; i < 2000; i++)
{
var frame = await ReadFrameAsync(cancellationToken);
if (frame is T typed)
return typed;
if (frame is ServerError error)
throw new XunitException(
$"Сервер ответил ошибкой {error.Code}: {error.Message}");
}
throw new XunitException($"Не дождались кадра {typeof(T).Name}.");
}
public async Task SendAsync(ClientFrame frame, CancellationToken cancellationToken)
{
var bytes = ProtocolSerializer.Encode(frame);
await _sendLock.WaitAsync(cancellationToken);
try
{
await _socket.SendAsync(
bytes.AsMemory(),
WebSocketMessageType.Binary,
endOfMessage: true,
cancellationToken);
}
finally
{
_sendLock.Release();
}
}
private async Task<ServerFrame> ReadFrameAsync(CancellationToken cancellationToken)
{
var buffer = new byte[16 * 1024];
using var stream = new MemoryStream();
while (true)
{
var result = await _socket.ReceiveAsync(buffer.AsMemory(), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
throw new XunitException(
$"Сокет закрыт: {_socket.CloseStatus} {_socket.CloseStatusDescription}");
if (result.MessageType != WebSocketMessageType.Binary)
throw new XunitException("Сервер прислал текстовый кадр.");
stream.Write(buffer, 0, result.Count);
if (stream.Length > ProtocolConstants.MaxFrameBytes)
throw new XunitException("Кадр сервера слишком велик.");
if (result.EndOfMessage)
break;
}
return ProtocolSerializer.DecodeServer(stream.ToArray());
}
public async ValueTask DisposeAsync()
{
try
{
if (_socket.State == WebSocketState.Open)
await _socket.CloseOutputAsync(
WebSocketCloseStatus.NormalClosure,
"bye",
CancellationToken.None);
}
catch (Exception)
{
// тестовый клиент мог уже отвалиться
}
_socket.Dispose();
_sendLock.Dispose();
}
}

View File

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Vanguard.Server\Vanguard.Server.csproj" />
</ItemGroup>
</Project>