Finish server persistence with PostgreSQL, Redis and Docker

This commit is contained in:
MpakobecEx 2026-10-21 14:53:28 +05:00
parent 99d5b95000
commit 083cebf978
31 changed files with 1831 additions and 179 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
VANGUARD_PORT=8080
VANGUARD_WS_URL=ws://localhost:8080/game

View File

@ -70,9 +70,8 @@
/tools/import_cards.py
```
- [x] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`.
- [~] `docker-compose.yml`: сейчас только game-server; postgres, redis (AOF), meta-api, nginx — со следующим блоком.
- [ ] `docker compose up` поднимает БД и пустой API; healthcheck отвечает.
— сервер уже поднимается и отвечает `/health`, БД ещё нет.
- [x] `docker-compose.yml`: postgres, redis (AOF), game-server с контентом.
- [x] `docker compose up` поднимает БД, Redis и API; `/health` отвечает.
- [ ] CI: сборка solution + `dotnet test` на push.
**Готово, когда:** `docker compose up` + `dotnet run` дают живой `/health`.
@ -182,13 +181,15 @@
- [x] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось».
- [x] Политика отсутствия: 5 минут → авто-поражение.
- [x] Таймер хода: 90 с + банк 5 мин.
- [ ] Снапшот матча в PostgreSQL + восстановление grain после рестарта.
- [x] Снапшот матча в PostgreSQL + восстановление grain после рестарта.
— заголовок + журнал интентов, реплей команд при старте.
- [x] Redis: одноразовые тикеты и очередь.
- [x] Idempotency по `intentId`.
- [x] HTTP-очередь и тикеты: `docs/server-api.md`.
- [x] E2E: два клиента играют полный матч по WebSocket; обрыв и возврат проверены.
**Готово, когда:** два клиента играют полный матч по сети; kill процесса клиента на любом шаге восстанавливается.
— выполнено в `Vanguard.Server.Tests`; рестарт сервера пока теряет матчи (ждёт PostgreSQL).
— выполнено в `Vanguard.Server.Tests`; рестарт сервера восстанавливает матчи из PostgreSQL (проверено на `ssh home`).
---

View File

@ -13,8 +13,9 @@
| Контент-пайплайн и валидатор | готово |
| Игровое ядро (правила) | каркас: фазы, райд, призыв, бой, драйв, урон |
| Протокол и контракты (`docs/protocol.md`, `Vanguard.Shared`) | готово |
| Сервер: матч по сети, матчмейкинг, реконнект | готово (in-memory) |
| Персистентность (PostgreSQL, Redis) и Orleans | не начато |
| Сервер: матч по сети, матчмейкинг, реконнект | готово |
| PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово |
| Orleans и мета-API (JWT, коллекции, магазин) | не начато |
| Клиент и лаунчер | не начато |
## Требования
@ -46,7 +47,8 @@ dotnet run --project server/src/Vanguard.Server --urls http://localhost:5080
```
Контейнер: `docker compose up --build`, затем `http://localhost:8080/health`.
HTTP-контракт очереди и тикетов — `docs/server-api.md`.
Порт задаётся в `.env` (`VANGUARD_PORT`, `VANGUARD_WS_URL`); стек включает
PostgreSQL и Redis (AOF). HTTP-контракт очереди и тикетов — `docs/server-api.md`.
Ожидаемый вывод валидатора:
@ -63,6 +65,9 @@ OK: ошибок 0, предупреждений 0
/server/src/Vanguard.Content загрузка и проверка YAML-контента
/server/src/Vanguard.Shared сетевые контракты (netstandard2.1 для Unity + net10.0)
/server/src/Vanguard.Server ASP.NET Core: WebSocket, очередь, тикеты, матчи
/Storage PostgreSQL-снапшоты и восстановление матчей
/Matchmaking очередь, MMR, Redis-хранилище очереди
/Tickets тикеты: память или Redis
/server/src/Vanguard.Cli консольные команды
/server/tests тесты правил, контрактов и сервера (включая E2E)
/content/sets карты в YAML

View File

