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
|
/tools/import_cards.py
|
||||||
```
|
```
|
||||||
- [x] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`.
|
- [x] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`.
|
||||||
- [~] `docker-compose.yml`: сейчас только game-server; postgres, redis (AOF), meta-api, nginx — со следующим блоком.
|
- [x] `docker-compose.yml`: postgres, redis (AOF), game-server с контентом.
|
||||||
- [ ] `docker compose up` поднимает БД и пустой API; healthcheck отвечает.
|
- [x] `docker compose up` поднимает БД, Redis и API; `/health` отвечает.
|
||||||
— сервер уже поднимается и отвечает `/health`, БД ещё нет.
|
|
||||||
- [ ] CI: сборка solution + `dotnet test` на push.
|
- [ ] CI: сборка solution + `dotnet test` на push.
|
||||||
|
|
||||||
**Готово, когда:** `docker compose up` + `dotnet run` дают живой `/health`.
|
**Готово, когда:** `docker compose up` + `dotnet run` дают живой `/health`.
|
||||||
|
|
@ -182,13 +181,15 @@
|
||||||
- [x] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось».
|
- [x] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось».
|
||||||
- [x] Политика отсутствия: 5 минут → авто-поражение.
|
- [x] Политика отсутствия: 5 минут → авто-поражение.
|
||||||
- [x] Таймер хода: 90 с + банк 5 мин.
|
- [x] Таймер хода: 90 с + банк 5 мин.
|
||||||
- [ ] Снапшот матча в PostgreSQL + восстановление grain после рестарта.
|
- [x] Снапшот матча в PostgreSQL + восстановление grain после рестарта.
|
||||||
|
— заголовок + журнал интентов, реплей команд при старте.
|
||||||
|
- [x] Redis: одноразовые тикеты и очередь.
|
||||||
- [x] Idempotency по `intentId`.
|
- [x] Idempotency по `intentId`.
|
||||||
- [x] HTTP-очередь и тикеты: `docs/server-api.md`.
|
- [x] HTTP-очередь и тикеты: `docs/server-api.md`.
|
||||||
- [x] E2E: два клиента играют полный матч по WebSocket; обрыв и возврат проверены.
|
- [x] E2E: два клиента играют полный матч по WebSocket; обрыв и возврат проверены.
|
||||||
|
|
||||||
**Готово, когда:** два клиента играют полный матч по сети; kill процесса клиента на любом шаге восстанавливается.
|
**Готово, когда:** два клиента играют полный матч по сети; 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`) | готово |
|
| Протокол и контракты (`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`.
|
Контейнер: `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.Content загрузка и проверка YAML-контента
|
||||||
/server/src/Vanguard.Shared сетевые контракты (netstandard2.1 для Unity + net10.0)
|
/server/src/Vanguard.Shared сетевые контракты (netstandard2.1 для Unity + net10.0)
|
||||||
/server/src/Vanguard.Server ASP.NET Core: WebSocket, очередь, тикеты, матчи
|
/server/src/Vanguard.Server ASP.NET Core: WebSocket, очередь, тикеты, матчи
|
||||||
|
/Storage PostgreSQL-снапшоты и восстановление матчей
|
||||||
|
/Matchmaking очередь, MMR, Redis-хранилище очереди
|
||||||
|
/Tickets тикеты: память или Redis
|
||||||
/server/src/Vanguard.Cli консольные команды
|
/server/src/Vanguard.Cli консольные команды
|
||||||
/server/tests тесты правил, контрактов и сервера (включая E2E)
|
/server/tests тесты правил, контрактов и сервера (включая E2E)
|
||||||
/content/sets карты в YAML
|
/content/sets карты в YAML
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,48 @@
|
||||||
services:
|
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:
|
server:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: server/src/Vanguard.Server/Dockerfile
|
dockerfile: server/src/Vanguard.Server/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "${VANGUARD_PORT:-8080}:8080"
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_URLS: http://+:8080
|
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
|
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 предупреждений |
|
| `validate` | 0 ошибок, 0 предупреждений |
|
||||||
| `translations check` | 94/94 |
|
| `translations check` | 94/94 |
|
||||||
| `stress 1000` | 1000/1000 партий, 0 ошибок |
|
| `stress 1000` | 1000/1000 партий, 0 ошибок |
|
||||||
| Карты | **94/94** (66 с текстом + 28 ванильных) |
|
| Карты | **94/94** (66 с текстом + 28 ванильных) |
|
||||||
| Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы |
|
| Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы |
|
||||||
| Сервер | полный матч по сети, матчмейкинг, реконнект (in-memory) |
|
| Сервер | сеть, матчмейкинг, реконнект, PostgreSQL + Redis, Docker |
|
||||||
| Клиент / лаунчер | не начаты |
|
| Клиент / лаунчер | не начаты |
|
||||||
|
|
||||||
Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям:
|
Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям:
|
||||||
|
|
@ -70,7 +70,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000
|
||||||
|
|
||||||
```
|
```
|
||||||
Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests)
|
Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests)
|
||||||
Пройден! : не пройдено 0, пройдено 27 (Vanguard.Server.Tests)
|
Пройден! : не пройдено 0, пройдено 33 (Vanguard.Server.Tests)
|
||||||
OK: ошибок 0, предупреждений 0
|
OK: ошибок 0, предупреждений 0
|
||||||
Переведено: 94/94
|
Переведено: 94/94
|
||||||
Партий: 1000, завершено: 1000, ошибок: 0
|
Партий: 1000, завершено: 1000, ошибок: 0
|
||||||
|
|
@ -83,6 +83,20 @@ dotnet run --project server\src\Vanguard.Server --urls http://localhost:5080
|
||||||
# GET http://localhost:5080/health
|
# 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`.
|
Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -201,7 +215,9 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
|
|
||||||
### 4.8. Сервер
|
### 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}`,
|
- **HTTP**: `POST /api/game/queue`, `GET /api/game/queue/{id}`,
|
||||||
`DELETE /api/game/queue/{id}`, `POST /api/game/tickets`, `GET /health`.
|
`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.
|
окно ±100 с расширением на 50 каждые 5 с до 2000, выбор ближайшего MMR.
|
||||||
Ранкед меняет MMR по Эло (K = 32), casual — нет.
|
Ранкед меняет MMR по Эло (K = 32), casual — нет.
|
||||||
- **Тикеты**: одноразовые, TTL 60 с, привязаны к `(playerId, matchId, seat)`;
|
- **Тикеты**: одноразовые, TTL 60 с, привязаны к `(playerId, matchId, seat)`;
|
||||||
для реконнекта выдаётся новый тикет.
|
при настроенном Redis изымаются через `GETDEL` (одноразовость между
|
||||||
|
инстансами), иначе — из памяти.
|
||||||
- **`IMatchGrain`**: 1 матч = 1 экземпляр, все входы (интенты, подключения,
|
- **`IMatchGrain`**: 1 матч = 1 экземпляр, все входы (интенты, подключения,
|
||||||
тики) проходят через один семафор; внутри нет ни ASP.NET Core, ни WebSocket —
|
тики) проходят через один семафор; внутри нет ни ASP.NET Core, ни WebSocket —
|
||||||
только `IMatchConnection`. Orleans-хостинг подключается поверх контракта.
|
только `IMatchConnection`. Orleans-хостинг подключается поверх контракта.
|
||||||
|
|
@ -224,19 +241,41 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
результат пишется в профили.
|
результат пишется в профили.
|
||||||
- **Проекция**: своя рука, свой выбор, свои легальные интенты; у противника
|
- **Проекция**: своя рука, свой выбор, свои легальные интенты; у противника
|
||||||
только количества и открытые зоны. `seed` и RNG не покидают сервер.
|
только количества и открытые зоны. `seed` и RNG не покидают сервер.
|
||||||
- **Docker**: `Dockerfile` + `docker-compose.yml` поднимают сервер с контентом
|
- **PostgreSQL**: таблицы `matches` и `match_intents`. В заголовке — seed,
|
||||||
на `http://localhost:8080`.
|
колоды, игроки, часы, банки, статус; в журнале — `intentId`, команда,
|
||||||
- **Тесты** (`Vanguard.Server.Tests`, 27):
|
результат. При старте незавершённые матчи восстанавливаются повтором
|
||||||
|
принятых команд на детерминированном движке, включая ожидания выбора:
|
||||||
|
делегаты движка не сериализуются. Журнал реплея сверяется по `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;
|
- тикеты: одноразовость и TTL;
|
||||||
- heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром;
|
- heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром;
|
||||||
- grain: attach, чужое место, duplicate intent, чужой ход, сдача,
|
- grain: attach, чужое место, duplicate intent, чужой ход, сдача,
|
||||||
команды после финала, банк времени, отсутствие, старт часов,
|
команды после финала, банк времени, отсутствие, старт часов,
|
||||||
reconnect с `eventSeq`, захват сессии;
|
reconnect с `eventSeq`, захват сессии;
|
||||||
- матчмейкинг: подбор, разные очереди, окно, плохая колода,
|
- матчмейкинг: подбор, разные очереди, окно, плохая колода,
|
||||||
отсутствие стартового Авангарда, отмена, замена очереди, истечение, MMR;
|
отсутствие стартового Авангарда, отмена, замена очереди, истечение, MMR,
|
||||||
|
перезагрузка очереди из хранилища;
|
||||||
|
- персистентность: EF-хранилище, восстановление матча повтором команд,
|
||||||
|
`MatchRecoveryService` после «рестарта»;
|
||||||
|
- внешние хранилища (`ExternalStorageTests`, запускаются при
|
||||||
|
`VANGUARD_TEST_POSTGRES` / `VANGUARD_TEST_REDIS`): реальные PostgreSQL
|
||||||
|
и Redis, изъятие тикета, очередь;
|
||||||
- E2E: два клиента играют полный матч по WebSocket и сходятся в победителе;
|
- E2E: два клиента играют полный матч по WebSocket и сходятся в победителе;
|
||||||
обрыв и возврат по новому тикету с догоном состояния.
|
обрыв и возврат по новому тикету с догоном состояния.
|
||||||
|
|
||||||
|
Проверено на `ssh home`: 164/164 тестов, включая реальные PostgreSQL и Redis;
|
||||||
|
`docker compose up` поднимает стенд, матч создаётся через HTTP-очередь,
|
||||||
|
а после `docker compose restart server` восстанавливается (`Recovery: 1 of 1`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Структура и ключевые файлы
|
## 5. Структура и ключевые файлы
|
||||||
|
|
@ -258,28 +297,30 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
/Replay/ запись и воспроизведение
|
/Replay/ запись и воспроизведение
|
||||||
/Diagnostics/GameInvariants.cs
|
/Diagnostics/GameInvariants.cs
|
||||||
/server/src/Vanguard.Content загрузка YAML и валидация
|
/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
|
/server/src/Vanguard.Shared
|
||||||
/Protocol/*.cs кадры, интенты, события, view, кодек
|
/Protocol/*.cs кадры, интенты, события, view, кодек
|
||||||
/Projection/*.cs проекция состояния, фильтр событий, маппинг
|
/Projection/*.cs проекция состояния, фильтр событий, маппинг
|
||||||
/server/src/Vanguard.Server
|
/server/src/Vanguard.Server
|
||||||
/Program.cs ASP.NET Core, DI, WebSocket endpoint
|
/Program.cs ASP.NET Core, DI, WebSocket endpoint
|
||||||
/Api/ очередь, тикеты, /health
|
/Api/ очередь, тикеты, /health
|
||||||
/Matchmaking/ две очереди, MMR, расширяющееся окно
|
/Matchmaking/ очереди, MMR, Redis-хранилище очереди
|
||||||
/Matches/ IMatchGrain, таймеры, тикер, регистр
|
/Matches/ IMatchGrain, таймеры, тикер, регистр
|
||||||
/Realtime/ WebSocket-обвязка, чтение кадров
|
/Realtime/ WebSocket-обвязка, чтение кадров
|
||||||
/Tickets/ одноразовые тикеты
|
/Tickets/ одноразовые тикеты: память или Redis
|
||||||
|
/Storage/ EF-хранилище, восстановление матчей
|
||||||
/Profiles/ скрытый MMR и статистика
|
/Profiles/ скрытый MMR и статистика
|
||||||
/Dockerfile
|
/Dockerfile
|
||||||
/server/tests/Vanguard.Rules.Tests 131 тест
|
/server/tests/Vanguard.Rules.Tests 131 тест
|
||||||
/server/tests/Vanguard.Server.Tests 27 тестов, включая E2E по WebSocket
|
/server/tests/Vanguard.Server.Tests 33 теста, включая E2E и внешние хранилища
|
||||||
/content/sets карты
|
/content/sets карты
|
||||||
/content/translations/ru.yaml переводы
|
/content/translations/ru.yaml переводы
|
||||||
/docs/translation-rules.md канон перевода
|
/docs/translation-rules.md канон перевода
|
||||||
/docs/protocol.md протокол матча, версия 1
|
/docs/protocol.md протокол матча, версия 1
|
||||||
/docs/server-api.md HTTP-очередь, тикеты, /health
|
/docs/server-api.md HTTP-очередь, тикеты, /health
|
||||||
/docs/HANDOFF.md этот файл
|
/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 получает новый тикет.
|
не в URL; reconnect получает новый тикет.
|
||||||
- `playerId` в HTTP-API — dev-заглушка до JWT. Выставлять сервер в интернет
|
- `playerId` в HTTP-API — dev-заглушка до JWT. Выставлять сервер в интернет
|
||||||
в таком виде нельзя.
|
в таком виде нельзя.
|
||||||
- Состояние сервера пока in-memory: `IMatchGrain` отделён от транспорта,
|
- Состояние сервера: PostgreSQL — снапшот/журнал матчей, Redis — тикеты
|
||||||
Orleans и PostgreSQL подключаются поверх.
|
и очередь; без конфигурации работает in-memory. `IMatchGrain` отделён
|
||||||
|
от транспорта, Orleans подключается поверх.
|
||||||
- Rate limit — 16 интентов в секунду на соединение.
|
- Rate limit — 16 интентов в секунду на соединение.
|
||||||
- Скрытый MMR: старт 1000, Эло K = 32, casual рейтинг не меняет.
|
- Скрытый MMR: старт 1000, Эло K = 32, casual рейтинг не меняет.
|
||||||
- Порядок триггеров выбирает игрок; опциональные способности спрашиваются.
|
- Порядок триггеров выбирает игрок; опциональные способности спрашиваются.
|
||||||
|
|
@ -337,15 +379,18 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
4. **[x] Проекция состояния** подключена к грану и проверена E2E-тестом:
|
4. **[x] Проекция состояния** подключена к грану и проверена E2E-тестом:
|
||||||
своя рука и свой выбор полностью, у противника только количества
|
своя рука и свой выбор полностью, у противника только количества
|
||||||
и открытые зоны, seed и RNG не уходят.
|
и открытые зоны, 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 и граны.
|
7. **Orleans** — заменить in-process регистр на настоящий silo и граны.
|
||||||
`IMatchGrain` уже отделён от транспорта и ASP.NET Core.
|
`IMatchGrain` уже отделён от транспорта и ASP.NET Core; персистентность
|
||||||
7. **Персистентность**:
|
и проекция от хостинга не зависят.
|
||||||
- PostgreSQL — снапшот матча, восстановление grain после рестарта;
|
|
||||||
- Redis — тикеты, очереди, кэш.
|
|
||||||
8. **JWT и мета-API** — `playerId` перестанет быть dev-заглушкой.
|
8. **JWT и мета-API** — `playerId` перестанет быть dev-заглушкой.
|
||||||
|
|
||||||
Критерий готовности серверного блока выполнен: два клиента играют полный
|
Критерий готовности серверного блока выполнен: два клиента играют полный
|
||||||
|
|
@ -393,10 +438,10 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
|
|
||||||
1. Прочитать `docs/HANDOFF.md`, `IMPLEMENTATION_CHECKLIST.md`, `README.md`.
|
1. Прочитать `docs/HANDOFF.md`, `IMPLEMENTATION_CHECKLIST.md`, `README.md`.
|
||||||
2. Прогнать проверки из раздела 3.
|
2. Прогнать проверки из раздела 3.
|
||||||
3. Следующий блок — персистентность и Orleans: PostgreSQL-снапшоты,
|
3. Следующий блок — Orleans и мета-API: заменить in-process регистр на silo,
|
||||||
Redis для тикетов/очередей, замена in-process регистра на silo.
|
затем JWT, аккаунты, коллекция, колоды и магазин. Сетевой матч,
|
||||||
Сетевой матч уже работает: `docs/protocol.md`, `Vanguard.Shared`,
|
PostgreSQL/Redis и Docker уже готовы: `docs/protocol.md`,
|
||||||
`Vanguard.Server`, `docs/server-api.md`.
|
`Vanguard.Shared`, `Vanguard.Server`, `docs/server-api.md`.
|
||||||
4. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
|
4. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
|
||||||
5. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
|
5. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
|
||||||
|
|
||||||
|
|
@ -418,8 +463,26 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
для наблюдателя нельзя — в списке есть printingId из чужой руки.
|
для наблюдателя нельзя — в списке есть printingId из чужой руки.
|
||||||
- **Сервер: rate limit.** 16 интентов в секунду на соединение. Быстрый бот
|
- **Сервер: rate limit.** 16 интентов в секунду на соединение. Быстрый бот
|
||||||
в E2E специально притормаживается; живой UI в лимит не упирается.
|
в E2E специально притормаживается; живой UI в лимит не упирается.
|
||||||
- **Сервер: in-memory.** Очереди, тикеты, матчи и профили живут в памяти.
|
- **Сервер: хранилище.** По умолчанию `Storage:Provider=None` — матчи живут
|
||||||
Рестарт сервера теряет матчи; kill клиента и реконнект переживаются.
|
в памяти. Для рестарта нужен `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>`,
|
- **Сервер: тесты.** E2E поднимает `WebApplicationFactory<Program>`,
|
||||||
играет полный матч по WebSocket и проверяет, что победитель совпал.
|
играет полный матч по WebSocket и проверяет, что победитель совпал.
|
||||||
Случайные партии завершаются за ~350 действий; таймаут теста — 120 с.
|
Случайные партии завершаются за ~350 действий; таймаут теста — 120 с.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
Служебный HTTP-слой матчевого блока: очередь, тикеты на подключение и `/health`.
|
Служебный HTTP-слой матчевого блока: очередь, тикеты на подключение и `/health`.
|
||||||
После подключения клиент переходит на WebSocket и работает по `docs/protocol.md`.
|
После подключения клиент переходит на 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
|
Invoke-RestMethod http://localhost:5080/health
|
||||||
```
|
```
|
||||||
|
|
||||||
Контейнер:
|
Контейнер (PostgreSQL + Redis + сервер):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
# http://localhost:8080/health
|
# 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/sets` вверх от каталога сборки. Если контент
|
||||||
лежит в другом месте, задайте `Content:Path` (или `Content__Path` в окружении).
|
лежит в другом месте, задайте `Content:Path` (или `Content__Path` в окружении).
|
||||||
|
|
||||||
|
|
@ -211,13 +228,55 @@ WS /game → ClientHello(ticket, resumeFromSeq)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Ограничения текущего этапа
|
## 7. Хранилище и восстановление
|
||||||
|
|
||||||
- Всё состояние (очереди, тикеты, матчи, профили) — в памяти процесса.
|
### 7.1. Конфигурация
|
||||||
Рестарт сервера теряет матчи; клиентский kill и реконнект переживаются.
|
|
||||||
|
| Ключ | Значения | Смысл |
|
||||||
|
|---|---|---|
|
||||||
|
| `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-хостинг ещё впереди.
|
- Один процесс: горизонтальное масштабирование и Orleans-хостинг ещё впереди.
|
||||||
- `IMatchGrain` уже отделён от ASP.NET Core и WebSocket: замена на Orleans
|
- `IMatchGrain` уже отделён от ASP.NET Core и WebSocket: замена на Orleans
|
||||||
grain не затронет транспорт и правила.
|
grain не затронет транспорт и правила.
|
||||||
- Нет PostgreSQL/Redis, JWT, коллекций и наград — это следующие блоки.
|
- Нет JWT, коллекций и наград — это следующие блоки.
|
||||||
- Rate limit: 16 интентов в секунду с одного соединения. Живой UI в него
|
- 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.CardText;
|
||||||
using Vanguard.Content.Translations;
|
using Vanguard.Content.Translations;
|
||||||
using Vanguard.Rules.Cards;
|
using Vanguard.Rules.Cards;
|
||||||
|
|
@ -15,6 +16,7 @@ return command switch
|
||||||
"stats" => RunStats(PickPath(1) ?? Path.Combine(root, "content", "sets")),
|
"stats" => RunStats(PickPath(1) ?? Path.Combine(root, "content", "sets")),
|
||||||
"translations" => RunTranslations(root),
|
"translations" => RunTranslations(root),
|
||||||
"stress" => RunStress(root, args),
|
"stress" => RunStress(root, args),
|
||||||
|
"deck" => RunDeck(root),
|
||||||
_ => Usage(),
|
_ => Usage(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -132,6 +134,13 @@ static int RunStress(string repoRoot, string[] args)
|
||||||
return failures == 0 && finished == games ? 0 : 1;
|
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)
|
static List<string> BuildBotDeck(CardCatalog catalog)
|
||||||
{
|
{
|
||||||
var deck = new List<string>();
|
var deck = new List<string>();
|
||||||
|
|
@ -265,6 +274,7 @@ static int Usage()
|
||||||
translations build [путь] собрать файл переводов
|
translations build [путь] собрать файл переводов
|
||||||
translations check [путь] проверить переводы
|
translations check [путь] проверить переводы
|
||||||
stress [партий] прогнать случайного бота и проверить инварианты
|
stress [партий] прогнать случайного бота и проверить инварианты
|
||||||
|
deck JSON-колода из 50 printingId для ручной проверки
|
||||||
""");
|
""");
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ public static class ApiEndpoints
|
||||||
app.MapGet("/health", Health);
|
app.MapGet("/health", Health);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IResult Enqueue(
|
private static async Task<IResult> Enqueue(
|
||||||
QueueRequest request,
|
QueueRequest request,
|
||||||
MatchmakingService matchmaking)
|
MatchmakingService matchmaking)
|
||||||
{
|
{
|
||||||
|
|
@ -36,7 +36,7 @@ public static class ApiEndpoints
|
||||||
|
|
||||||
try
|
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));
|
return Results.Ok(new QueueAccepted(ticket.QueueId, "queued", ticket.Mode));
|
||||||
}
|
}
|
||||||
catch (DeckRejectedException exception)
|
catch (DeckRejectedException exception)
|
||||||
|
|
@ -48,7 +48,7 @@ public static class ApiEndpoints
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IResult PollQueue(
|
private static async Task<IResult> PollQueue(
|
||||||
Guid queueId,
|
Guid queueId,
|
||||||
string? playerId,
|
string? playerId,
|
||||||
MatchmakingService matchmaking,
|
MatchmakingService matchmaking,
|
||||||
|
|
@ -58,7 +58,7 @@ public static class ApiEndpoints
|
||||||
if (!IsValidPlayerId(playerId))
|
if (!IsValidPlayerId(playerId))
|
||||||
return Results.BadRequest(new ApiError("player.invalid", "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)
|
if (snapshot is null)
|
||||||
return Results.NotFound(new ApiError("queue.not-found", "Очередь не найдена."));
|
return Results.NotFound(new ApiError("queue.not-found", "Очередь не найдена."));
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ public static class ApiEndpoints
|
||||||
snapshot.MatchId is null ? null : BuildWsUrl(http, options)));
|
snapshot.MatchId is null ? null : BuildWsUrl(http, options)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IResult CancelQueue(
|
private static async Task<IResult> CancelQueue(
|
||||||
Guid queueId,
|
Guid queueId,
|
||||||
string? playerId,
|
string? playerId,
|
||||||
MatchmakingService matchmaking)
|
MatchmakingService matchmaking)
|
||||||
|
|
@ -84,12 +84,12 @@ public static class ApiEndpoints
|
||||||
if (!IsValidPlayerId(playerId))
|
if (!IsValidPlayerId(playerId))
|
||||||
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен."));
|
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен."));
|
||||||
|
|
||||||
return matchmaking.Cancel(queueId, playerId!)
|
return await matchmaking.CancelAsync(queueId, playerId!)
|
||||||
? Results.NoContent()
|
? Results.NoContent()
|
||||||
: Results.Conflict(new ApiError("queue.not-cancellable", "Очередь уже не активна."));
|
: Results.Conflict(new ApiError("queue.not-cancellable", "Очередь уже не активна."));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IResult IssueTicket(
|
private static async Task<IResult> IssueTicket(
|
||||||
TicketRequest request,
|
TicketRequest request,
|
||||||
MatchGrainRegistry matches,
|
MatchGrainRegistry matches,
|
||||||
GameTicketService tickets,
|
GameTicketService tickets,
|
||||||
|
|
@ -117,7 +117,7 @@ public static class ApiEndpoints
|
||||||
new ApiError("match.forbidden", "Этот игрок не участвует в матче."),
|
new ApiError("match.forbidden", "Этот игрок не участвует в матче."),
|
||||||
statusCode: StatusCodes.Status403Forbidden);
|
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(
|
return Results.Ok(new TicketResponse(
|
||||||
ticket.Token,
|
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
|
RUN dotnet publish server/src/Vanguard.Server/Vanguard.Server.csproj -c Release -o /app
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=build /app ./
|
COPY --from=build /app ./
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ public sealed record MatchSetup(
|
||||||
string MatchId,
|
string MatchId,
|
||||||
GameMode Mode,
|
GameMode Mode,
|
||||||
MatchSeatSetup Seat0,
|
MatchSeatSetup Seat0,
|
||||||
MatchSeatSetup Seat1);
|
MatchSeatSetup Seat1,
|
||||||
|
ulong? Seed = null);
|
||||||
|
|
||||||
/// <summary>Снимок и итог матча для регистратуры, health и записи результата.</summary>
|
/// <summary>Снимок и итог матча для регистратуры, health и записи результата.</summary>
|
||||||
public sealed record MatchSummary(
|
public sealed record MatchSummary(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using Vanguard.Rules.Cards;
|
using Vanguard.Rules.Cards;
|
||||||
using Vanguard.Rules.Engine;
|
using Vanguard.Rules.Engine;
|
||||||
|
using Vanguard.Rules.Replay;
|
||||||
using Vanguard.Server.Configuration;
|
using Vanguard.Server.Configuration;
|
||||||
|
using Vanguard.Server.Storage;
|
||||||
using Vanguard.Shared.Projection;
|
using Vanguard.Shared.Projection;
|
||||||
using Vanguard.Shared.Protocol;
|
using Vanguard.Shared.Protocol;
|
||||||
|
|
||||||
|
|
@ -39,6 +41,7 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
private readonly GameOptions _options;
|
private readonly GameOptions _options;
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
private readonly IMatchResultSink _results;
|
private readonly IMatchResultSink _results;
|
||||||
|
private readonly IMatchStore _store;
|
||||||
private readonly ILogger<MatchGrain> _logger;
|
private readonly ILogger<MatchGrain> _logger;
|
||||||
private readonly string _matchId;
|
private readonly string _matchId;
|
||||||
private readonly GameMode _mode;
|
private readonly GameMode _mode;
|
||||||
|
|
@ -50,6 +53,9 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
private DateTimeOffset _lastClockSent;
|
private DateTimeOffset _lastClockSent;
|
||||||
private DateTimeOffset? _finishedAt;
|
private DateTimeOffset? _finishedAt;
|
||||||
private bool _resultReported;
|
private bool _resultReported;
|
||||||
|
private bool _restoring;
|
||||||
|
|
||||||
|
private const string ConcedeCommand = "concede";
|
||||||
|
|
||||||
public MatchGrain(
|
public MatchGrain(
|
||||||
MatchSetup setup,
|
MatchSetup setup,
|
||||||
|
|
@ -57,6 +63,7 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
GameOptions options,
|
GameOptions options,
|
||||||
TimeProvider time,
|
TimeProvider time,
|
||||||
IMatchResultSink results,
|
IMatchResultSink results,
|
||||||
|
IMatchStore store,
|
||||||
ILogger<MatchGrain> logger)
|
ILogger<MatchGrain> logger)
|
||||||
{
|
{
|
||||||
_matchId = setup.MatchId;
|
_matchId = setup.MatchId;
|
||||||
|
|
@ -64,6 +71,7 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
_options = options;
|
_options = options;
|
||||||
_time = time;
|
_time = time;
|
||||||
_results = results;
|
_results = results;
|
||||||
|
_store = store;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_createdAt = time.GetUtcNow();
|
_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(
|
_engine = new GameEngine(
|
||||||
catalog,
|
catalog,
|
||||||
seed,
|
seed,
|
||||||
|
|
@ -174,6 +182,8 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
"Match {MatchId}: seat {Seat} attached (resume from {Seq}, now {Now}).",
|
"Match {MatchId}: seat {Seat} attached (resume from {Seq}, now {Now}).",
|
||||||
_matchId, seat, resumeFromSeq, to);
|
_matchId, seat, resumeFromSeq, to);
|
||||||
|
|
||||||
|
await PersistHeaderAsync();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
|
|
@ -205,6 +215,8 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
state.AbsentSince = _time.GetUtcNow();
|
state.AbsentSince = _time.GetUtcNow();
|
||||||
|
|
||||||
_logger.LogInformation("Match {MatchId}: seat {Seat} detached.", _matchId, seat);
|
_logger.LogInformation("Match {MatchId}: seat {Seat} detached.", _matchId, seat);
|
||||||
|
|
||||||
|
await PersistHeaderAsync();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -254,9 +266,12 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
var decisionBefore = _engine.State.ActivePlayer;
|
var decisionBefore = _engine.State.ActivePlayer;
|
||||||
|
|
||||||
CommandResult result;
|
CommandResult result;
|
||||||
|
var commandText = string.Empty;
|
||||||
|
|
||||||
if (isConcede)
|
if (isConcede)
|
||||||
{
|
{
|
||||||
_engine.Finish(1 - seat);
|
_engine.Finish(1 - seat);
|
||||||
|
commandText = ConcedeCommand;
|
||||||
result = CommandResult.Ok();
|
result = CommandResult.Ok();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -269,6 +284,8 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
}
|
}
|
||||||
|
|
||||||
result = _engine.Execute(command);
|
result = _engine.Execute(command);
|
||||||
|
if (result.Accepted)
|
||||||
|
commandText = CommandCodec.Encode(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
var code = result.Accepted ? IntentRejectCode.None : IntentRejectCode.Illegal;
|
var code = result.Accepted ? IntentRejectCode.None : IntentRejectCode.Illegal;
|
||||||
|
|
@ -276,6 +293,9 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
_intents[intentId] = record;
|
_intents[intentId] = record;
|
||||||
RefreshSummary();
|
RefreshSummary();
|
||||||
|
|
||||||
|
await PersistIntentAsync(new PersistedIntent(
|
||||||
|
intentId, seat, commandText, record.Accepted, record.Code, record.Reason, record.EventSeq));
|
||||||
|
|
||||||
await SendResultAsync(
|
await SendResultAsync(
|
||||||
seat, intentId, record.Accepted, record.Code, record.Reason, record.EventSeq, duplicate: false);
|
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))
|
if (now - _lastClockSent >= TimeSpan.FromSeconds(_options.ClockSyncSeconds))
|
||||||
|
{
|
||||||
await SendClockToAllAsync(now);
|
await SendClockToAllAsync(now);
|
||||||
|
await PersistHeaderAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -389,6 +412,9 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
{
|
{
|
||||||
_intents[intentId] = new IntentRecord(false, code, reason, _engine.Events.Count);
|
_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(
|
await SendResultAsync(
|
||||||
seat, intentId, accepted: false, code, reason, _engine.Events.Count, duplicate: false);
|
seat, intentId, accepted: false, code, reason, _engine.Events.Count, duplicate: false);
|
||||||
}
|
}
|
||||||
|
|
@ -512,6 +538,7 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
_resultReported = true;
|
_resultReported = true;
|
||||||
_finishedAt = now;
|
_finishedAt = now;
|
||||||
RefreshSummary();
|
RefreshSummary();
|
||||||
|
await PersistHeaderAsync();
|
||||||
|
|
||||||
var winner = _engine.State.Winner ?? -1;
|
var winner = _engine.State.Winner ?? -1;
|
||||||
if (winner is not (0 or 1))
|
if (winner is not (0 or 1))
|
||||||
|
|
@ -559,6 +586,140 @@ public sealed class MatchGrain : IMatchGrain
|
||||||
ServerTimeMs = now.ToUnixTimeMilliseconds(),
|
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()
|
private void RefreshSummary()
|
||||||
{
|
{
|
||||||
_summary = new MatchSummary(
|
_summary = new MatchSummary(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Vanguard.Server.Configuration;
|
using Vanguard.Server.Configuration;
|
||||||
|
using Vanguard.Server.Storage;
|
||||||
|
|
||||||
namespace Vanguard.Server.Matches;
|
namespace Vanguard.Server.Matches;
|
||||||
|
|
||||||
|
|
@ -13,25 +14,38 @@ public sealed class MatchGrainRegistry
|
||||||
private readonly ConcurrentDictionary<string, IMatchGrain> _matches = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, IMatchGrain> _matches = new(StringComparer.Ordinal);
|
||||||
private readonly IServiceProvider _services;
|
private readonly IServiceProvider _services;
|
||||||
private readonly GameOptions _options;
|
private readonly GameOptions _options;
|
||||||
|
private readonly IMatchStore _store;
|
||||||
private readonly ILogger<MatchGrainRegistry> _logger;
|
private readonly ILogger<MatchGrainRegistry> _logger;
|
||||||
|
|
||||||
public MatchGrainRegistry(
|
public MatchGrainRegistry(
|
||||||
IServiceProvider services,
|
IServiceProvider services,
|
||||||
GameOptions options,
|
GameOptions options,
|
||||||
|
IMatchStore store,
|
||||||
ILogger<MatchGrainRegistry> logger)
|
ILogger<MatchGrainRegistry> logger)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
_options = options;
|
_options = options;
|
||||||
|
_store = store;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IMatchGrain Create(MatchSetup setup)
|
public async Task<IMatchGrain> CreateAsync(MatchSetup setup)
|
||||||
{
|
{
|
||||||
var grain = ActivatorUtilities.CreateInstance<MatchGrain>(_services, setup);
|
var grain = ActivatorUtilities.CreateInstance<MatchGrain>(_services, setup);
|
||||||
|
|
||||||
if (!_matches.TryAdd(setup.MatchId, grain))
|
if (!_matches.TryAdd(setup.MatchId, grain))
|
||||||
throw new InvalidOperationException($"Матч уже существует: {setup.MatchId}");
|
throw new InvalidOperationException($"Матч уже существует: {setup.MatchId}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _store.CreateAsync(grain.BuildHeader());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_matches.TryRemove(setup.MatchId, out _);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Match {MatchId} created: {Mode}, {Player0} vs {Player1}.",
|
"Match {MatchId} created: {Mode}, {Player0} vs {Player1}.",
|
||||||
setup.MatchId, setup.Mode, setup.Seat0.PlayerId, setup.Seat1.PlayerId);
|
setup.MatchId, setup.Mode, setup.Seat0.PlayerId, setup.Seat1.PlayerId);
|
||||||
|
|
@ -39,6 +53,30 @@ public sealed class MatchGrainRegistry
|
||||||
return grain;
|
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) =>
|
public bool TryGet(string matchId, [NotNullWhen(true)] out IMatchGrain? match) =>
|
||||||
_matches.TryGetValue(matchId, out 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 int ActiveCount => _matches.Values.Count(match => !match.Summary.IsFinished);
|
||||||
|
|
||||||
|
public void Remove(string matchId) => _matches.TryRemove(matchId, out _);
|
||||||
|
|
||||||
public void RemoveExpired(DateTimeOffset now)
|
public void RemoveExpired(DateTimeOffset now)
|
||||||
{
|
{
|
||||||
var ttl = TimeSpan.FromSeconds(_options.CompletedMatchTtlSeconds);
|
var ttl = TimeSpan.FromSeconds(_options.CompletedMatchTtlSeconds);
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,9 @@ public sealed class DeckRejectedException : Exception
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Две очереди, скрытый MMR и расширяющееся окно.
|
/// Две очереди, скрытый MMR и расширяющееся окно.
|
||||||
///
|
///
|
||||||
/// Состояние в памяти: до Redis этого достаточно для одного процесса.
|
/// Очередь живёт в памяти, но каждый матч сразу создаётся через registry,
|
||||||
/// Наружу MMR и колоды не отдаются — только статус, тикет и имя противника.
|
/// который пишет снапшот в хранилище. После рестарта игрок встаёт в очередь
|
||||||
|
/// заново; уже созданные матчи восстанавливаются из PostgreSQL.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MatchmakingService
|
public sealed class MatchmakingService
|
||||||
{
|
{
|
||||||
|
|
@ -68,7 +69,7 @@ public sealed class MatchmakingService
|
||||||
public string? Reason { get; set; }
|
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<Guid, Entry> _entries = new();
|
||||||
private readonly Dictionary<GameMode, List<Guid>> _queues = new()
|
private readonly Dictionary<GameMode, List<Guid>> _queues = new()
|
||||||
{
|
{
|
||||||
|
|
@ -81,6 +82,7 @@ public sealed class MatchmakingService
|
||||||
private readonly GameTicketService _tickets;
|
private readonly GameTicketService _tickets;
|
||||||
private readonly PlayerProfileStore _profiles;
|
private readonly PlayerProfileStore _profiles;
|
||||||
private readonly GameOptions _options;
|
private readonly GameOptions _options;
|
||||||
|
private readonly IQueueStore _queueStore;
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
private readonly ILogger<MatchmakingService> _logger;
|
private readonly ILogger<MatchmakingService> _logger;
|
||||||
private long _sequence;
|
private long _sequence;
|
||||||
|
|
@ -91,6 +93,7 @@ public sealed class MatchmakingService
|
||||||
GameTicketService tickets,
|
GameTicketService tickets,
|
||||||
PlayerProfileStore profiles,
|
PlayerProfileStore profiles,
|
||||||
GameOptions options,
|
GameOptions options,
|
||||||
|
IQueueStore queueStore,
|
||||||
TimeProvider time,
|
TimeProvider time,
|
||||||
ILogger<MatchmakingService> logger)
|
ILogger<MatchmakingService> logger)
|
||||||
{
|
{
|
||||||
|
|
@ -99,6 +102,7 @@ public sealed class MatchmakingService
|
||||||
_tickets = tickets;
|
_tickets = tickets;
|
||||||
_profiles = profiles;
|
_profiles = profiles;
|
||||||
_options = options;
|
_options = options;
|
||||||
|
_queueStore = queueStore;
|
||||||
_time = time;
|
_time = time;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
@ -107,12 +111,22 @@ public sealed class MatchmakingService
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
lock (_gate)
|
_gate.Wait();
|
||||||
|
try
|
||||||
|
{
|
||||||
return _entries.Values.Count(entry => entry.State == QueueEntryState.Queued);
|
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();
|
var violations = DeckValidator.Validate(_catalog, deck.ToList()).ToList();
|
||||||
|
|
||||||
|
|
@ -128,7 +142,8 @@ public sealed class MatchmakingService
|
||||||
if (violations.Count > 0)
|
if (violations.Count > 0)
|
||||||
throw new DeckRejectedException(violations);
|
throw new DeckRejectedException(violations);
|
||||||
|
|
||||||
lock (_gate)
|
await _gate.WaitAsync();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
// один игрок — одна активная очередь
|
// один игрок — одна активная очередь
|
||||||
foreach (var existing in _entries.Values
|
foreach (var existing in _entries.Values
|
||||||
|
|
@ -159,24 +174,36 @@ public sealed class MatchmakingService
|
||||||
"Queue: {PlayerId} joined {Mode} (queue {QueueId}).",
|
"Queue: {PlayerId} joined {Mode} (queue {QueueId}).",
|
||||||
playerId, mode, entry.QueueId);
|
playerId, mode, entry.QueueId);
|
||||||
|
|
||||||
|
await PersistAsync(entry);
|
||||||
|
|
||||||
return new QueueTicket(entry.QueueId, GameModes.ToWire(mode));
|
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)
|
if (!_entries.TryGetValue(queueId, out var entry) || entry.PlayerId != playerId)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return Snapshot(entry, _time.GetUtcNow());
|
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)
|
if (!_entries.TryGetValue(queueId, out var entry) || entry.PlayerId != playerId)
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -187,25 +214,35 @@ public sealed class MatchmakingService
|
||||||
entry.State = QueueEntryState.Cancelled;
|
entry.State = QueueEntryState.Cancelled;
|
||||||
entry.Reason = "Игрок отменил очередь.";
|
entry.Reason = "Игрок отменил очередь.";
|
||||||
RemoveFromQueue(entry);
|
RemoveFromQueue(entry);
|
||||||
|
await PersistAsync(entry);
|
||||||
|
|
||||||
_logger.LogInformation("Queue: {PlayerId} cancelled {QueueId}.", playerId, queueId);
|
_logger.LogInformation("Queue: {PlayerId} cancelled {QueueId}.", playerId, queueId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_gate.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Один проход подбора. Вызывается фоновым воркером раз в секунду.</summary>
|
/// <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 })
|
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]
|
var entries = _queues[mode]
|
||||||
.Select(id => _entries[id])
|
.Select(id => _entries[id])
|
||||||
|
|
@ -251,27 +288,30 @@ public sealed class MatchmakingService
|
||||||
|
|
||||||
used.Add(first.QueueId);
|
used.Add(first.QueueId);
|
||||||
used.Add(best.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 first = a.Sequence < b.Sequence ? a : b;
|
||||||
var second = ReferenceEquals(first, a) ? b : a;
|
var second = ReferenceEquals(first, a) ? b : a;
|
||||||
|
|
||||||
var matchId = Guid.NewGuid().ToString("N");
|
var matchId = Guid.NewGuid().ToString("N");
|
||||||
|
var created = false;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_matches.Create(new MatchSetup(
|
await _matches.CreateAsync(new MatchSetup(
|
||||||
matchId,
|
matchId,
|
||||||
first.Mode,
|
first.Mode,
|
||||||
new MatchSeatSetup(first.PlayerId, first.Deck, first.Mmr),
|
new MatchSeatSetup(first.PlayerId, first.Deck, first.Mmr),
|
||||||
new MatchSeatSetup(second.PlayerId, second.Deck, second.Mmr)));
|
new MatchSeatSetup(second.PlayerId, second.Deck, second.Mmr)));
|
||||||
|
|
||||||
MarkMatched(first, matchId, 0, second.PlayerId);
|
created = true;
|
||||||
MarkMatched(second, matchId, 1, first.PlayerId);
|
|
||||||
|
await MarkMatchedAsync(first, matchId, 0, second.PlayerId);
|
||||||
|
await MarkMatchedAsync(second, matchId, 1, first.PlayerId);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Queue: matched {Player0} vs {Player1} in {MatchId} ({Mode}).",
|
"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);
|
_logger.LogError(exception, "Queue: creating match {MatchId} failed.", matchId);
|
||||||
|
|
||||||
MarkFailed(first, "Не удалось создать матч.");
|
if (created)
|
||||||
MarkFailed(second, "Не удалось создать матч.");
|
_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.State = QueueEntryState.Matched;
|
||||||
entry.MatchId = matchId;
|
entry.MatchId = matchId;
|
||||||
entry.Seat = seat;
|
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;
|
entry.Opponent = opponent;
|
||||||
RemoveFromQueue(entry);
|
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.State = QueueEntryState.Failed;
|
||||||
entry.Reason = reason;
|
entry.Reason = reason;
|
||||||
|
|
@ -304,6 +348,7 @@ public sealed class MatchmakingService
|
||||||
entry.Ticket = null;
|
entry.Ticket = null;
|
||||||
entry.Seat = -1;
|
entry.Seat = -1;
|
||||||
RemoveFromQueue(entry);
|
RemoveFromQueue(entry);
|
||||||
|
await PersistAsync(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveFromQueue(Entry entry) => _queues[entry.Mode].Remove(entry.QueueId);
|
private void RemoveFromQueue(Entry entry) => _queues[entry.Mode].Remove(entry.QueueId);
|
||||||
|
|
@ -319,7 +364,79 @@ public sealed class MatchmakingService
|
||||||
_options.MmrInitialWindow + steps * _options.MmrWindowStep);
|
_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 ttl = TimeSpan.FromSeconds(_options.QueueExpirySeconds);
|
||||||
var keepFinished = TimeSpan.FromSeconds(_options.QueueExpirySeconds * 2.0);
|
var keepFinished = TimeSpan.FromSeconds(_options.QueueExpirySeconds * 2.0);
|
||||||
|
|
@ -331,12 +448,16 @@ public sealed class MatchmakingService
|
||||||
entry.State = QueueEntryState.Expired;
|
entry.State = QueueEntryState.Expired;
|
||||||
entry.Reason = "Очередь истекла.";
|
entry.Reason = "Очередь истекла.";
|
||||||
RemoveFromQueue(entry);
|
RemoveFromQueue(entry);
|
||||||
|
await PersistAsync(entry);
|
||||||
_logger.LogInformation("Queue: {QueueId} expired.", entry.QueueId);
|
_logger.LogInformation("Queue: {QueueId} expired.", entry.QueueId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.State != QueueEntryState.Queued && now - entry.EnqueuedAt >= keepFinished)
|
if (entry.State != QueueEntryState.Queued && now - entry.EnqueuedAt >= keepFinished)
|
||||||
|
{
|
||||||
_entries.Remove(entry.QueueId);
|
_entries.Remove(entry.QueueId);
|
||||||
|
await _queueStore.RemoveAsync(entry.QueueId, entry.Mode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ public sealed class MatchmakingWorker : BackgroundService
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_matchmaking.Matchmake(_time.GetUtcNow());
|
await _matchmaking.MatchmakeAsync(_time.GetUtcNow());
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
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 Microsoft.Extensions.Options;
|
||||||
|
using StackExchange.Redis;
|
||||||
using Vanguard.Content;
|
using Vanguard.Content;
|
||||||
using Vanguard.Rules.Cards;
|
using Vanguard.Rules.Cards;
|
||||||
using Vanguard.Server.Api;
|
using Vanguard.Server.Api;
|
||||||
|
|
@ -7,15 +9,20 @@ using Vanguard.Server.Matches;
|
||||||
using Vanguard.Server.Matchmaking;
|
using Vanguard.Server.Matchmaking;
|
||||||
using Vanguard.Server.Profiles;
|
using Vanguard.Server.Profiles;
|
||||||
using Vanguard.Server.Realtime;
|
using Vanguard.Server.Realtime;
|
||||||
|
using Vanguard.Server.Storage;
|
||||||
using Vanguard.Server.Tickets;
|
using Vanguard.Server.Tickets;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.Services.Configure<GameOptions>(builder.Configuration.GetSection("Game"));
|
builder.Services.Configure<GameOptions>(builder.Configuration.GetSection("Game"));
|
||||||
builder.Services.Configure<ContentOptions>(builder.Configuration.GetSection("Content"));
|
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<GameOptions>>().Value);
|
||||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<ContentOptions>>().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(TimeProvider.System);
|
||||||
|
|
||||||
builder.Services.AddSingleton(sp =>
|
builder.Services.AddSingleton(sp =>
|
||||||
|
|
@ -25,10 +32,58 @@ builder.Services.AddSingleton(sp =>
|
||||||
return CatalogLoader.LoadDirectory(directory);
|
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<GameTicketService>();
|
||||||
builder.Services.AddSingleton<PlayerProfileStore>();
|
builder.Services.AddSingleton<PlayerProfileStore>();
|
||||||
builder.Services.AddSingleton<IMatchResultSink>(sp => sp.GetRequiredService<PlayerProfileStore>());
|
builder.Services.AddSingleton<IMatchResultSink>(sp => sp.GetRequiredService<PlayerProfileStore>());
|
||||||
builder.Services.AddSingleton<MatchGrainRegistry>();
|
builder.Services.AddSingleton<MatchGrainRegistry>();
|
||||||
|
builder.Services.AddSingleton<MatchRecoveryService>();
|
||||||
builder.Services.AddSingleton<MatchmakingService>();
|
builder.Services.AddSingleton<MatchmakingService>();
|
||||||
builder.Services.AddHostedService<MatchTicker>();
|
builder.Services.AddHostedService<MatchTicker>();
|
||||||
builder.Services.AddHostedService<MatchmakingWorker>();
|
builder.Services.AddHostedService<MatchmakingWorker>();
|
||||||
|
|
@ -38,6 +93,17 @@ var app = builder.Build();
|
||||||
// каталог грузим сразу: сервер без карт бесполезен, лучше упасть на старте
|
// каталог грузим сразу: сервер без карт бесполезен, лучше упасть на старте
|
||||||
app.Services.GetRequiredService<CardCatalog>();
|
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
|
app.UseWebSockets(new WebSocketOptions
|
||||||
{
|
{
|
||||||
KeepAliveInterval = TimeSpan.FromSeconds(30),
|
KeepAliveInterval = TimeSpan.FromSeconds(30),
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ public static class GameWebSocketEndpoint
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ticket = tickets.Redeem(hello.Ticket);
|
var ticket = await tickets.RedeemAsync(hello.Ticket);
|
||||||
if (ticket is null)
|
if (ticket is null)
|
||||||
{
|
{
|
||||||
await FailAsync(connection, ProtocolErrorCode.InvalidTicket,
|
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 System.Security.Cryptography;
|
||||||
using Vanguard.Server.Configuration;
|
using Vanguard.Server.Configuration;
|
||||||
|
|
||||||
|
|
@ -13,36 +12,35 @@ public sealed record GameTicket(
|
||||||
DateTimeOffset ExpiresAt);
|
DateTimeOffset ExpiresAt);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Тикеты живут в памяти: TTL короткий, переживать рестарт им не нужно.
|
/// Выдаёт и принимает тикеты. Само хранение делегировано в ITicketStore:
|
||||||
/// При переезде на нескольких инстансов сервис заменяется на Redis-реализацию.
|
/// in-memory для тестов и одного процесса, Redis — для нескольких инстансов.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GameTicketService
|
public sealed class GameTicketService
|
||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<string, GameTicket> _tickets = new(StringComparer.Ordinal);
|
private readonly ITicketStore _store;
|
||||||
private readonly GameOptions _options;
|
private readonly GameOptions _options;
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
|
|
||||||
public GameTicketService(GameOptions options, TimeProvider time)
|
public GameTicketService(ITicketStore store, GameOptions options, TimeProvider time)
|
||||||
{
|
{
|
||||||
|
_store = store;
|
||||||
_options = options;
|
_options = options;
|
||||||
_time = time;
|
_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))
|
if (seat is not (0 or 1))
|
||||||
throw new ArgumentOutOfRangeException(nameof(seat), seat, "Место — 0 или 1.");
|
throw new ArgumentOutOfRangeException(nameof(seat), seat, "Место — 0 или 1.");
|
||||||
|
|
||||||
var now = _time.GetUtcNow();
|
|
||||||
var ticket = new GameTicket(
|
var ticket = new GameTicket(
|
||||||
NewToken(),
|
NewToken(),
|
||||||
playerId,
|
playerId,
|
||||||
matchId,
|
matchId,
|
||||||
seat,
|
seat,
|
||||||
now.AddSeconds(_options.TicketTtlSeconds));
|
_time.GetUtcNow().AddSeconds(_options.TicketTtlSeconds));
|
||||||
|
|
||||||
_tickets[ticket.Token] = ticket;
|
await _store.IssueAsync(ticket);
|
||||||
RemoveExpired(now);
|
|
||||||
return ticket;
|
return ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,26 +48,13 @@ public sealed class GameTicketService
|
||||||
/// Забирает тикет. null — неизвестный, уже использованный или просроченный.
|
/// Забирает тикет. null — неизвестный, уже использованный или просроченный.
|
||||||
/// Тикет одноразовый: повторный Redeem того же токена не сработает.
|
/// Тикет одноразовый: повторный Redeem того же токена не сработает.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public GameTicket? Redeem(string? token)
|
public async Task<GameTicket?> RedeemAsync(string? token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(token) || token.Length > 128)
|
if (string.IsNullOrWhiteSpace(token) || token.Length > 128)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if (!_tickets.TryRemove(token, out var ticket))
|
var ticket = await _store.TakeAsync(token);
|
||||||
return null;
|
return ticket is not null && ticket.ExpiresAt > _time.GetUtcNow() ? ticket : null;
|
||||||
|
|
||||||
return ticket.ExpiresAt > _time.GetUtcNow() ? ticket : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int Count => _tickets.Count;
|
|
||||||
|
|
||||||
private void RemoveExpired(DateTimeOffset now)
|
|
||||||
{
|
|
||||||
foreach (var (token, ticket) in _tickets)
|
|
||||||
{
|
|
||||||
if (ticket.ExpiresAt <= now)
|
|
||||||
_tickets.TryRemove(token, out _);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string NewToken()
|
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" />
|
<ProjectReference Include="..\Vanguard.Shared\Vanguard.Shared.csproj" />
|
||||||
</ItemGroup>
|
</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>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="Vanguard.Server.Tests" />
|
<InternalsVisibleTo Include="Vanguard.Server.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
|
|
@ -26,5 +27,13 @@
|
||||||
},
|
},
|
||||||
"Content": {
|
"Content": {
|
||||||
"Path": ""
|
"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
|
public class GameTicketServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Ticket_Is_Redeemed_Only_Once()
|
public async Task Ticket_Is_Redeemed_Only_Once()
|
||||||
{
|
{
|
||||||
var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
|
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 first = await service.RedeemAsync(ticket.Token);
|
||||||
var second = service.Redeem(ticket.Token);
|
var second = await service.RedeemAsync(ticket.Token);
|
||||||
|
|
||||||
Assert.NotNull(first);
|
Assert.NotNull(first);
|
||||||
Assert.Equal("p1", first!.PlayerId);
|
Assert.Equal("p1", first!.PlayerId);
|
||||||
|
|
@ -23,29 +23,32 @@ public class GameTicketServiceTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Ticket_Expires_After_Ttl()
|
public async Task Ticket_Expires_After_Ttl()
|
||||||
{
|
{
|
||||||
var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
|
var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
|
||||||
var options = ServerTestData.SmallOptions();
|
var options = ServerTestData.SmallOptions();
|
||||||
options.TicketTtlSeconds = 60;
|
options.TicketTtlSeconds = 60;
|
||||||
|
|
||||||
var service = new GameTicketService(options, time);
|
var service = NewService(time, options);
|
||||||
var ticket = service.Issue("p1", "m1", 1);
|
var ticket = await service.IssueAsync("p1", "m1", 1);
|
||||||
|
|
||||||
time.Advance(TimeSpan.FromSeconds(61));
|
time.Advance(TimeSpan.FromSeconds(61));
|
||||||
|
|
||||||
Assert.Null(service.Redeem(ticket.Token));
|
Assert.Null(await service.RedeemAsync(ticket.Token));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Unknown_Token_Is_Rejected()
|
public async Task Unknown_Token_Is_Rejected()
|
||||||
{
|
{
|
||||||
var service = new GameTicketService(
|
var service = NewService(new FakeTimeProvider(DateTimeOffset.UnixEpoch));
|
||||||
ServerTestData.SmallOptions(),
|
|
||||||
new FakeTimeProvider(DateTimeOffset.UnixEpoch));
|
|
||||||
|
|
||||||
Assert.Null(service.Redeem(null));
|
Assert.Null(await service.RedeemAsync(null));
|
||||||
Assert.Null(service.Redeem(""));
|
Assert.Null(await service.RedeemAsync(""));
|
||||||
Assert.Null(service.Redeem("no-such-ticket"));
|
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.Rules.Engine;
|
||||||
using Vanguard.Server.Configuration;
|
using Vanguard.Server.Configuration;
|
||||||
using Vanguard.Server.Matches;
|
using Vanguard.Server.Matches;
|
||||||
|
using Vanguard.Server.Storage;
|
||||||
using Vanguard.Shared.Protocol;
|
using Vanguard.Shared.Protocol;
|
||||||
|
|
||||||
namespace Vanguard.Server.Tests;
|
namespace Vanguard.Server.Tests;
|
||||||
|
|
@ -297,6 +298,7 @@ public class MatchGrainTests
|
||||||
options ?? ServerTestData.SmallOptions(),
|
options ?? ServerTestData.SmallOptions(),
|
||||||
time,
|
time,
|
||||||
sink ?? new RecordingResultSink(),
|
sink ?? new RecordingResultSink(),
|
||||||
|
new NullMatchStore(),
|
||||||
NullLogger<MatchGrain>.Instance);
|
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.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Vanguard.Rules.Cards;
|
||||||
using Vanguard.Server.Configuration;
|
using Vanguard.Server.Configuration;
|
||||||
using Vanguard.Server.Matches;
|
using Vanguard.Server.Matches;
|
||||||
using Vanguard.Server.Matchmaking;
|
using Vanguard.Server.Matchmaking;
|
||||||
using Vanguard.Server.Profiles;
|
using Vanguard.Server.Profiles;
|
||||||
|
using Vanguard.Server.Storage;
|
||||||
using Vanguard.Server.Tickets;
|
using Vanguard.Server.Tickets;
|
||||||
|
|
||||||
namespace Vanguard.Server.Tests;
|
namespace Vanguard.Server.Tests;
|
||||||
|
|
@ -11,17 +13,19 @@ namespace Vanguard.Server.Tests;
|
||||||
public class MatchmakingServiceTests
|
public class MatchmakingServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[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 harness = NewHarness();
|
||||||
|
|
||||||
var first = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
|
var first = await harness.Matchmaking.EnqueueAsync(
|
||||||
var second = harness.Matchmaking.Enqueue("p2", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
|
"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 firstStatus = await harness.Matchmaking.PollAsync(first.QueueId, "p1");
|
||||||
var secondStatus = harness.Matchmaking.Poll(second.QueueId, "p2");
|
var secondStatus = await harness.Matchmaking.PollAsync(second.QueueId, "p2");
|
||||||
|
|
||||||
Assert.NotNull(firstStatus);
|
Assert.NotNull(firstStatus);
|
||||||
Assert.NotNull(secondStatus);
|
Assert.NotNull(secondStatus);
|
||||||
|
|
@ -36,8 +40,8 @@ public class MatchmakingServiceTests
|
||||||
Assert.NotNull(firstStatus.Ticket);
|
Assert.NotNull(firstStatus.Ticket);
|
||||||
Assert.NotNull(secondStatus.Ticket);
|
Assert.NotNull(secondStatus.Ticket);
|
||||||
|
|
||||||
var firstTicket = harness.Tickets.Redeem(firstStatus.Ticket);
|
var firstTicket = await harness.Tickets.RedeemAsync(firstStatus.Ticket);
|
||||||
var secondTicket = harness.Tickets.Redeem(secondStatus.Ticket);
|
var secondTicket = await harness.Tickets.RedeemAsync(secondStatus.Ticket);
|
||||||
Assert.Equal(0, firstTicket!.Seat);
|
Assert.Equal(0, firstTicket!.Seat);
|
||||||
Assert.Equal(1, secondTicket!.Seat);
|
Assert.Equal(1, secondTicket!.Seat);
|
||||||
Assert.Equal(firstStatus.MatchId, firstTicket.MatchId);
|
Assert.Equal(firstStatus.MatchId, firstTicket.MatchId);
|
||||||
|
|
@ -48,53 +52,55 @@ public class MatchmakingServiceTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Ranked_And_Casual_Queues_Are_Separate()
|
public async Task Ranked_And_Casual_Queues_Are_Separate()
|
||||||
{
|
{
|
||||||
var harness = NewHarness();
|
var harness = NewHarness();
|
||||||
|
|
||||||
harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
|
await harness.Matchmaking.EnqueueAsync("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
|
||||||
var casual = harness.Matchmaking.Enqueue("p2", ServerTestData.BuildDeck(), GameMode.ClassicCasual);
|
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(0, harness.Registry.Count);
|
||||||
Assert.Equal(2, harness.Matchmaking.QueuedCount);
|
Assert.Equal(2, harness.Matchmaking.QueuedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Window_Expands_While_Waiting()
|
public async Task Window_Expands_While_Waiting()
|
||||||
{
|
{
|
||||||
var harness = NewHarness();
|
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")!;
|
var initial = await harness.Matchmaking.PollAsync(ticket.QueueId, "p1");
|
||||||
Assert.Equal(100, initial.Window);
|
Assert.Equal(100, initial!.Window);
|
||||||
|
|
||||||
harness.Time.Advance(TimeSpan.FromSeconds(20));
|
harness.Time.Advance(TimeSpan.FromSeconds(20));
|
||||||
var later = harness.Matchmaking.Poll(ticket.QueueId, "p1")!;
|
var later = await harness.Matchmaking.PollAsync(ticket.QueueId, "p1");
|
||||||
Assert.Equal(300, later.Window); // 100 + 4 * 50
|
Assert.Equal(300, later!.Window); // 100 + 4 * 50
|
||||||
|
|
||||||
harness.Time.Advance(TimeSpan.FromSeconds(200));
|
harness.Time.Advance(TimeSpan.FromSeconds(200));
|
||||||
var capped = harness.Matchmaking.Poll(ticket.QueueId, "p1")!;
|
var capped = await harness.Matchmaking.PollAsync(ticket.QueueId, "p1");
|
||||||
Assert.Equal(2000, capped.Window);
|
Assert.Equal(2000, capped!.Window);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Invalid_Deck_Is_Rejected_At_Enqueue()
|
public async Task Invalid_Deck_Is_Rejected_At_Enqueue()
|
||||||
{
|
{
|
||||||
var harness = NewHarness();
|
var harness = NewHarness();
|
||||||
var deck = ServerTestData.BuildDeck();
|
var deck = ServerTestData.BuildDeck();
|
||||||
deck.RemoveAt(0);
|
deck.RemoveAt(0);
|
||||||
|
|
||||||
var exception = Assert.Throws<DeckRejectedException>(
|
var exception = await Assert.ThrowsAsync<DeckRejectedException>(
|
||||||
() => harness.Matchmaking.Enqueue("p1", deck, GameMode.ClassicRanked));
|
() => harness.Matchmaking.EnqueueAsync("p1", deck, GameMode.ClassicRanked));
|
||||||
|
|
||||||
Assert.Contains(exception.Violations, violation => violation.Code == "deck.size");
|
Assert.Contains(exception.Violations, violation => violation.Code == "deck.size");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Deck_Without_Starting_Vanguard_Is_Rejected()
|
public async Task Deck_Without_Starting_Vanguard_Is_Rejected()
|
||||||
{
|
{
|
||||||
var harness = NewHarness();
|
var harness = NewHarness();
|
||||||
var deck = new List<string>();
|
var deck = new List<string>();
|
||||||
|
|
@ -107,7 +113,7 @@ public class MatchmakingServiceTests
|
||||||
break;
|
break;
|
||||||
|
|
||||||
var card = ServerTestData.Catalog.CardOf(printing.PrintingId);
|
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;
|
continue;
|
||||||
|
|
||||||
for (var i = 0; i < 4 && deck.Count < 50; i++)
|
for (var i = 0; i < 4 && deck.Count < 50; i++)
|
||||||
|
|
@ -116,49 +122,53 @@ public class MatchmakingServiceTests
|
||||||
|
|
||||||
Assert.Equal(50, deck.Count);
|
Assert.Equal(50, deck.Count);
|
||||||
|
|
||||||
var exception = Assert.Throws<DeckRejectedException>(
|
var exception = await Assert.ThrowsAsync<DeckRejectedException>(
|
||||||
() => harness.Matchmaking.Enqueue("p1", deck, GameMode.ClassicRanked));
|
() => harness.Matchmaking.EnqueueAsync("p1", deck, GameMode.ClassicRanked));
|
||||||
|
|
||||||
Assert.Contains(exception.Violations,
|
Assert.Contains(exception.Violations,
|
||||||
violation => violation.Code == "deck.starting-vanguard");
|
violation => violation.Code == "deck.starting-vanguard");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Cancel_Removes_From_Queue()
|
public async Task Cancel_Removes_From_Queue()
|
||||||
{
|
{
|
||||||
var harness = NewHarness();
|
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")!;
|
var status = await harness.Matchmaking.PollAsync(ticket.QueueId, "p1");
|
||||||
Assert.Equal("cancelled", status.Status);
|
Assert.Equal("cancelled", status!.Status);
|
||||||
Assert.Equal(0, harness.Matchmaking.QueuedCount);
|
Assert.Equal(0, harness.Matchmaking.QueuedCount);
|
||||||
Assert.False(harness.Matchmaking.Cancel(ticket.QueueId, "p1"));
|
Assert.False(await harness.Matchmaking.CancelAsync(ticket.QueueId, "p1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Second_Queue_Request_Replaces_The_First()
|
public async Task Second_Queue_Request_Replaces_The_First()
|
||||||
{
|
{
|
||||||
var harness = NewHarness();
|
var harness = NewHarness();
|
||||||
var old = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
|
var old = await harness.Matchmaking.EnqueueAsync(
|
||||||
var current = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked);
|
"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("cancelled", (await harness.Matchmaking.PollAsync(old.QueueId, "p1"))!.Status);
|
||||||
Assert.Equal("queued", harness.Matchmaking.Poll(current.QueueId, "p1")!.Status);
|
Assert.Equal("queued", (await harness.Matchmaking.PollAsync(current.QueueId, "p1"))!.Status);
|
||||||
Assert.Equal(1, harness.Matchmaking.QueuedCount);
|
Assert.Equal(1, harness.Matchmaking.QueuedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Queue_Expires_After_Timeout()
|
public async Task Queue_Expires_After_Timeout()
|
||||||
{
|
{
|
||||||
var harness = NewHarness();
|
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.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);
|
Assert.Equal(0, harness.Matchmaking.QueuedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,32 +193,77 @@ public class MatchmakingServiceTests
|
||||||
Assert.Equal(loserAfterRanked, profiles.GetMmr("loser"));
|
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()
|
private static Harness NewHarness()
|
||||||
{
|
{
|
||||||
var time = new FakeTimeProvider(new DateTimeOffset(2026, 10, 21, 12, 0, 0, TimeSpan.Zero));
|
var time = new FakeTimeProvider(new DateTimeOffset(2026, 10, 21, 12, 0, 0, TimeSpan.Zero));
|
||||||
var options = ServerTestData.SmallOptions();
|
var options = ServerTestData.SmallOptions();
|
||||||
var profiles = new PlayerProfileStore();
|
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();
|
var services = new ServiceCollection();
|
||||||
services.AddSingleton(ServerTestData.Catalog);
|
services.AddSingleton(ServerTestData.Catalog);
|
||||||
services.AddSingleton(options);
|
services.AddSingleton(options);
|
||||||
services.AddSingleton<TimeProvider>(time);
|
services.AddSingleton<TimeProvider>(time);
|
||||||
services.AddSingleton<IMatchResultSink>(profiles);
|
services.AddSingleton<IMatchResultSink>(profiles);
|
||||||
|
services.AddSingleton<IMatchStore>(store);
|
||||||
|
services.AddSingleton<IQueueStore>(queueStore);
|
||||||
services.AddLogging();
|
services.AddLogging();
|
||||||
|
|
||||||
var provider = services.BuildServiceProvider();
|
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(
|
var matchmaking = new MatchmakingService(
|
||||||
ServerTestData.Catalog,
|
ServerTestData.Catalog,
|
||||||
registry,
|
registry,
|
||||||
tickets,
|
tickets,
|
||||||
profiles,
|
profiles,
|
||||||
options,
|
options,
|
||||||
|
queueStore,
|
||||||
time,
|
time,
|
||||||
NullLogger<MatchmakingService>.Instance);
|
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(
|
private sealed record Harness(
|
||||||
|
|
@ -216,6 +271,8 @@ public class MatchmakingServiceTests
|
||||||
GameOptions Options,
|
GameOptions Options,
|
||||||
PlayerProfileStore Profiles,
|
PlayerProfileStore Profiles,
|
||||||
GameTicketService Tickets,
|
GameTicketService Tickets,
|
||||||
|
IMatchStore MatchStore,
|
||||||
|
IQueueStore QueueStore,
|
||||||
MatchGrainRegistry Registry,
|
MatchGrainRegistry Registry,
|
||||||
MatchmakingService Matchmaking);
|
MatchmakingService Matchmaking);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue