diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4b0853f --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VANGUARD_PORT=8080 +VANGUARD_WS_URL=ws://localhost:8080/game diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 4126fa9..8cf7b72 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -70,9 +70,8 @@ /tools/import_cards.py ``` - [x] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`. -- [~] `docker-compose.yml`: сейчас только game-server; postgres, redis (AOF), meta-api, nginx — со следующим блоком. -- [ ] `docker compose up` поднимает БД и пустой API; healthcheck отвечает. - — сервер уже поднимается и отвечает `/health`, БД ещё нет. +- [x] `docker-compose.yml`: postgres, redis (AOF), game-server с контентом. +- [x] `docker compose up` поднимает БД, Redis и API; `/health` отвечает. - [ ] CI: сборка solution + `dotnet test` на push. **Готово, когда:** `docker compose up` + `dotnet run` дают живой `/health`. @@ -182,13 +181,15 @@ - [x] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось». - [x] Политика отсутствия: 5 минут → авто-поражение. - [x] Таймер хода: 90 с + банк 5 мин. -- [ ] Снапшот матча в PostgreSQL + восстановление grain после рестарта. +- [x] Снапшот матча в PostgreSQL + восстановление grain после рестарта. + — заголовок + журнал интентов, реплей команд при старте. +- [x] Redis: одноразовые тикеты и очередь. - [x] Idempotency по `intentId`. - [x] HTTP-очередь и тикеты: `docs/server-api.md`. - [x] E2E: два клиента играют полный матч по WebSocket; обрыв и возврат проверены. **Готово, когда:** два клиента играют полный матч по сети; kill процесса клиента на любом шаге восстанавливается. - — выполнено в `Vanguard.Server.Tests`; рестарт сервера пока теряет матчи (ждёт PostgreSQL). + — выполнено в `Vanguard.Server.Tests`; рестарт сервера восстанавливает матчи из PostgreSQL (проверено на `ssh home`). --- diff --git a/README.md b/README.md index faebe2a..fe9189e 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ | Контент-пайплайн и валидатор | готово | | Игровое ядро (правила) | каркас: фазы, райд, призыв, бой, драйв, урон | | Протокол и контракты (`docs/protocol.md`, `Vanguard.Shared`) | готово | -| Сервер: матч по сети, матчмейкинг, реконнект | готово (in-memory) | -| Персистентность (PostgreSQL, Redis) и Orleans | не начато | +| Сервер: матч по сети, матчмейкинг, реконнект | готово | +| PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово | +| Orleans и мета-API (JWT, коллекции, магазин) | не начато | | Клиент и лаунчер | не начато | ## Требования @@ -46,7 +47,8 @@ dotnet run --project server/src/Vanguard.Server --urls http://localhost:5080 ``` Контейнер: `docker compose up --build`, затем `http://localhost:8080/health`. -HTTP-контракт очереди и тикетов — `docs/server-api.md`. +Порт задаётся в `.env` (`VANGUARD_PORT`, `VANGUARD_WS_URL`); стек включает +PostgreSQL и Redis (AOF). HTTP-контракт очереди и тикетов — `docs/server-api.md`. Ожидаемый вывод валидатора: @@ -63,6 +65,9 @@ OK: ошибок 0, предупреждений 0 /server/src/Vanguard.Content загрузка и проверка YAML-контента /server/src/Vanguard.Shared сетевые контракты (netstandard2.1 для Unity + net10.0) /server/src/Vanguard.Server ASP.NET Core: WebSocket, очередь, тикеты, матчи + /Storage PostgreSQL-снапшоты и восстановление матчей + /Matchmaking очередь, MMR, Redis-хранилище очереди + /Tickets тикеты: память или Redis /server/src/Vanguard.Cli консольные команды /server/tests тесты правил, контрактов и сервера (включая E2E) /content/sets карты в YAML diff --git a/docker-compose.yml b/docker-compose.yml index ebec902..8a115b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,48 @@ services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: vanguard + POSTGRES_USER: vanguard + POSTGRES_PASSWORD: vanguard + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U vanguard -d vanguard"] + interval: 5s + timeout: 3s + retries: 20 + + redis: + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + server: build: context: . dockerfile: server/src/Vanguard.Server/Dockerfile ports: - - "8080:8080" + - "${VANGUARD_PORT:-8080}:8080" environment: ASPNETCORE_URLS: http://+:8080 - Game__PublicWsUrl: ws://localhost:8080/game + Game__PublicWsUrl: ${VANGUARD_WS_URL:-ws://localhost:8080/game} + Storage__Provider: Postgres + Storage__ConnectionString: Host=postgres;Port=5432;Database=vanguard;Username=vanguard;Password=vanguard + Redis__Configuration: redis:6379 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy restart: unless-stopped + +volumes: + pgdata: + redisdata: diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index c27530e..2d094e2 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -38,13 +38,13 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты | Проверка | Результат | |---|---| -| Тесты | **158/158** (131 правила/контракты + 27 сервер) | +| Тесты | **164/164** (131 правила/контракты + 33 сервер) | | `validate` | 0 ошибок, 0 предупреждений | | `translations check` | 94/94 | | `stress 1000` | 1000/1000 партий, 0 ошибок | | Карты | **94/94** (66 с текстом + 28 ванильных) | | Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы | -| Сервер | полный матч по сети, матчмейкинг, реконнект (in-memory) | +| Сервер | сеть, матчмейкинг, реконнект, PostgreSQL + Redis, Docker | | Клиент / лаунчер | не начаты | Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям: @@ -70,7 +70,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000 ``` Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests) -Пройден! : не пройдено 0, пройдено 27 (Vanguard.Server.Tests) +Пройден! : не пройдено 0, пройдено 33 (Vanguard.Server.Tests) OK: ошибок 0, предупреждений 0 Переведено: 94/94 Партий: 1000, завершено: 1000, ошибок: 0 @@ -83,6 +83,20 @@ dotnet run --project server\src\Vanguard.Server --urls http://localhost:5080 # GET http://localhost:5080/health ``` +Тестовый стенд на `ssh home` (Linux, Docker): + +```bash +ssh home +cd ~/VanguardReMaster +# .env: VANGUARD_PORT=18080, VANGUARD_WS_URL=ws://192.168.31.158:18080/game +docker compose up -d --build +curl http://127.0.0.1:18080/health +# остановить: docker compose down +``` + +`Storage:Provider=None` по умолчанию: локальный запуск без базы. Стенд +использует `Postgres`, `Redis` и восстанавливает матчи после рестарта. + Solution — **`server/Vanguard.slnx`**, не `.sln`. --- @@ -201,7 +215,9 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. ### 4.8. Сервер -`server/src/Vanguard.Server` — ASP.NET Core 10, один процесс, состояние в памяти. +`server/src/Vanguard.Server` — ASP.NET Core 10, один процесс. +Матчи, очередь и тикеты сохраняются в PostgreSQL/Redis, если они настроены; +по умолчанию (`Storage:Provider=None`) сервер работает без внешних сервисов. - **HTTP**: `POST /api/game/queue`, `GET /api/game/queue/{id}`, `DELETE /api/game/queue/{id}`, `POST /api/game/tickets`, `GET /health`. @@ -210,7 +226,8 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. окно ±100 с расширением на 50 каждые 5 с до 2000, выбор ближайшего MMR. Ранкед меняет MMR по Эло (K = 32), casual — нет. - **Тикеты**: одноразовые, TTL 60 с, привязаны к `(playerId, matchId, seat)`; - для реконнекта выдаётся новый тикет. + при настроенном Redis изымаются через `GETDEL` (одноразовость между + инстансами), иначе — из памяти. - **`IMatchGrain`**: 1 матч = 1 экземпляр, все входы (интенты, подключения, тики) проходят через один семафор; внутри нет ни ASP.NET Core, ни WebSocket — только `IMatchConnection`. Orleans-хостинг подключается поверх контракта. @@ -224,19 +241,41 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. результат пишется в профили. - **Проекция**: своя рука, свой выбор, свои легальные интенты; у противника только количества и открытые зоны. `seed` и RNG не покидают сервер. -- **Docker**: `Dockerfile` + `docker-compose.yml` поднимают сервер с контентом - на `http://localhost:8080`. -- **Тесты** (`Vanguard.Server.Tests`, 27): +- **PostgreSQL**: таблицы `matches` и `match_intents`. В заголовке — seed, + колоды, игроки, часы, банки, статус; в журнале — `intentId`, команда, + результат. При старте незавершённые матчи восстанавливаются повтором + принятых команд на детерминированном движке, включая ожидания выбора: + делегаты движка не сериализуются. Журнал реплея сверяется по `eventSeq`. + Банк и дедлайн пишутся каждые ~5 с; потеря при аварии — не больше тика. +- **Redis**: тикеты и записи очереди (JSON + sorted set по режимам, TTL); + очередь поднимается при старте. Ключи с префиксом `vanguard:`. +- **Конфигурация**: `Storage:Provider` = `None` / `Memory` / `Postgres`, + `Storage:ConnectionString`; `Redis:Configuration`. `Memory` — EF InMemory + для тестов, `Postgres` — постоянное хранилище. +- **Docker**: `Dockerfile` + `docker-compose.yml` поднимают postgres, redis + (AOF) и сервер; `/health` на `http://localhost:8080` (порт меняется + через `VANGUARD_PORT`, адрес WebSocket — `VANGUARD_WS_URL`). +- **Тесты** (`Vanguard.Server.Tests`, 33): - тикеты: одноразовость и TTL; - heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром; - grain: attach, чужое место, duplicate intent, чужой ход, сдача, команды после финала, банк времени, отсутствие, старт часов, reconnect с `eventSeq`, захват сессии; - матчмейкинг: подбор, разные очереди, окно, плохая колода, - отсутствие стартового Авангарда, отмена, замена очереди, истечение, MMR; + отсутствие стартового Авангарда, отмена, замена очереди, истечение, MMR, + перезагрузка очереди из хранилища; + - персистентность: EF-хранилище, восстановление матча повтором команд, + `MatchRecoveryService` после «рестарта»; + - внешние хранилища (`ExternalStorageTests`, запускаются при + `VANGUARD_TEST_POSTGRES` / `VANGUARD_TEST_REDIS`): реальные PostgreSQL + и Redis, изъятие тикета, очередь; - E2E: два клиента играют полный матч по WebSocket и сходятся в победителе; обрыв и возврат по новому тикету с догоном состояния. +Проверено на `ssh home`: 164/164 тестов, включая реальные PostgreSQL и Redis; +`docker compose up` поднимает стенд, матч создаётся через HTTP-очередь, +а после `docker compose restart server` восстанавливается (`Recovery: 1 of 1`). + --- ## 5. Структура и ключевые файлы @@ -258,28 +297,30 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. /Replay/ запись и воспроизведение /Diagnostics/GameInvariants.cs /server/src/Vanguard.Content загрузка YAML и валидация -/server/src/Vanguard.Cli validate, stats, translations, stress +/server/src/Vanguard.Cli validate, stats, translations, stress, deck /server/src/Vanguard.Shared /Protocol/*.cs кадры, интенты, события, view, кодек /Projection/*.cs проекция состояния, фильтр событий, маппинг /server/src/Vanguard.Server /Program.cs ASP.NET Core, DI, WebSocket endpoint /Api/ очередь, тикеты, /health - /Matchmaking/ две очереди, MMR, расширяющееся окно + /Matchmaking/ очереди, MMR, Redis-хранилище очереди /Matches/ IMatchGrain, таймеры, тикер, регистр /Realtime/ WebSocket-обвязка, чтение кадров - /Tickets/ одноразовые тикеты + /Tickets/ одноразовые тикеты: память или Redis + /Storage/ EF-хранилище, восстановление матчей /Profiles/ скрытый MMR и статистика /Dockerfile /server/tests/Vanguard.Rules.Tests 131 тест -/server/tests/Vanguard.Server.Tests 27 тестов, включая E2E по WebSocket +/server/tests/Vanguard.Server.Tests 33 теста, включая E2E и внешние хранилища /content/sets карты /content/translations/ru.yaml переводы /docs/translation-rules.md канон перевода /docs/protocol.md протокол матча, версия 1 /docs/server-api.md HTTP-очередь, тикеты, /health /docs/HANDOFF.md этот файл -/docker-compose.yml сервер в контейнере +/docker-compose.yml postgres, redis, server +/.env.example VANGUARD_PORT и VANGUARD_WS_URL ``` --- @@ -305,8 +346,9 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. не в URL; reconnect получает новый тикет. - `playerId` в HTTP-API — dev-заглушка до JWT. Выставлять сервер в интернет в таком виде нельзя. -- Состояние сервера пока in-memory: `IMatchGrain` отделён от транспорта, - Orleans и PostgreSQL подключаются поверх. +- Состояние сервера: PostgreSQL — снапшот/журнал матчей, Redis — тикеты + и очередь; без конфигурации работает in-memory. `IMatchGrain` отделён + от транспорта, Orleans подключается поверх. - Rate limit — 16 интентов в секунду на соединение. - Скрытый MMR: старт 1000, Эло K = 32, casual рейтинг не меняет. - Порядок триггеров выбирает игрок; опциональные способности спрашиваются. @@ -337,15 +379,18 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. 4. **[x] Проекция состояния** подключена к грану и проверена E2E-тестом: своя рука и свой выбор полностью, у противника только количества и открытые зоны, seed и RNG не уходят. -5. **[x] `Docker Compose`** — сервер с контентом на `http://localhost:8080`. +5. **[x] `Docker Compose`** — postgres, redis (AOF) и сервер; `/health`, + порт через `VANGUARD_PORT`. +6. **[x] Персистентность**: + - PostgreSQL — заголовок матча + журнал интентов, восстановление повтором + команд после рестарта, `MatchRecoveryService`; + - Redis — одноразовые тикеты (`GETDEL`) и очередь с перезагрузкой. Осталось в серверном направлении: -6. **Orleans** — заменить in-process регистр на настоящий silo и граны. - `IMatchGrain` уже отделён от транспорта и ASP.NET Core. -7. **Персистентность**: - - PostgreSQL — снапшот матча, восстановление grain после рестарта; - - Redis — тикеты, очереди, кэш. +7. **Orleans** — заменить in-process регистр на настоящий silo и граны. + `IMatchGrain` уже отделён от транспорта и ASP.NET Core; персистентность + и проекция от хостинга не зависят. 8. **JWT и мета-API** — `playerId` перестанет быть dev-заглушкой. Критерий готовности серверного блока выполнен: два клиента играют полный @@ -393,10 +438,10 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. 1. Прочитать `docs/HANDOFF.md`, `IMPLEMENTATION_CHECKLIST.md`, `README.md`. 2. Прогнать проверки из раздела 3. -3. Следующий блок — персистентность и Orleans: PostgreSQL-снапшоты, - Redis для тикетов/очередей, замена in-process регистра на silo. - Сетевой матч уже работает: `docs/protocol.md`, `Vanguard.Shared`, - `Vanguard.Server`, `docs/server-api.md`. +3. Следующий блок — Orleans и мета-API: заменить in-process регистр на silo, + затем JWT, аккаунты, коллекция, колоды и магазин. Сетевой матч, + PostgreSQL/Redis и Docker уже готовы: `docs/protocol.md`, + `Vanguard.Shared`, `Vanguard.Server`, `docs/server-api.md`. 4. После каждого блока: тесты, `validate`, `translations check`, коммит, push. 5. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот. @@ -418,8 +463,26 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. для наблюдателя нельзя — в списке есть printingId из чужой руки. - **Сервер: rate limit.** 16 интентов в секунду на соединение. Быстрый бот в E2E специально притормаживается; живой UI в лимит не упирается. -- **Сервер: in-memory.** Очереди, тикеты, матчи и профили живут в памяти. - Рестарт сервера теряет матчи; kill клиента и реконнект переживаются. +- **Сервер: хранилище.** По умолчанию `Storage:Provider=None` — матчи живут + в памяти. Для рестарта нужен `Postgres` + `Storage:ConnectionString`; + схема создаётся `EnsureCreated`, миграций пока нет. `Memory` — EF InMemory. +- **Сервер: снапшот матча.** Сериализуется не состояние движка, а seed, колоды, + игроки, часы и журнал интентов. При восстановлении команды проигрываются + заново; `EventSeq` обязан совпасть, иначе матч пропускается с ошибкой. + Делегаты `PendingChoice`/`Continuation` восстанавливаются самим реплеем. +- **Сервер: восстановление.** `MatchRecoveryService` поднимает только + незавершённые матчи за последние 7 дней. Завершённые остаются историей + в PostgreSQL и не восстанавливаются в память. Банк/дедлайн пишутся + раз в ~5 с, поэтому авария теряет не больше тика. +- **Сервер: Redis.** Тикеты изымаются через `GETDEL` (Redis 6.2+; в compose 7). + Очередь — JSON + sorted set по режимам, TTL в два раза больше очереди. + Префикс ключей — `Redis:Prefix` (`vanguard:`). +- **Сервер: Docker.** На домашнем сервере порт 8080 занят, поэтому стенд + поднимается с `VANGUARD_PORT=18080` и `VANGUARD_WS_URL` в `.env`. + `docker compose restart server` проверяет восстановление матчей. +- **Сервер: внешние тесты.** `VANGUARD_TEST_POSTGRES` и `VANGUARD_TEST_REDIS` + включают `ExternalStorageTests`; без них тесты проходят как no-op. + Postgres-тест делает `EnsureDeleted`, поэтому БД должна быть тестовой. - **Сервер: тесты.** E2E поднимает `WebApplicationFactory`, играет полный матч по WebSocket и проверяет, что победитель совпал. Случайные партии завершаются за ~350 действий; таймаут теста — 120 с. diff --git a/docs/server-api.md b/docs/server-api.md index ad8bb62..b4dab3e 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -3,7 +3,9 @@ Служебный HTTP-слой матчевого блока: очередь, тикеты на подключение и `/health`. После подключения клиент переходит на WebSocket и работает по `docs/protocol.md`. -Базовая часть стека: **ASP.NET Core 10**, один процесс, состояние в памяти. +Базовая часть стека: **ASP.NET Core 10**, один процесс. +Матчи и очередь сохраняются в PostgreSQL/Redis, если они настроены; без +внешних сервисов сервер работает in-memory (удобно для тестов). --- @@ -21,13 +23,28 @@ dotnet run --project server\src\Vanguard.Server --urls http://localhost:5080 Invoke-RestMethod http://localhost:5080/health ``` -Контейнер: +Контейнер (PostgreSQL + Redis + сервер): ```powershell docker compose up --build # http://localhost:8080/health ``` +Порт и внешний адрес WebSocket задаются в `.env`: + +``` +VANGUARD_PORT=8080 +VANGUARD_WS_URL=ws://localhost:8080/game +``` + +`VANGUARD_PORT` нужен, если 8080 занят (на тестовом стенде — 18080). + +Для ручной проверки колоду на 50 карт можно получить так: + +```powershell +dotnet run --project server\src\Vanguard.Cli -- deck +``` + Каталог карт ищется в `content/sets` вверх от каталога сборки. Если контент лежит в другом месте, задайте `Content:Path` (или `Content__Path` в окружении). @@ -211,13 +228,55 @@ WS /game → ClientHello(ticket, resumeFromSeq) --- -## 7. Ограничения текущего этапа +## 7. Хранилище и восстановление -- Всё состояние (очереди, тикеты, матчи, профили) — в памяти процесса. - Рестарт сервера теряет матчи; клиентский kill и реконнект переживаются. +### 7.1. Конфигурация + +| Ключ | Значения | Смысл | +|---|---|---| +| `Storage:Provider` | `None`, `Memory`, `Postgres` | `None` — без сохранения, `Memory` — EF InMemory, `Postgres` — постоянное | +| `Storage:ConnectionString` | строка Npgsql | обязательно для `Postgres` | +| `Redis:Configuration` | `host:port` | пусто — тикеты и очередь в памяти | +| `Redis:Prefix` | `vanguard:` | префикс ключей | + +В контейнере переменные задаются как `Storage__Provider`, +`Storage__ConnectionString`, `Redis__Configuration`. + +### 7.2. Матчи в PostgreSQL + +- `matches` — заголовок: игроки, колоды, seed, часы, банки, статус, `eventSeq`. +- `match_intents` — журнал: `intentId`, место, команда, результат, код отказа. +- На каждый интент пишется строка журнала и обновляется заголовок. +- После рестарта `MatchRecoveryService` поднимает незавершённые матчи: + создаёт движок с сохранённым seed и проигрывает принятые команды заново. + `GameFinished`, ожидания выбора и продолжения восстанавливаются реплеем, + делегаты никуда не сериализуются. +- `eventSeq` сверяется: если реплей разошёлся со снапшотом, матч пропускается + с ошибкой в логе. +- Банк и дедлайн сохраняются раз в ~5 секунд; авария теряет не больше тика. +- Завершённые матчи остаются историей и при старте в память не поднимаются. + +### 7.3. Redis + +- Тикеты: ключ `vanguard:ticket:{token}`, TTL 60 с, изъятие через `GETDEL` + (одноразовость сохраняется между инстансами). +- Очередь: запись `vanguard:queue:entry:{id}` (JSON) и sorted set + `vanguard:queue:{mode}` по номеру постановки. При старте очередь + загружается обратно. +- Состояние профилей (MMR) пока остаётся в памяти: это закроет мета-API + вместе с аккаунтами. + +--- + +## 8. Ограничения текущего этапа + +- PostgreSQL/Redis опциональны. По умолчанию (`Storage:Provider=None`) + матчи, тикеты и очередь живут только в памяти. +- Профили и MMR пока не персистентны: после рестарта рейтинг начинается + с 1000. Матчи и очередь при этом восстанавливаются. - Один процесс: горизонтальное масштабирование и Orleans-хостинг ещё впереди. - `IMatchGrain` уже отделён от ASP.NET Core и WebSocket: замена на Orleans grain не затронет транспорт и правила. -- Нет PostgreSQL/Redis, JWT, коллекций и наград — это следующие блоки. +- Нет JWT, коллекций и наград — это следующие блоки. - Rate limit: 16 интентов в секунду с одного соединения. Живой UI в него не упирается; тестовый бот специально притормаживается. diff --git a/server/src/Vanguard.Cli/Program.cs b/server/src/Vanguard.Cli/Program.cs index cf0b686..8ef8390 100644 --- a/server/src/Vanguard.Cli/Program.cs +++ b/server/src/Vanguard.Cli/Program.cs @@ -1,4 +1,5 @@ -using Vanguard.Content; +using System.Text.Json; +using Vanguard.Content; using Vanguard.Content.CardText; using Vanguard.Content.Translations; using Vanguard.Rules.Cards; @@ -15,6 +16,7 @@ return command switch "stats" => RunStats(PickPath(1) ?? Path.Combine(root, "content", "sets")), "translations" => RunTranslations(root), "stress" => RunStress(root, args), + "deck" => RunDeck(root), _ => Usage(), }; @@ -132,6 +134,13 @@ static int RunStress(string repoRoot, string[] args) return failures == 0 && finished == games ? 0 : 1; } +static int RunDeck(string repoRoot) +{ + var catalog = CatalogLoader.LoadDirectory(Path.Combine(repoRoot, "content", "sets")); + Console.WriteLine(JsonSerializer.Serialize(BuildBotDeck(catalog))); + return 0; +} + static List BuildBotDeck(CardCatalog catalog) { var deck = new List(); @@ -265,6 +274,7 @@ static int Usage() translations build [путь] собрать файл переводов translations check [путь] проверить переводы stress [партий] прогнать случайного бота и проверить инварианты + deck JSON-колода из 50 printingId для ручной проверки """); return 2; } diff --git a/server/src/Vanguard.Server/Api/ApiEndpoints.cs b/server/src/Vanguard.Server/Api/ApiEndpoints.cs index d04fd93..3928c9c 100644 --- a/server/src/Vanguard.Server/Api/ApiEndpoints.cs +++ b/server/src/Vanguard.Server/Api/ApiEndpoints.cs @@ -21,7 +21,7 @@ public static class ApiEndpoints app.MapGet("/health", Health); } - private static IResult Enqueue( + private static async Task Enqueue( QueueRequest request, MatchmakingService matchmaking) { @@ -36,7 +36,7 @@ public static class ApiEndpoints try { - var ticket = matchmaking.Enqueue(request.PlayerId!, request.Deck, mode); + var ticket = await matchmaking.EnqueueAsync(request.PlayerId!, request.Deck, mode); return Results.Ok(new QueueAccepted(ticket.QueueId, "queued", ticket.Mode)); } catch (DeckRejectedException exception) @@ -48,7 +48,7 @@ public static class ApiEndpoints } } - private static IResult PollQueue( + private static async Task PollQueue( Guid queueId, string? playerId, MatchmakingService matchmaking, @@ -58,7 +58,7 @@ public static class ApiEndpoints if (!IsValidPlayerId(playerId)) return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен.")); - var snapshot = matchmaking.Poll(queueId, playerId!); + var snapshot = await matchmaking.PollAsync(queueId, playerId!); if (snapshot is null) return Results.NotFound(new ApiError("queue.not-found", "Очередь не найдена.")); @@ -76,7 +76,7 @@ public static class ApiEndpoints snapshot.MatchId is null ? null : BuildWsUrl(http, options))); } - private static IResult CancelQueue( + private static async Task CancelQueue( Guid queueId, string? playerId, MatchmakingService matchmaking) @@ -84,12 +84,12 @@ public static class ApiEndpoints if (!IsValidPlayerId(playerId)) return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен.")); - return matchmaking.Cancel(queueId, playerId!) + return await matchmaking.CancelAsync(queueId, playerId!) ? Results.NoContent() : Results.Conflict(new ApiError("queue.not-cancellable", "Очередь уже не активна.")); } - private static IResult IssueTicket( + private static async Task IssueTicket( TicketRequest request, MatchGrainRegistry matches, GameTicketService tickets, @@ -117,7 +117,7 @@ public static class ApiEndpoints new ApiError("match.forbidden", "Этот игрок не участвует в матче."), statusCode: StatusCodes.Status403Forbidden); - var ticket = tickets.Issue(request.PlayerId!, request.MatchId!, seat); + var ticket = await tickets.IssueAsync(request.PlayerId!, request.MatchId!, seat); return Results.Ok(new TicketResponse( ticket.Token, diff --git a/server/src/Vanguard.Server/Configuration/StorageOptions.cs b/server/src/Vanguard.Server/Configuration/StorageOptions.cs new file mode 100644 index 0000000..81da763 --- /dev/null +++ b/server/src/Vanguard.Server/Configuration/StorageOptions.cs @@ -0,0 +1,17 @@ +namespace Vanguard.Server.Configuration; + +/// Где хранить снапшоты матчей. +public sealed class StorageOptions +{ + /// None — без сохранения, Memory — EF InMemory, Postgres — постоянное хранилище. + public string Provider { get; set; } = "None"; + + public string ConnectionString { get; set; } = string.Empty; +} + +/// Redis для тикетов и распределённых блокировок. Пусто — in-memory. +public sealed class RedisOptions +{ + public string Configuration { get; set; } = string.Empty; + public string Prefix { get; set; } = "vg:"; +} diff --git a/server/src/Vanguard.Server/Dockerfile b/server/src/Vanguard.Server/Dockerfile index 079b3ff..bcfbb2e 100644 --- a/server/src/Vanguard.Server/Dockerfile +++ b/server/src/Vanguard.Server/Dockerfile @@ -8,6 +8,12 @@ COPY content/ ./content/ RUN dotnet publish server/src/Vanguard.Server/Vanguard.Server.csproj -c Release -o /app FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime + +# Npgsql при старте пробует GSSAPI; без библиотеки в лог попадает ошибка. +RUN apt-get update \ + && apt-get install -y --no-install-recommends libgssapi-krb5-2 \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY --from=build /app ./ diff --git a/server/src/Vanguard.Server/Matches/IMatchGrain.cs b/server/src/Vanguard.Server/Matches/IMatchGrain.cs index 2cb2b5d..791dff4 100644 --- a/server/src/Vanguard.Server/Matches/IMatchGrain.cs +++ b/server/src/Vanguard.Server/Matches/IMatchGrain.cs @@ -21,7 +21,8 @@ public sealed record MatchSetup( string MatchId, GameMode Mode, MatchSeatSetup Seat0, - MatchSeatSetup Seat1); + MatchSeatSetup Seat1, + ulong? Seed = null); /// Снимок и итог матча для регистратуры, health и записи результата. public sealed record MatchSummary( diff --git a/server/src/Vanguard.Server/Matches/MatchGrain.cs b/server/src/Vanguard.Server/Matches/MatchGrain.cs index 07106ad..682c675 100644 --- a/server/src/Vanguard.Server/Matches/MatchGrain.cs +++ b/server/src/Vanguard.Server/Matches/MatchGrain.cs @@ -1,6 +1,8 @@ using Vanguard.Rules.Cards; using Vanguard.Rules.Engine; +using Vanguard.Rules.Replay; using Vanguard.Server.Configuration; +using Vanguard.Server.Storage; using Vanguard.Shared.Projection; using Vanguard.Shared.Protocol; @@ -39,6 +41,7 @@ public sealed class MatchGrain : IMatchGrain private readonly GameOptions _options; private readonly TimeProvider _time; private readonly IMatchResultSink _results; + private readonly IMatchStore _store; private readonly ILogger _logger; private readonly string _matchId; private readonly GameMode _mode; @@ -50,6 +53,9 @@ public sealed class MatchGrain : IMatchGrain private DateTimeOffset _lastClockSent; private DateTimeOffset? _finishedAt; private bool _resultReported; + private bool _restoring; + + private const string ConcedeCommand = "concede"; public MatchGrain( MatchSetup setup, @@ -57,6 +63,7 @@ public sealed class MatchGrain : IMatchGrain GameOptions options, TimeProvider time, IMatchResultSink results, + IMatchStore store, ILogger logger) { _matchId = setup.MatchId; @@ -64,6 +71,7 @@ public sealed class MatchGrain : IMatchGrain _options = options; _time = time; _results = results; + _store = store; _logger = logger; _createdAt = time.GetUtcNow(); @@ -87,7 +95,7 @@ public sealed class MatchGrain : IMatchGrain }, }; - var seed = unchecked((ulong)Random.Shared.NextInt64()); + var seed = setup.Seed ?? unchecked((ulong)Random.Shared.NextInt64()); _engine = new GameEngine( catalog, seed, @@ -174,6 +182,8 @@ public sealed class MatchGrain : IMatchGrain "Match {MatchId}: seat {Seat} attached (resume from {Seq}, now {Now}).", _matchId, seat, resumeFromSeq, to); + await PersistHeaderAsync(); + return true; } catch (Exception exception) @@ -205,6 +215,8 @@ public sealed class MatchGrain : IMatchGrain state.AbsentSince = _time.GetUtcNow(); _logger.LogInformation("Match {MatchId}: seat {Seat} detached.", _matchId, seat); + + await PersistHeaderAsync(); } finally { @@ -254,9 +266,12 @@ public sealed class MatchGrain : IMatchGrain var decisionBefore = _engine.State.ActivePlayer; CommandResult result; + var commandText = string.Empty; + if (isConcede) { _engine.Finish(1 - seat); + commandText = ConcedeCommand; result = CommandResult.Ok(); } else @@ -269,6 +284,8 @@ public sealed class MatchGrain : IMatchGrain } result = _engine.Execute(command); + if (result.Accepted) + commandText = CommandCodec.Encode(command); } var code = result.Accepted ? IntentRejectCode.None : IntentRejectCode.Illegal; @@ -276,6 +293,9 @@ public sealed class MatchGrain : IMatchGrain _intents[intentId] = record; RefreshSummary(); + await PersistIntentAsync(new PersistedIntent( + intentId, seat, commandText, record.Accepted, record.Code, record.Reason, record.EventSeq)); + await SendResultAsync( seat, intentId, record.Accepted, record.Code, record.Reason, record.EventSeq, duplicate: false); @@ -373,7 +393,10 @@ public sealed class MatchGrain : IMatchGrain } if (now - _lastClockSent >= TimeSpan.FromSeconds(_options.ClockSyncSeconds)) + { await SendClockToAllAsync(now); + await PersistHeaderAsync(); + } } finally { @@ -389,6 +412,9 @@ public sealed class MatchGrain : IMatchGrain { _intents[intentId] = new IntentRecord(false, code, reason, _engine.Events.Count); + await PersistIntentAsync(new PersistedIntent( + intentId, seat, string.Empty, false, code, reason, _engine.Events.Count)); + await SendResultAsync( seat, intentId, accepted: false, code, reason, _engine.Events.Count, duplicate: false); } @@ -512,6 +538,7 @@ public sealed class MatchGrain : IMatchGrain _resultReported = true; _finishedAt = now; RefreshSummary(); + await PersistHeaderAsync(); var winner = _engine.State.Winner ?? -1; if (winner is not (0 or 1)) @@ -559,6 +586,140 @@ public sealed class MatchGrain : IMatchGrain ServerTimeMs = now.ToUnixTimeMilliseconds(), }; + /// Снимок заголовка для хранилища. + 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); + } + } + + /// + /// Восстановить матч из снапшота: заново проиграть принятые команды на + /// детерминированном движке. Состояние выборов и продолжений + /// восстанавливается само; делегаты никуда не сериализуются. + /// + public async Task RestoreAsync(MatchSnapshot snapshot) + { + await _gate.WaitAsync(); + try + { + _restoring = true; + + var header = snapshot.Header; + if (header.MatchId != _matchId) + throw new InvalidOperationException( + $"Снапшот {header.MatchId} не принадлежит матчу {_matchId}."); + + foreach (var intent in snapshot.Intents) + { + if (!intent.Accepted) + { + _intents[intent.IntentId] = new IntentRecord( + false, intent.Code, intent.Reason, intent.EventSeq); + continue; + } + + if (intent.Command == ConcedeCommand) + { + _engine.Finish(intent.Seat == 0 ? 1 : 0); + } + else + { + if (string.IsNullOrWhiteSpace(intent.Command)) + throw new InvalidOperationException( + $"Принятый интент {intent.IntentId} не содержит команды."); + + var command = CommandCodec.Decode(intent.Command); + var result = _engine.Execute(command); + if (!result.Accepted) + throw new InvalidOperationException( + $"Реплей отклонил {intent.IntentId}: {result.Reason}"); + } + + _intents[intent.IntentId] = new IntentRecord( + true, IntentRejectCode.None, null, _engine.Events.Count); + } + + if (_engine.Events.Count != header.EventSeq) + throw new InvalidOperationException( + $"Рассинхрон реплея {header.MatchId}: событий {_engine.Events.Count}, " + + $"в снапшоте {header.EventSeq}."); + + _started = header.Started; + _seats[0].BankMs = header.Bank0Ms; + _seats[1].BankMs = header.Bank1Ms; + _seats[0].EverConnected = header.Started; + _seats[1].EverConnected = header.Started; + + var now = _time.GetUtcNow(); + _seats[0].AbsentSince = now; + _seats[1].AbsentSince = now; + + _lastClockSent = header.LastClockSent; + _finishedAt = header.FinishedAt; + _resultReported = header.IsFinished; + + if (_started) + _turnDeadline = header.TurnDeadline ?? now.AddSeconds(_options.TurnSeconds); + + RefreshSummary(); + + _logger.LogInformation( + "Match {MatchId}: restored {Intents} intents, sequence {Seq}.", + _matchId, snapshot.Intents.Count, header.EventSeq); + } + finally + { + _restoring = false; + _gate.Release(); + } + } + private void RefreshSummary() { _summary = new MatchSummary( diff --git a/server/src/Vanguard.Server/Matches/MatchGrainRegistry.cs b/server/src/Vanguard.Server/Matches/MatchGrainRegistry.cs index 469f93a..7d65a89 100644 --- a/server/src/Vanguard.Server/Matches/MatchGrainRegistry.cs +++ b/server/src/Vanguard.Server/Matches/MatchGrainRegistry.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using Vanguard.Server.Configuration; +using Vanguard.Server.Storage; namespace Vanguard.Server.Matches; @@ -13,25 +14,38 @@ public sealed class MatchGrainRegistry private readonly ConcurrentDictionary _matches = new(StringComparer.Ordinal); private readonly IServiceProvider _services; private readonly GameOptions _options; + private readonly IMatchStore _store; private readonly ILogger _logger; public MatchGrainRegistry( IServiceProvider services, GameOptions options, + IMatchStore store, ILogger logger) { _services = services; _options = options; + _store = store; _logger = logger; } - public IMatchGrain Create(MatchSetup setup) + public async Task CreateAsync(MatchSetup setup) { var grain = ActivatorUtilities.CreateInstance(_services, setup); if (!_matches.TryAdd(setup.MatchId, grain)) throw new InvalidOperationException($"Матч уже существует: {setup.MatchId}"); + try + { + await _store.CreateAsync(grain.BuildHeader()); + } + catch + { + _matches.TryRemove(setup.MatchId, out _); + throw; + } + _logger.LogInformation( "Match {MatchId} created: {Mode}, {Player0} vs {Player1}.", setup.MatchId, setup.Mode, setup.Seat0.PlayerId, setup.Seat1.PlayerId); @@ -39,6 +53,30 @@ public sealed class MatchGrainRegistry return grain; } + /// Поднять матч после рестарта сервера из снапшота. + public async Task 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(_services, setup); + await grain.RestoreAsync(snapshot); + + if (!_matches.TryAdd(header.MatchId, grain)) + throw new InvalidOperationException($"Матч уже восстановлен: {header.MatchId}"); + + _logger.LogInformation( + "Match {MatchId} restored: {Mode}, {Player0} vs {Player1}, seq {Seq}.", + header.MatchId, header.Mode, header.Player0Id, header.Player1Id, header.EventSeq); + + return grain; + } + public bool TryGet(string matchId, [NotNullWhen(true)] out IMatchGrain? match) => _matches.TryGetValue(matchId, out match); @@ -48,6 +86,8 @@ public sealed class MatchGrainRegistry public int ActiveCount => _matches.Values.Count(match => !match.Summary.IsFinished); + public void Remove(string matchId) => _matches.TryRemove(matchId, out _); + public void RemoveExpired(DateTimeOffset now) { var ttl = TimeSpan.FromSeconds(_options.CompletedMatchTtlSeconds); diff --git a/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs b/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs index 1a3af29..ad024c7 100644 --- a/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs +++ b/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs @@ -45,8 +45,9 @@ public sealed class DeckRejectedException : Exception /// /// Две очереди, скрытый MMR и расширяющееся окно. /// -/// Состояние в памяти: до Redis этого достаточно для одного процесса. -/// Наружу MMR и колоды не отдаются — только статус, тикет и имя противника. +/// Очередь живёт в памяти, но каждый матч сразу создаётся через registry, +/// который пишет снапшот в хранилище. После рестарта игрок встаёт в очередь +/// заново; уже созданные матчи восстанавливаются из PostgreSQL. /// public sealed class MatchmakingService { @@ -68,7 +69,7 @@ public sealed class MatchmakingService public string? Reason { get; set; } } - private readonly object _gate = new(); + private readonly SemaphoreSlim _gate = new(1, 1); private readonly Dictionary _entries = new(); private readonly Dictionary> _queues = new() { @@ -81,6 +82,7 @@ public sealed class MatchmakingService private readonly GameTicketService _tickets; private readonly PlayerProfileStore _profiles; private readonly GameOptions _options; + private readonly IQueueStore _queueStore; private readonly TimeProvider _time; private readonly ILogger _logger; private long _sequence; @@ -91,6 +93,7 @@ public sealed class MatchmakingService GameTicketService tickets, PlayerProfileStore profiles, GameOptions options, + IQueueStore queueStore, TimeProvider time, ILogger logger) { @@ -99,6 +102,7 @@ public sealed class MatchmakingService _tickets = tickets; _profiles = profiles; _options = options; + _queueStore = queueStore; _time = time; _logger = logger; } @@ -107,12 +111,22 @@ public sealed class MatchmakingService { get { - lock (_gate) + _gate.Wait(); + try + { return _entries.Values.Count(entry => entry.State == QueueEntryState.Queued); + } + finally + { + _gate.Release(); + } } } - public QueueTicket Enqueue(string playerId, IReadOnlyList deck, GameMode mode) + public async Task EnqueueAsync( + string playerId, + IReadOnlyList deck, + GameMode mode) { var violations = DeckValidator.Validate(_catalog, deck.ToList()).ToList(); @@ -128,7 +142,8 @@ public sealed class MatchmakingService if (violations.Count > 0) throw new DeckRejectedException(violations); - lock (_gate) + await _gate.WaitAsync(); + try { // один игрок — одна активная очередь foreach (var existing in _entries.Values @@ -159,24 +174,36 @@ public sealed class MatchmakingService "Queue: {PlayerId} joined {Mode} (queue {QueueId}).", playerId, mode, entry.QueueId); + await PersistAsync(entry); + return new QueueTicket(entry.QueueId, GameModes.ToWire(mode)); } + finally + { + _gate.Release(); + } } - public QueueSnapshot? Poll(Guid queueId, string playerId) + public async Task PollAsync(Guid queueId, string playerId) { - lock (_gate) + await _gate.WaitAsync(); + try { if (!_entries.TryGetValue(queueId, out var entry) || entry.PlayerId != playerId) return null; return Snapshot(entry, _time.GetUtcNow()); } + finally + { + _gate.Release(); + } } - public bool Cancel(Guid queueId, string playerId) + public async Task CancelAsync(Guid queueId, string playerId) { - lock (_gate) + await _gate.WaitAsync(); + try { if (!_entries.TryGetValue(queueId, out var entry) || entry.PlayerId != playerId) return false; @@ -187,25 +214,35 @@ public sealed class MatchmakingService entry.State = QueueEntryState.Cancelled; entry.Reason = "Игрок отменил очередь."; RemoveFromQueue(entry); + await PersistAsync(entry); _logger.LogInformation("Queue: {PlayerId} cancelled {QueueId}.", playerId, queueId); return true; } - } - - /// Один проход подбора. Вызывается фоновым воркером раз в секунду. - public void Matchmake(DateTimeOffset now) - { - lock (_gate) + finally { - Expire(now); - - foreach (var mode in new[] { GameMode.ClassicRanked, GameMode.ClassicCasual }) - MatchmakeMode(mode, now); + _gate.Release(); } } - private void MatchmakeMode(GameMode mode, DateTimeOffset now) + /// Один проход подбора. Вызывается фоновым воркером раз в секунду. + public async Task MatchmakeAsync(DateTimeOffset now) + { + await _gate.WaitAsync(); + try + { + await ExpireAsync(now); + + foreach (var mode in new[] { GameMode.ClassicRanked, GameMode.ClassicCasual }) + await MatchmakeModeAsync(mode, now); + } + finally + { + _gate.Release(); + } + } + + private async Task MatchmakeModeAsync(GameMode mode, DateTimeOffset now) { var entries = _queues[mode] .Select(id => _entries[id]) @@ -251,27 +288,30 @@ public sealed class MatchmakingService used.Add(first.QueueId); used.Add(best.QueueId); - TryPair(first, best); + await TryPairAsync(first, best); } } - private void TryPair(Entry a, Entry b) + private async Task TryPairAsync(Entry a, Entry b) { var first = a.Sequence < b.Sequence ? a : b; var second = ReferenceEquals(first, a) ? b : a; var matchId = Guid.NewGuid().ToString("N"); + var created = false; try { - _matches.Create(new MatchSetup( + await _matches.CreateAsync(new MatchSetup( matchId, first.Mode, new MatchSeatSetup(first.PlayerId, first.Deck, first.Mmr), new MatchSeatSetup(second.PlayerId, second.Deck, second.Mmr))); - MarkMatched(first, matchId, 0, second.PlayerId); - MarkMatched(second, matchId, 1, first.PlayerId); + created = true; + + await MarkMatchedAsync(first, matchId, 0, second.PlayerId); + await MarkMatchedAsync(second, matchId, 1, first.PlayerId); _logger.LogInformation( "Queue: matched {Player0} vs {Player1} in {MatchId} ({Mode}).", @@ -281,22 +321,26 @@ public sealed class MatchmakingService { _logger.LogError(exception, "Queue: creating match {MatchId} failed.", matchId); - MarkFailed(first, "Не удалось создать матч."); - MarkFailed(second, "Не удалось создать матч."); + if (created) + _matches.Remove(matchId); + + await MarkFailedAsync(first, "Не удалось создать матч."); + await MarkFailedAsync(second, "Не удалось создать матч."); } } - private void MarkMatched(Entry entry, string matchId, int seat, string opponent) + private async Task MarkMatchedAsync(Entry entry, string matchId, int seat, string opponent) { entry.State = QueueEntryState.Matched; entry.MatchId = matchId; entry.Seat = seat; - entry.Ticket = _tickets.Issue(entry.PlayerId, matchId, seat).Token; + entry.Ticket = (await _tickets.IssueAsync(entry.PlayerId, matchId, seat)).Token; entry.Opponent = opponent; RemoveFromQueue(entry); + await PersistAsync(entry); } - private void MarkFailed(Entry entry, string reason) + private async Task MarkFailedAsync(Entry entry, string reason) { entry.State = QueueEntryState.Failed; entry.Reason = reason; @@ -304,6 +348,7 @@ public sealed class MatchmakingService entry.Ticket = null; entry.Seat = -1; RemoveFromQueue(entry); + await PersistAsync(entry); } private void RemoveFromQueue(Entry entry) => _queues[entry.Mode].Remove(entry.QueueId); @@ -319,7 +364,79 @@ public sealed class MatchmakingService _options.MmrInitialWindow + steps * _options.MmrWindowStep); } - private void Expire(DateTimeOffset now) + /// Поднять сохранённые очереди после рестарта сервера. + public async Task LoadQueuedAsync() + { + await _gate.WaitAsync(); + try + { + var stored = await _queueStore.LoadQueuedAsync(); + var loaded = 0; + + foreach (var snapshot in stored.OrderBy(entry => entry.Sequence)) + { + if (_entries.ContainsKey(snapshot.QueueId)) + continue; + + if (_entries.Values.Any(entry => + entry.PlayerId == snapshot.PlayerId + && entry.State == QueueEntryState.Queued)) + continue; + + var entry = new Entry + { + QueueId = snapshot.QueueId, + PlayerId = snapshot.PlayerId, + Deck = snapshot.Deck, + Mode = snapshot.Mode, + Mmr = snapshot.Mmr, + EnqueuedAt = snapshot.EnqueuedAt, + Sequence = snapshot.Sequence, + State = QueueEntryState.Queued, + }; + + _entries[entry.QueueId] = entry; + _queues[entry.Mode].Add(entry.QueueId); + _sequence = Math.Max(_sequence, entry.Sequence); + loaded++; + } + + _logger.LogInformation("Queue: loaded {Count} saved entries.", loaded); + } + finally + { + _gate.Release(); + } + } + + private async Task PersistAsync(Entry entry) + { + try + { + await _queueStore.SaveAsync(ToSnapshot(entry)); + } + catch (Exception exception) + { + _logger.LogError(exception, "Queue: persisting {QueueId} failed.", entry.QueueId); + } + } + + private static QueueEntrySnapshot ToSnapshot(Entry entry) => new( + entry.QueueId, + entry.PlayerId, + entry.Deck.ToList(), + entry.Mode, + entry.Mmr, + entry.EnqueuedAt, + entry.Sequence, + entry.State, + entry.MatchId, + entry.Seat, + entry.Ticket, + entry.Opponent, + entry.Reason); + + private async Task ExpireAsync(DateTimeOffset now) { var ttl = TimeSpan.FromSeconds(_options.QueueExpirySeconds); var keepFinished = TimeSpan.FromSeconds(_options.QueueExpirySeconds * 2.0); @@ -331,12 +448,16 @@ public sealed class MatchmakingService entry.State = QueueEntryState.Expired; entry.Reason = "Очередь истекла."; RemoveFromQueue(entry); + await PersistAsync(entry); _logger.LogInformation("Queue: {QueueId} expired.", entry.QueueId); continue; } if (entry.State != QueueEntryState.Queued && now - entry.EnqueuedAt >= keepFinished) + { _entries.Remove(entry.QueueId); + await _queueStore.RemoveAsync(entry.QueueId, entry.Mode); + } } } diff --git a/server/src/Vanguard.Server/Matchmaking/MatchmakingWorker.cs b/server/src/Vanguard.Server/Matchmaking/MatchmakingWorker.cs index 42212e4..e6d6d0b 100644 --- a/server/src/Vanguard.Server/Matchmaking/MatchmakingWorker.cs +++ b/server/src/Vanguard.Server/Matchmaking/MatchmakingWorker.cs @@ -25,7 +25,7 @@ public sealed class MatchmakingWorker : BackgroundService { try { - _matchmaking.Matchmake(_time.GetUtcNow()); + await _matchmaking.MatchmakeAsync(_time.GetUtcNow()); } catch (Exception exception) { diff --git a/server/src/Vanguard.Server/Matchmaking/QueueStore.cs b/server/src/Vanguard.Server/Matchmaking/QueueStore.cs new file mode 100644 index 0000000..cb17f73 --- /dev/null +++ b/server/src/Vanguard.Server/Matchmaking/QueueStore.cs @@ -0,0 +1,147 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using StackExchange.Redis; +using Vanguard.Server.Configuration; + +namespace Vanguard.Server.Matchmaking; + +/// Сериализуемая запись очереди: нужна, чтобы пережить рестарт. +public sealed record QueueEntrySnapshot( + Guid QueueId, + string PlayerId, + List 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> LoadQueuedAsync(); + + Task RemoveAsync(Guid queueId, GameMode mode); +} + +/// Очередь в памяти: используется без Redis и в тестах. +public sealed class InMemoryQueueStore : IQueueStore +{ + private readonly object _gate = new(); + private readonly Dictionary _entries = new(); + + public Task SaveAsync(QueueEntrySnapshot entry) + { + lock (_gate) + _entries[entry.QueueId] = entry; + + return Task.CompletedTask; + } + + public Task> LoadQueuedAsync() + { + lock (_gate) + { + return Task.FromResult>( + _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; + } +} + +/// +/// Redis-очередь: запись — JSON по ключу, порядок — sorted set по режимам. +/// TTL записи в два раза больше срока жизни очереди. +/// +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> LoadQueuedAsync() + { + var result = new List(); + + 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(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)}"; +} diff --git a/server/src/Vanguard.Server/Program.cs b/server/src/Vanguard.Server/Program.cs index de996db..41185e0 100644 --- a/server/src/Vanguard.Server/Program.cs +++ b/server/src/Vanguard.Server/Program.cs @@ -1,4 +1,6 @@ +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using StackExchange.Redis; using Vanguard.Content; using Vanguard.Rules.Cards; using Vanguard.Server.Api; @@ -7,15 +9,20 @@ using Vanguard.Server.Matches; using Vanguard.Server.Matchmaking; using Vanguard.Server.Profiles; using Vanguard.Server.Realtime; +using Vanguard.Server.Storage; using Vanguard.Server.Tickets; var builder = WebApplication.CreateBuilder(args); builder.Services.Configure(builder.Configuration.GetSection("Game")); builder.Services.Configure(builder.Configuration.GetSection("Content")); +builder.Services.Configure(builder.Configuration.GetSection("Storage")); +builder.Services.Configure(builder.Configuration.GetSection("Redis")); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); +builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); +builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(sp => @@ -25,10 +32,58 @@ builder.Services.AddSingleton(sp => return CatalogLoader.LoadDirectory(directory); }); +var storage = builder.Configuration.GetSection("Storage").Get() ?? 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(options => + options.UseNpgsql(storage.ConnectionString)); + builder.Services.AddSingleton(); +} +else if (storageProvider.Equals("Memory", StringComparison.OrdinalIgnoreCase)) +{ + builder.Services.AddDbContextFactory(options => + options.UseInMemoryDatabase("vanguard-matches")); + builder.Services.AddSingleton(); +} +else if (storageProvider.Equals("None", StringComparison.OrdinalIgnoreCase)) +{ + builder.Services.AddSingleton(); +} +else +{ + throw new InvalidOperationException( + $"Неизвестный Storage:Provider «{storageProvider}». Ожидается None, Memory или Postgres."); +} + +var redis = builder.Configuration.GetSection("Redis").Get() ?? new RedisOptions(); + +if (!string.IsNullOrWhiteSpace(redis.Configuration)) +{ + builder.Services.AddSingleton(_ => + { + var configuration = ConfigurationOptions.Parse(redis.Configuration); + configuration.AbortOnConnectFail = false; + return ConnectionMultiplexer.Connect(configuration); + }); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); +} +else +{ + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); +} + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -38,6 +93,17 @@ var app = builder.Build(); // каталог грузим сразу: сервер без карт бесполезен, лучше упасть на старте app.Services.GetRequiredService(); +if (app.Services.GetRequiredService() is EfMatchStore) +{ + var factory = app.Services.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + await db.Database.EnsureCreatedAsync(); +} + +// незавершённые матчи поднимаем до того, как сервер начнёт принимать подключения +await app.Services.GetRequiredService().RecoverAsync(); +await app.Services.GetRequiredService().LoadQueuedAsync(); + app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30), diff --git a/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs b/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs index 9e47b67..557acf9 100644 --- a/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs +++ b/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs @@ -74,7 +74,7 @@ public static class GameWebSocketEndpoint return; } - var ticket = tickets.Redeem(hello.Ticket); + var ticket = await tickets.RedeemAsync(hello.Ticket); if (ticket is null) { await FailAsync(connection, ProtocolErrorCode.InvalidTicket, diff --git a/server/src/Vanguard.Server/Storage/EfMatchStore.cs b/server/src/Vanguard.Server/Storage/EfMatchStore.cs new file mode 100644 index 0000000..6887ca5 --- /dev/null +++ b/server/src/Vanguard.Server/Storage/EfMatchStore.cs @@ -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 _factory; + private readonly TimeProvider _time; + + public EfMatchStore(IDbContextFactory 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 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> 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(); + + 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())) + .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 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 DeserializeDeck(string json) => + JsonSerializer.Deserialize(json) ?? Array.Empty(); +} diff --git a/server/src/Vanguard.Server/Storage/MatchDbContext.cs b/server/src/Vanguard.Server/Storage/MatchDbContext.cs new file mode 100644 index 0000000..ffd5b45 --- /dev/null +++ b/server/src/Vanguard.Server/Storage/MatchDbContext.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore; + +namespace Vanguard.Server.Storage; + +public sealed class MatchDbContext : DbContext +{ + public MatchDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Matches => Set(); + public DbSet Intents => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(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(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; } +} diff --git a/server/src/Vanguard.Server/Storage/MatchRecoveryService.cs b/server/src/Vanguard.Server/Storage/MatchRecoveryService.cs new file mode 100644 index 0000000..6cbdfeb --- /dev/null +++ b/server/src/Vanguard.Server/Storage/MatchRecoveryService.cs @@ -0,0 +1,58 @@ +using Vanguard.Server.Matches; +using Vanguard.Server.Storage; + +namespace Vanguard.Server.Storage; + +/// +/// Поднимает незавершённые матчи при старте сервера. Вызывается до того, +/// как сервер начнёт принимать подключения. +/// +public sealed class MatchRecoveryService +{ + private readonly IMatchStore _store; + private readonly MatchGrainRegistry _registry; + private readonly TimeProvider _time; + private readonly ILogger _logger; + + public MatchRecoveryService( + IMatchStore store, + MatchGrainRegistry registry, + TimeProvider time, + ILogger 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); + } +} diff --git a/server/src/Vanguard.Server/Storage/MatchStoreContracts.cs b/server/src/Vanguard.Server/Storage/MatchStoreContracts.cs new file mode 100644 index 0000000..bcfa4b4 --- /dev/null +++ b/server/src/Vanguard.Server/Storage/MatchStoreContracts.cs @@ -0,0 +1,70 @@ +using Vanguard.Server.Configuration; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Server.Storage; + +/// Всё, что нужно, чтобы восстановить матч без сериализации делегатов движка. +public sealed record MatchHeader( + string MatchId, + GameMode Mode, + string Player0Id, + string Player1Id, + IReadOnlyList Deck0, + IReadOnlyList 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); + +/// Запись журнала интентов. Принятые команды воспроизводятся при восстановлении. +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 Intents); + +/// +/// Хранилище матчей. Пишется на каждый интент и при изменениях часов; +/// при старте сервера незавершённые матчи восстанавливаются повтором команд. +/// +public interface IMatchStore +{ + Task CreateAsync(MatchHeader header); + + Task AppendIntentAsync(MatchHeader header, PersistedIntent intent); + + Task UpdateHeaderAsync(MatchHeader header); + + Task GetAsync(string matchId); + + Task> LoadUnfinishedAsync(DateTimeOffset updatedAfter); +} + +/// Заглушка для локального запуска без базы. +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 GetAsync(string matchId) => Task.FromResult(null); + + public Task> LoadUnfinishedAsync(DateTimeOffset updatedAfter) => + Task.FromResult>(Array.Empty()); +} diff --git a/server/src/Vanguard.Server/Tickets/GameTicketService.cs b/server/src/Vanguard.Server/Tickets/GameTicketService.cs index e7923f4..47c3222 100644 --- a/server/src/Vanguard.Server/Tickets/GameTicketService.cs +++ b/server/src/Vanguard.Server/Tickets/GameTicketService.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Security.Cryptography; using Vanguard.Server.Configuration; @@ -13,36 +12,35 @@ public sealed record GameTicket( DateTimeOffset ExpiresAt); /// -/// Тикеты живут в памяти: TTL короткий, переживать рестарт им не нужно. -/// При переезде на нескольких инстансов сервис заменяется на Redis-реализацию. +/// Выдаёт и принимает тикеты. Само хранение делегировано в ITicketStore: +/// in-memory для тестов и одного процесса, Redis — для нескольких инстансов. /// public sealed class GameTicketService { - private readonly ConcurrentDictionary _tickets = new(StringComparer.Ordinal); + private readonly ITicketStore _store; private readonly GameOptions _options; private readonly TimeProvider _time; - public GameTicketService(GameOptions options, TimeProvider time) + public GameTicketService(ITicketStore store, GameOptions options, TimeProvider time) { + _store = store; _options = options; _time = time; } - public GameTicket Issue(string playerId, string matchId, int seat) + public async Task IssueAsync(string playerId, string matchId, int seat) { if (seat is not (0 or 1)) throw new ArgumentOutOfRangeException(nameof(seat), seat, "Место — 0 или 1."); - var now = _time.GetUtcNow(); var ticket = new GameTicket( NewToken(), playerId, matchId, seat, - now.AddSeconds(_options.TicketTtlSeconds)); + _time.GetUtcNow().AddSeconds(_options.TicketTtlSeconds)); - _tickets[ticket.Token] = ticket; - RemoveExpired(now); + await _store.IssueAsync(ticket); return ticket; } @@ -50,26 +48,13 @@ public sealed class GameTicketService /// Забирает тикет. null — неизвестный, уже использованный или просроченный. /// Тикет одноразовый: повторный Redeem того же токена не сработает. /// - public GameTicket? Redeem(string? token) + public async Task RedeemAsync(string? token) { if (string.IsNullOrWhiteSpace(token) || token.Length > 128) return null; - if (!_tickets.TryRemove(token, out var ticket)) - return null; - - return ticket.ExpiresAt > _time.GetUtcNow() ? ticket : null; - } - - public int Count => _tickets.Count; - - private void RemoveExpired(DateTimeOffset now) - { - foreach (var (token, ticket) in _tickets) - { - if (ticket.ExpiresAt <= now) - _tickets.TryRemove(token, out _); - } + var ticket = await _store.TakeAsync(token); + return ticket is not null && ticket.ExpiresAt > _time.GetUtcNow() ? ticket : null; } private static string NewToken() diff --git a/server/src/Vanguard.Server/Tickets/TicketStore.cs b/server/src/Vanguard.Server/Tickets/TicketStore.cs new file mode 100644 index 0000000..c024e48 --- /dev/null +++ b/server/src/Vanguard.Server/Tickets/TicketStore.cs @@ -0,0 +1,92 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using StackExchange.Redis; +using Vanguard.Server.Configuration; + +namespace Vanguard.Server.Tickets; + +/// Хранилище одноразовых тикетов. +public interface ITicketStore +{ + Task IssueAsync(GameTicket ticket); + + /// Забрать тикет; повторный вызов для того же токена вернёт null. + Task TakeAsync(string token); +} + +public sealed class InMemoryTicketStore : ITicketStore +{ + private readonly ConcurrentDictionary _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 TakeAsync(string token) + { + if (!_tickets.TryRemove(token, out var ticket)) + return Task.FromResult(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 _); + } + } +} + +/// +/// Redis-хранилище тикетов: TTL выставляет сам Redis, а GETDEL делает +/// изъятие атомарным (тикет одноразовый даже при нескольких инстансах). +/// +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 TakeAsync(string token) + { + var value = await _database.StringGetDeleteAsync(Key(token)); + if (value.IsNullOrEmpty) + return null; + + try + { + return JsonSerializer.Deserialize(value.ToString()); + } + catch (JsonException) + { + return null; + } + } + + private string Key(string token) => $"{_options.Prefix}ticket:{token}"; +} diff --git a/server/src/Vanguard.Server/Vanguard.Server.csproj b/server/src/Vanguard.Server/Vanguard.Server.csproj index 761cd9d..0a3a0c5 100644 --- a/server/src/Vanguard.Server/Vanguard.Server.csproj +++ b/server/src/Vanguard.Server/Vanguard.Server.csproj @@ -13,6 +13,13 @@ + + + + + + + diff --git a/server/src/Vanguard.Server/appsettings.json b/server/src/Vanguard.Server/appsettings.json index 2214025..0c93590 100644 --- a/server/src/Vanguard.Server/appsettings.json +++ b/server/src/Vanguard.Server/appsettings.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" } }, "AllowedHosts": "*", @@ -26,5 +27,13 @@ }, "Content": { "Path": "" + }, + "Storage": { + "Provider": "None", + "ConnectionString": "" + }, + "Redis": { + "Configuration": "", + "Prefix": "vanguard:" } } diff --git a/server/tests/Vanguard.Server.Tests/ExternalStorageTests.cs b/server/tests/Vanguard.Server.Tests/ExternalStorageTests.cs new file mode 100644 index 0000000..7f2004b --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/ExternalStorageTests.cs @@ -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; + +/// +/// Проверки настоящих PostgreSQL и Redis. Запускаются только при заданных +/// переменных окружения: +/// VANGUARD_TEST_POSTGRES — connection string к отдельной тестовой БД; +/// VANGUARD_TEST_REDIS — configuration Redis (лучше отдельный DB). +/// +/// Postgres-тест делает EnsureDeleted/EnsureCreated, поэтому БД должна быть +/// заведомо тестовой. +/// +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() + .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 + { + private readonly DbContextOptions _options; + + public TestDbContextFactory(DbContextOptions options) => _options = options; + + public MatchDbContext CreateDbContext() => new(_options); + } +} diff --git a/server/tests/Vanguard.Server.Tests/GameTicketServiceTests.cs b/server/tests/Vanguard.Server.Tests/GameTicketServiceTests.cs index a6164cc..de1c5ec 100644 --- a/server/tests/Vanguard.Server.Tests/GameTicketServiceTests.cs +++ b/server/tests/Vanguard.Server.Tests/GameTicketServiceTests.cs @@ -5,15 +5,15 @@ namespace Vanguard.Server.Tests; public class GameTicketServiceTests { [Fact] - public void Ticket_Is_Redeemed_Only_Once() + public async Task Ticket_Is_Redeemed_Only_Once() { var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch); - var service = new GameTicketService(ServerTestData.SmallOptions(), time); + var service = NewService(time); - var ticket = service.Issue("p1", "m1", 0); + var ticket = await service.IssueAsync("p1", "m1", 0); - var first = service.Redeem(ticket.Token); - var second = service.Redeem(ticket.Token); + var first = await service.RedeemAsync(ticket.Token); + var second = await service.RedeemAsync(ticket.Token); Assert.NotNull(first); Assert.Equal("p1", first!.PlayerId); @@ -23,29 +23,32 @@ public class GameTicketServiceTests } [Fact] - public void Ticket_Expires_After_Ttl() + public async Task Ticket_Expires_After_Ttl() { var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch); var options = ServerTestData.SmallOptions(); options.TicketTtlSeconds = 60; - var service = new GameTicketService(options, time); - var ticket = service.Issue("p1", "m1", 1); + var service = NewService(time, options); + var ticket = await service.IssueAsync("p1", "m1", 1); time.Advance(TimeSpan.FromSeconds(61)); - Assert.Null(service.Redeem(ticket.Token)); + Assert.Null(await service.RedeemAsync(ticket.Token)); } [Fact] - public void Unknown_Token_Is_Rejected() + public async Task Unknown_Token_Is_Rejected() { - var service = new GameTicketService( - ServerTestData.SmallOptions(), - new FakeTimeProvider(DateTimeOffset.UnixEpoch)); + var service = NewService(new FakeTimeProvider(DateTimeOffset.UnixEpoch)); - Assert.Null(service.Redeem(null)); - Assert.Null(service.Redeem("")); - Assert.Null(service.Redeem("no-such-ticket")); + Assert.Null(await service.RedeemAsync(null)); + Assert.Null(await service.RedeemAsync("")); + Assert.Null(await service.RedeemAsync("no-such-ticket")); } + + private static GameTicketService NewService( + FakeTimeProvider time, + Vanguard.Server.Configuration.GameOptions? options = null) => + new(new InMemoryTicketStore(time), options ?? ServerTestData.SmallOptions(), time); } diff --git a/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs b/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs index 1b10144..0961e45 100644 --- a/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs +++ b/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Vanguard.Rules.Engine; using Vanguard.Server.Configuration; using Vanguard.Server.Matches; +using Vanguard.Server.Storage; using Vanguard.Shared.Protocol; namespace Vanguard.Server.Tests; @@ -297,6 +298,7 @@ public class MatchGrainTests options ?? ServerTestData.SmallOptions(), time, sink ?? new RecordingResultSink(), + new NullMatchStore(), NullLogger.Instance); } diff --git a/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs b/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs new file mode 100644 index 0000000..e1e18fd --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs @@ -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().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().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(); + 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().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().View; + + var options = ServerTestData.SmallOptions(); + var profiles = new RecordingResultSink(); + + var services = new ServiceCollection(); + services.AddSingleton(ServerTestData.Catalog); + services.AddSingleton(options); + services.AddSingleton(time); + services.AddSingleton(profiles); + services.AddSingleton(store); + services.AddLogging(); + + await using var provider = services.BuildServiceProvider(); + + var registry = new MatchGrainRegistry( + provider, options, store, NullLogger.Instance); + + var recovery = new MatchRecoveryService( + store, registry, time, NullLogger.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().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() + .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.Instance); + + private static ClientIntent Intent(string intentId, IntentPayload payload) => new() + { + IntentId = intentId, + BasedOnSeq = 0, + Intent = payload, + }; + + private static async Task 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 + { + private readonly DbContextOptions _options; + + public TestDbContextFactory(DbContextOptions options) => _options = options; + + public MatchDbContext CreateDbContext() => new(_options); + } +} diff --git a/server/tests/Vanguard.Server.Tests/MatchmakingServiceTests.cs b/server/tests/Vanguard.Server.Tests/MatchmakingServiceTests.cs index 2b99c93..b00bd9c 100644 --- a/server/tests/Vanguard.Server.Tests/MatchmakingServiceTests.cs +++ b/server/tests/Vanguard.Server.Tests/MatchmakingServiceTests.cs @@ -1,9 +1,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using Vanguard.Rules.Cards; using Vanguard.Server.Configuration; using Vanguard.Server.Matches; using Vanguard.Server.Matchmaking; using Vanguard.Server.Profiles; +using Vanguard.Server.Storage; using Vanguard.Server.Tickets; namespace Vanguard.Server.Tests; @@ -11,17 +13,19 @@ namespace Vanguard.Server.Tests; public class MatchmakingServiceTests { [Fact] - public void Two_Players_Are_Matched_And_Get_One_Time_Tickets() + public async Task Two_Players_Are_Matched_And_Get_One_Time_Tickets() { var harness = NewHarness(); - var first = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); - var second = harness.Matchmaking.Enqueue("p2", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + var first = await harness.Matchmaking.EnqueueAsync( + "p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + var second = await harness.Matchmaking.EnqueueAsync( + "p2", ServerTestData.BuildDeck(), GameMode.ClassicRanked); - harness.Matchmaking.Matchmake(harness.Time.GetUtcNow()); + await harness.Matchmaking.MatchmakeAsync(harness.Time.GetUtcNow()); - var firstStatus = harness.Matchmaking.Poll(first.QueueId, "p1"); - var secondStatus = harness.Matchmaking.Poll(second.QueueId, "p2"); + var firstStatus = await harness.Matchmaking.PollAsync(first.QueueId, "p1"); + var secondStatus = await harness.Matchmaking.PollAsync(second.QueueId, "p2"); Assert.NotNull(firstStatus); Assert.NotNull(secondStatus); @@ -36,8 +40,8 @@ public class MatchmakingServiceTests Assert.NotNull(firstStatus.Ticket); Assert.NotNull(secondStatus.Ticket); - var firstTicket = harness.Tickets.Redeem(firstStatus.Ticket); - var secondTicket = harness.Tickets.Redeem(secondStatus.Ticket); + var firstTicket = await harness.Tickets.RedeemAsync(firstStatus.Ticket); + var secondTicket = await harness.Tickets.RedeemAsync(secondStatus.Ticket); Assert.Equal(0, firstTicket!.Seat); Assert.Equal(1, secondTicket!.Seat); Assert.Equal(firstStatus.MatchId, firstTicket.MatchId); @@ -48,53 +52,55 @@ public class MatchmakingServiceTests } [Fact] - public void Ranked_And_Casual_Queues_Are_Separate() + public async Task Ranked_And_Casual_Queues_Are_Separate() { var harness = NewHarness(); - harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); - var casual = harness.Matchmaking.Enqueue("p2", ServerTestData.BuildDeck(), GameMode.ClassicCasual); + await harness.Matchmaking.EnqueueAsync("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + var casual = await harness.Matchmaking.EnqueueAsync( + "p2", ServerTestData.BuildDeck(), GameMode.ClassicCasual); - harness.Matchmaking.Matchmake(harness.Time.GetUtcNow()); + await harness.Matchmaking.MatchmakeAsync(harness.Time.GetUtcNow()); - Assert.Equal("queued", harness.Matchmaking.Poll(casual.QueueId, "p2")!.Status); + Assert.Equal("queued", (await harness.Matchmaking.PollAsync(casual.QueueId, "p2"))!.Status); Assert.Equal(0, harness.Registry.Count); Assert.Equal(2, harness.Matchmaking.QueuedCount); } [Fact] - public void Window_Expands_While_Waiting() + public async Task Window_Expands_While_Waiting() { var harness = NewHarness(); - var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + var ticket = await harness.Matchmaking.EnqueueAsync( + "p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); - var initial = harness.Matchmaking.Poll(ticket.QueueId, "p1")!; - Assert.Equal(100, initial.Window); + var initial = await harness.Matchmaking.PollAsync(ticket.QueueId, "p1"); + Assert.Equal(100, initial!.Window); harness.Time.Advance(TimeSpan.FromSeconds(20)); - var later = harness.Matchmaking.Poll(ticket.QueueId, "p1")!; - Assert.Equal(300, later.Window); // 100 + 4 * 50 + var later = await harness.Matchmaking.PollAsync(ticket.QueueId, "p1"); + Assert.Equal(300, later!.Window); // 100 + 4 * 50 harness.Time.Advance(TimeSpan.FromSeconds(200)); - var capped = harness.Matchmaking.Poll(ticket.QueueId, "p1")!; - Assert.Equal(2000, capped.Window); + var capped = await harness.Matchmaking.PollAsync(ticket.QueueId, "p1"); + Assert.Equal(2000, capped!.Window); } [Fact] - public void Invalid_Deck_Is_Rejected_At_Enqueue() + public async Task Invalid_Deck_Is_Rejected_At_Enqueue() { var harness = NewHarness(); var deck = ServerTestData.BuildDeck(); deck.RemoveAt(0); - var exception = Assert.Throws( - () => harness.Matchmaking.Enqueue("p1", deck, GameMode.ClassicRanked)); + var exception = await Assert.ThrowsAsync( + () => harness.Matchmaking.EnqueueAsync("p1", deck, GameMode.ClassicRanked)); Assert.Contains(exception.Violations, violation => violation.Code == "deck.size"); } [Fact] - public void Deck_Without_Starting_Vanguard_Is_Rejected() + public async Task Deck_Without_Starting_Vanguard_Is_Rejected() { var harness = NewHarness(); var deck = new List(); @@ -107,7 +113,7 @@ public class MatchmakingServiceTests break; var card = ServerTestData.Catalog.CardOf(printing.PrintingId); - if (card.Grade == 0 || card.IsSentinel || card.Trigger == Vanguard.Rules.Cards.TriggerType.Heal) + if (card.Grade == 0 || card.IsSentinel || card.Trigger == TriggerType.Heal) continue; for (var i = 0; i < 4 && deck.Count < 50; i++) @@ -116,49 +122,53 @@ public class MatchmakingServiceTests Assert.Equal(50, deck.Count); - var exception = Assert.Throws( - () => harness.Matchmaking.Enqueue("p1", deck, GameMode.ClassicRanked)); + var exception = await Assert.ThrowsAsync( + () => harness.Matchmaking.EnqueueAsync("p1", deck, GameMode.ClassicRanked)); Assert.Contains(exception.Violations, violation => violation.Code == "deck.starting-vanguard"); } [Fact] - public void Cancel_Removes_From_Queue() + public async Task Cancel_Removes_From_Queue() { var harness = NewHarness(); - var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + var ticket = await harness.Matchmaking.EnqueueAsync( + "p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); - Assert.True(harness.Matchmaking.Cancel(ticket.QueueId, "p1")); + Assert.True(await harness.Matchmaking.CancelAsync(ticket.QueueId, "p1")); - var status = harness.Matchmaking.Poll(ticket.QueueId, "p1")!; - Assert.Equal("cancelled", status.Status); + var status = await harness.Matchmaking.PollAsync(ticket.QueueId, "p1"); + Assert.Equal("cancelled", status!.Status); Assert.Equal(0, harness.Matchmaking.QueuedCount); - Assert.False(harness.Matchmaking.Cancel(ticket.QueueId, "p1")); + Assert.False(await harness.Matchmaking.CancelAsync(ticket.QueueId, "p1")); } [Fact] - public void Second_Queue_Request_Replaces_The_First() + public async Task Second_Queue_Request_Replaces_The_First() { var harness = NewHarness(); - var old = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); - var current = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + var old = await harness.Matchmaking.EnqueueAsync( + "p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + var current = await harness.Matchmaking.EnqueueAsync( + "p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); - Assert.Equal("cancelled", harness.Matchmaking.Poll(old.QueueId, "p1")!.Status); - Assert.Equal("queued", harness.Matchmaking.Poll(current.QueueId, "p1")!.Status); + Assert.Equal("cancelled", (await harness.Matchmaking.PollAsync(old.QueueId, "p1"))!.Status); + Assert.Equal("queued", (await harness.Matchmaking.PollAsync(current.QueueId, "p1"))!.Status); Assert.Equal(1, harness.Matchmaking.QueuedCount); } [Fact] - public void Queue_Expires_After_Timeout() + public async Task Queue_Expires_After_Timeout() { var harness = NewHarness(); - var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + var ticket = await harness.Matchmaking.EnqueueAsync( + "p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); harness.Time.Advance(TimeSpan.FromSeconds(harness.Options.QueueExpirySeconds + 1)); - harness.Matchmaking.Matchmake(harness.Time.GetUtcNow()); + await harness.Matchmaking.MatchmakeAsync(harness.Time.GetUtcNow()); - Assert.Equal("expired", harness.Matchmaking.Poll(ticket.QueueId, "p1")!.Status); + Assert.Equal("expired", (await harness.Matchmaking.PollAsync(ticket.QueueId, "p1"))!.Status); Assert.Equal(0, harness.Matchmaking.QueuedCount); } @@ -183,32 +193,77 @@ public class MatchmakingServiceTests Assert.Equal(loserAfterRanked, profiles.GetMmr("loser")); } + [Fact] + public async Task Queue_Survives_Reload_From_Store() + { + var harness = NewHarness(); + var ticket = await harness.Matchmaking.EnqueueAsync( + "p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + + // новый процесс: очередь пуста, но записи лежат в store + var services = new ServiceCollection(); + services.AddSingleton(ServerTestData.Catalog); + services.AddSingleton(harness.Options); + services.AddSingleton(harness.Time); + services.AddSingleton(harness.Profiles); + services.AddSingleton(harness.MatchStore); + services.AddSingleton(harness.QueueStore); + services.AddLogging(); + + await using var provider = services.BuildServiceProvider(); + var registry = new MatchGrainRegistry( + provider, harness.Options, harness.MatchStore, NullLogger.Instance); + var matchmaking = new MatchmakingService( + ServerTestData.Catalog, + registry, + harness.Tickets, + harness.Profiles, + harness.Options, + harness.QueueStore, + harness.Time, + NullLogger.Instance); + + await matchmaking.LoadQueuedAsync(); + + var status = await matchmaking.PollAsync(ticket.QueueId, "p1"); + Assert.NotNull(status); + Assert.Equal("queued", status!.Status); + Assert.Equal(1, matchmaking.QueuedCount); + } + private static Harness NewHarness() { var time = new FakeTimeProvider(new DateTimeOffset(2026, 10, 21, 12, 0, 0, TimeSpan.Zero)); var options = ServerTestData.SmallOptions(); var profiles = new PlayerProfileStore(); - var tickets = new GameTicketService(options, time); + var ticketStore = new InMemoryTicketStore(time); + var tickets = new GameTicketService(ticketStore, options, time); + var store = new NullMatchStore(); + var queueStore = new InMemoryQueueStore(); var services = new ServiceCollection(); services.AddSingleton(ServerTestData.Catalog); services.AddSingleton(options); services.AddSingleton(time); services.AddSingleton(profiles); + services.AddSingleton(store); + services.AddSingleton(queueStore); services.AddLogging(); var provider = services.BuildServiceProvider(); - var registry = new MatchGrainRegistry(provider, options, NullLogger.Instance); + var registry = new MatchGrainRegistry( + provider, options, store, NullLogger.Instance); var matchmaking = new MatchmakingService( ServerTestData.Catalog, registry, tickets, profiles, options, + queueStore, time, NullLogger.Instance); - return new Harness(time, options, profiles, tickets, registry, matchmaking); + return new Harness(time, options, profiles, tickets, store, queueStore, registry, matchmaking); } private sealed record Harness( @@ -216,6 +271,8 @@ public class MatchmakingServiceTests GameOptions Options, PlayerProfileStore Profiles, GameTicketService Tickets, + IMatchStore MatchStore, + IQueueStore QueueStore, MatchGrainRegistry Registry, MatchmakingService Matchmaking); }