@ -1,11 +1,48 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: vanguard
POSTGRES_USER: vanguard
POSTGRES_PASSWORD: vanguard
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vanguard -d vanguard"]
interval: 5s
timeout: 3s
retries: 20
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
server:
build:
context: .
dockerfile: server/src/Vanguard.Server/Dockerfile
ports:
- "8080:8080"
- "${VANGUARD_PORT:-8080}:8080"
environment:
ASPNETCORE_URLS: http://+:8080
Game__PublicWsUrl: ws://localhost:8080/game
Game__PublicWsUrl: ${VANGUARD_WS_URL:-ws://localhost:8080/game}
Storage__Provider: Postgres
Storage__ConnectionString: Host=postgres;Port=5432;Database=vanguard;Username=vanguard;Password=vanguard
Redis__Configuration: redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
volumes:
pgdata:
redisdata:

View File

@ -38,13 +38,13 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
| Проверка | Результат |
|---|---|
| Тесты | **158/158** (131 правила/контракты + 27 сервер) |
| Тесты | **164/164** (131 правила/контракты + 33 сервер) |
| `validate` | 0 ошибок, 0 предупреждений |
| `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок |
| Карты | **94/94** (66 с текстом + 28 ванильных) |
| Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы |
| Сервер | полный матч по сети, матчмейкинг, реконнект (in-memory) |
| Сервер | сеть, матчмейкинг, реконнект, PostgreSQL + Redis, Docker |
| Клиент / лаунчер | не начаты |
Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям:
@ -70,7 +70,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000
```
Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests)
Пройден! : не пройдено 0, пройдено 27 (Vanguard.Server.Tests)
Пройден! : не пройдено 0, пройдено 33 (Vanguard.Server.Tests)
OK: ошибок 0, предупреждений 0
Переведено: 94/94
Партий: 1000, завершено: 1000, ошибок: 0
@ -83,6 +83,20 @@ dotnet run --project server\src\Vanguard.Server --urls http://localhost:5080
# GET http://localhost:5080/health
```
Тестовый стенд на `ssh home` (Linux, Docker):
```bash
ssh home
cd ~/VanguardReMaster
# .env: VANGUARD_PORT=18080, VANGUARD_WS_URL=ws://192.168.31.158:18080/game
docker compose up -d --build
curl http://127.0.0.1:18080/health
# остановить: docker compose down
```
`Storage:Provider=None` по умолчанию: локальный запуск без базы. Стенд
использует `Postgres`, `Redis` и восстанавливает матчи после рестарта.
Solution — **`server/Vanguard.slnx`**, не `.sln`.
---
@ -201,7 +215,9 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
### 4.8. Сервер
`server/src/Vanguard.Server` — ASP.NET Core 10, один процесс, состояние в памяти.
`server/src/Vanguard.Server` — ASP.NET Core 10, один процесс.
Матчи, очередь и тикеты сохраняются в PostgreSQL/Redis, если они настроены;
по умолчанию (`Storage:Provider=None`) сервер работает без внешних сервисов.
- **HTTP**: `POST /api/game/queue`, `GET /api/game/queue/{id}`,
`DELETE /api/game/queue/{id}`, `POST /api/game/tickets`, `GET /health`.
@ -210,7 +226,8 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
окно ±100 с расширением на 50 каждые 5 с до 2000, выбор ближайшего MMR.
Ранкед меняет MMR по Эло (K = 32), casual — нет.
- **Тикеты**: одноразовые, TTL 60 с, привязаны к `(playerId, matchId, seat)`;
для реконнекта выдаётся новый тикет.
при настроенном Redis изымаются через `GETDEL` (одноразовость между
инстансами), иначе — из памяти.
- **`IMatchGrain`**: 1 матч = 1 экземпляр, все входы (интенты, подключения,
тики) проходят через один семафор; внутри нет ни ASP.NET Core, ни WebSocket —
только `IMatchConnection`. Orleans-хостинг подключается поверх контракта.
@ -224,19 +241,41 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
результат пишется в профили.
- **Проекция**: своя рука, свой выбор, свои легальные интенты; у противника
только количества и открытые зоны. `seed` и RNG не покидают сервер.
- **Docker**: `Dockerfile` + `docker-compose.yml` поднимают сервер с контентом
на `http://localhost:8080`.
- **Тесты** (`Vanguard.Server.Tests`, 27):
- **PostgreSQL**: таблицы `matches` и `match_intents`. В заголовке — seed,
колоды, игроки, часы, банки, статус; в журнале — `intentId`, команда,
результат. При старте незавершённые матчи восстанавливаются повтором
принятых команд на детерминированном движке, включая ожидания выбора:
делегаты движка не сериализуются. Журнал реплея сверяется по `eventSeq`.
Банк и дедлайн пишутся каждые ~5 с; потеря при аварии — не больше тика.
- **Redis**: тикеты и записи очереди (JSON + sorted set по режимам, TTL);
очередь поднимается при старте. Ключи с префиксом `vanguard:`.
- **Конфигурация**: `Storage:Provider` = `None` / `Memory` / `Postgres`,
`Storage:ConnectionString`; `Redis:Configuration`. `Memory` — EF InMemory
для тестов, `Postgres` — постоянное хранилище.
- **Docker**: `Dockerfile` + `docker-compose.yml` поднимают postgres, redis
(AOF) и сервер; `/health` на `http://localhost:8080` (порт меняется
через `VANGUARD_PORT`, адрес WebSocket — `VANGUARD_WS_URL`).
- **Тесты** (`Vanguard.Server.Tests`, 33):
- тикеты: одноразовость и TTL;
- heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром;
- grain: attach, чужое место, duplicate intent, чужой ход, сдача,
команды после финала, банк времени, отсутствие, старт часов,
reconnect с `eventSeq`, захват сессии;
- матчмейкинг: подбор, разные очереди, окно, плохая колода,
отсутствие стартового Авангарда, отмена, замена очереди, истечение, MMR;
отсутствие стартового Авангарда, отмена, замена очереди, истечение, MMR,
перезагрузка очереди из хранилища;
- персистентность: EF-хранилище, восстановление матча повтором команд,
`MatchRecoveryService` после «рестарта»;
- внешние хранилища (`ExternalStorageTests`, запускаются при
`VANGUARD_TEST_POSTGRES` / `VANGUARD_TEST_REDIS`): реальные PostgreSQL
и Redis, изъятие тикета, очередь;
- E2E: два клиента играют полный матч по WebSocket и сходятся в победителе;
обрыв и возврат по новому тикету с догоном состояния.
Проверено на `ssh home`: 164/164 тестов, включая реальные PostgreSQL и Redis;
`docker compose up` поднимает стенд, матч создаётся через HTTP-очередь,
а после `docker compose restart server` восстанавливается (`Recovery: 1 of 1`).
---
## 5. Структура и ключевые файлы
@ -258,28 +297,30 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
/Replay/ запись и воспроизведение
/Diagnostics/GameInvariants.cs
/server/src/Vanguard.Content загрузка YAML и валидация
/server/src/Vanguard.Cli validate, stats, translations, stress
/server/src/Vanguard.Cli validate, stats, translations, stress, deck
/server/src/Vanguard.Shared
/Protocol/*.cs кадры, интенты, события, view, кодек
/Projection/*.cs проекция состояния, фильтр событий, маппинг
/server/src/Vanguard.Server
/Program.cs ASP.NET Core, DI, WebSocket endpoint
/Api/ очередь, тикеты, /health
/Matchmaking/ две очереди, MMR, расширяющееся окно
/Matchmaking/ очереди, MMR, Redis-хранилище очереди
/Matches/ IMatchGrain, таймеры, тикер, регистр
/Realtime/ WebSocket-обвязка, чтение кадров
/Tickets/ одноразовые тикеты
/Tickets/ одноразовые тикеты: память или Redis
/Storage/ EF-хранилище, восстановление матчей
/Profiles/ скрытый MMR и статистика
/Dockerfile
/server/tests/Vanguard.Rules.Tests 131 тест
/server/tests/Vanguard.Server.Tests 27 тестов, включая E2E по WebSocket
/server/tests/Vanguard.Server.Tests 33 теста, включая E2E и внешние хранилища
/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 сервер в контейнере
/docker-compose.yml postgres, redis, server
/.env.example VANGUARD_PORT и VANGUARD_WS_URL
```
---
@ -305,8 +346,9 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
не в URL; reconnect получает новый тикет.
- `playerId` в HTTP-API — dev-заглушка до JWT. Выставлять сервер в интернет
в таком виде нельзя.
- Состояние сервера пока in-memory: `IMatchGrain` отделён от транспорта,
Orleans и PostgreSQL подключаются поверх.
- Состояние сервера: PostgreSQL — снапшот/журнал матчей, Redis — тикеты
и очередь; без конфигурации работает in-memory. `IMatchGrain` отделён
от транспорта, Orleans подключается поверх.
- Rate limit — 16 интентов в секунду на соединение.
- Скрытый MMR: старт 1000, Эло K = 32, casual рейтинг не меняет.
- Порядок триггеров выбирает игрок; опциональные способности спрашиваются.
@ -337,15 +379,18 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
4. **[x] Проекция состояния** подключена к грану и проверена E2E-тестом:
своя рука и свой выбор полностью, у противника только количества
и открытые зоны, seed и RNG не уходят.
5. **[x] `Docker Compose`** — сервер с контентом на `http://localhost:8080`.
5. **[x] `Docker Compose`** — postgres, redis (AOF) и сервер; `/health`,
порт через `VANGUARD_PORT`.
6. **[x] Персистентность**:
- PostgreSQL — заголовок матча + журнал интентов, восстановление повтором
команд после рестарта, `MatchRecoveryService`;
- Redis — одноразовые тикеты (`GETDEL`) и очередь с перезагрузкой.
Осталось в серверном направлении:
6. **Orleans** — заменить in-process регистр на настоящий silo и граны.
`IMatchGrain` уже отделён от транспорта и ASP.NET Core.
7. **Персистентность**:
- PostgreSQL — снапшот матча, восстановление grain после рестарта;
- Redis — тикеты, очереди, кэш.
7. **Orleans** — заменить in-process регистр на настоящий silo и граны.
`IMatchGrain` уже отделён от транспорта и ASP.NET Core; персистентность
и проекция от хостинга не зависят.
8. **JWT и мета-API** — `playerId` перестанет быть dev-заглушкой.
Критерий готовности серверного блока выполнен: два клиента играют полный
@ -393,10 +438,10 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
1. Прочитать `docs/HANDOFF.md`, `IMPLEMENTATION_CHECKLIST.md`, `README.md`.
2. Прогнать проверки из раздела 3.
3. Следующий блок — персистентность и Orleans: PostgreSQL-снапшоты,
Redis для тикетов/очередей, замена in-process регистра на silo.
Сетевой матч уже работает: `docs/protocol.md`, `Vanguard.Shared`,
`Vanguard.Server`, `docs/server-api.md`.
3. Следующий блок — Orleans и мета-API: заменить in-process регистр на silo,
затем JWT, аккаунты, коллекция, колоды и магазин. Сетевой матч,
PostgreSQL/Redis и Docker уже готовы: `docs/protocol.md`,
`Vanguard.Shared`, `Vanguard.Server`, `docs/server-api.md`.
4. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
5. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
@ -418,8 +463,26 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
для наблюдателя нельзя — в списке есть printingId из чужой руки.
- **Сервер: rate limit.** 16 интентов в секунду на соединение. Быстрый бот
в E2E специально притормаживается; живой UI в лимит не упирается.
- **Сервер: in-memory.** Очереди, тикеты, матчи и профили живут в памяти.
Рестарт сервера теряет матчи; kill клиента и реконнект переживаются.
- **Сервер: хранилище.** По умолчанию `Storage:Provider=None` — матчи живут
в памяти. Для рестарта нужен `Postgres` + `Storage:ConnectionString`;
схема создаётся `EnsureCreated`, миграций пока нет. `Memory` — EF InMemory.
- **Сервер: снапшот матча.** Сериализуется не состояние движка, а seed, колоды,
игроки, часы и журнал интентов. При восстановлении команды проигрываются
заново; `EventSeq` обязан совпасть, иначе матч пропускается с ошибкой.
Делегаты `PendingChoice`/`Continuation` восстанавливаются самим реплеем.
- **Сервер: восстановление.** `MatchRecoveryService` поднимает только
незавершённые матчи за последние 7 дней. Завершённые остаются историей
в PostgreSQL и не восстанавливаются в память. Банк/дедлайн пишутся
раз в ~5 с, поэтому авария теряет не больше тика.
- **Сервер: Redis.** Тикеты изымаются через `GETDEL` (Redis 6.2+; в compose 7).
Очередь — JSON + sorted set по режимам, TTL в два раза больше очереди.
Префикс ключей — `Redis:Prefix` (`vanguard:`).
- **Сервер: Docker.** На домашнем сервере порт 8080 занят, поэтому стенд
поднимается с `VANGUARD_PORT=18080` и `VANGUARD_WS_URL` в `.env`.
`docker compose restart server` проверяет восстановление матчей.
- **Сервер: внешние тесты.** `VANGUARD_TEST_POSTGRES` и `VANGUARD_TEST_REDIS`
включают `ExternalStorageTests`; без них тесты проходят как no-op.
Postgres-тест делает `EnsureDeleted`, поэтому БД должна быть тестовой.
- **Сервер: тесты.** E2E поднимает `WebApplicationFactory<Program>`,
играет полный матч по WebSocket и проверяет, что победитель совпал.
Случайные партии завершаются за ~350 действий; таймаут теста — 120 с.

View File

@ -3,7 +3,9 @@
Служебный HTTP-слой матчевого блока: очередь, тикеты на подключение и `/health`.
После подключения клиент переходит на WebSocket и работает по `docs/protocol.md`.
Базовая часть стека: **ASP.NET Core 10**, один процесс, состояние в памяти.
Базовая часть стека: **ASP.NET Core 10**, один процесс.
Матчи и очередь сохраняются в PostgreSQL/Redis, если они настроены; без
внешних сервисов сервер работает in-memory (удобно для тестов).
---
@ -21,13 +23,28 @@ dotnet run --project server\src\Vanguard.Server --urls http://localhost:5080
Invoke-RestMethod http://localhost:5080/health
```
Контейнер:
Контейнер (PostgreSQL + Redis + сервер):
```powershell
docker compose up --build
# http://localhost:8080/health
```
Порт и внешний адрес WebSocket задаются в `.env`:
```
VANGUARD_PORT=8080
VANGUARD_WS_URL=ws://localhost:8080/game
```
`VANGUARD_PORT` нужен, если 8080 занят (на тестовом стенде — 18080).
Для ручной проверки колоду на 50 карт можно получить так:
```powershell
dotnet run --project server\src\Vanguard.Cli -- deck
```
Каталог карт ищется в `content/sets` вверх от каталога сборки. Если контент
лежит в другом месте, задайте `Content:Path` (или `Content__Path` в окружении).
@ -211,13 +228,55 @@ WS /game → ClientHello(ticket, resumeFromSeq)
---
## 7. Ограничения текущего этапа
## 7. Хранилище и восстановление
- Всё состояние (очереди, тикеты, матчи, профили) — в памяти процесса.
Рестарт сервера теряет матчи; клиентский kill и реконнект переживаются.
### 7.1. Конфигурация
| Ключ | Значения | Смысл |
|---|---|---|
| `Storage:Provider` | `None`, `Memory`, `Postgres` | `None` — без сохранения, `Memory` — EF InMemory, `Postgres` — постоянное |
| `Storage:ConnectionString` | строка Npgsql | обязательно для `Postgres` |
| `Redis:Configuration` | `host:port` | пусто — тикеты и очередь в памяти |
| `Redis:Prefix` | `vanguard:` | префикс ключей |
В контейнере переменные задаются как `Storage__Provider`,
`Storage__ConnectionString`, `Redis__Configuration`.
### 7.2. Матчи в PostgreSQL
- `matches` — заголовок: игроки, колоды, seed, часы, банки, статус, `eventSeq`.
- `match_intents` — журнал: `intentId`, место, команда, результат, код отказа.
- На каждый интент пишется строка журнала и обновляется заголовок.
- После рестарта `MatchRecoveryService` поднимает незавершённые матчи:
создаёт движок с сохранённым seed и проигрывает принятые команды заново.
`GameFinished`, ожидания выбора и продолжения восстанавливаются реплеем,
делегаты никуда не сериализуются.
- `eventSeq` сверяется: если реплей разошёлся со снапшотом, матч пропускается
с ошибкой в логе.
- Банк и дедлайн сохраняются раз в ~5 секунд; авария теряет не больше тика.
- Завершённые матчи остаются историей и при старте в память не поднимаются.
### 7.3. Redis
- Тикеты: ключ `vanguard:ticket:{token}`, TTL 60 с, изъятие через `GETDEL`
(одноразовость сохраняется между инстансами).
- Очередь: запись `vanguard:queue:entry:{id}` (JSON) и sorted set
`vanguard:queue:{mode}` по номеру постановки. При старте очередь
загружается обратно.
- Состояние профилей (MMR) пока остаётся в памяти: это закроет мета-API
вместе с аккаунтами.
---
## 8. Ограничения текущего этапа
- PostgreSQL/Redis опциональны. По умолчанию (`Storage:Provider=None`)
матчи, тикеты и очередь живут только в памяти.
- Профили и MMR пока не персистентны: после рестарта рейтинг начинается
с 1000. Матчи и очередь при этом восстанавливаются.
- Один процесс: горизонтальное масштабирование и Orleans-хостинг ещё впереди.
- `IMatchGrain` уже отделён от ASP.NET Core и WebSocket: замена на Orleans
grain не затронет транспорт и правила.
- Нет PostgreSQL/Redis, JWT, коллекций и наград — это следующие блоки.
- Нет JWT, коллекций и наград — это следующие блоки.
- Rate limit: 16 интентов в секунду с одного соединения. Живой UI в него
не упирается; тестовый бот специально притормаживается.

View File

@ -1,4 +1,5 @@
using Vanguard.Content;
using System.Text.Json;
using Vanguard.Content;
using Vanguard.Content.CardText;
using Vanguard.Content.Translations;
using Vanguard.Rules.Cards;
@ -15,6 +16,7 @@ return command switch
"stats" => RunStats(PickPath(1) ?? Path.Combine(root, "content", "sets")),
"translations" => RunTranslations(root),
"stress" => RunStress(root, args),
"deck" => RunDeck(root),
_ => Usage(),
};
@ -132,6 +134,13 @@ static int RunStress(string repoRoot, string[] args)
return failures == 0 && finished == games ? 0 : 1;
}
static int RunDeck(string repoRoot)
{
var catalog = CatalogLoader.LoadDirectory(Path.Combine(repoRoot, "content", "sets"));
Console.WriteLine(JsonSerializer.Serialize(BuildBotDeck(catalog)));
return 0;
}
static List<string> BuildBotDeck(CardCatalog catalog)
{
var deck = new List<string>();
@ -265,6 +274,7 @@ static int Usage()
translations build [путь] собрать файл переводов
translations check [путь] проверить переводы
stress [партий] прогнать случайного бота и проверить инварианты
deck JSON-колода из 50 printingId для ручной проверки
""");
return 2;
}

View File

@ -21,7 +21,7 @@ public static class ApiEndpoints
app.MapGet("/health", Health);
}
private static IResult Enqueue(
private static async Task<IResult> Enqueue(
QueueRequest request,
MatchmakingService matchmaking)
{
@ -36,7 +36,7 @@ public static class ApiEndpoints
try
{
var ticket = matchmaking.Enqueue(request.PlayerId!, request.Deck, mode);
var ticket = await matchmaking.EnqueueAsync(request.PlayerId!, request.Deck, mode);
return Results.Ok(new QueueAccepted(ticket.QueueId, "queued", ticket.Mode));
}
catch (DeckRejectedException exception)
@ -48,7 +48,7 @@ public static class ApiEndpoints
}
}
private static IResult PollQueue(
private static async Task<IResult> PollQueue(
Guid queueId,
string? playerId,
MatchmakingService matchmaking,
@ -58,7 +58,7 @@ public static class ApiEndpoints
if (!IsValidPlayerId(playerId))
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен."));
var snapshot = matchmaking.Poll(queueId, playerId!);
var snapshot = await matchmaking.PollAsync(queueId, playerId!);
if (snapshot is null)
return Results.NotFound(new ApiError("queue.not-found", "Очередь не найдена."));
@ -76,7 +76,7 @@ public static class ApiEndpoints
snapshot.MatchId is null ? null : BuildWsUrl(http, options)));
}
private static IResult CancelQueue(
private static async Task<IResult> CancelQueue(
Guid queueId,
string? playerId,
MatchmakingService matchmaking)
@ -84,12 +84,12 @@ public static class ApiEndpoints
if (!IsValidPlayerId(playerId))
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен."));
return matchmaking.Cancel(queueId, playerId!)
return await matchmaking.CancelAsync(queueId, playerId!)
? Results.NoContent()
: Results.Conflict(new ApiError("queue.not-cancellable", "Очередь уже не активна."));
}
private static IResult IssueTicket(
private static async Task<IResult> IssueTicket(
TicketRequest request,
MatchGrainRegistry matches,
GameTicketService tickets,
@ -117,7 +117,7 @@ public static class ApiEndpoints
new ApiError("match.forbidden", "Этот игрок не участвует в матче."),
statusCode: StatusCodes.Status403Forbidden);
var ticket = tickets.Issue(request.PlayerId!, request.MatchId!, seat);
var ticket = await tickets.IssueAsync(request.PlayerId!, request.MatchId!, seat);
return Results.Ok(new TicketResponse(
ticket.Token,

View File

@ -0,0 +1,17 @@
namespace Vanguard.Server.Configuration;
/// <summary>Где хранить снапшоты матчей.</summary>
public sealed class StorageOptions
{
/// <summary>None — без сохранения, Memory — EF InMemory, Postgres — постоянное хранилище.</summary>
public string Provider { get; set; } = "None";
public string ConnectionString { get; set; } = string.Empty;
}
/// <summary>Redis для тикетов и распределённых блокировок. Пусто — in-memory.</summary>
public sealed class RedisOptions
{
public string Configuration { get; set; } = string.Empty;
public string Prefix { get; set; } = "vg:";
}

View File

@ -8,6 +8,12 @@ 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
# Npgsql при старте пробует GSSAPI; без библиотеки в лог попадает ошибка.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libgssapi-krb5-2 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app ./

View File

@ -21,7 +21,8 @@ public sealed record MatchSetup(
string MatchId,
GameMode Mode,
MatchSeatSetup Seat0,
MatchSeatSetup Seat1);
MatchSeatSetup Seat1,
ulong? Seed = null);
/// <summary>Снимок и итог матча для регистратуры, health и записи результата.</summary>
public sealed record MatchSummary(

View File

@ -1,6 +1,8 @@
using Vanguard.Rules.Cards;
using Vanguard.Rules.Engine;
using Vanguard.Rules.Replay;
using Vanguard.Server.Configuration;
using Vanguard.Server.Storage;
using Vanguard.Shared.Projection;
using Vanguard.Shared.Protocol;
@ -39,6 +41,7 @@ public sealed class MatchGrain : IMatchGrain
private readonly GameOptions _options;
private readonly TimeProvider _time;
private readonly IMatchResultSink _results;
private readonly IMatchStore _store;
private readonly ILogger<MatchGrain> _logger;
private readonly string _matchId;
private readonly GameMode _mode;
@ -50,6 +53,9 @@ public sealed class MatchGrain : IMatchGrain
private DateTimeOffset _lastClockSent;
private DateTimeOffset? _finishedAt;
private bool _resultReported;
private bool _restoring;
private const string ConcedeCommand = "concede";
public MatchGrain(
MatchSetup setup,
@ -57,6 +63,7 @@ public sealed class MatchGrain : IMatchGrain
GameOptions options,
TimeProvider time,
IMatchResultSink results,
IMatchStore store,
ILogger<MatchGrain> logger)
{
_matchId = setup.MatchId;
@ -64,6 +71,7 @@ public sealed class MatchGrain : IMatchGrain
_options = options;
_time = time;
_results = results;
_store = store;
_logger = logger;
_createdAt = time.GetUtcNow();
@ -87,7 +95,7 @@ public sealed class MatchGrain : IMatchGrain
},
};
var seed = unchecked((ulong)Random.Shared.NextInt64());
var seed = setup.Seed ?? unchecked((ulong)Random.Shared.NextInt64());
_engine = new GameEngine(
catalog,
seed,
@ -174,6 +182,8 @@ public sealed class MatchGrain : IMatchGrain
"Match {MatchId}: seat {Seat} attached (resume from {Seq}, now {Now}).",
_matchId, seat, resumeFromSeq, to);
await PersistHeaderAsync();
return true;
}
catch (Exception exception)
@ -205,6 +215,8 @@ public sealed class MatchGrain : IMatchGrain
state.AbsentSince = _time.GetUtcNow();
_logger.LogInformation("Match {MatchId}: seat {Seat} detached.", _matchId, seat);
await PersistHeaderAsync();
}
finally
{
@ -254,9 +266,12 @@ public sealed class MatchGrain : IMatchGrain
var decisionBefore = _engine.State.ActivePlayer;
CommandResult result;
var commandText = string.Empty;
if (isConcede)
{
_engine.Finish(1 - seat);
commandText = ConcedeCommand;
result = CommandResult.Ok();
}
else
@ -269,6 +284,8 @@ public sealed class MatchGrain : IMatchGrain
}
result = _engine.Execute(command);
if (result.Accepted)
commandText = CommandCodec.Encode(command);
}
var code = result.Accepted ? IntentRejectCode.None : IntentRejectCode.Illegal;
@ -276,6 +293,9 @@ public sealed class MatchGrain : IMatchGrain
_intents[intentId] = record;
RefreshSummary();
await PersistIntentAsync(new PersistedIntent(
intentId, seat, commandText, record.Accepted, record.Code, record.Reason, record.EventSeq));
await SendResultAsync(
seat, intentId, record.Accepted, record.Code, record.Reason, record.EventSeq, duplicate: false);
@ -373,7 +393,10 @@ public sealed class MatchGrain : IMatchGrain
}
if (now - _lastClockSent >= TimeSpan.FromSeconds(_options.ClockSyncSeconds))
{
await SendClockToAllAsync(now);
await PersistHeaderAsync();
}
}
finally
{
@ -389,6 +412,9 @@ public sealed class MatchGrain : IMatchGrain
{
_intents[intentId] = new IntentRecord(false, code, reason, _engine.Events.Count);
await PersistIntentAsync(new PersistedIntent(
intentId, seat, string.Empty, false, code, reason, _engine.Events.Count));
await SendResultAsync(
seat, intentId, accepted: false, code, reason, _engine.Events.Count, duplicate: false);
}
@ -512,6 +538,7 @@ public sealed class MatchGrain : IMatchGrain
_resultReported = true;
_finishedAt = now;
RefreshSummary();
await PersistHeaderAsync();
var winner = _engine.State.Winner ?? -1;
if (winner is not (0 or 1))
@ -559,6 +586,140 @@ public sealed class MatchGrain : IMatchGrain
ServerTimeMs = now.ToUnixTimeMilliseconds(),
};
/// <summary>Снимок заголовка для хранилища.</summary>
internal MatchHeader BuildHeader() => new(
_matchId,
_mode,
_seats[0].PlayerId,
_seats[1].PlayerId,
_seats[0].Deck,
_seats[1].Deck,
_engine.State.Seed,
_createdAt,
_started,
_seats[0].BankMs,
_seats[1].BankMs,
_started ? _turnDeadline : null,
_seats[0].AbsentSince,
_seats[1].AbsentSince,
_started ? _lastClockSent : _createdAt,
_engine.State.IsFinished,
_engine.State.Winner ?? -1,
_finishedAt,
_engine.Events.Count);
private async Task PersistHeaderAsync()
{
if (_restoring)
return;
try
{
await _store.UpdateHeaderAsync(BuildHeader());
}
catch (Exception exception)
{
_logger.LogError(exception, "Match {MatchId}: persisting header failed.", _matchId);
}
}
private async Task PersistIntentAsync(PersistedIntent intent)
{
if (_restoring)
return;
try
{
await _store.AppendIntentAsync(BuildHeader(), intent);
}
catch (Exception exception)
{
_logger.LogError(exception, "Match {MatchId}: persisting intent failed.", _matchId);
}
}
/// <summary>
/// Восстановить матч из снапшота: заново проиграть принятые команды на
/// детерминированном движке. Состояние выборов и продолжений
/// восстанавливается само; делегаты никуда не сериализуются.
/// </summary>
public async Task RestoreAsync(MatchSnapshot snapshot)
{
await _gate.WaitAsync();
try
{
_restoring = true;
var header = snapshot.Header;
if (header.MatchId != _matchId)
throw new InvalidOperationException(
$"Снапшот {header.MatchId} не принадлежит матчу {_matchId}.");
foreach (var intent in snapshot.Intents)
{
if (!intent.Accepted)
{
_intents[intent.IntentId] = new IntentRecord(
false, intent.Code, intent.Reason, intent.EventSeq);
continue;
}
if (intent.Command == ConcedeCommand)
{
_engine.Finish(intent.Seat == 0 ? 1 : 0);
}
else
{
if (string.IsNullOrWhiteSpace(intent.Command))
throw new InvalidOperationException(
$"Принятый интент {intent.IntentId} не содержит команды.");
var command = CommandCodec.Decode(intent.Command);
var result = _engine.Execute(command);
if (!result.Accepted)
throw new InvalidOperationException(
$"Реплей отклонил {intent.IntentId}: {result.Reason}");
}
_intents[intent.IntentId] = new IntentRecord(
true, IntentRejectCode.None, null, _engine.Events.Count);
}
if (_engine.Events.Count != header.EventSeq)
throw new InvalidOperationException(
$"Рассинхрон реплея {header.MatchId}: событий {_engine.Events.Count}, " +
$"в снапшоте {header.EventSeq}.");
_started = header.Started;
_seats[0].BankMs = header.Bank0Ms;
_seats[1].BankMs = header.Bank1Ms;
_seats[0].EverConnected = header.Started;
_seats[1].EverConnected = header.Started;
var now = _time.GetUtcNow();
_seats[0].AbsentSince = now;
_seats[1].AbsentSince = now;
_lastClockSent = header.LastClockSent;
_finishedAt = header.FinishedAt;
_resultReported = header.IsFinished;
if (_started)
_turnDeadline = header.TurnDeadline ?? now.AddSeconds(_options.TurnSeconds);
RefreshSummary();
_logger.LogInformation(
"Match {MatchId}: restored {Intents} intents, sequence {Seq}.",
_matchId, snapshot.Intents.Count, header.EventSeq);
}
finally
{
_restoring = false;
_gate.Release();
}
}
private void RefreshSummary()
{
_summary = new MatchSummary(

View File

@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Vanguard.Server.Configuration;
using Vanguard.Server.Storage;
namespace Vanguard.Server.Matches;
@ -13,25 +14,38 @@ public sealed class MatchGrainRegistry
private readonly ConcurrentDictionary<string, IMatchGrain> _matches = new(StringComparer.Ordinal);
private readonly IServiceProvider _services;
private readonly GameOptions _options;
private readonly IMatchStore _store;
private readonly ILogger<MatchGrainRegistry> _logger;
public MatchGrainRegistry(
IServiceProvider services,
GameOptions options,
IMatchStore store,
ILogger<MatchGrainRegistry> logger)
{
_services = services;
_options = options;
_store = store;
_logger = logger;
}
public IMatchGrain Create(MatchSetup setup)
public async Task<IMatchGrain> CreateAsync(MatchSetup setup)
{
var grain = ActivatorUtilities.CreateInstance<MatchGrain>(_services, setup);
if (!_matches.TryAdd(setup.MatchId, grain))
throw new InvalidOperationException($"Матч уже существует: {setup.MatchId}");
try
{
await _store.CreateAsync(grain.BuildHeader());
}
catch
{
_matches.TryRemove(setup.MatchId, out _);
throw;
}
_logger.LogInformation(
"Match {MatchId} created: {Mode}, {Player0} vs {Player1}.",
setup.MatchId, setup.Mode, setup.Seat0.PlayerId, setup.Seat1.PlayerId);
@ -39,6 +53,30 @@ public sealed class MatchGrainRegistry
return grain;
}
/// <summary>Поднять матч после рестарта сервера из снапшота.</summary>
public async Task<IMatchGrain> CreateRestoredAsync(MatchSnapshot snapshot)
{
var header = snapshot.Header;
var setup = new MatchSetup(
header.MatchId,
header.Mode,
new MatchSeatSetup(header.Player0Id, header.Deck0, 0),
new MatchSeatSetup(header.Player1Id, header.Deck1, 0),
header.Seed);
var grain = ActivatorUtilities.CreateInstance<MatchGrain>(_services, setup);
await grain.RestoreAsync(snapshot);
if (!_matches.TryAdd(header.MatchId, grain))
throw new InvalidOperationException($"Матч уже восстановлен: {header.MatchId}");
_logger.LogInformation(
"Match {MatchId} restored: {Mode}, {Player0} vs {Player1}, seq {Seq}.",
header.MatchId, header.Mode, header.Player0Id, header.Player1Id, header.EventSeq);
return grain;
}
public bool TryGet(string matchId, [NotNullWhen(true)] out IMatchGrain? match) =>
_matches.TryGetValue(matchId, out match);
@ -48,6 +86,8 @@ public sealed class MatchGrainRegistry
public int ActiveCount => _matches.Values.Count(match => !match.Summary.IsFinished);
public void Remove(string matchId) => _matches.TryRemove(matchId, out _);
public void RemoveExpired(DateTimeOffset now)
{
var ttl = TimeSpan.FromSeconds(_options.CompletedMatchTtlSeconds);

View File

@ -45,8 +45,9 @@ public sealed class DeckRejectedException : Exception
/// <summary>
/// Две очереди, скрытый MMR и расширяющееся окно.
///
/// Состояние в памяти: до Redis этого достаточно для одного процесса.
/// Наружу MMR и колоды не отдаются — только статус, тикет и имя противника.
/// Очередь живёт в памяти, но каждый матч сразу создаётся через registry,
/// который пишет снапшот в хранилище. После рестарта игрок встаёт в очередь
/// заново; уже созданные матчи восстанавливаются из PostgreSQL.
/// </summary>
public sealed class MatchmakingService
{
@ -68,7 +69,7 @@ public sealed class MatchmakingService
public string? Reason { get; set; }
}
private readonly object _gate = new();
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly Dictionary<Guid, Entry> _entries = new();
private readonly Dictionary<GameMode, List<Guid>> _queues = new()
{
@ -81,6 +82,7 @@ public sealed class MatchmakingService
private readonly GameTicketService _tickets;
private readonly PlayerProfileStore _profiles;
private readonly GameOptions _options;
private readonly IQueueStore _queueStore;
private readonly TimeProvider _time;
private readonly ILogger<MatchmakingService> _logger;
private long _sequence;
@ -91,6 +93,7 @@ public sealed class MatchmakingService
GameTicketService tickets,
PlayerProfileStore profiles,
GameOptions options,
IQueueStore queueStore,
TimeProvider time,
ILogger<MatchmakingService> logger)
{
@ -99,6 +102,7 @@ public sealed class MatchmakingService
_tickets = tickets;
_profiles = profiles;
_options = options;
_queueStore = queueStore;
_time = time;
_logger = logger;
}
@ -107,12 +111,22 @@ public sealed class MatchmakingService
{
get
{
lock (_gate)
_gate.Wait();
try
{
return _entries.Values.Count(entry => entry.State == QueueEntryState.Queued);
}
finally
{
_gate.Release();
}
}
}
public QueueTicket Enqueue(string playerId, IReadOnlyList<string> deck, GameMode mode)
public async Task<QueueTicket> EnqueueAsync(
string playerId,
IReadOnlyList<string> deck,
GameMode mode)
{
var violations = DeckValidator.Validate(_catalog, deck.ToList()).ToList();
@ -128,7 +142,8 @@ public sealed class MatchmakingService
if (violations.Count > 0)
throw new DeckRejectedException(violations);
lock (_gate)
await _gate.WaitAsync();
try
{
// один игрок — одна активная очередь
foreach (var existing in _entries.Values
@ -159,24 +174,36 @@ public sealed class MatchmakingService
"Queue: {PlayerId} joined {Mode} (queue {QueueId}).",
playerId, mode, entry.QueueId);
await PersistAsync(entry);
return new QueueTicket(entry.QueueId, GameModes.ToWire(mode));
}
finally
{
_gate.Release();
}
}
public QueueSnapshot? Poll(Guid queueId, string playerId)
public async Task<QueueSnapshot?> PollAsync(Guid queueId, string playerId)
{
lock (_gate)
await _gate.WaitAsync();
try
{
if (!_entries.TryGetValue(queueId, out var entry) || entry.PlayerId != playerId)
return null;
return Snapshot(entry, _time.GetUtcNow());
}
finally
{
_gate.Release();
}
}
public bool Cancel(Guid queueId, string playerId)
public async Task<bool> CancelAsync(Guid queueId, string playerId)
{
lock (_gate)
await _gate.WaitAsync();
try
{
if (!_entries.TryGetValue(queueId, out var entry) || entry.PlayerId != playerId)
return false;
@ -187,25 +214,35 @@ public sealed class MatchmakingService
entry.State = QueueEntryState.Cancelled;
entry.Reason = "Игрок отменил очередь.";
RemoveFromQueue(entry);
await PersistAsync(entry);
_logger.LogInformation("Queue: {PlayerId} cancelled {QueueId}.", playerId, queueId);
return true;
}
finally
{
_gate.Release();
}
}
/// <summary>Один проход подбора. Вызывается фоновым воркером раз в секунду.</summary>
public void Matchmake(DateTimeOffset now)
public async Task MatchmakeAsync(DateTimeOffset now)
{
lock (_gate)
await _gate.WaitAsync();
try
{
Expire(now);
await ExpireAsync(now);
foreach (var mode in new[] { GameMode.ClassicRanked, GameMode.ClassicCasual })
MatchmakeMode(mode, now);
await MatchmakeModeAsync(mode, now);
}
finally
{
_gate.Release();
}
}
private void MatchmakeMode(GameMode mode, DateTimeOffset now)
private async Task MatchmakeModeAsync(GameMode mode, DateTimeOffset now)
{
var entries = _queues[mode]
.Select(id => _entries[id])
@ -251,27 +288,30 @@ public sealed class MatchmakingService
used.Add(first.QueueId);
used.Add(best.QueueId);
TryPair(first, best);
await TryPairAsync(first, best);
}
}
private void TryPair(Entry a, Entry b)
private async Task TryPairAsync(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");
var created = false;
try
{
_matches.Create(new MatchSetup(
await _matches.CreateAsync(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);
created = true;
await MarkMatchedAsync(first, matchId, 0, second.PlayerId);
await MarkMatchedAsync(second, matchId, 1, first.PlayerId);
_logger.LogInformation(
"Queue: matched {Player0} vs {Player1} in {MatchId} ({Mode}).",
@ -281,22 +321,26 @@ public sealed class MatchmakingService
{
_logger.LogError(exception, "Queue: creating match {MatchId} failed.", matchId);
MarkFailed(first, "Не удалось создать матч.");
MarkFailed(second, "Не удалось создать матч.");
if (created)
_matches.Remove(matchId);
await MarkFailedAsync(first, "Не удалось создать матч.");
await MarkFailedAsync(second, "Не удалось создать матч.");
}
}
private void MarkMatched(Entry entry, string matchId, int seat, string opponent)
private async Task MarkMatchedAsync(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.Ticket = (await _tickets.IssueAsync(entry.PlayerId, matchId, seat)).Token;
entry.Opponent = opponent;
RemoveFromQueue(entry);
await PersistAsync(entry);
}
private void MarkFailed(Entry entry, string reason)
private async Task MarkFailedAsync(Entry entry, string reason)
{
entry.State = QueueEntryState.Failed;
entry.Reason = reason;
@ -304,6 +348,7 @@ public sealed class MatchmakingService
entry.Ticket = null;
entry.Seat = -1;
RemoveFromQueue(entry);
await PersistAsync(entry);
}
private void RemoveFromQueue(Entry entry) => _queues[entry.Mode].Remove(entry.QueueId);
@ -319,7 +364,79 @@ public sealed class MatchmakingService
_options.MmrInitialWindow + steps * _options.MmrWindowStep);
}
private void Expire(DateTimeOffset now)
/// <summary>Поднять сохранённые очереди после рестарта сервера.</summary>
public async Task LoadQueuedAsync()
{
await _gate.WaitAsync();
try
{
var stored = await _queueStore.LoadQueuedAsync();
var loaded = 0;
foreach (var snapshot in stored.OrderBy(entry => entry.Sequence))
{
if (_entries.ContainsKey(snapshot.QueueId))
continue;
if (_entries.Values.Any(entry =>
entry.PlayerId == snapshot.PlayerId
&& entry.State == QueueEntryState.Queued))
continue;
var entry = new Entry
{
QueueId = snapshot.QueueId,
PlayerId = snapshot.PlayerId,
Deck = snapshot.Deck,
Mode = snapshot.Mode,
Mmr = snapshot.Mmr,
EnqueuedAt = snapshot.EnqueuedAt,
Sequence = snapshot.Sequence,
State = QueueEntryState.Queued,
};
_entries[entry.QueueId] = entry;
_queues[entry.Mode].Add(entry.QueueId);
_sequence = Math.Max(_sequence, entry.Sequence);
loaded++;
}
_logger.LogInformation("Queue: loaded {Count} saved entries.", loaded);
}
finally
{
_gate.Release();
}
}
private async Task PersistAsync(Entry entry)
{
try
{
await _queueStore.SaveAsync(ToSnapshot(entry));
}
catch (Exception exception)
{
_logger.LogError(exception, "Queue: persisting {QueueId} failed.", entry.QueueId);
}
}
private static QueueEntrySnapshot ToSnapshot(Entry entry) => new(
entry.QueueId,
entry.PlayerId,
entry.Deck.ToList(),
entry.Mode,
entry.Mmr,
entry.EnqueuedAt,
entry.Sequence,
entry.State,
entry.MatchId,
entry.Seat,
entry.Ticket,
entry.Opponent,
entry.Reason);
private async Task ExpireAsync(DateTimeOffset now)
{
var ttl = TimeSpan.FromSeconds(_options.QueueExpirySeconds);
var keepFinished = TimeSpan.FromSeconds(_options.QueueExpirySeconds * 2.0);
@ -331,12 +448,16 @@ public sealed class MatchmakingService
entry.State = QueueEntryState.Expired;
entry.Reason = "Очередь истекла.";
RemoveFromQueue(entry);
await PersistAsync(entry);
_logger.LogInformation("Queue: {QueueId} expired.", entry.QueueId);
continue;
}
if (entry.State != QueueEntryState.Queued && now - entry.EnqueuedAt >= keepFinished)
{
_entries.Remove(entry.QueueId);
await _queueStore.RemoveAsync(entry.QueueId, entry.Mode);
}
}
}

View File

@ -25,7 +25,7 @@ public sealed class MatchmakingWorker : BackgroundService
{
try
{
_matchmaking.Matchmake(_time.GetUtcNow());
await _matchmaking.MatchmakeAsync(_time.GetUtcNow());
}
catch (Exception exception)
{

View File

@ -0,0 +1,147 @@
using System.Collections.Concurrent;
using System.Text.Json;
using StackExchange.Redis;
using Vanguard.Server.Configuration;
namespace Vanguard.Server.Matchmaking;
/// <summary>Сериализуемая запись очереди: нужна, чтобы пережить рестарт.</summary>
public sealed record QueueEntrySnapshot(
Guid QueueId,
string PlayerId,
List<string> Deck,
GameMode Mode,
int Mmr,
DateTimeOffset EnqueuedAt,
long Sequence,
QueueEntryState State,
string? MatchId,
int Seat,
string? Ticket,
string? Opponent,
string? Reason);
public interface IQueueStore
{
Task SaveAsync(QueueEntrySnapshot entry);
Task<IReadOnlyList<QueueEntrySnapshot>> LoadQueuedAsync();
Task RemoveAsync(Guid queueId, GameMode mode);
}
/// <summary>Очередь в памяти: используется без Redis и в тестах.</summary>
public sealed class InMemoryQueueStore : IQueueStore
{
private readonly object _gate = new();
private readonly Dictionary<Guid, QueueEntrySnapshot> _entries = new();
public Task SaveAsync(QueueEntrySnapshot entry)
{
lock (_gate)
_entries[entry.QueueId] = entry;
return Task.CompletedTask;
}
public Task<IReadOnlyList<QueueEntrySnapshot>> LoadQueuedAsync()
{
lock (_gate)
{
return Task.FromResult<IReadOnlyList<QueueEntrySnapshot>>(
_entries.Values
.Where(entry => entry.State == QueueEntryState.Queued)
.OrderBy(entry => entry.Sequence)
.ToList());
}
}
public Task RemoveAsync(Guid queueId, GameMode mode)
{
lock (_gate)
_entries.Remove(queueId);
return Task.CompletedTask;
}
}
/// <summary>
/// Redis-очередь: запись — JSON по ключу, порядок — sorted set по режимам.
/// TTL записи в два раза больше срока жизни очереди.
/// </summary>
public sealed class RedisQueueStore : IQueueStore
{
private readonly IDatabase _database;
private readonly RedisOptions _options;
private readonly int _ttlSeconds;
public RedisQueueStore(
IConnectionMultiplexer redis,
RedisOptions options,
GameOptions gameOptions)
{
_database = redis.GetDatabase();
_options = options;
_ttlSeconds = Math.Max(60, gameOptions.QueueExpirySeconds * 2);
}
public async Task SaveAsync(QueueEntrySnapshot entry)
{
var key = EntryKey(entry.QueueId);
var json = JsonSerializer.Serialize(entry);
await _database.StringSetAsync(key, json, TimeSpan.FromSeconds(_ttlSeconds));
var modeKey = QueueKey(entry.Mode);
if (entry.State == QueueEntryState.Queued)
await _database.SortedSetAddAsync(modeKey, entry.QueueId.ToString("N"), entry.Sequence);
else
await _database.SortedSetRemoveAsync(modeKey, entry.QueueId.ToString("N"));
}
public async Task<IReadOnlyList<QueueEntrySnapshot>> LoadQueuedAsync()
{
var result = new List<QueueEntrySnapshot>();
foreach (var mode in new[] { GameMode.ClassicRanked, GameMode.ClassicCasual })
{
var ids = await _database.SortedSetRangeByRankAsync(QueueKey(mode));
foreach (var id in ids)
{
if (!Guid.TryParse(id.ToString(), out var queueId))
continue;
var value = await _database.StringGetAsync(EntryKey(queueId));
if (value.IsNullOrEmpty)
continue;
try
{
var entry = JsonSerializer.Deserialize<QueueEntrySnapshot>(value.ToString());
if (entry is not null && entry.State == QueueEntryState.Queued)
result.Add(entry);
}
catch (JsonException)
{
// битая запись — просто пропускаем
}
}
}
return result
.OrderBy(entry => entry.Sequence)
.ToList();
}
public async Task RemoveAsync(Guid queueId, GameMode mode)
{
await _database.KeyDeleteAsync(EntryKey(queueId));
await _database.SortedSetRemoveAsync(QueueKey(mode), queueId.ToString("N"));
}
private string EntryKey(Guid queueId) => $"{_options.Prefix}queue:entry:{queueId:N}";
private string QueueKey(GameMode mode) => $"{_options.Prefix}queue:{GameModes.ToWire(mode)}";
}

View File

@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using Vanguard.Content;
using Vanguard.Rules.Cards;
using Vanguard.Server.Api;
@ -7,15 +9,20 @@ using Vanguard.Server.Matches;
using Vanguard.Server.Matchmaking;
using Vanguard.Server.Profiles;
using Vanguard.Server.Realtime;
using Vanguard.Server.Storage;
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.Configure<StorageOptions>(builder.Configuration.GetSection("Storage"));
builder.Services.Configure<RedisOptions>(builder.Configuration.GetSection("Redis"));
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<GameOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<ContentOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<StorageOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<RedisOptions>>().Value);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton(sp =>
@ -25,10 +32,58 @@ builder.Services.AddSingleton(sp =>
return CatalogLoader.LoadDirectory(directory);
});
var storage = builder.Configuration.GetSection("Storage").Get<StorageOptions>() ?? new StorageOptions();
var storageProvider = storage.Provider?.Trim() ?? "None";
if (storageProvider.Equals("Postgres", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(storage.ConnectionString))
throw new InvalidOperationException("Storage:ConnectionString обязателен для Provider=Postgres.");
builder.Services.AddDbContextFactory<MatchDbContext>(options =>
options.UseNpgsql(storage.ConnectionString));
builder.Services.AddSingleton<IMatchStore, EfMatchStore>();
}
else if (storageProvider.Equals("Memory", StringComparison.OrdinalIgnoreCase))
{
builder.Services.AddDbContextFactory<MatchDbContext>(options =>
options.UseInMemoryDatabase("vanguard-matches"));
builder.Services.AddSingleton<IMatchStore, EfMatchStore>();
}
else if (storageProvider.Equals("None", StringComparison.OrdinalIgnoreCase))
{
builder.Services.AddSingleton<IMatchStore, NullMatchStore>();
}
else
{
throw new InvalidOperationException(
$"Неизвестный Storage:Provider «{storageProvider}». Ожидается None, Memory или Postgres.");
}
var redis = builder.Configuration.GetSection("Redis").Get<RedisOptions>() ?? new RedisOptions();
if (!string.IsNullOrWhiteSpace(redis.Configuration))
{
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
{
var configuration = ConfigurationOptions.Parse(redis.Configuration);
configuration.AbortOnConnectFail = false;
return ConnectionMultiplexer.Connect(configuration);
});
builder.Services.AddSingleton<ITicketStore, RedisTicketStore>();
builder.Services.AddSingleton<IQueueStore, RedisQueueStore>();
}
else
{
builder.Services.AddSingleton<ITicketStore, InMemoryTicketStore>();
builder.Services.AddSingleton<IQueueStore, InMemoryQueueStore>();
}
builder.Services.AddSingleton<GameTicketService>();
builder.Services.AddSingleton<PlayerProfileStore>();
builder.Services.AddSingleton<IMatchResultSink>(sp => sp.GetRequiredService<PlayerProfileStore>());
builder.Services.AddSingleton<MatchGrainRegistry>();
builder.Services.AddSingleton<MatchRecoveryService>();
builder.Services.AddSingleton<MatchmakingService>();
builder.Services.AddHostedService<MatchTicker>();
builder.Services.AddHostedService<MatchmakingWorker>();
@ -38,6 +93,17 @@ var app = builder.Build();
// каталог грузим сразу: сервер без карт бесполезен, лучше упасть на старте
app.Services.GetRequiredService<CardCatalog>();
if (app.Services.GetRequiredService<IMatchStore>() is EfMatchStore)
{
var factory = app.Services.GetRequiredService<IDbContextFactory<MatchDbContext>>();
await using var db = await factory.CreateDbContextAsync();
await db.Database.EnsureCreatedAsync();
}
// незавершённые матчи поднимаем до того, как сервер начнёт принимать подключения
await app.Services.GetRequiredService<MatchRecoveryService>().RecoverAsync();
await app.Services.GetRequiredService<MatchmakingService>().LoadQueuedAsync();
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30),

View File

@ -74,7 +74,7 @@ public static class GameWebSocketEndpoint
return;
}
var ticket = tickets.Redeem(hello.Ticket);
var ticket = await tickets.RedeemAsync(hello.Ticket);
if (ticket is null)
{
await FailAsync(connection, ProtocolErrorCode.InvalidTicket,

View File

@ -0,0 +1,183 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Vanguard.Server.Configuration;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Storage;
public sealed class EfMatchStore : IMatchStore
{
private readonly IDbContextFactory<MatchDbContext> _factory;
private readonly TimeProvider _time;
public EfMatchStore(IDbContextFactory<MatchDbContext> factory, TimeProvider time)
{
_factory = factory;
_time = time;
}
public async Task CreateAsync(MatchHeader header)
{
await using var db = await _factory.CreateDbContextAsync();
var entity = new MatchEntity();
Apply(entity, header);
entity.CreatedAt = header.CreatedAt;
entity.UpdatedAt = _time.GetUtcNow();
db.Matches.Add(entity);
await db.SaveChangesAsync();
}
public async Task AppendIntentAsync(MatchHeader header, PersistedIntent intent)
{
await using var db = await _factory.CreateDbContextAsync();
var entity = await db.Matches.FindAsync(header.MatchId)
?? throw new InvalidOperationException($"Матч не найден в хранилище: {header.MatchId}");
Apply(entity, header);
entity.UpdatedAt = _time.GetUtcNow();
db.Intents.Add(new MatchIntentEntity
{
MatchId = header.MatchId,
IntentId = intent.IntentId,
Seat = intent.Seat,
Command = intent.Command,
Accepted = intent.Accepted,
Code = (int)intent.Code,
Reason = intent.Reason,
EventSeq = intent.EventSeq,
});
await db.SaveChangesAsync();
}
public async Task UpdateHeaderAsync(MatchHeader header)
{
await using var db = await _factory.CreateDbContextAsync();
var entity = await db.Matches.FindAsync(header.MatchId);
if (entity is null)
return;
Apply(entity, header);
entity.UpdatedAt = _time.GetUtcNow();
await db.SaveChangesAsync();
}
public async Task<MatchSnapshot?> GetAsync(string matchId)
{
await using var db = await _factory.CreateDbContextAsync();
var entity = await db.Matches.AsNoTracking()
.SingleOrDefaultAsync(match => match.MatchId == matchId);
if (entity is null)
return null;
var intents = await db.Intents.AsNoTracking()
.Where(intent => intent.MatchId == matchId)
.OrderBy(intent => intent.Id)
.ToListAsync();
return ToSnapshot(entity, intents);
}
public async Task<IReadOnlyList<MatchSnapshot>> LoadUnfinishedAsync(DateTimeOffset updatedAfter)
{
await using var db = await _factory.CreateDbContextAsync();
var matches = await db.Matches.AsNoTracking()
.Where(match => !match.IsFinished && match.UpdatedAt >= updatedAfter)
.OrderBy(match => match.CreatedAt)
.ToListAsync();
if (matches.Count == 0)
return Array.Empty<MatchSnapshot>();
var ids = matches.Select(match => match.MatchId).ToList();
var intents = await db.Intents.AsNoTracking()
.Where(intent => ids.Contains(intent.MatchId))
.OrderBy(intent => intent.Id)
.ToListAsync();
var byMatch = intents
.GroupBy(intent => intent.MatchId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal);
return matches
.Select(match => ToSnapshot(
match,
byMatch.TryGetValue(match.MatchId, out var list) ? list : new List<MatchIntentEntity>()))
.ToList();
}
private static void Apply(MatchEntity entity, MatchHeader header)
{
entity.MatchId = header.MatchId;
entity.Mode = GameModes.ToWire(header.Mode);
entity.Player0Id = header.Player0Id;
entity.Player1Id = header.Player1Id;
entity.Deck0Json = JsonSerializer.Serialize(header.Deck0);
entity.Deck1Json = JsonSerializer.Serialize(header.Deck1);
entity.Seed = header.Seed.ToString(CultureInfo.InvariantCulture);
entity.Started = header.Started;
entity.Bank0Ms = header.Bank0Ms;
entity.Bank1Ms = header.Bank1Ms;
entity.TurnDeadline = header.TurnDeadline;
entity.AbsentSince0 = header.AbsentSince0;
entity.AbsentSince1 = header.AbsentSince1;
entity.LastClockSent = header.LastClockSent;
entity.IsFinished = header.IsFinished;
entity.Winner = header.Winner;
entity.FinishedAt = header.FinishedAt;
entity.EventSeq = header.EventSeq;
}
private static MatchSnapshot ToSnapshot(MatchEntity entity, IReadOnlyList<MatchIntentEntity> intents)
{
var mode = GameModes.TryParse(entity.Mode, out var parsed)
? parsed
: GameMode.ClassicRanked;
var header = new MatchHeader(
entity.MatchId,
mode,
entity.Player0Id,
entity.Player1Id,
DeserializeDeck(entity.Deck0Json),
DeserializeDeck(entity.Deck1Json),
ulong.Parse(entity.Seed, CultureInfo.InvariantCulture),
entity.CreatedAt,
entity.Started,
entity.Bank0Ms,
entity.Bank1Ms,
entity.TurnDeadline,
entity.AbsentSince0,
entity.AbsentSince1,
entity.LastClockSent,
entity.IsFinished,
entity.Winner,
entity.FinishedAt,
entity.EventSeq);
var persisted = intents
.Select(intent => new PersistedIntent(
intent.IntentId,
intent.Seat,
intent.Command,
intent.Accepted,
(IntentRejectCode)intent.Code,
intent.Reason,
intent.EventSeq))
.ToList();
return new MatchSnapshot(header, persisted);
}
private static IReadOnlyList<string> DeserializeDeck(string json) =>
JsonSerializer.Deserialize<string[]>(json) ?? Array.Empty<string>();
}

View File

@ -0,0 +1,82 @@
using Microsoft.EntityFrameworkCore;
namespace Vanguard.Server.Storage;
public sealed class MatchDbContext : DbContext
{
public MatchDbContext(DbContextOptions<MatchDbContext> options)
: base(options)
{
}
public DbSet<MatchEntity> Matches => Set<MatchEntity>();
public DbSet<MatchIntentEntity> Intents => Set<MatchIntentEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<MatchEntity>(entity =>
{
entity.ToTable("matches");
entity.HasKey(e => e.MatchId);
entity.Property(e => e.MatchId).HasMaxLength(64);
entity.Property(e => e.Mode).HasMaxLength(32);
entity.Property(e => e.Player0Id).HasMaxLength(64);
entity.Property(e => e.Player1Id).HasMaxLength(64);
entity.Property(e => e.Deck0Json).HasColumnType("text");
entity.Property(e => e.Deck1Json).HasColumnType("text");
entity.Property(e => e.Seed).HasMaxLength(32);
entity.HasIndex(e => new { e.IsFinished, e.UpdatedAt });
});
modelBuilder.Entity<MatchIntentEntity>(entity =>
{
entity.ToTable("match_intents");
entity.HasKey(e => e.Id);
entity.Property(e => e.MatchId).HasMaxLength(64);
entity.Property(e => e.IntentId).HasMaxLength(64);
entity.Property(e => e.Command).HasColumnType("text");
entity.Property(e => e.Reason).HasColumnType("text");
entity.HasIndex(e => e.MatchId);
});
}
}
public sealed class MatchEntity
{
public string MatchId { get; set; } = string.Empty;
public string Mode { get; set; } = string.Empty;
public string Player0Id { get; set; } = string.Empty;
public string Player1Id { get; set; } = string.Empty;
public string Deck0Json { get; set; } = "[]";
public string Deck1Json { get; set; } = "[]";
public string Seed { get; set; } = "0";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public bool Started { get; set; }
public long Bank0Ms { get; set; }
public long Bank1Ms { get; set; }
public DateTimeOffset? TurnDeadline { get; set; }
public DateTimeOffset? AbsentSince0 { get; set; }
public DateTimeOffset? AbsentSince1 { get; set; }
public DateTimeOffset LastClockSent { get; set; }
public bool IsFinished { get; set; }
public int Winner { get; set; }
public DateTimeOffset? FinishedAt { get; set; }
public long EventSeq { get; set; }
}
public sealed class MatchIntentEntity
{
public long Id { get; set; }
public string MatchId { get; set; } = string.Empty;
public string IntentId { get; set; } = string.Empty;
public int Seat { get; set; }
public string Command { get; set; } = string.Empty;
public bool Accepted { get; set; }
public int Code { get; set; }
public string? Reason { get; set; }
public long EventSeq { get; set; }
}

View File

@ -0,0 +1,58 @@
using Vanguard.Server.Matches;
using Vanguard.Server.Storage;
namespace Vanguard.Server.Storage;
/// <summary>
/// Поднимает незавершённые матчи при старте сервера. Вызывается до того,
/// как сервер начнёт принимать подключения.
/// </summary>
public sealed class MatchRecoveryService
{
private readonly IMatchStore _store;
private readonly MatchGrainRegistry _registry;
private readonly TimeProvider _time;
private readonly ILogger<MatchRecoveryService> _logger;
public MatchRecoveryService(
IMatchStore store,
MatchGrainRegistry registry,
TimeProvider time,
ILogger<MatchRecoveryService> logger)
{
_store = store;
_registry = registry;
_time = time;
_logger = logger;
}
public async Task RecoverAsync(CancellationToken cancellationToken = default)
{
var since = _time.GetUtcNow().AddDays(-7);
var snapshots = await _store.LoadUnfinishedAsync(since);
var restored = 0;
foreach (var snapshot in snapshots)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await _registry.CreateRestoredAsync(snapshot);
restored++;
}
catch (Exception exception)
{
_logger.LogError(
exception,
"Match {MatchId}: recovery failed, the match is skipped.",
snapshot.Header.MatchId);
}
}
_logger.LogInformation(
"Recovery: {Restored} of {Total} unfinished matches restored.",
restored, snapshots.Count);
}
}

View File

@ -0,0 +1,70 @@
using Vanguard.Server.Configuration;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Storage;
/// <summary>Всё, что нужно, чтобы восстановить матч без сериализации делегатов движка.</summary>
public sealed record MatchHeader(
string MatchId,
GameMode Mode,
string Player0Id,
string Player1Id,
IReadOnlyList<string> Deck0,
IReadOnlyList<string> Deck1,
ulong Seed,
DateTimeOffset CreatedAt,
bool Started,
long Bank0Ms,
long Bank1Ms,
DateTimeOffset? TurnDeadline,
DateTimeOffset? AbsentSince0,
DateTimeOffset? AbsentSince1,
DateTimeOffset LastClockSent,
bool IsFinished,
int Winner,
DateTimeOffset? FinishedAt,
long EventSeq);
/// <summary>Запись журнала интентов. Принятые команды воспроизводятся при восстановлении.</summary>
public sealed record PersistedIntent(
string IntentId,
int Seat,
string Command,
bool Accepted,
IntentRejectCode Code,
string? Reason,
long EventSeq);
public sealed record MatchSnapshot(MatchHeader Header, IReadOnlyList<PersistedIntent> Intents);
/// <summary>
/// Хранилище матчей. Пишется на каждый интент и при изменениях часов;
/// при старте сервера незавершённые матчи восстанавливаются повтором команд.
/// </summary>
public interface IMatchStore
{
Task CreateAsync(MatchHeader header);
Task AppendIntentAsync(MatchHeader header, PersistedIntent intent);
Task UpdateHeaderAsync(MatchHeader header);
Task<MatchSnapshot?> GetAsync(string matchId);
Task<IReadOnlyList<MatchSnapshot>> LoadUnfinishedAsync(DateTimeOffset updatedAfter);
}
/// <summary>Заглушка для локального запуска без базы.</summary>
public sealed class NullMatchStore : IMatchStore
{
public Task CreateAsync(MatchHeader header) => Task.CompletedTask;
public Task AppendIntentAsync(MatchHeader header, PersistedIntent intent) => Task.CompletedTask;
public Task UpdateHeaderAsync(MatchHeader header) => Task.CompletedTask;
public Task<MatchSnapshot?> GetAsync(string matchId) => Task.FromResult<MatchSnapshot?>(null);
public Task<IReadOnlyList<MatchSnapshot>> LoadUnfinishedAsync(DateTimeOffset updatedAfter) =>
Task.FromResult<IReadOnlyList<MatchSnapshot>>(Array.Empty<MatchSnapshot>());
}

View File

@ -1,4 +1,3 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using Vanguard.Server.Configuration;
@ -13,36 +12,35 @@ public sealed record GameTicket(
DateTimeOffset ExpiresAt);
/// <summary>
/// Тикеты живут в памяти: TTL короткий, переживать рестарт им не нужно.
/// При переезде на нескольких инстансов сервис заменяется на Redis-реализацию.
/// Выдаёт и принимает тикеты. Само хранение делегировано в ITicketStore:
/// in-memory для тестов и одного процесса, Redis — для нескольких инстансов.
/// </summary>
public sealed class GameTicketService
{
private readonly ConcurrentDictionary<string, GameTicket> _tickets = new(StringComparer.Ordinal);
private readonly ITicketStore _store;
private readonly GameOptions _options;
private readonly TimeProvider _time;
public GameTicketService(GameOptions options, TimeProvider time)
public GameTicketService(ITicketStore store, GameOptions options, TimeProvider time)
{
_store = store;
_options = options;
_time = time;
}
public GameTicket Issue(string playerId, string matchId, int seat)
public async Task<GameTicket> IssueAsync(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));
_time.GetUtcNow().AddSeconds(_options.TicketTtlSeconds));
_tickets[ticket.Token] = ticket;
RemoveExpired(now);
await _store.IssueAsync(ticket);
return ticket;
}
@ -50,26 +48,13 @@ public sealed class GameTicketService
/// Забирает тикет. null — неизвестный, уже использованный или просроченный.
/// Тикет одноразовый: повторный Redeem того же токена не сработает.
/// </summary>
public GameTicket? Redeem(string? token)
public async Task<GameTicket?> RedeemAsync(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 _);
}
var ticket = await _store.TakeAsync(token);
return ticket is not null && ticket.ExpiresAt > _time.GetUtcNow() ? ticket : null;
}
private static string NewToken()

View File

@ -0,0 +1,92 @@
using System.Collections.Concurrent;
using System.Text.Json;
using StackExchange.Redis;
using Vanguard.Server.Configuration;
namespace Vanguard.Server.Tickets;
/// <summary>Хранилище одноразовых тикетов.</summary>
public interface ITicketStore
{
Task IssueAsync(GameTicket ticket);
/// <summary>Забрать тикет; повторный вызов для того же токена вернёт null.</summary>
Task<GameTicket?> TakeAsync(string token);
}
public sealed class InMemoryTicketStore : ITicketStore
{
private readonly ConcurrentDictionary<string, GameTicket> _tickets = new(StringComparer.Ordinal);
private readonly TimeProvider _time;
public InMemoryTicketStore(TimeProvider time) => _time = time;
public Task IssueAsync(GameTicket ticket)
{
_tickets[ticket.Token] = ticket;
RemoveExpired(_time.GetUtcNow());
return Task.CompletedTask;
}
public Task<GameTicket?> TakeAsync(string token)
{
if (!_tickets.TryRemove(token, out var ticket))
return Task.FromResult<GameTicket?>(null);
return Task.FromResult(ticket.ExpiresAt > _time.GetUtcNow() ? ticket : null);
}
private void RemoveExpired(DateTimeOffset now)
{
foreach (var (token, ticket) in _tickets)
{
if (ticket.ExpiresAt <= now)
_tickets.TryRemove(token, out _);
}
}
}
/// <summary>
/// Redis-хранилище тикетов: TTL выставляет сам Redis, а GETDEL делает
/// изъятие атомарным (тикет одноразовый даже при нескольких инстансах).
/// </summary>
public sealed class RedisTicketStore : ITicketStore
{
private readonly IDatabase _database;
private readonly RedisOptions _options;
private readonly TimeProvider _time;
public RedisTicketStore(IConnectionMultiplexer redis, RedisOptions options, TimeProvider time)
{
_database = redis.GetDatabase();
_options = options;
_time = time;
}
public async Task IssueAsync(GameTicket ticket)
{
var ttl = ticket.ExpiresAt - _time.GetUtcNow();
if (ttl <= TimeSpan.Zero)
return;
await _database.StringSetAsync(Key(ticket.Token), JsonSerializer.Serialize(ticket), ttl);
}
public async Task<GameTicket?> TakeAsync(string token)
{
var value = await _database.StringGetDeleteAsync(Key(token));
if (value.IsNullOrEmpty)
return null;
try
{
return JsonSerializer.Deserialize<GameTicket>(value.ToString());
}
catch (JsonException)
{
return null;
}
}
private string Key(string token) => $"{_options.Prefix}ticket:{token}";
}

View File

@ -13,6 +13,13 @@
<ProjectReference Include="..\Vanguard.Shared\Vanguard.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageReference Include="StackExchange.Redis" Version="2.13.17" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Vanguard.Server.Tests" />
</ItemGroup>

View File

@ -2,7 +2,8 @@
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*",
@ -26,5 +27,13 @@
},
"Content": {
"Path": ""
},
"Storage": {
"Provider": "None",
"ConnectionString": ""
},
"Redis": {
"Configuration": "",
"Prefix": "vanguard:"
}
}

View File

@ -0,0 +1,135 @@
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matchmaking;
using Vanguard.Server.Storage;
using Vanguard.Server.Tickets;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Tests;
/// <summary>
/// Проверки настоящих PostgreSQL и Redis. Запускаются только при заданных
/// переменных окружения:
/// VANGUARD_TEST_POSTGRES — connection string к отдельной тестовой БД;
/// VANGUARD_TEST_REDIS — configuration Redis (лучше отдельный DB).
///
/// Postgres-тест делает EnsureDeleted/EnsureCreated, поэтому БД должна быть
/// заведомо тестовой.
/// </summary>
public class ExternalStorageTests
{
private static readonly DateTimeOffset Start = new(2026, 10, 21, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Postgres_Store_RoundTrips_Header_And_Intents()
{
var connectionString = Environment.GetEnvironmentVariable("VANGUARD_TEST_POSTGRES");
if (string.IsNullOrWhiteSpace(connectionString))
return;
var time = new FakeTimeProvider(Start);
var options = new DbContextOptionsBuilder<MatchDbContext>()
.UseNpgsql(connectionString)
.Options;
await using (var db = new MatchDbContext(options))
{
await db.Database.EnsureDeletedAsync();
await db.Database.EnsureCreatedAsync();
}
var store = new EfMatchStore(new TestDbContextFactory(options), time);
var deck = ServerTestData.BuildDeck();
var header = new MatchHeader(
"pg-1", GameMode.ClassicRanked, "p1", "p2", deck, deck, 7,
time.GetUtcNow(), Started: false, 300_000, 300_000,
TurnDeadline: null,
AbsentSince0: time.GetUtcNow(),
AbsentSince1: time.GetUtcNow(),
LastClockSent: time.GetUtcNow(),
IsFinished: false, Winner: -1, FinishedAt: null, EventSeq: 1);
await store.CreateAsync(header);
await store.AppendIntentAsync(
header with { EventSeq = 2 },
new PersistedIntent("i1", 0, "finishMulligan", true, IntentRejectCode.None, null, 2));
var loaded = await store.GetAsync("pg-1");
Assert.NotNull(loaded);
Assert.Equal(7UL, loaded!.Header.Seed);
Assert.Equal(deck, loaded.Header.Deck0);
Assert.Single(loaded.Intents);
Assert.Equal("i1", loaded.Intents[0].IntentId);
var unfinished = await store.LoadUnfinishedAsync(time.GetUtcNow().AddMinutes(-1));
Assert.Single(unfinished);
}
[Fact]
public async Task Redis_Ticket_And_Queue_Stores_Work()
{
var configuration = Environment.GetEnvironmentVariable("VANGUARD_TEST_REDIS");
if (string.IsNullOrWhiteSpace(configuration))
return;
await using var redis = await ConnectionMultiplexer.ConnectAsync(configuration);
var options = new RedisOptions
{
Configuration = configuration,
Prefix = $"vgtest:{Guid.NewGuid():N}:",
};
var time = new FakeTimeProvider(Start);
var game = ServerTestData.SmallOptions();
var tickets = new GameTicketService(
new RedisTicketStore(redis, options, time), game, time);
var ticket = await tickets.IssueAsync("p1", "m1", 0);
var redeemed = await tickets.RedeemAsync(ticket.Token);
Assert.NotNull(redeemed);
Assert.Equal("p1", redeemed!.PlayerId);
Assert.Equal(0, redeemed.Seat);
Assert.Null(await tickets.RedeemAsync(ticket.Token));
var queue = new RedisQueueStore(redis, options, game);
var entry = new QueueEntrySnapshot(
Guid.NewGuid(),
"p1",
ServerTestData.BuildDeck(),
GameMode.ClassicRanked,
1000,
time.GetUtcNow(),
1,
QueueEntryState.Queued,
null,
-1,
null,
null,
null);
await queue.SaveAsync(entry);
var loaded = await queue.LoadQueuedAsync();
Assert.Single(loaded);
Assert.Equal(entry.QueueId, loaded[0].QueueId);
Assert.Equal(entry.PlayerId, loaded[0].PlayerId);
await queue.RemoveAsync(entry.QueueId, entry.Mode);
Assert.Empty(await queue.LoadQueuedAsync());
}
private sealed class TestDbContextFactory : IDbContextFactory<MatchDbContext>
{
private readonly DbContextOptions<MatchDbContext> _options;
public TestDbContextFactory(DbContextOptions<MatchDbContext> options) => _options = options;
public MatchDbContext CreateDbContext() => new(_options);
}
}

View File

@ -5,15 +5,15 @@ namespace Vanguard.Server.Tests;
public class GameTicketServiceTests
{
[Fact]
public void Ticket_Is_Redeemed_Only_Once()
public async Task Ticket_Is_Redeemed_Only_Once()
{
var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
var service = new GameTicketService(ServerTestData.SmallOptions(), time);
var service = NewService(time);
var ticket = service.Issue("p1", "m1", 0);
var ticket = await service.IssueAsync("p1", "m1", 0);
var first = service.Redeem(ticket.Token);
var second = service.Redeem(ticket.Token);
var first = await service.RedeemAsync(ticket.Token);
var second = await service.RedeemAsync(ticket.Token);
Assert.NotNull(first);
Assert.Equal("p1", first!.PlayerId);
@ -23,29 +23,32 @@ public class GameTicketServiceTests
}
[Fact]
public void Ticket_Expires_After_Ttl()
public async Task 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);
var service = NewService(time, options);
var ticket = await service.IssueAsync("p1", "m1", 1);
time.Advance(TimeSpan.FromSeconds(61));
Assert.Null(service.Redeem(ticket.Token));
Assert.Null(await service.RedeemAsync(ticket.Token));
}
[Fact]
public void Unknown_Token_Is_Rejected()
public async Task Unknown_Token_Is_Rejected()
{
var service = new GameTicketService(
ServerTestData.SmallOptions(),
new FakeTimeProvider(DateTimeOffset.UnixEpoch));
var service = NewService(new FakeTimeProvider(DateTimeOffset.UnixEpoch));
Assert.Null(service.Redeem(null));
Assert.Null(service.Redeem(""));
Assert.Null(service.Redeem("no-such-ticket"));
Assert.Null(await service.RedeemAsync(null));
Assert.Null(await service.RedeemAsync(""));
Assert.Null(await service.RedeemAsync("no-such-ticket"));
}
private static GameTicketService NewService(
FakeTimeProvider time,
Vanguard.Server.Configuration.GameOptions? options = null) =>
new(new InMemoryTicketStore(time), options ?? ServerTestData.SmallOptions(), time);
}

View File

@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using Vanguard.Rules.Engine;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Storage;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Tests;
@ -297,6 +298,7 @@ public class MatchGrainTests
options ?? ServerTestData.SmallOptions(),
time,
sink ?? new RecordingResultSink(),
new NullMatchStore(),
NullLogger<MatchGrain>.Instance);
}

View File

@ -0,0 +1,233 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Storage;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Tests;
public class MatchPersistenceTests
{
private static readonly DateTimeOffset Start = new(2026, 10, 21, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task EfStore_RoundTrips_Header_And_Intents()
{
var time = new FakeTimeProvider(Start);
var store = NewStore(out _, time);
var deck = ServerTestData.BuildDeck();
var header = new MatchHeader(
"m1", GameMode.ClassicRanked, "p1", "p2", deck, deck, 42,
time.GetUtcNow(), Started: false, 300_000, 300_000,
TurnDeadline: null,
AbsentSince0: time.GetUtcNow(),
AbsentSince1: time.GetUtcNow(),
LastClockSent: time.GetUtcNow(),
IsFinished: false, Winner: -1, FinishedAt: null, EventSeq: 1);
await store.CreateAsync(header);
await store.AppendIntentAsync(
header with { EventSeq = 2 },
new PersistedIntent("i1", 0, "finishMulligan", true, IntentRejectCode.None, null, 2));
var loaded = await store.GetAsync("m1");
Assert.NotNull(loaded);
Assert.Equal(42UL, loaded!.Header.Seed);
Assert.Equal(deck, loaded.Header.Deck0);
Assert.Equal(2, loaded.Header.EventSeq);
Assert.Single(loaded.Intents);
Assert.Equal("i1", loaded.Intents[0].IntentId);
Assert.True(loaded.Intents[0].Accepted);
var unfinished = await store.LoadUnfinishedAsync(time.GetUtcNow().AddMinutes(-1));
Assert.Single(unfinished);
await store.UpdateHeaderAsync(loaded.Header with
{
IsFinished = true,
Winner = 0,
FinishedAt = time.GetUtcNow(),
});
Assert.Empty(await store.LoadUnfinishedAsync(time.GetUtcNow().AddMinutes(-1)));
}
[Fact]
public async Task Match_Is_Restored_By_Replaying_Accepted_Intents()
{
var time = new FakeTimeProvider(Start);
var store = NewStore(out _, time);
var setup = NewSetup("m1");
var original = NewGrain(setup, time, store);
await store.CreateAsync(original.BuildHeader());
var first = await Attach(original, 0, "p1");
var second = await Attach(original, 1, "p2");
await original.SubmitAsync(0, Intent("i1", new FinishMulliganIntent()));
await original.SubmitAsync(1, Intent("i2", new FinishMulliganIntent()));
await original.ResyncAsync(0, 0, fullSnapshot: true);
var originalView = first.Last<ServerSnapshot>().View;
var snapshot = await store.GetAsync("m1");
Assert.NotNull(snapshot);
Assert.Equal(originalView.EventSeq, snapshot!.Header.EventSeq);
var restored = NewGrain(NewSetup("m1", snapshot.Header.Seed), time, store);
await restored.RestoreAsync(snapshot);
Assert.Equal(original.Summary.EventSeq, restored.Summary.EventSeq);
Assert.False(restored.Summary.IsFinished);
var restoredFirst = await Attach(restored, 0, "p1");
var restoredSecond = await Attach(restored, 1, "p2");
await restored.ResyncAsync(0, 0, fullSnapshot: true);
var restoredView = restoredFirst.Last<ServerSnapshot>().View;
Assert.Equal(originalView.EventSeq, restoredView.EventSeq);
Assert.Equal(originalView.ActivePlayer, restoredView.ActivePlayer);
Assert.Equal(originalView.TurnNumber, restoredView.TurnNumber);
Assert.Equal(originalView.Phase, restoredView.Phase);
Assert.Equal(originalView.Step, restoredView.Step);
Assert.Equal(
originalView.Players[0].Hand.Select(card => card.PrintingId),
restoredView.Players[0].Hand.Select(card => card.PrintingId));
Assert.Equal(originalView.Players[1].HandCount, restoredView.Players[1].HandCount);
Assert.Equal(originalView.Players[0].DeckCount, restoredView.Players[0].DeckCount);
Assert.Equal(
originalView.Players[0].Vanguard?.PrintingId,
restoredView.Players[0].Vanguard?.PrintingId);
// повтор уже принятого интента после восстановления не применяется
await restored.SubmitAsync(1, Intent("i2", new FinishMulliganIntent()));
var duplicate = restoredSecond.Last<ServerIntentResult>();
Assert.True(duplicate.Duplicate);
Assert.Equal(restored.Summary.EventSeq, duplicate.EventSeq);
// и матч продолжает принимать новые интенты
var activeSeat = restoredView.ActivePlayer;
var activeConnection = activeSeat == 0 ? restoredFirst : restoredSecond;
await restored.ResyncAsync(activeSeat, 0, fullSnapshot: true);
var activeView = activeConnection.Last<ServerSnapshot>().View;
var next = activeView.LegalIntents.First(intent => intent is not ConcedeIntent);
await restored.SubmitAsync(activeSeat, Intent("after-restore", next));
Assert.True(restored.Summary.EventSeq > restoredView.EventSeq);
}
[Fact]
public async Task RecoveryService_Restores_Unfinished_Match_After_Restart()
{
var time = new FakeTimeProvider(Start);
var store = NewStore(out _, time);
var setup = NewSetup("m1");
var original = NewGrain(setup, time, store);
await store.CreateAsync(original.BuildHeader());
var first = await Attach(original, 0, "p1");
await Attach(original, 1, "p2");
await original.SubmitAsync(0, Intent("i1", new FinishMulliganIntent()));
await original.SubmitAsync(1, Intent("i2", new FinishMulliganIntent()));
await original.ResyncAsync(0, 0, fullSnapshot: true);
var originalView = first.Last<ServerSnapshot>().View;
var options = ServerTestData.SmallOptions();
var profiles = new RecordingResultSink();
var services = new ServiceCollection();
services.AddSingleton(ServerTestData.Catalog);
services.AddSingleton(options);
services.AddSingleton<TimeProvider>(time);
services.AddSingleton<IMatchResultSink>(profiles);
services.AddSingleton<IMatchStore>(store);
services.AddLogging();
await using var provider = services.BuildServiceProvider();
var registry = new MatchGrainRegistry(
provider, options, store, NullLogger<MatchGrainRegistry>.Instance);
var recovery = new MatchRecoveryService(
store, registry, time, NullLogger<MatchRecoveryService>.Instance);
await recovery.RecoverAsync();
Assert.Equal(1, registry.Count);
Assert.True(registry.TryGet("m1", out var recovered));
Assert.Equal(originalView.EventSeq, recovered!.Summary.EventSeq);
var connection = new FakeMatchConnection();
Assert.True(await recovered.AttachAsync(0, "p1", connection, 0));
var view = connection.Last<ServerSnapshot>().View;
Assert.Equal(originalView.EventSeq, view.EventSeq);
Assert.Equal(originalView.ActivePlayer, view.ActivePlayer);
Assert.Equal(originalView.Phase, view.Phase);
Assert.Equal(
originalView.Players[0].Hand.Select(card => card.PrintingId),
view.Players[0].Hand.Select(card => card.PrintingId));
}
private static EfMatchStore NewStore(out string databaseName, TimeProvider time)
{
databaseName = "matches-" + Guid.NewGuid().ToString("N");
var options = new DbContextOptionsBuilder<MatchDbContext>()
.UseInMemoryDatabase(databaseName)
.Options;
return new EfMatchStore(new TestDbContextFactory(options), time);
}
private static MatchSetup NewSetup(string matchId, ulong? seed = null) => new(
matchId,
GameMode.ClassicRanked,
new MatchSeatSetup("p1", ServerTestData.BuildDeck(), 1000),
new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000),
seed);
private static MatchGrain NewGrain(MatchSetup setup, TimeProvider time, IMatchStore store) => new(
setup,
ServerTestData.Catalog,
ServerTestData.SmallOptions(),
time,
new RecordingResultSink(),
store,
NullLogger<MatchGrain>.Instance);
private static ClientIntent Intent(string intentId, IntentPayload payload) => new()
{
IntentId = intentId,
BasedOnSeq = 0,
Intent = payload,
};
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;
}
private sealed class TestDbContextFactory : IDbContextFactory<MatchDbContext>
{
private readonly DbContextOptions<MatchDbContext> _options;
public TestDbContextFactory(DbContextOptions<MatchDbContext> options) => _options = options;
public MatchDbContext CreateDbContext() => new(_options);
}
}

View File

@ -1,9 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Vanguard.Rules.Cards;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Matchmaking;
using Vanguard.Server.Profiles;
using Vanguard.Server.Storage;
using Vanguard.Server.Tickets;
namespace Vanguard.Server.Tests;
@ -11,17 +13,19 @@ namespace Vanguard.Server.Tests;
public class MatchmakingServiceTests
{
[Fact]
public void Two_Players_Are_Matched_And_Get_One_Time_Tickets()
public async Task 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);
var first = await harness.Matchmaking.EnqueueAsync(
"p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
var second = await harness.Matchmaking.EnqueueAsync(
"p2", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
harness.Matchmaking.Matchmake(harness.Time.GetUtcNow());
await harness.Matchmaking.MatchmakeAsync(harness.Time.GetUtcNow());
var firstStatus = harness.Matchmaking.Poll(first.QueueId, "p1");
var secondStatus = harness.Matchmaking.Poll(second.QueueId, "p2");
var firstStatus = await harness.Matchmaking.PollAsync(first.QueueId, "p1");
var secondStatus = await harness.Matchmaking.PollAsync(second.QueueId, "p2");
Assert.NotNull(firstStatus);
Assert.NotNull(secondStatus);
@ -36,8 +40,8 @@ public class MatchmakingServiceTests
Assert.NotNull(firstStatus.Ticket);
Assert.NotNull(secondStatus.Ticket);
var firstTicket = harness.Tickets.Redeem(firstStatus.Ticket);
var secondTicket = harness.Tickets.Redeem(secondStatus.Ticket);
var firstTicket = await harness.Tickets.RedeemAsync(firstStatus.Ticket);
var secondTicket = await harness.Tickets.RedeemAsync(secondStatus.Ticket);
Assert.Equal(0, firstTicket!.Seat);
Assert.Equal(1, secondTicket!.Seat);
Assert.Equal(firstStatus.MatchId, firstTicket.MatchId);
@ -48,53 +52,55 @@ public class MatchmakingServiceTests
}
[Fact]
public void Ranked_And_Casual_Queues_Are_Separate()
public async Task 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);
await harness.Matchmaking.EnqueueAsync("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
var casual = await harness.Matchmaking.EnqueueAsync(
"p2", ServerTestData.BuildDeck(), GameMode.ClassicCasual);
harness.Matchmaking.Matchmake(harness.Time.GetUtcNow());
await harness.Matchmaking.MatchmakeAsync(harness.Time.GetUtcNow());
Assert.Equal("queued", harness.Matchmaking.Poll(casual.QueueId, "p2")!.Status);
Assert.Equal("queued", (await harness.Matchmaking.PollAsync(casual.QueueId, "p2"))!.Status);
Assert.Equal(0, harness.Registry.Count);
Assert.Equal(2, harness.Matchmaking.QueuedCount);
}
[Fact]
public void Window_Expands_While_Waiting()
public async Task Window_Expands_While_Waiting()
{
var harness = NewHarness();
var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
var ticket = await harness.Matchmaking.EnqueueAsync(
"p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
var initial = harness.Matchmaking.Poll(ticket.QueueId, "p1")!;
Assert.Equal(100, initial.Window);
var initial = await harness.Matchmaking.PollAsync(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
var later = await harness.Matchmaking.PollAsync(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);
var capped = await harness.Matchmaking.PollAsync(ticket.QueueId, "p1");
Assert.Equal(2000, capped!.Window);
}
[Fact]
public void Invalid_Deck_Is_Rejected_At_Enqueue()
public async Task 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));
var exception = await Assert.ThrowsAsync<DeckRejectedException>(
() => harness.Matchmaking.EnqueueAsync("p1", deck, GameMode.ClassicRanked));
Assert.Contains(exception.Violations, violation => violation.Code == "deck.size");
}
[Fact]
public void Deck_Without_Starting_Vanguard_Is_Rejected()
public async Task Deck_Without_Starting_Vanguard_Is_Rejected()
{
var harness = NewHarness();
var deck = new List<string>();
@ -107,7 +113,7 @@ public class MatchmakingServiceTests
break;
var card = ServerTestData.Catalog.CardOf(printing.PrintingId);
if (card.Grade == 0 || card.IsSentinel || card.Trigger == Vanguard.Rules.Cards.TriggerType.Heal)
if (card.Grade == 0 || card.IsSentinel || card.Trigger == TriggerType.Heal)
continue;
for (var i = 0; i < 4 && deck.Count < 50; i++)
@ -116,49 +122,53 @@ public class MatchmakingServiceTests
Assert.Equal(50, deck.Count);
var exception = Assert.Throws<DeckRejectedException>(
() => harness.Matchmaking.Enqueue("p1", deck, GameMode.ClassicRanked));
var exception = await Assert.ThrowsAsync<DeckRejectedException>(
() => harness.Matchmaking.EnqueueAsync("p1", deck, GameMode.ClassicRanked));
Assert.Contains(exception.Violations,
violation => violation.Code == "deck.starting-vanguard");
}
[Fact]
public void Cancel_Removes_From_Queue()
public async Task Cancel_Removes_From_Queue()
{
var harness = NewHarness();
var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
var ticket = await harness.Matchmaking.EnqueueAsync(
"p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
Assert.True(harness.Matchmaking.Cancel(ticket.QueueId, "p1"));
Assert.True(await harness.Matchmaking.CancelAsync(ticket.QueueId, "p1"));
var status = harness.Matchmaking.Poll(ticket.QueueId, "p1")!;
Assert.Equal("cancelled", status.Status);
var status = await harness.Matchmaking.PollAsync(ticket.QueueId, "p1");
Assert.Equal("cancelled", status!.Status);
Assert.Equal(0, harness.Matchmaking.QueuedCount);
Assert.False(harness.Matchmaking.Cancel(ticket.QueueId, "p1"));
Assert.False(await harness.Matchmaking.CancelAsync(ticket.QueueId, "p1"));
}
[Fact]
public void Second_Queue_Request_Replaces_The_First()
public async Task 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);
var old = await harness.Matchmaking.EnqueueAsync(
"p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
var current = await harness.Matchmaking.EnqueueAsync(
"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("cancelled", (await harness.Matchmaking.PollAsync(old.QueueId, "p1"))!.Status);
Assert.Equal("queued", (await harness.Matchmaking.PollAsync(current.QueueId, "p1"))!.Status);
Assert.Equal(1, harness.Matchmaking.QueuedCount);
}
[Fact]
public void Queue_Expires_After_Timeout()
public async Task Queue_Expires_After_Timeout()
{
var harness = NewHarness();
var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
var ticket = await harness.Matchmaking.EnqueueAsync(
"p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
harness.Time.Advance(TimeSpan.FromSeconds(harness.Options.QueueExpirySeconds + 1));
harness.Matchmaking.Matchmake(harness.Time.GetUtcNow());
await harness.Matchmaking.MatchmakeAsync(harness.Time.GetUtcNow());
Assert.Equal("expired", harness.Matchmaking.Poll(ticket.QueueId, "p1")!.Status);
Assert.Equal("expired", (await harness.Matchmaking.PollAsync(ticket.QueueId, "p1"))!.Status);
Assert.Equal(0, harness.Matchmaking.QueuedCount);
}
@ -183,32 +193,77 @@ public class MatchmakingServiceTests
Assert.Equal(loserAfterRanked, profiles.GetMmr("loser"));
}
[Fact]
public async Task Queue_Survives_Reload_From_Store()
{
var harness = NewHarness();
var ticket = await harness.Matchmaking.EnqueueAsync(
"p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
// новый процесс: очередь пуста, но записи лежат в store
var services = new ServiceCollection();
services.AddSingleton(ServerTestData.Catalog);
services.AddSingleton(harness.Options);
services.AddSingleton<TimeProvider>(harness.Time);
services.AddSingleton<IMatchResultSink>(harness.Profiles);
services.AddSingleton<IMatchStore>(harness.MatchStore);
services.AddSingleton<IQueueStore>(harness.QueueStore);
services.AddLogging();
await using var provider = services.BuildServiceProvider();
var registry = new MatchGrainRegistry(
provider, harness.Options, harness.MatchStore, NullLogger<MatchGrainRegistry>.Instance);
var matchmaking = new MatchmakingService(
ServerTestData.Catalog,
registry,
harness.Tickets,
harness.Profiles,
harness.Options,
harness.QueueStore,
harness.Time,
NullLogger<MatchmakingService>.Instance);
await matchmaking.LoadQueuedAsync();
var status = await matchmaking.PollAsync(ticket.QueueId, "p1");
Assert.NotNull(status);
Assert.Equal("queued", status!.Status);
Assert.Equal(1, matchmaking.QueuedCount);
}
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 ticketStore = new InMemoryTicketStore(time);
var tickets = new GameTicketService(ticketStore, options, time);
var store = new NullMatchStore();
var queueStore = new InMemoryQueueStore();
var services = new ServiceCollection();
services.AddSingleton(ServerTestData.Catalog);
services.AddSingleton(options);
services.AddSingleton<TimeProvider>(time);
services.AddSingleton<IMatchResultSink>(profiles);
services.AddSingleton<IMatchStore>(store);
services.AddSingleton<IQueueStore>(queueStore);
services.AddLogging();
var provider = services.BuildServiceProvider();
var registry = new MatchGrainRegistry(provider, options, NullLogger<MatchGrainRegistry>.Instance);
var registry = new MatchGrainRegistry(
provider, options, store, NullLogger<MatchGrainRegistry>.Instance);
var matchmaking = new MatchmakingService(
ServerTestData.Catalog,
registry,
tickets,
profiles,
options,
queueStore,
time,
NullLogger<MatchmakingService>.Instance);
return new Harness(time, options, profiles, tickets, registry, matchmaking);
return new Harness(time, options, profiles, tickets, store, queueStore, registry, matchmaking);
}
private sealed record Harness(
@ -216,6 +271,8 @@ public class MatchmakingServiceTests
GameOptions Options,
PlayerProfileStore Profiles,
GameTicketService Tickets,
IMatchStore MatchStore,
IQueueStore QueueStore,
MatchGrainRegistry Registry,
MatchmakingService Matchmaking);
}