Finish server persistence with PostgreSQL, Redis and Docker
This commit is contained in:
parent
99d5b95000
commit
083cebf978
|
|
@ -0,0 +1,2 @@
|
|||
VANGUARD_PORT=8080
|
||||
VANGUARD_WS_URL=ws://localhost:8080/game
|
||||
|
|
@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
119
docs/HANDOFF.md
119
docs/HANDOFF.md
|
|
@ -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 с.
|
||||
|
|
|
|||
|
|
@ -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 в него
|
||||
не упирается; тестовый бот специально притормаживается.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:";
|
||||
}
|
||||
|
|
@ -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 ./
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ public sealed class MatchmakingWorker : BackgroundService
|
|||
{
|
||||
try
|
||||
{
|
||||
_matchmaking.Matchmake(_time.GetUtcNow());
|
||||
await _matchmaking.MatchmakeAsync(_time.GetUtcNow());
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)}";
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>());
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue