diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4657b85 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +**/bin +**/obj +client +launcher +tools +docs +server/tests diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index f9cc38c..4126fa9 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -70,8 +70,9 @@ /tools/import_cards.py ``` - [x] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`. -- [ ] `docker-compose.yml`: postgres, redis (AOF), meta-api, game-silo, nginx. +- [~] `docker-compose.yml`: сейчас только game-server; postgres, redis (AOF), meta-api, nginx — со следующим блоком. - [ ] `docker compose up` поднимает БД и пустой API; healthcheck отвечает. + — сервер уже поднимается и отвечает `/health`, БД ещё нет. - [ ] CI: сборка solution + `dotnet test` на push. **Готово, когда:** `docker compose up` + `dotnet run` дают живой `/health`. @@ -172,18 +173,22 @@ - [x] Спецификация протокола в `docs/protocol.md`: кадры, handshake, версия, ping/pong, reconnect. - [x] `Vanguard.Shared`: DTO интентов и событий, MessagePack-резолвер. -- [ ] `IGameTicketService`: одноразовый тикет на подключение (TTL 60 с). -- [ ] Транспорт матча: WebSocket (ASP.NET Core); резерв — TCP с префиксом длины. Accept, handshake, auth по тикету, привязка к grain. -- [ ] `IMatchGrain`: 1 матч = 1 grain, последовательная обработка. +- [x] `IGameTicketService`: одноразовый тикет на подключение (TTL 60 с). +- [x] Транспорт матча: WebSocket (ASP.NET Core); резерв — TCP с префиксом длины. Accept, handshake, auth по тикету, привязка к grain. +- [x] `IMatchGrain`: 1 матч = 1 grain, последовательная обработка. + — реализовано in-process; Orleans-хостинг — следующий шаг, контракт отделён. - [x] Проекция состояния под клиента: своя рука, свой trigger, открытые зоны; **ничего** о колоде, руке противника и закрытом уроне. -- [~] `eventSeq` + `resync` по запросу. -- [~] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось». -- [~] Политика отсутствия: 5 минут → авто-поражение. -- [~] Таймер хода: 90 с + банк 5 мин. +- [x] `eventSeq` + `resync` по запросу. +- [x] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось». +- [x] Политика отсутствия: 5 минут → авто-поражение. +- [x] Таймер хода: 90 с + банк 5 мин. - [ ] Снапшот матча в PostgreSQL + восстановление grain после рестарта. -- [~] Idempotency по `intentId`. +- [x] Idempotency по `intentId`. +- [x] HTTP-очередь и тикеты: `docs/server-api.md`. +- [x] E2E: два клиента играют полный матч по WebSocket; обрыв и возврат проверены. **Готово, когда:** два клиента играют полный матч по сети; kill процесса клиента на любом шаге восстанавливается. + — выполнено в `Vanguard.Server.Tests`; рестарт сервера пока теряет матчи (ждёт PostgreSQL). --- diff --git a/README.md b/README.md index 0ff1b03..faebe2a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ | Контент-пайплайн и валидатор | готово | | Игровое ядро (правила) | каркас: фазы, райд, призыв, бой, драйв, урон | | Протокол и контракты (`docs/protocol.md`, `Vanguard.Shared`) | готово | -| Сервер, клиент, лаунчер | не начаты | +| Сервер: матч по сети, матчмейкинг, реконнект | готово (in-memory) | +| Персистентность (PostgreSQL, Redis) и Orleans | не начато | +| Клиент и лаунчер | не начато | ## Требования @@ -36,6 +38,16 @@ dotnet run --project server/src/Vanguard.Cli -- stress 1000 dotnet test server/Vanguard.slnx ``` +Запуск игрового сервера: + +```powershell +dotnet run --project server/src/Vanguard.Server --urls http://localhost:5080 +# проверка: http://localhost:5080/health +``` + +Контейнер: `docker compose up --build`, затем `http://localhost:8080/health`. +HTTP-контракт очереди и тикетов — `docs/server-api.md`. + Ожидаемый вывод валидатора: ``` @@ -50,13 +62,15 @@ OK: ошибок 0, предупреждений 0 /server/src/Vanguard.Rules ядро правил: карты, колоды, состояние игры /server/src/Vanguard.Content загрузка и проверка YAML-контента /server/src/Vanguard.Shared сетевые контракты (netstandard2.1 для Unity + net10.0) +/server/src/Vanguard.Server ASP.NET Core: WebSocket, очередь, тикеты, матчи /server/src/Vanguard.Cli консольные команды -/server/tests тесты правил и контента +/server/tests тесты правил, контрактов и сервера (включая E2E) /content/sets карты в YAML /content/keywords.yaml ключевые слова и иконки /content/translations/ru.yaml русские тексты /tools импорт и сид переводов -/docs правила перевода и прочие документы +/docs протокол, API, правила перевода +/docker-compose.yml сервер в контейнере ``` `Vanguard.Rules` — чистая библиотека без Orleans, БД и сети. Это позволяет прогонять @@ -116,4 +130,5 @@ dotnet run --project server/src/Vanguard.Cli -- translations check - `IMPLEMENTATION_CHECKLIST.md` — план реализации по блокам и гейтам. - `docs/HANDOFF.md` — состояние на конец сессии и порядок следующих шагов. - `docs/protocol.md` — протокол матча: кадры, интенты, события, таймеры, реконнект. +- `docs/server-api.md` — HTTP-очередь, тикеты и `/health`. - `content/cards-mvp.md` — каталог карт с разбором состава сетов. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ebec902 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + server: + build: + context: . + dockerfile: server/src/Vanguard.Server/Dockerfile + ports: + - "8080:8080" + environment: + ASPNETCORE_URLS: http://+:8080 + Game__PublicWsUrl: ws://localhost:8080/game + restart: unless-stopped diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 9fae7af..c27530e 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -38,13 +38,14 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты | Проверка | Результат | |---|---| -| Тесты | **129/129** | +| Тесты | **158/158** (131 правила/контракты + 27 сервер) | | `validate` | 0 ошибок, 0 предупреждений | | `translations check` | 94/94 | | `stress 1000` | 1000/1000 партий, 0 ошибок | | Карты | **94/94** (66 с текстом + 28 ванильных) | | Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы | -| Сервер / клиент / лаунчер | не начаты | +| Сервер | полный матч по сети, матчмейкинг, реконнект (in-memory) | +| Клиент / лаунчер | не начаты | Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям: боты доигрывают 1000 партий, инварианты держатся, реплей воспроизводится @@ -68,12 +69,20 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000 Ожидаемо: ``` -Пройден! : не пройдено 0, пройдено 129 +Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests) +Пройден! : не пройдено 0, пройдено 27 (Vanguard.Server.Tests) OK: ошибок 0, предупреждений 0 Переведено: 94/94 Партий: 1000, завершено: 1000, ошибок: 0 ``` +Сервер локально: + +```powershell +dotnet run --project server\src\Vanguard.Server --urls http://localhost:5080 +# GET http://localhost:5080/health +``` + Solution — **`server/Vanguard.slnx`**, не `.sln`. --- @@ -190,6 +199,44 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. - Проекция вызывается для наблюдателя без побочных эффектов и не трогает `LegalCommands()` чужого игрока (иначе утекала бы рука решающего). +### 4.8. Сервер + +`server/src/Vanguard.Server` — ASP.NET Core 10, один процесс, состояние в памяти. + +- **HTTP**: `POST /api/game/queue`, `GET /api/game/queue/{id}`, + `DELETE /api/game/queue/{id}`, `POST /api/game/tickets`, `GET /health`. + Контракт — `docs/server-api.md`. +- **Матчмейкинг**: две очереди (Classic Ranked / Casual), скрытый MMR, + окно ±100 с расширением на 50 каждые 5 с до 2000, выбор ближайшего MMR. + Ранкед меняет MMR по Эло (K = 32), casual — нет. +- **Тикеты**: одноразовые, TTL 60 с, привязаны к `(playerId, matchId, seat)`; + для реконнекта выдаётся новый тикет. +- **`IMatchGrain`**: 1 матч = 1 экземпляр, все входы (интенты, подключения, + тики) проходят через один семафор; внутри нет ни ASP.NET Core, ни WebSocket — + только `IMatchConnection`. Orleans-хостинг подключается поверх контракта. +- **WebSocket** `/game`: handshake, MessagePack, `intentId`, idempotency, + `eventSeq`, `resync`, heartbeat (20 с простоя → ping, ещё 20 с без ответа + → `4408`), реконнект, захват сессии (`4409`), rate limit 16 интентов/с. +- **Таймеры**: 90 с на решение + банк 300 с, отсутствие 300 с → поражение; + `ServerClock` каждые 5 с. +- **Результат**: сдача, таймер и отсутствие завершают партию через + `GameEngine.Finish(winner)`; событие `GameFinished` уходит обоим, + результат пишется в профили. +- **Проекция**: своя рука, свой выбор, свои легальные интенты; у противника + только количества и открытые зоны. `seed` и RNG не покидают сервер. +- **Docker**: `Dockerfile` + `docker-compose.yml` поднимают сервер с контентом + на `http://localhost:8080`. +- **Тесты** (`Vanguard.Server.Tests`, 27): + - тикеты: одноразовость и TTL; + - heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром; + - grain: attach, чужое место, duplicate intent, чужой ход, сдача, + команды после финала, банк времени, отсутствие, старт часов, + reconnect с `eventSeq`, захват сессии; + - матчмейкинг: подбор, разные очереди, окно, плохая колода, + отсутствие стартового Авангарда, отмена, замена очереди, истечение, MMR; + - E2E: два клиента играют полный матч по WebSocket и сходятся в победителе; + обрыв и возврат по новому тикету с догоном состояния. + --- ## 5. Структура и ключевые файлы @@ -215,12 +262,24 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. /server/src/Vanguard.Shared /Protocol/*.cs кадры, интенты, события, view, кодек /Projection/*.cs проекция состояния, фильтр событий, маппинг -/server/tests/Vanguard.Rules.Tests 129 тестов +/server/src/Vanguard.Server + /Program.cs ASP.NET Core, DI, WebSocket endpoint + /Api/ очередь, тикеты, /health + /Matchmaking/ две очереди, MMR, расширяющееся окно + /Matches/ IMatchGrain, таймеры, тикер, регистр + /Realtime/ WebSocket-обвязка, чтение кадров + /Tickets/ одноразовые тикеты + /Profiles/ скрытый MMR и статистика + /Dockerfile +/server/tests/Vanguard.Rules.Tests 131 тест +/server/tests/Vanguard.Server.Tests 27 тестов, включая E2E по WebSocket /content/sets карты /content/translations/ru.yaml переводы /docs/translation-rules.md канон перевода /docs/protocol.md протокол матча, версия 1 +/docs/server-api.md HTTP-очередь, тикеты, /health /docs/HANDOFF.md этот файл +/docker-compose.yml сервер в контейнере ``` --- @@ -240,6 +299,16 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. - Бустер: 5 карт = 4 C + 1 R+; веса R 70 / RR 22 / RRR 7 / SP 1. - Gold за матч: 30/15/20 (победа/поражение/ничья); Gold = XP / 5. - Таймер хода 90 с + банк 5 мин. +- Транспорт матча — **WebSocket** + MessagePack; TCP с префиксом длины — + резерв для Unity, если WebSocket не подойдёт. +- Тикет на подключение — одноразовый, TTL 60 с, передаётся в `ClientHello`, + не в URL; reconnect получает новый тикет. +- `playerId` в HTTP-API — dev-заглушка до JWT. Выставлять сервер в интернет + в таком виде нельзя. +- Состояние сервера пока in-memory: `IMatchGrain` отделён от транспорта, + Orleans и PostgreSQL подключаются поверх. +- Rate limit — 16 интентов в секунду на соединение. +- Скрытый MMR: старт 1000, Эло K = 32, casual рейтинг не меняет. - Порядок триггеров выбирает игрок; опциональные способности спрашиваются. - Непрерывные эффекты пересчитываются при каждом запросе. - Поиск в колоде: 0 — уведомление и перемешивание, 1 — сразу, 2+ — выбор. @@ -261,28 +330,28 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. 2. **[x] `Vanguard.Shared`**: DTO интентов и событий, MessagePack-резолвер, проекция состояния на каждого игрока, фильтрация скрытых событий, маппинг интентов. +3. **[x] `Vanguard.Server`** — сетевой матч работает: + ASP.NET Core + WebSocket, тикеты, `IMatchGrain` (in-process), + матчмейкинг, MMR, идемпотентность, таймеры, реконнект, `/health`, + Dockerfile и compose. Контракт HTTP — `docs/server-api.md`. +4. **[x] Проекция состояния** подключена к грану и проверена E2E-тестом: + своя рука и свой выбор полностью, у противника только количества + и открытые зоны, seed и RNG не уходят. +5. **[x] `Docker Compose`** — сервер с контентом на `http://localhost:8080`. -Осталось: +Осталось в серверном направлении: -3. **`Vanguard.Server`**: - - ASP.NET Core + WebSocket; - - `IGameTicketService` (одноразовый тикет, TTL 60 с); - - `IMatchGrain` — 1 матч = 1 grain, последовательная обработка; - - матчмейкинг: 2 очереди (Classic Ranked / Casual); - - скрытый MMR, расширяющееся окно; - - идемпотентность по `intentId`. -4. **Проекция состояния** — контракты готовы, подключить к грану: - - своя рука и свой триггер — полностью; - - открытые зоны — полностью; - - колода, рука противника, закрытый урон — только количество; - - seed и RNG не уходят. -5. **Персистентность**: - - PostgreSQL — снапшот матча, восстановление grain; +6. **Orleans** — заменить in-process регистр на настоящий silo и граны. + `IMatchGrain` уже отделён от транспорта и ASP.NET Core. +7. **Персистентность**: + - PostgreSQL — снапшот матча, восстановление grain после рестарта; - Redis — тикеты, очереди, кэш. -6. **Docker Compose** + `/health`. +8. **JWT и мета-API** — `playerId` перестанет быть dev-заглушкой. -Критерий готовности: два клиента играют полный матч по сети; kill клиента -на любом шаге восстанавливается. +Критерий готовности серверного блока выполнен: два клиента играют полный +матч по сети; kill клиента на любом шаге восстанавливается через новый +тикет и `resumeFromSeq`. Рестарт **сервера** пока теряет матчи: это +закрывается пунктом 7. ### 7.2. Мета-API @@ -324,8 +393,10 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. 1. Прочитать `docs/HANDOFF.md`, `IMPLEMENTATION_CHECKLIST.md`, `README.md`. 2. Прогнать проверки из раздела 3. -3. Продолжить с `Vanguard.Server`: ASP.NET Core + WebSocket, тикеты, - `IMatchGrain`, матчмейкинг. `docs/protocol.md` и `Vanguard.Shared` уже готовы. +3. Следующий блок — персистентность и Orleans: PostgreSQL-снапшоты, + Redis для тикетов/очередей, замена in-process регистра на silo. + Сетевой матч уже работает: `docs/protocol.md`, `Vanguard.Shared`, + `Vanguard.Server`, `docs/server-api.md`. 4. После каждого блока: тесты, `validate`, `translations check`, коммит, push. 5. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот. @@ -345,6 +416,20 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. `net10.0`; в `netstandard2.1` (Unity) ссылки на `Vanguard.Rules` нет. - **Видимость.** `LegalCommands()` описывает решающего игрока. Вызывать его для наблюдателя нельзя — в списке есть printingId из чужой руки. +- **Сервер: rate limit.** 16 интентов в секунду на соединение. Быстрый бот + в E2E специально притормаживается; живой UI в лимит не упирается. +- **Сервер: in-memory.** Очереди, тикеты, матчи и профили живут в памяти. + Рестарт сервера теряет матчи; kill клиента и реконнект переживаются. +- **Сервер: тесты.** E2E поднимает `WebApplicationFactory`, + играет полный матч по WebSocket и проверяет, что победитель совпал. + Случайные партии завершаются за ~350 действий; таймаут теста — 120 с. +- **Сервер: таймеры.** Тик раз в секунду. После 90 с банк списывается + по фактическому превышению; `TurnDeadlineMs` в этот момент сдвигается, + поэтому UI должен показывать ещё и `BankMs`. +- **Сервер: часы.** Отсчёт стартует только когда подключились оба места. + До этого `ClockView.IsRunning = false`, но таймер отсутствия уже идёт. +- **Сервер: проекция.** `ServerSnapshot` и `ServerEvents` собираются + отдельно на каждое место; не переиспользовать один `MatchView` на двоих. - **Порядок фаз.** Вход в Основную — `EnterMainPhase()` с `OnPhaseStart`. - **Отложенные продолжения.** `State.Continuation` — что выполнить после паузы; `State.PendingQueue` + `_resolvingDepth` запрещают вложенность эффектов. diff --git a/docs/protocol.md b/docs/protocol.md index 9badbad..face674 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -8,6 +8,7 @@ - `docs/HANDOFF.md` — состояние проекта и порядок блоков; - `IMPLEMENTATION_CHECKLIST.md`, раздел 5 — план сетевого блока; +- `docs/server-api.md` — HTTP-очередь, тикеты и `/health`; - `server/src/Vanguard.Shared` — DTO, MessagePack-резолвер и проекция состояния. --- @@ -454,6 +455,7 @@ WebSocket-транспорта. Логика handshake, кадров и тайм | `MalformedFrame` | мусор, пустой кадр, превышен размер | | `RateLimited` | слишком много интентов | | `MatchFinished` | партия закончена, соединение больше не нужно | +| `Forbidden` | тикет валиден, но место в матче чужое | | `Internal` | непредвиденная ошибка сервера | Коды закрытия WebSocket (диапазон приложения 4000–4999): @@ -462,6 +464,7 @@ WebSocket-транспорта. Логика handshake, кадров и тайм |---|---| | `4400` | нарушение протокола (handshake, битый кадр) | | `4401` | не авторизован (тикет) | +| `4403` | место в матче не принадлежит игроку | | `4408` | heartbeat не получен | | `4409` | соединение заменено новым | | `4410` | неподдерживаемая версия протокола | diff --git a/docs/server-api.md b/docs/server-api.md new file mode 100644 index 0000000..ad8bb62 --- /dev/null +++ b/docs/server-api.md @@ -0,0 +1,223 @@ +# HTTP API сервера + +Служебный HTTP-слой матчевого блока: очередь, тикеты на подключение и `/health`. +После подключения клиент переходит на WebSocket и работает по `docs/protocol.md`. + +Базовая часть стека: **ASP.NET Core 10**, один процесс, состояние в памяти. + +--- + +## 1. Запуск + +```powershell +$env:PATH = "$env:LOCALAPPDATA\dotnet;$env:PATH" + +dotnet run --project server\src\Vanguard.Server --urls http://localhost:5080 +``` + +Проверка: + +```powershell +Invoke-RestMethod http://localhost:5080/health +``` + +Контейнер: + +```powershell +docker compose up --build +# http://localhost:8080/health +``` + +Каталог карт ищется в `content/sets` вверх от каталога сборки. Если контент +лежит в другом месте, задайте `Content:Path` (или `Content__Path` в окружении). + +--- + +## 2. Авторизация + +Пока это **dev-заглушка**: игрок представляется строкой `playerId`. +Никаких токенов и паролей нет, поэтому сервер нельзя выставлять в интернет +как есть. + +Когда появится мета-API, `playerId` будет браться из JWT, а контракты очереди +и тикетов не изменятся. Ограничения на формат: 1–64 символа, буквы, цифры, +`_`, `.`, `-`. + +--- + +## 3. Очередь + +### `POST /api/game/queue` + +Поставить игрока в очередь. + +```json +{ + "playerId": "p1", + "mode": "classic-ranked", + "deck": ["BT01/001EN", "BT01/002EN", "..."] +} +``` + +- `mode`: `classic-ranked` или `classic-casual`. +- `deck`: ровно 50 `printingId`. Проверяются те же правила, что и в декбилдере: + 50 карт, ≤4 по `nameId`, ≤4 Sentinel, ≤4 Heal, известные принты и хотя бы + одна карта grade 0 без триггера для стартового Авангарда. + +Ответ `200`: + +```json +{ + "queueId": "5b5c7a4e-9d5a-4f7b-a0d8-6f1b2c3d4e5f", + "status": "queued", + "mode": "classic-ranked" +} +``` + +Ответ `400` при плохой колоде: + +```json +{ + "error": "deck.invalid", + "message": "Колода не проходит проверку.", + "violations": [ + { "code": "deck.size", "message": "В колоде 49 карт, требуется 50." } + ] +} +``` + +Повторный запрос от того же `playerId` отменяет его предыдущую очередь +в любом режиме: один игрок — одна активная очередь. + +### `GET /api/game/queue/{queueId}?playerId=p1` + +Статус очереди. Клиент опрашивает, пока не получит `matched`. + +```json +{ + "queueId": "5b5c7a4e-9d5a-4f7b-a0d8-6f1b2c3d4e5f", + "status": "matched", + "mode": "classic-ranked", + "waitedSeconds": 7, + "window": 100, + "matchId": "9f2c...", + "seat": 0, + "ticket": "c2VjcmV0...", + "opponent": "p2", + "reason": null, + "wsUrl": "ws://localhost:5080/game" +} +``` + +`status`: + +| Значение | Смысл | +|---|---| +| `queued` | ждёт противника, `window` — текущее окно MMR | +| `matched` | матч найден, `ticket` — одноразовый тикет | +| `cancelled` | игрок отменил очередь или встал в новую | +| `expired` | очередь истекла (10 минут) | +| `failed` | не удалось создать матч | + +`wsUrl` заполняется только для `matched`. Он строится из `Game:PublicWsUrl` +или из текущего запроса. Тикет живёт 60 секунд: если клиент не успел +подключиться, он запрашивает новый через `POST /api/game/tickets` с `matchId`. + +Подбор: + +- две независимые очереди — ranked и casual; +- скрытый MMR, стартовое окно ±100; +- окно расширяется на 50 каждые 5 секунд, максимум 2000; +- пара выбирается по ближайшему MMR в пределах взаимных окон; +- кто встал раньше — садится за место 0. + +MMR наружу не отдаётся. В ранкеде после матча он меняется по Эло (K = 32), +в casual — нет. + +### `DELETE /api/game/queue/{queueId}?playerId=p1` + +Отменить очередь. `204` — отменена, `409` — уже не активна. + +--- + +## 4. Тикеты + +### `POST /api/game/tickets` + +Выдать новый одноразовый тикет на подключение. Нужен для переподключения +после обрыва: старый тикет уже израсходован. + +```json +{ "playerId": "p1", "matchId": "9f2c..." } +``` + +Ответ `200`: + +```json +{ + "ticket": "c2VjcmV0...", + "matchId": "9f2c...", + "seat": 0, + "wsUrl": "ws://localhost:5080/game", + "expiresAt": "2026-10-21T12:01:00+00:00" +} +``` + +- TTL — 60 секунд, тикет одноразовый. +- Тикет привязан к `(playerId, matchId, seat)`. +- Если матч уже завершён, тикет всё равно выдаётся: клиент может подключиться + и посмотреть итоговое состояние. +- `404` — матч не найден, `403` — игрок не сидит в этом матче. + +--- + +## 5. Health + +### `GET /health` + +```json +{ + "status": "ok", + "matches": 3, + "activeMatches": 2, + "queued": 4, + "serverTimeMs": 1760000000000 +} +``` + +`matches` включает завершённые матчи, которые ещё живут в памяти +(10 минут после финала — для реконнекта и просмотра результата). + +--- + +## 6. Полный сценарий + +``` +POST /api/game/queue → queueId +GET /api/game/queue/{id} → matched + ticket + wsUrl +WS /game → ClientHello(ticket) + ← ServerHello + ServerSnapshot + → ClientIntent(...) + ← ServerIntentResult + ServerEvents +... +обрыв +POST /api/game/tickets → новый ticket +WS /game → ClientHello(ticket, resumeFromSeq) + ← ServerHello + ServerEvents (догон) +``` + +Формат WebSocket-кадров, интентов, событий, таймеров и кодов ошибок — +`docs/protocol.md`. + +--- + +## 7. Ограничения текущего этапа + +- Всё состояние (очереди, тикеты, матчи, профили) — в памяти процесса. + Рестарт сервера теряет матчи; клиентский kill и реконнект переживаются. +- Один процесс: горизонтальное масштабирование и Orleans-хостинг ещё впереди. +- `IMatchGrain` уже отделён от ASP.NET Core и WebSocket: замена на Orleans + grain не затронет транспорт и правила. +- Нет PostgreSQL/Redis, JWT, коллекций и наград — это следующие блоки. +- Rate limit: 16 интентов в секунду с одного соединения. Живой UI в него + не упирается; тестовый бот специально притормаживается. diff --git a/server/Vanguard.slnx b/server/Vanguard.slnx index 6ab5b7b..2dcacca 100644 --- a/server/Vanguard.slnx +++ b/server/Vanguard.slnx @@ -3,9 +3,11 @@ + + diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index bf7aa42..7172274 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -47,7 +47,9 @@ public sealed class GameEngine ulong seed, IReadOnlyList deck0, IReadOnlyList deck1, - IReadOnlyDictionary? scripts = null) + IReadOnlyDictionary? scripts = null, + string player0Name = "Player 1", + string player1Name = "Player 2") { var violations = DeckValidator.Validate(catalog, deck0.ToList()) .Concat(DeckValidator.Validate(catalog, deck1.ToList())) @@ -58,7 +60,7 @@ public sealed class GameEngine "Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message))); _scripts = scripts ?? CardScripts.Default; - State = new GameState(catalog, seed, "Player 1", "Player 2"); + State = new GameState(catalog, seed, player0Name, player1Name); FillDeck(0, deck0); FillDeck(1, deck1); } @@ -1595,6 +1597,9 @@ public sealed class GameEngine public CommandResult Execute(GameCommand command) { + if (State.IsFinished) + return CommandResult.Reject("Партия завершена."); + // пока идёт разрешение способности или триггера, посторонние команды запрещены if (State.Step is Step.Trigger or Step.Ability or Step.Choice) { @@ -2607,4 +2612,28 @@ public sealed class GameEngine State.Winner = 1 - player; _events.Add(new GameFinishedEvent(State.Winner.Value)); } + + /// + /// Завершить партию извне: сдача, истечение времени, отсутствие игрока. + /// Победа достаётся , событие пишется в общий журнал. + /// + public void Finish(int winner) + { + if (winner is not (0 or 1)) + throw new ArgumentOutOfRangeException(nameof(winner), winner, "Победитель — 0 или 1."); + + if (State.IsFinished) + return; + + Defeat(1 - winner); + + // незавершённые выборы больше не имеют смысла: партия закончена + State.PendingChoice = null; + State.PendingTrigger = null; + State.PendingAbility = null; + State.PendingOrder = null; + State.PendingQueue = null; + State.Continuation = null; + State.Step = Step.None; + } } diff --git a/server/src/Vanguard.Server/Api/ApiContracts.cs b/server/src/Vanguard.Server/Api/ApiContracts.cs new file mode 100644 index 0000000..23bdc99 --- /dev/null +++ b/server/src/Vanguard.Server/Api/ApiContracts.cs @@ -0,0 +1,34 @@ +using Vanguard.Rules.Decks; + +namespace Vanguard.Server.Api; + +public sealed record QueueRequest(string? PlayerId, string? Mode, List? Deck); + +public sealed record QueueAccepted(Guid QueueId, string Status, string Mode); + +public sealed record QueueStatusResponse( + Guid QueueId, + string Status, + string Mode, + int WaitedSeconds, + int Window, + string? MatchId, + int Seat, + string? Ticket, + string? Opponent, + string? Reason, + string? WsUrl); + +public sealed record TicketRequest(string? PlayerId, string? MatchId); + +public sealed record TicketResponse( + string Ticket, + string MatchId, + int Seat, + string WsUrl, + DateTimeOffset ExpiresAt); + +public sealed record ApiError( + string Error, + string Message, + IReadOnlyList? Violations = null); diff --git a/server/src/Vanguard.Server/Api/ApiEndpoints.cs b/server/src/Vanguard.Server/Api/ApiEndpoints.cs new file mode 100644 index 0000000..d04fd93 --- /dev/null +++ b/server/src/Vanguard.Server/Api/ApiEndpoints.cs @@ -0,0 +1,153 @@ +using Vanguard.Server.Configuration; +using Vanguard.Server.Matches; +using Vanguard.Server.Matchmaking; +using Vanguard.Server.Tickets; + +namespace Vanguard.Server.Api; + +/// +/// HTTP-часть матчевого блока: очередь, тикеты, здоровье. +/// Авторизация здесь ещё dev-заглушка: игрок называется playerId. +/// JWT из мета-API встанет на это место без изменения контрактов очереди. +/// +public static class ApiEndpoints +{ + public static void MapGameApi(this WebApplication app) + { + app.MapPost("/api/game/queue", Enqueue); + app.MapGet("/api/game/queue/{queueId:guid}", PollQueue); + app.MapDelete("/api/game/queue/{queueId:guid}", CancelQueue); + app.MapPost("/api/game/tickets", IssueTicket); + app.MapGet("/health", Health); + } + + private static IResult Enqueue( + QueueRequest request, + MatchmakingService matchmaking) + { + if (!IsValidPlayerId(request.PlayerId)) + return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен: 1–64 символа, буквы, цифры, _ . -")); + + if (!GameModes.TryParse(request.Mode, out var mode)) + return Results.BadRequest(new ApiError("mode.invalid", $"Режим: {GameModes.Ranked} или {GameModes.Casual}.")); + + if (request.Deck is not { Count: > 0 }) + return Results.BadRequest(new ApiError("deck.missing", "Нужен список printingId на 50 карт.")); + + try + { + var ticket = matchmaking.Enqueue(request.PlayerId!, request.Deck, mode); + return Results.Ok(new QueueAccepted(ticket.QueueId, "queued", ticket.Mode)); + } + catch (DeckRejectedException exception) + { + return Results.BadRequest(new ApiError( + "deck.invalid", + "Колода не проходит проверку.", + exception.Violations)); + } + } + + private static IResult PollQueue( + Guid queueId, + string? playerId, + MatchmakingService matchmaking, + HttpRequest http, + GameOptions options) + { + if (!IsValidPlayerId(playerId)) + return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен.")); + + var snapshot = matchmaking.Poll(queueId, playerId!); + if (snapshot is null) + return Results.NotFound(new ApiError("queue.not-found", "Очередь не найдена.")); + + return Results.Ok(new QueueStatusResponse( + snapshot.QueueId, + snapshot.Status, + snapshot.Mode, + snapshot.WaitedSeconds, + snapshot.Window, + snapshot.MatchId, + snapshot.Seat, + snapshot.Ticket, + snapshot.Opponent, + snapshot.Reason, + snapshot.MatchId is null ? null : BuildWsUrl(http, options))); + } + + private static IResult CancelQueue( + Guid queueId, + string? playerId, + MatchmakingService matchmaking) + { + if (!IsValidPlayerId(playerId)) + return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен.")); + + return matchmaking.Cancel(queueId, playerId!) + ? Results.NoContent() + : Results.Conflict(new ApiError("queue.not-cancellable", "Очередь уже не активна.")); + } + + private static IResult IssueTicket( + TicketRequest request, + MatchGrainRegistry matches, + GameTicketService tickets, + HttpRequest http, + GameOptions options) + { + if (!IsValidPlayerId(request.PlayerId)) + return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен.")); + + if (string.IsNullOrWhiteSpace(request.MatchId)) + return Results.BadRequest(new ApiError("match.missing", "matchId обязателен.")); + + if (!matches.TryGet(request.MatchId!, out var match)) + return Results.NotFound(new ApiError("match.not-found", "Матч не найден.")); + + var summary = match.Summary; + var seat = summary.Player0Id == request.PlayerId + ? 0 + : summary.Player1Id == request.PlayerId + ? 1 + : -1; + + if (seat < 0) + return Results.Json( + new ApiError("match.forbidden", "Этот игрок не участвует в матче."), + statusCode: StatusCodes.Status403Forbidden); + + var ticket = tickets.Issue(request.PlayerId!, request.MatchId!, seat); + + return Results.Ok(new TicketResponse( + ticket.Token, + ticket.MatchId, + ticket.Seat, + BuildWsUrl(http, options), + ticket.ExpiresAt)); + } + + private static IResult Health(MatchGrainRegistry matches, MatchmakingService matchmaking) => + Results.Ok(new + { + status = "ok", + matches = matches.Count, + activeMatches = matches.ActiveCount, + queued = matchmaking.QueuedCount, + serverTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }); + + private static string BuildWsUrl(HttpRequest request, GameOptions options) + { + if (!string.IsNullOrWhiteSpace(options.PublicWsUrl)) + return options.PublicWsUrl; + + var scheme = request.IsHttps ? "wss" : "ws"; + return $"{scheme}://{request.Host}/game"; + } + + private static bool IsValidPlayerId(string? playerId) => + !string.IsNullOrWhiteSpace(playerId) + && playerId.Length <= 64 + && playerId.All(ch => char.IsLetterOrDigit(ch) || ch is '_' or '-' or '.'); +} diff --git a/server/src/Vanguard.Server/Configuration/ContentPaths.cs b/server/src/Vanguard.Server/Configuration/ContentPaths.cs new file mode 100644 index 0000000..47f9da3 --- /dev/null +++ b/server/src/Vanguard.Server/Configuration/ContentPaths.cs @@ -0,0 +1,36 @@ +namespace Vanguard.Server.Configuration; + +/// +/// Ищет каталог content/sets: сначала явный путь из конфигурации, +/// затем вверх от каталога сборки и текущего каталога. +/// +public static class ContentPaths +{ + public static string Resolve(string? configured) + { + if (!string.IsNullOrWhiteSpace(configured)) + { + var full = Path.GetFullPath(configured); + if (!Directory.Exists(full)) + throw new DirectoryNotFoundException($"Каталог контента не найден: {full}"); + + return full; + } + + foreach (var start in new[] { AppContext.BaseDirectory, Directory.GetCurrentDirectory() }) + { + var directory = new DirectoryInfo(start); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, "content", "sets"); + if (Directory.Exists(candidate)) + return candidate; + + directory = directory.Parent; + } + } + + throw new DirectoryNotFoundException( + "Не найден content/sets. Укажите Content:Path в конфигурации."); + } +} diff --git a/server/src/Vanguard.Server/Configuration/GameModes.cs b/server/src/Vanguard.Server/Configuration/GameModes.cs new file mode 100644 index 0000000..af78701 --- /dev/null +++ b/server/src/Vanguard.Server/Configuration/GameModes.cs @@ -0,0 +1,37 @@ +namespace Vanguard.Server.Configuration; + +public enum GameMode +{ + ClassicRanked = 0, + ClassicCasual = 1, +} + +/// Стабильные имена режимов на проводе. Меняются только вместе с API. +public static class GameModes +{ + public const string Ranked = "classic-ranked"; + public const string Casual = "classic-casual"; + + public static bool TryParse(string? value, out GameMode mode) + { + switch (value) + { + case Ranked: + mode = GameMode.ClassicRanked; + return true; + case Casual: + mode = GameMode.ClassicCasual; + return true; + default: + mode = default; + return false; + } + } + + public static string ToWire(GameMode mode) => mode switch + { + GameMode.ClassicRanked => Ranked, + GameMode.ClassicCasual => Casual, + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null), + }; +} diff --git a/server/src/Vanguard.Server/Configuration/GameOptions.cs b/server/src/Vanguard.Server/Configuration/GameOptions.cs new file mode 100644 index 0000000..2e175dd --- /dev/null +++ b/server/src/Vanguard.Server/Configuration/GameOptions.cs @@ -0,0 +1,40 @@ +using Vanguard.Shared.Protocol; + +namespace Vanguard.Server.Configuration; + +/// +/// Настройки матча. Значения по умолчанию совпадают с протоколом, +/// но тесты и локальный стенд могут их уменьшить. +/// +public sealed class GameOptions +{ + public int TurnSeconds { get; set; } = ProtocolConstants.TurnSeconds; + public int BankSeconds { get; set; } = ProtocolConstants.TurnBankSeconds; + public int AbsenceSeconds { get; set; } = ProtocolConstants.AbsenceLossSeconds; + public int ClockSyncSeconds { get; set; } = 5; + public int TicketTtlSeconds { get; set; } = ProtocolConstants.TicketTtlSeconds; + public int ConnectTimeoutSeconds { get; set; } = 5; + public int HeartbeatIdleSeconds { get; set; } = 20; + public int HeartbeatTimeoutSeconds { get; set; } = 20; + public int HeartbeatCheckSeconds { get; set; } = 5; + public int CompletedMatchTtlSeconds { get; set; } = 600; + public int QueueExpirySeconds { get; set; } = 600; + + /// Стартовое окно подбора по скрытому MMR. + public int MmrInitialWindow { get; set; } = 100; + + /// Насколько окно расширяется за интервал ожидания. + public int MmrWindowStep { get; set; } = 50; + public int MmrWindowIntervalSeconds { get; set; } = 5; + public int MmrMaxWindow { get; set; } = 2000; + + /// Внешний адрес WebSocket. Пусто — строится из запроса. + public string PublicWsUrl { get; set; } = string.Empty; +} + +/// Где лежит контент: YAML сетов. +public sealed class ContentOptions +{ + /// Путь к каталогу с BT01.yaml, TD01.yaml, TD02.yaml. Пусто — искать вверх по дереву. + public string Path { get; set; } = string.Empty; +} diff --git a/server/src/Vanguard.Server/Dockerfile b/server/src/Vanguard.Server/Dockerfile new file mode 100644 index 0000000..079b3ff --- /dev/null +++ b/server/src/Vanguard.Server/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY global.json ./ +COPY server/ ./server/ +COPY content/ ./content/ + +RUN dotnet publish server/src/Vanguard.Server/Vanguard.Server.csproj -c Release -o /app + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +COPY --from=build /app ./ +COPY --from=build /src/content ./content + +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "Vanguard.Server.dll"] diff --git a/server/src/Vanguard.Server/Matches/IMatchGrain.cs b/server/src/Vanguard.Server/Matches/IMatchGrain.cs new file mode 100644 index 0000000..2cb2b5d --- /dev/null +++ b/server/src/Vanguard.Server/Matches/IMatchGrain.cs @@ -0,0 +1,87 @@ +using Vanguard.Server.Configuration; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Server.Matches; + +/// Канал доставки кадров одному месту в матче. +public interface IMatchConnection +{ + string Id { get; } + + Task SendAsync(ServerFrame frame, CancellationToken cancellationToken = default); + + Task CloseAsync(int code, string reason); +} + +/// Одно место в матче: игрок и его колода. +public sealed record MatchSeatSetup(string PlayerId, IReadOnlyList Deck, int Mmr); + +/// Всё, что нужно для создания матча. +public sealed record MatchSetup( + string MatchId, + GameMode Mode, + MatchSeatSetup Seat0, + MatchSeatSetup Seat1); + +/// Снимок и итог матча для регистратуры, health и записи результата. +public sealed record MatchSummary( + string MatchId, + GameMode Mode, + string Player0Id, + string Player1Id, + bool IsFinished, + int Winner, + long EventSeq, + DateTimeOffset CreatedAt, + DateTimeOffset? FinishedAt); + +/// Результат для записи в профили. MMR меняется только в ранкеде. +public sealed record MatchResult( + string MatchId, + GameMode Mode, + string Player0Id, + string Player1Id, + int WinnerSeat) +{ + public string WinnerPlayerId => WinnerSeat == 0 ? Player0Id : Player1Id; +} + +public interface IMatchResultSink +{ + Task RecordAsync(MatchResult result); +} + +/// +/// Контракт матча: 1 матч = 1 grain. Все методы, меняющие состояние, +/// выполняются последовательно внутри реализации. +/// +/// Сейчас grain живёт в памяти процесса. Orleans-хостинг подключается +/// поверх этого же контракта: сетевая часть и правила от него не зависят. +/// +public interface IMatchGrain +{ + string MatchId { get; } + + MatchSummary Summary { get; } + + bool HasPlayer(string playerId); + + /// Подключить место. false — место не принадлежит игроку. + Task AttachAsync( + int seat, + string playerId, + IMatchConnection connection, + long resumeFromSeq); + + /// Отключить место. Отсчёт отсутствия начинается здесь. + Task DetachAsync(int seat, IMatchConnection connection); + + /// Обработать интент и отправить результат и события в соединения. + Task SubmitAsync(int seat, ClientIntent intent); + + /// Догнать состояние: журналом или полным снимком. + Task ResyncAsync(int seat, long fromSeq, bool fullSnapshot); + + /// Один тик часов и проверки отсутствия. + Task TickAsync(DateTimeOffset now); +} diff --git a/server/src/Vanguard.Server/Matches/MatchGrain.cs b/server/src/Vanguard.Server/Matches/MatchGrain.cs new file mode 100644 index 0000000..07106ad --- /dev/null +++ b/server/src/Vanguard.Server/Matches/MatchGrain.cs @@ -0,0 +1,575 @@ +using Vanguard.Rules.Cards; +using Vanguard.Rules.Engine; +using Vanguard.Server.Configuration; +using Vanguard.Shared.Projection; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Server.Matches; + +/// +/// Один матч. Все входы — интенты, подключения и тики — проходят через +/// один семафор, поэтому состояние движка меняется строго последовательно. +/// Ничего из ASP.NET Core и WebSocket внутри не лежит: только IMatchConnection. +/// +public sealed class MatchGrain : IMatchGrain +{ + private readonly record struct IntentRecord( + bool Accepted, + IntentRejectCode Code, + string? Reason, + long EventSeq); + + private sealed class Seat + { + public required string PlayerId { get; init; } + public required IReadOnlyList Deck { get; init; } + public required int Mmr { get; init; } + + public IMatchConnection? Connection { get; set; } + public DateTimeOffset? AbsentSince { get; set; } + public bool EverConnected { get; set; } + public long SentSeq { get; set; } + public long BankMs { get; set; } + } + + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Dictionary _intents = new(StringComparer.Ordinal); + private readonly GameEngine _engine; + private readonly Seat[] _seats; + private readonly GameOptions _options; + private readonly TimeProvider _time; + private readonly IMatchResultSink _results; + private readonly ILogger _logger; + private readonly string _matchId; + private readonly GameMode _mode; + private readonly DateTimeOffset _createdAt; + + private volatile MatchSummary _summary = null!; + private bool _started; + private DateTimeOffset _turnDeadline; + private DateTimeOffset _lastClockSent; + private DateTimeOffset? _finishedAt; + private bool _resultReported; + + public MatchGrain( + MatchSetup setup, + CardCatalog catalog, + GameOptions options, + TimeProvider time, + IMatchResultSink results, + ILogger logger) + { + _matchId = setup.MatchId; + _mode = setup.Mode; + _options = options; + _time = time; + _results = results; + _logger = logger; + _createdAt = time.GetUtcNow(); + + _seats = new[] + { + new Seat + { + PlayerId = setup.Seat0.PlayerId, + Deck = setup.Seat0.Deck, + Mmr = setup.Seat0.Mmr, + BankMs = options.BankSeconds * 1000L, + AbsentSince = _createdAt, + }, + new Seat + { + PlayerId = setup.Seat1.PlayerId, + Deck = setup.Seat1.Deck, + Mmr = setup.Seat1.Mmr, + BankMs = options.BankSeconds * 1000L, + AbsentSince = _createdAt, + }, + }; + + var seed = unchecked((ulong)Random.Shared.NextInt64()); + _engine = new GameEngine( + catalog, + seed, + setup.Seat0.Deck.ToList(), + setup.Seat1.Deck.ToList(), + player0Name: setup.Seat0.PlayerId, + player1Name: setup.Seat1.PlayerId); + + _engine.Start(); + RefreshSummary(); + } + + public string MatchId => _matchId; + + public MatchSummary Summary => _summary; + + public bool HasPlayer(string playerId) => + _seats[0].PlayerId == playerId || _seats[1].PlayerId == playerId; + + public async Task AttachAsync( + int seat, + string playerId, + IMatchConnection connection, + long resumeFromSeq) + { + if (seat is not (0 or 1) || _seats[seat].PlayerId != playerId) + { + _logger.LogWarning( + "Match {MatchId}: player {PlayerId} tried to take seat {Seat}.", + _matchId, playerId, seat); + return false; + } + + await _gate.WaitAsync(); + try + { + var state = _seats[seat]; + var now = _time.GetUtcNow(); + + if (state.Connection is { } old && !ReferenceEquals(old, connection)) + { + _logger.LogInformation( + "Match {MatchId}: seat {Seat} replaced a live connection.", + _matchId, seat); + + try + { + await old.CloseAsync(4409, "replaced"); + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Match {MatchId}: closing old connection failed.", _matchId); + } + } + + state.Connection = connection; + state.AbsentSince = null; + state.EverConnected = true; + + if (!_started && _seats[0].EverConnected && _seats[1].EverConnected) + StartClock(now); + + var to = _engine.Events.Count; + var resumed = resumeFromSeq > 0 && resumeFromSeq <= to; + + await connection.SendAsync(new ServerHello + { + ProtocolVersion = ProtocolConstants.Version, + MatchId = _matchId, + PlayerIndex = seat, + Resumed = resumed, + EventSeq = to, + ServerTimeMs = now.ToUnixTimeMilliseconds(), + }); + + if (resumed) + await SendEventsAsync(seat, resumeFromSeq + 1); + else + await SendSnapshotAsync(seat, now); + + await SendClockAsync(seat, now); + + _logger.LogInformation( + "Match {MatchId}: seat {Seat} attached (resume from {Seq}, now {Now}).", + _matchId, seat, resumeFromSeq, to); + + return true; + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Match {MatchId}: attach for seat {Seat} failed.", _matchId, seat); + _seats[seat].Connection = null; + _seats[seat].AbsentSince ??= _time.GetUtcNow(); + return false; + } + finally + { + _gate.Release(); + } + } + + public async Task DetachAsync(int seat, IMatchConnection connection) + { + if (seat is not (0 or 1)) + return; + + await _gate.WaitAsync(); + try + { + var state = _seats[seat]; + if (!ReferenceEquals(state.Connection, connection)) + return; // место уже занято новым соединением + + state.Connection = null; + state.AbsentSince = _time.GetUtcNow(); + + _logger.LogInformation("Match {MatchId}: seat {Seat} detached.", _matchId, seat); + } + finally + { + _gate.Release(); + } + } + + public async Task SubmitAsync(int seat, ClientIntent intent) + { + if (seat is not (0 or 1)) + return; + + await _gate.WaitAsync(); + try + { + var intentId = intent.IntentId ?? string.Empty; + var now = _time.GetUtcNow(); + + if (intentId.Length is 0 or > 64 || intent.Intent is null) + { + await SendResultAsync( + seat, intentId, accepted: false, IntentRejectCode.Malformed, + "Некорректный intentId или полезная нагрузка.", _engine.Events.Count, duplicate: false); + return; + } + + if (_intents.TryGetValue(intentId, out var seen)) + { + await SendResultAsync( + seat, intentId, seen.Accepted, seen.Code, seen.Reason, seen.EventSeq, duplicate: true); + return; + } + + if (_engine.State.IsFinished) + { + await RejectAsync(seat, intentId, IntentRejectCode.MatchFinished, "Партия завершена."); + return; + } + + var isConcede = intent.Intent is ConcedeIntent; + if (!isConcede && seat != _engine.State.ActivePlayer) + { + await RejectAsync(seat, intentId, IntentRejectCode.NotYourTurn, "Сейчас решает другой игрок."); + return; + } + + var decisionBefore = _engine.State.ActivePlayer; + + CommandResult result; + if (isConcede) + { + _engine.Finish(1 - seat); + result = CommandResult.Ok(); + } + else + { + var command = IntentMapping.ToCommand(intent.Intent); + if (command is null) + { + await RejectAsync(seat, intentId, IntentRejectCode.NotSupported, "Интент не поддержан."); + return; + } + + result = _engine.Execute(command); + } + + var code = result.Accepted ? IntentRejectCode.None : IntentRejectCode.Illegal; + var record = new IntentRecord(result.Accepted, code, result.Reason, _engine.Events.Count); + _intents[intentId] = record; + RefreshSummary(); + + await SendResultAsync( + seat, intentId, record.Accepted, record.Code, record.Reason, record.EventSeq, duplicate: false); + + if (!record.Accepted) + return; + + if (!_engine.State.IsFinished && _engine.State.ActivePlayer != decisionBefore) + ResetTurnClock(now); + + await PushEventsAsync(0); + await PushEventsAsync(1); + await SendClockToAllAsync(now); + + if (_engine.State.IsFinished) + await OnFinishedAsync(now); + } + finally + { + _gate.Release(); + } + } + + public async Task ResyncAsync(int seat, long fromSeq, bool fullSnapshot) + { + if (seat is not (0 or 1)) + return; + + await _gate.WaitAsync(); + try + { + if (_seats[seat].Connection is null) + return; + + var now = _time.GetUtcNow(); + var to = _engine.Events.Count; + + if (fullSnapshot || fromSeq < 1 || fromSeq > to + 1) + await SendSnapshotAsync(seat, now); + else + await SendEventsAsync(seat, fromSeq); + + await SendClockAsync(seat, now); + } + finally + { + _gate.Release(); + } + } + + public async Task TickAsync(DateTimeOffset now) + { + await _gate.WaitAsync(); + try + { + if (_engine.State.IsFinished) + return; + + for (var seat = 0; seat < _seats.Length; seat++) + { + var state = _seats[seat]; + if (state.Connection is null && state.AbsentSince is { } since) + { + if (now - since < TimeSpan.FromSeconds(_options.AbsenceSeconds)) + continue; + + _logger.LogInformation( + "Match {MatchId}: seat {Seat} absent for too long, opponent wins.", + _matchId, seat); + + await FinishFromServerAsync(1 - seat, now); + return; + } + } + + if (!_started) + return; + + var active = _engine.State.ActivePlayer; + if (now > _turnDeadline) + { + var overMs = (long)(now - _turnDeadline).TotalMilliseconds; + var remaining = _seats[active].BankMs - overMs; + + if (remaining <= 0) + { + _seats[active].BankMs = 0; + _logger.LogInformation( + "Match {MatchId}: seat {Seat} ran out of time.", _matchId, active); + await FinishFromServerAsync(1 - active, now); + return; + } + + _seats[active].BankMs = remaining; + _turnDeadline = now; + } + + if (now - _lastClockSent >= TimeSpan.FromSeconds(_options.ClockSyncSeconds)) + await SendClockToAllAsync(now); + } + finally + { + _gate.Release(); + } + } + + private async Task RejectAsync( + int seat, + string intentId, + IntentRejectCode code, + string reason) + { + _intents[intentId] = new IntentRecord(false, code, reason, _engine.Events.Count); + + await SendResultAsync( + seat, intentId, accepted: false, code, reason, _engine.Events.Count, duplicate: false); + } + + private async Task SendResultAsync( + int seat, + string intentId, + bool accepted, + IntentRejectCode code, + string? reason, + long eventSeq, + bool duplicate) + { + await SendToSeatAsync(seat, new ServerIntentResult + { + IntentId = intentId, + Accepted = accepted, + Code = code, + Reason = reason, + EventSeq = eventSeq, + Duplicate = duplicate, + }); + } + + private async Task PushEventsAsync(int seat) + { + if (_seats[seat].Connection is null) + return; + + var from = _seats[seat].SentSeq + 1; + var to = _engine.Events.Count; + if (from > to) + return; + + await SendEventsAsync(seat, from); + } + + private async Task SendEventsAsync(int seat, long fromSeq) + { + var to = _engine.Events.Count; + var from = Math.Max(1, fromSeq); + var events = EventProjection.ProjectRange(_engine.Events, seat, from); + + _seats[seat].SentSeq = to; + + await SendToSeatAsync(seat, new ServerEvents + { + FromSeq = from, + ToSeq = to, + Events = events, + }); + } + + private async Task SendSnapshotAsync(int seat, DateTimeOffset now) + { + var to = _engine.Events.Count; + var view = MatchProjection.ForViewer(_engine, seat, _matchId, to, BuildClock(now)); + + _seats[seat].SentSeq = to; + + await SendToSeatAsync(seat, new ServerSnapshot { View = view }); + } + + private async Task SendClockToAllAsync(DateTimeOffset now) + { + _lastClockSent = now; + + var clock = BuildClock(now); + var eventSeq = _engine.Events.Count; + + for (var seat = 0; seat < _seats.Length; seat++) + { + if (_seats[seat].Connection is not null) + await SendToSeatAsync(seat, new ServerClock { Clock = clock, EventSeq = eventSeq }); + } + } + + private async Task SendClockAsync(int seat, DateTimeOffset now) => + await SendToSeatAsync(seat, new ServerClock + { + Clock = BuildClock(now), + EventSeq = _engine.Events.Count, + }); + + private async Task SendToSeatAsync(int seat, ServerFrame frame) + { + var state = _seats[seat]; + if (state.Connection is not { } connection) + return false; + + try + { + await connection.SendAsync(frame); + return true; + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Match {MatchId}: send to seat {Seat} failed.", _matchId, seat); + state.Connection = null; + state.AbsentSince ??= _time.GetUtcNow(); + return false; + } + } + + private async Task FinishFromServerAsync(int winner, DateTimeOffset now) + { + _engine.Finish(winner); + RefreshSummary(); + + await PushEventsAsync(0); + await PushEventsAsync(1); + await SendClockToAllAsync(now); + await OnFinishedAsync(now); + } + + private async Task OnFinishedAsync(DateTimeOffset now) + { + if (_resultReported) + return; + + _resultReported = true; + _finishedAt = now; + RefreshSummary(); + + var winner = _engine.State.Winner ?? -1; + if (winner is not (0 or 1)) + return; + + try + { + await _results.RecordAsync(new MatchResult( + _matchId, + _mode, + _seats[0].PlayerId, + _seats[1].PlayerId, + winner)); + } + catch (Exception exception) + { + _logger.LogError(exception, "Match {MatchId}: recording the result failed.", _matchId); + } + } + + private void StartClock(DateTimeOffset now) + { + _started = true; + _turnDeadline = now.AddSeconds(_options.TurnSeconds); + _lastClockSent = now.AddSeconds(-_options.ClockSyncSeconds); + } + + private void ResetTurnClock(DateTimeOffset now) + { + if (!_started) + return; + + _turnDeadline = now.AddSeconds(_options.TurnSeconds); + } + + private ClockView BuildClock(DateTimeOffset now) => new() + { + DecisionPlayer = _engine.State.ActivePlayer, + TurnDeadlineMs = _started && !_engine.State.IsFinished + ? _turnDeadline.ToUnixTimeMilliseconds() + : 0, + Bank0Ms = _seats[0].BankMs, + Bank1Ms = _seats[1].BankMs, + IsRunning = _started && !_engine.State.IsFinished, + ServerTimeMs = now.ToUnixTimeMilliseconds(), + }; + + private void RefreshSummary() + { + _summary = new MatchSummary( + _matchId, + _mode, + _seats[0].PlayerId, + _seats[1].PlayerId, + _engine.State.IsFinished, + _engine.State.Winner ?? -1, + _engine.Events.Count, + _createdAt, + _finishedAt); + } +} diff --git a/server/src/Vanguard.Server/Matches/MatchGrainRegistry.cs b/server/src/Vanguard.Server/Matches/MatchGrainRegistry.cs new file mode 100644 index 0000000..469f93a --- /dev/null +++ b/server/src/Vanguard.Server/Matches/MatchGrainRegistry.cs @@ -0,0 +1,65 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Vanguard.Server.Configuration; + +namespace Vanguard.Server.Matches; + +/// +/// Регистр матчей: ровно один экземпляр grain на matchId. +/// В Orleans эту роль играет активация грана; контракт совпадает. +/// +public sealed class MatchGrainRegistry +{ + private readonly ConcurrentDictionary _matches = new(StringComparer.Ordinal); + private readonly IServiceProvider _services; + private readonly GameOptions _options; + private readonly ILogger _logger; + + public MatchGrainRegistry( + IServiceProvider services, + GameOptions options, + ILogger logger) + { + _services = services; + _options = options; + _logger = logger; + } + + public IMatchGrain Create(MatchSetup setup) + { + var grain = ActivatorUtilities.CreateInstance(_services, setup); + + if (!_matches.TryAdd(setup.MatchId, grain)) + throw new InvalidOperationException($"Матч уже существует: {setup.MatchId}"); + + _logger.LogInformation( + "Match {MatchId} created: {Mode}, {Player0} vs {Player1}.", + setup.MatchId, setup.Mode, setup.Seat0.PlayerId, setup.Seat1.PlayerId); + + return grain; + } + + public bool TryGet(string matchId, [NotNullWhen(true)] out IMatchGrain? match) => + _matches.TryGetValue(matchId, out match); + + public ICollection All => _matches.Values; + + public int Count => _matches.Count; + + public int ActiveCount => _matches.Values.Count(match => !match.Summary.IsFinished); + + public void RemoveExpired(DateTimeOffset now) + { + var ttl = TimeSpan.FromSeconds(_options.CompletedMatchTtlSeconds); + + foreach (var (matchId, match) in _matches) + { + var summary = match.Summary; + if (summary.IsFinished && summary.FinishedAt is { } finishedAt && now - finishedAt >= ttl) + { + _matches.TryRemove(matchId, out _); + _logger.LogInformation("Match {MatchId} removed after completion.", matchId); + } + } + } +} diff --git a/server/src/Vanguard.Server/Matches/MatchTicker.cs b/server/src/Vanguard.Server/Matches/MatchTicker.cs new file mode 100644 index 0000000..66bcd4f --- /dev/null +++ b/server/src/Vanguard.Server/Matches/MatchTicker.cs @@ -0,0 +1,48 @@ +using Vanguard.Server.Configuration; + +namespace Vanguard.Server.Matches; + +/// +/// Один общий тикер на процесс: часы и проверки отсутствия. +/// Тик раз в секунду, отправка часов клиентам — по своему интервалу. +/// +public sealed class MatchTicker : BackgroundService +{ + private readonly MatchGrainRegistry _registry; + private readonly TimeProvider _time; + private readonly ILogger _logger; + + public MatchTicker( + MatchGrainRegistry registry, + TimeProvider time, + ILogger logger) + { + _registry = registry; + _time = time; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + var now = _time.GetUtcNow(); + + foreach (var match in _registry.All) + { + try + { + await match.TickAsync(now); + } + catch (Exception exception) + { + _logger.LogError(exception, "Match {MatchId}: tick failed.", match.MatchId); + } + } + + _registry.RemoveExpired(now); + } + } +} diff --git a/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs b/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs new file mode 100644 index 0000000..1a3af29 --- /dev/null +++ b/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs @@ -0,0 +1,362 @@ +using Vanguard.Rules.Cards; +using Vanguard.Rules.Decks; +using Vanguard.Server.Configuration; +using Vanguard.Server.Matches; +using Vanguard.Server.Profiles; +using Vanguard.Server.Tickets; + +namespace Vanguard.Server.Matchmaking; + +public enum QueueEntryState +{ + Queued, + Matched, + Cancelled, + Expired, + Failed, +} + +public sealed record QueueTicket(Guid QueueId, string Mode); + +public sealed record QueueSnapshot( + Guid QueueId, + string Status, + string Mode, + int WaitedSeconds, + int Window, + string? MatchId, + int Seat, + string? Ticket, + string? Opponent, + string? Reason); + +/// Колода не прошла правила декбилдера. +public sealed class DeckRejectedException : Exception +{ + public DeckRejectedException(IReadOnlyList violations) + : base("Колода не проходит проверку.") + { + Violations = violations; + } + + public IReadOnlyList Violations { get; } +} + +/// +/// Две очереди, скрытый MMR и расширяющееся окно. +/// +/// Состояние в памяти: до Redis этого достаточно для одного процесса. +/// Наружу MMR и колоды не отдаются — только статус, тикет и имя противника. +/// +public sealed class MatchmakingService +{ + private sealed class Entry + { + public required Guid QueueId { get; init; } + public required string PlayerId { get; init; } + public required IReadOnlyList Deck { get; init; } + public required GameMode Mode { get; init; } + public required int Mmr { get; init; } + public required DateTimeOffset EnqueuedAt { get; init; } + public required long Sequence { get; init; } + + public QueueEntryState State { get; set; } = QueueEntryState.Queued; + public string? MatchId { get; set; } + public int Seat { get; set; } = -1; + public string? Ticket { get; set; } + public string? Opponent { get; set; } + public string? Reason { get; set; } + } + + private readonly object _gate = new(); + private readonly Dictionary _entries = new(); + private readonly Dictionary> _queues = new() + { + [GameMode.ClassicRanked] = new List(), + [GameMode.ClassicCasual] = new List(), + }; + + private readonly CardCatalog _catalog; + private readonly MatchGrainRegistry _matches; + private readonly GameTicketService _tickets; + private readonly PlayerProfileStore _profiles; + private readonly GameOptions _options; + private readonly TimeProvider _time; + private readonly ILogger _logger; + private long _sequence; + + public MatchmakingService( + CardCatalog catalog, + MatchGrainRegistry matches, + GameTicketService tickets, + PlayerProfileStore profiles, + GameOptions options, + TimeProvider time, + ILogger logger) + { + _catalog = catalog; + _matches = matches; + _tickets = tickets; + _profiles = profiles; + _options = options; + _time = time; + _logger = logger; + } + + public int QueuedCount + { + get + { + lock (_gate) + return _entries.Values.Count(entry => entry.State == QueueEntryState.Queued); + } + } + + public QueueTicket Enqueue(string playerId, IReadOnlyList deck, GameMode mode) + { + var violations = DeckValidator.Validate(_catalog, deck.ToList()).ToList(); + + // колода без grade 0 без триггера не может выставить стартовый Авангард + if (violations.Count == 0 + && !deck.Any(printingId => _catalog.CardOf(printingId).IsStartingVanguardCandidate)) + { + violations.Add(new DeckViolation( + "deck.starting-vanguard", + "Нужна карта grade 0 без триггера для стартового Авангарда.")); + } + + if (violations.Count > 0) + throw new DeckRejectedException(violations); + + lock (_gate) + { + // один игрок — одна активная очередь + foreach (var existing in _entries.Values + .Where(entry => entry.PlayerId == playerId + && entry.State == QueueEntryState.Queued) + .ToList()) + { + existing.State = QueueEntryState.Cancelled; + existing.Reason = "Заменено новым запросом в очередь."; + RemoveFromQueue(existing); + } + + var entry = new Entry + { + QueueId = Guid.NewGuid(), + PlayerId = playerId, + Deck = deck.ToList(), + Mode = mode, + Mmr = _profiles.GetMmr(playerId), + EnqueuedAt = _time.GetUtcNow(), + Sequence = ++_sequence, + }; + + _entries[entry.QueueId] = entry; + _queues[mode].Add(entry.QueueId); + + _logger.LogInformation( + "Queue: {PlayerId} joined {Mode} (queue {QueueId}).", + playerId, mode, entry.QueueId); + + return new QueueTicket(entry.QueueId, GameModes.ToWire(mode)); + } + } + + public QueueSnapshot? Poll(Guid queueId, string playerId) + { + lock (_gate) + { + if (!_entries.TryGetValue(queueId, out var entry) || entry.PlayerId != playerId) + return null; + + return Snapshot(entry, _time.GetUtcNow()); + } + } + + public bool Cancel(Guid queueId, string playerId) + { + lock (_gate) + { + if (!_entries.TryGetValue(queueId, out var entry) || entry.PlayerId != playerId) + return false; + + if (entry.State != QueueEntryState.Queued) + return false; + + entry.State = QueueEntryState.Cancelled; + entry.Reason = "Игрок отменил очередь."; + RemoveFromQueue(entry); + + _logger.LogInformation("Queue: {PlayerId} cancelled {QueueId}.", playerId, queueId); + return true; + } + } + + /// Один проход подбора. Вызывается фоновым воркером раз в секунду. + public void Matchmake(DateTimeOffset now) + { + lock (_gate) + { + Expire(now); + + foreach (var mode in new[] { GameMode.ClassicRanked, GameMode.ClassicCasual }) + MatchmakeMode(mode, now); + } + } + + private void MatchmakeMode(GameMode mode, DateTimeOffset now) + { + var entries = _queues[mode] + .Select(id => _entries[id]) + .Where(entry => entry.State == QueueEntryState.Queued) + .OrderBy(entry => entry.Sequence) + .ToList(); + + var used = new HashSet(); + + for (var i = 0; i < entries.Count; i++) + { + var first = entries[i]; + if (used.Contains(first.QueueId)) + continue; + + Entry? best = null; + var bestDiff = int.MaxValue; + + for (var j = 0; j < entries.Count; j++) + { + if (i == j) + continue; + + var other = entries[j]; + if (used.Contains(other.QueueId) || other.PlayerId == first.PlayerId) + continue; + + var diff = Math.Abs(first.Mmr - other.Mmr); + var window = Math.Min(Window(first, now), Window(other, now)); + if (diff > window) + continue; + + if (diff < bestDiff + || (diff == bestDiff && best is not null && other.Sequence < best.Sequence)) + { + best = other; + bestDiff = diff; + } + } + + if (best is null) + continue; + + used.Add(first.QueueId); + used.Add(best.QueueId); + TryPair(first, best); + } + } + + private void TryPair(Entry a, Entry b) + { + var first = a.Sequence < b.Sequence ? a : b; + var second = ReferenceEquals(first, a) ? b : a; + + var matchId = Guid.NewGuid().ToString("N"); + + try + { + _matches.Create(new MatchSetup( + matchId, + first.Mode, + new MatchSeatSetup(first.PlayerId, first.Deck, first.Mmr), + new MatchSeatSetup(second.PlayerId, second.Deck, second.Mmr))); + + MarkMatched(first, matchId, 0, second.PlayerId); + MarkMatched(second, matchId, 1, first.PlayerId); + + _logger.LogInformation( + "Queue: matched {Player0} vs {Player1} in {MatchId} ({Mode}).", + first.PlayerId, second.PlayerId, matchId, first.Mode); + } + catch (Exception exception) + { + _logger.LogError(exception, "Queue: creating match {MatchId} failed.", matchId); + + MarkFailed(first, "Не удалось создать матч."); + MarkFailed(second, "Не удалось создать матч."); + } + } + + private void MarkMatched(Entry entry, string matchId, int seat, string opponent) + { + entry.State = QueueEntryState.Matched; + entry.MatchId = matchId; + entry.Seat = seat; + entry.Ticket = _tickets.Issue(entry.PlayerId, matchId, seat).Token; + entry.Opponent = opponent; + RemoveFromQueue(entry); + } + + private void MarkFailed(Entry entry, string reason) + { + entry.State = QueueEntryState.Failed; + entry.Reason = reason; + entry.MatchId = null; + entry.Ticket = null; + entry.Seat = -1; + RemoveFromQueue(entry); + } + + private void RemoveFromQueue(Entry entry) => _queues[entry.Mode].Remove(entry.QueueId); + + private int Window(Entry entry, DateTimeOffset now) + { + var interval = Math.Max(1, _options.MmrWindowIntervalSeconds); + var waited = Math.Max(0, (now - entry.EnqueuedAt).TotalSeconds); + var steps = (int)(waited / interval); + + return Math.Min( + _options.MmrMaxWindow, + _options.MmrInitialWindow + steps * _options.MmrWindowStep); + } + + private void Expire(DateTimeOffset now) + { + var ttl = TimeSpan.FromSeconds(_options.QueueExpirySeconds); + var keepFinished = TimeSpan.FromSeconds(_options.QueueExpirySeconds * 2.0); + + foreach (var entry in _entries.Values.ToList()) + { + if (entry.State == QueueEntryState.Queued && now - entry.EnqueuedAt >= ttl) + { + entry.State = QueueEntryState.Expired; + entry.Reason = "Очередь истекла."; + RemoveFromQueue(entry); + _logger.LogInformation("Queue: {QueueId} expired.", entry.QueueId); + continue; + } + + if (entry.State != QueueEntryState.Queued && now - entry.EnqueuedAt >= keepFinished) + _entries.Remove(entry.QueueId); + } + } + + private QueueSnapshot Snapshot(Entry entry, DateTimeOffset now) => new( + entry.QueueId, + entry.State switch + { + QueueEntryState.Queued => "queued", + QueueEntryState.Matched => "matched", + QueueEntryState.Cancelled => "cancelled", + QueueEntryState.Expired => "expired", + QueueEntryState.Failed => "failed", + _ => "unknown", + }, + GameModes.ToWire(entry.Mode), + Math.Max(0, (int)(now - entry.EnqueuedAt).TotalSeconds), + entry.State == QueueEntryState.Queued ? Window(entry, now) : 0, + entry.MatchId, + entry.Seat, + entry.Ticket, + entry.Opponent, + entry.Reason); +} diff --git a/server/src/Vanguard.Server/Matchmaking/MatchmakingWorker.cs b/server/src/Vanguard.Server/Matchmaking/MatchmakingWorker.cs new file mode 100644 index 0000000..42212e4 --- /dev/null +++ b/server/src/Vanguard.Server/Matchmaking/MatchmakingWorker.cs @@ -0,0 +1,36 @@ +namespace Vanguard.Server.Matchmaking; + +/// Раз в секунду двигает подбор очередей. +public sealed class MatchmakingWorker : BackgroundService +{ + private readonly MatchmakingService _matchmaking; + private readonly TimeProvider _time; + private readonly ILogger _logger; + + public MatchmakingWorker( + MatchmakingService matchmaking, + TimeProvider time, + ILogger logger) + { + _matchmaking = matchmaking; + _time = time; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + _matchmaking.Matchmake(_time.GetUtcNow()); + } + catch (Exception exception) + { + _logger.LogError(exception, "Matchmaking pass failed."); + } + } + } +} diff --git a/server/src/Vanguard.Server/Profiles/PlayerProfileStore.cs b/server/src/Vanguard.Server/Profiles/PlayerProfileStore.cs new file mode 100644 index 0000000..d2c87c2 --- /dev/null +++ b/server/src/Vanguard.Server/Profiles/PlayerProfileStore.cs @@ -0,0 +1,76 @@ +using System.Collections.Concurrent; +using Vanguard.Server.Configuration; +using Vanguard.Server.Matches; + +namespace Vanguard.Server.Profiles; + +/// +/// Скрытый MMR и простая статистика. Наружу MMR не отдаётся: +/// клиент видит только результат матча. +/// +/// Хранилище в памяти — до появления PostgreSQL. Контракт уже отделён, +/// чтобы заменить реализацию, не трогая матч. +/// +public sealed class PlayerProfileStore : IMatchResultSink +{ + private const int InitialMmr = 1000; + private const int EloK = 32; + + private readonly object _gate = new(); + private readonly Dictionary _profiles = new(StringComparer.Ordinal); + + public int GetMmr(string playerId) + { + lock (_gate) + { + return _profiles.TryGetValue(playerId, out var profile) ? profile.Mmr : InitialMmr; + } + } + + public Task RecordAsync(MatchResult result) + { + lock (_gate) + { + var winnerId = result.WinnerPlayerId; + var loserId = winnerId == result.Player0Id ? result.Player1Id : result.Player0Id; + var winner = GetOrCreate(winnerId); + var loser = GetOrCreate(loserId); + + winner.Matches++; + winner.Wins++; + loser.Matches++; + + // Casual не двигает скрытый рейтинг: он остаётся только у ранкеда. + if (result.Mode == GameMode.ClassicRanked) + { + var expected = 1.0 / (1.0 + Math.Pow(10.0, (loser.Mmr - winner.Mmr) / 400.0)); + var delta = (int)Math.Round(EloK * (1.0 - expected)); + winner.Mmr = Math.Max(0, winner.Mmr + delta); + loser.Mmr = Math.Max(0, loser.Mmr - delta); + } + } + + return Task.CompletedTask; + } + + private Profile GetOrCreate(string playerId) + { + if (!_profiles.TryGetValue(playerId, out var profile)) + { + profile = new Profile(playerId); + _profiles[playerId] = profile; + } + + return profile; + } + + private sealed class Profile + { + public Profile(string playerId) => PlayerId = playerId; + + public string PlayerId { get; } + public int Mmr { get; set; } = InitialMmr; + public int Matches { get; set; } + public int Wins { get; set; } + } +} diff --git a/server/src/Vanguard.Server/Program.cs b/server/src/Vanguard.Server/Program.cs new file mode 100644 index 0000000..de996db --- /dev/null +++ b/server/src/Vanguard.Server/Program.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Options; +using Vanguard.Content; +using Vanguard.Rules.Cards; +using Vanguard.Server.Api; +using Vanguard.Server.Configuration; +using Vanguard.Server.Matches; +using Vanguard.Server.Matchmaking; +using Vanguard.Server.Profiles; +using Vanguard.Server.Realtime; +using Vanguard.Server.Tickets; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection("Game")); +builder.Services.Configure(builder.Configuration.GetSection("Content")); + +builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); +builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); +builder.Services.AddSingleton(TimeProvider.System); + +builder.Services.AddSingleton(sp => +{ + var content = sp.GetRequiredService(); + var directory = ContentPaths.Resolve(content.Path); + return CatalogLoader.LoadDirectory(directory); +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +// каталог грузим сразу: сервер без карт бесполезен, лучше упасть на старте +app.Services.GetRequiredService(); + +app.UseWebSockets(new WebSocketOptions +{ + KeepAliveInterval = TimeSpan.FromSeconds(30), +}); + +app.MapGameApi(); +app.Map("/game", GameWebSocketEndpoint.HandleAsync); + +app.Run(); + +/// Точка входа доступна интеграционным тестам через WebApplicationFactory. +public partial class Program +{ +} diff --git a/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs b/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs new file mode 100644 index 0000000..9e47b67 --- /dev/null +++ b/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs @@ -0,0 +1,320 @@ +using System.Net.WebSockets; +using Vanguard.Server.Configuration; +using Vanguard.Server.Matches; +using Vanguard.Server.Tickets; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Server.Realtime; + +/// +/// Точка входа матчевого WebSocket. Отвечает только за транспорт: +/// handshake, чтение кадров, лимит частоты и закрытие. Вся игра — в grain. +/// +public static class GameWebSocketEndpoint +{ + public static async Task HandleAsync(HttpContext context) + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + var services = context.RequestServices; + var options = services.GetRequiredService(); + var tickets = services.GetRequiredService(); + var matches = services.GetRequiredService(); + var time = services.GetRequiredService(); + var logger = services.GetRequiredService().CreateLogger("GameSocket"); + + using var socket = await context.WebSockets.AcceptWebSocketAsync(); + var connection = new WebSocketMatchConnection(socket); + + IMatchGrain? match = null; + var seat = -1; + HeartbeatState? heartbeat = null; + CancellationTokenSource? heartbeatCts = null; + Task? heartbeatTask = null; + + try + { + ClientFrame? first; + try + { + first = await ReadWithTimeoutAsync( + socket, + TimeSpan.FromSeconds(options.ConnectTimeoutSeconds), + context.RequestAborted); + } + catch (OperationCanceledException) when (!context.RequestAborted.IsCancellationRequested) + { + await FailAsync(connection, ProtocolErrorCode.MalformedFrame, + "Handshake не получен вовремя.", 4400, "handshake timeout"); + return; + } + catch (ProtocolFormatException exception) + { + logger.LogDebug(exception, "Bad handshake frame."); + await FailAsync(connection, ProtocolErrorCode.MalformedFrame, + "Первым кадром должен быть ClientHello.", 4400, "invalid handshake"); + return; + } + + if (first is not ClientHello hello) + { + await FailAsync(connection, ProtocolErrorCode.MalformedFrame, + "Первым кадром должен быть ClientHello.", 4400, "invalid handshake"); + return; + } + + if (hello.ProtocolVersion != ProtocolConstants.Version) + { + await FailAsync(connection, ProtocolErrorCode.UnsupportedVersion, + $"Поддерживается версия {ProtocolConstants.Version}.", 4410, "unsupported version"); + return; + } + + var ticket = tickets.Redeem(hello.Ticket); + if (ticket is null) + { + await FailAsync(connection, ProtocolErrorCode.InvalidTicket, + "Тикет неизвестен, использован или просрочен.", 4401, "invalid ticket"); + return; + } + + if (!matches.TryGet(ticket.MatchId, out match)) + { + await FailAsync(connection, ProtocolErrorCode.MatchNotFound, + "Матч не найден.", 4401, "match not found"); + return; + } + + seat = ticket.Seat; + if (!await match.AttachAsync(seat, ticket.PlayerId, connection, hello.ResumeFromSeq)) + { + await FailAsync(connection, ProtocolErrorCode.Forbidden, + "Место в матче не принадлежит игроку.", 4403, "forbidden"); + return; + } + + logger.LogInformation( + "Socket {ConnectionId}: {PlayerId} connected to {MatchId} seat {Seat}.", + connection.Id, ticket.PlayerId, ticket.MatchId, seat); + + heartbeat = new HeartbeatState(time.GetUtcNow()); + heartbeatCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); + heartbeatTask = HeartbeatLoopAsync(connection, heartbeat, time, options, heartbeatCts.Token); + + var limiter = new IntentRateLimiter(ProtocolConstants.MaxIntentsPerSecond, time); + + while (!context.RequestAborted.IsCancellationRequested + && socket.State == WebSocketState.Open) + { + ClientFrame? frame; + try + { + frame = await ProtocolFrameReader.ReadAsync(socket, context.RequestAborted); + } + catch (OperationCanceledException) + { + break; + } + catch (WebSocketException) + { + break; + } + catch (ProtocolFormatException exception) + { + logger.LogDebug(exception, "Match {MatchId}: bad frame.", match.MatchId); + await FailAsync(connection, ProtocolErrorCode.MalformedFrame, + "Некорректный кадр.", 4400, "malformed frame", match); + break; + } + + if (frame is null) + break; + + heartbeat.Touch(time.GetUtcNow()); + + try + { + switch (frame) + { + case ClientIntent intent: + if (!limiter.TryAcquire()) + { + await connection.SendAsync(new ServerError + { + Code = ProtocolErrorCode.RateLimited, + Message = "Слишком много интентов.", + Fatal = false, + EventSeq = match.Summary.EventSeq, + }); + break; + } + + await match.SubmitAsync(seat, intent); + break; + + case ClientResync resync: + await match.ResyncAsync(seat, resync.FromSeq, resync.FullSnapshot); + break; + + case ClientPing ping: + await connection.SendAsync(new ServerPong + { + Nonce = ping.Nonce, + ServerTimeMs = time.GetUtcNow().ToUnixTimeMilliseconds(), + }); + break; + + case ClientPong: + break; + + case ClientLeave: + return; + + default: + await FailAsync(connection, ProtocolErrorCode.MalformedFrame, + "Неожиданный кадр после handshake.", 4400, "unexpected frame", match); + return; + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception exception) + { + logger.LogError(exception, "Match {MatchId}: intent processing failed.", match.MatchId); + await FailAsync(connection, ProtocolErrorCode.Internal, + "Внутренняя ошибка сервера.", 4500, "internal error", match); + break; + } + } + } + catch (WebSocketException exception) + { + logger.LogDebug(exception, "Socket {ConnectionId} failed.", connection.Id); + } + catch (OperationCanceledException) + { + // штатное завершение запроса + } + finally + { + if (heartbeatCts is not null) + { + heartbeatCts.Cancel(); + + if (heartbeatTask is not null) + { + try + { + await heartbeatTask; + } + catch (Exception) + { + // heartbeat уже завершился или соединение умерло + } + } + + heartbeatCts.Dispose(); + } + + if (match is not null && (seat == 0 || seat == 1)) + { + try + { + await match.DetachAsync(seat, connection); + } + catch (Exception exception) + { + logger.LogDebug(exception, "Socket {ConnectionId}: detach failed.", connection.Id); + } + } + + await connection.CloseAsync(1000, "bye"); + } + } + + private static async Task ReadWithTimeoutAsync( + WebSocket socket, + TimeSpan timeout, + CancellationToken cancellationToken) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linked.CancelAfter(timeout); + return await ProtocolFrameReader.ReadAsync(socket, linked.Token); + } + + private static async Task HeartbeatLoopAsync( + IMatchConnection connection, + HeartbeatState state, + TimeProvider time, + GameOptions options, + CancellationToken cancellationToken) + { + var check = TimeSpan.FromSeconds(Math.Max(1, options.HeartbeatCheckSeconds)); + var idle = TimeSpan.FromSeconds(options.HeartbeatIdleSeconds); + var timeout = TimeSpan.FromSeconds(options.HeartbeatTimeoutSeconds); + + using var timer = new PeriodicTimer(check); + + try + { + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + var action = state.Evaluate(time.GetUtcNow(), idle, timeout); + + if (action.Kind == HeartbeatActionKind.Ping) + { + await connection.SendAsync(new ServerPing + { + Nonce = action.Nonce, + ServerTimeMs = time.GetUtcNow().ToUnixTimeMilliseconds(), + }, cancellationToken); + } + else if (action.Kind == HeartbeatActionKind.Timeout) + { + await connection.CloseAsync(4408, "heartbeat timeout"); + return; + } + } + } + catch (OperationCanceledException) + { + // штатное завершение соединения + } + catch (Exception) + { + // отправка не удалась — соединение закроет основной цикл чтения + } + } + + private static async Task FailAsync( + IMatchConnection connection, + ProtocolErrorCode code, + string message, + int closeCode, + string closeReason, + IMatchGrain? match = null) + { + try + { + await connection.SendAsync(new ServerError + { + Code = code, + Message = message, + Fatal = true, + EventSeq = match?.Summary.EventSeq ?? 0, + }); + } + catch (Exception) + { + // клиент мог уже отвалиться + } + + await connection.CloseAsync(closeCode, closeReason); + } +} diff --git a/server/src/Vanguard.Server/Realtime/HeartbeatState.cs b/server/src/Vanguard.Server/Realtime/HeartbeatState.cs new file mode 100644 index 0000000..67d5845 --- /dev/null +++ b/server/src/Vanguard.Server/Realtime/HeartbeatState.cs @@ -0,0 +1,56 @@ +namespace Vanguard.Server.Realtime; + +internal enum HeartbeatActionKind +{ + None, + Ping, + Timeout, +} + +internal readonly record struct HeartbeatAction(HeartbeatActionKind Kind, long Nonce = 0); + +/// +/// Состояние heartbeat одного соединения. Любой кадр клиента считается +/// активностью; если активности нет — сервер шлёт ping, а без ответа +/// закрывает соединение. +/// +internal sealed class HeartbeatState +{ + private readonly object _gate = new(); + private DateTimeOffset _lastReceived; + private DateTimeOffset? _pingSent; + private long _nextNonce; + + public HeartbeatState(DateTimeOffset now) => _lastReceived = now; + + public void Touch(DateTimeOffset now) + { + lock (_gate) + { + _lastReceived = now; + _pingSent = null; + } + } + + public HeartbeatAction Evaluate( + DateTimeOffset now, + TimeSpan idle, + TimeSpan timeout) + { + lock (_gate) + { + if (_pingSent is { } sent) + { + return now - sent >= timeout + ? new HeartbeatAction(HeartbeatActionKind.Timeout) + : new HeartbeatAction(HeartbeatActionKind.None); + } + + if (now - _lastReceived < idle) + return new HeartbeatAction(HeartbeatActionKind.None); + + _pingSent = now; + return new HeartbeatAction(HeartbeatActionKind.Ping, ++_nextNonce); + } + } +} diff --git a/server/src/Vanguard.Server/Realtime/ProtocolFrameReader.cs b/server/src/Vanguard.Server/Realtime/ProtocolFrameReader.cs new file mode 100644 index 0000000..1955faf --- /dev/null +++ b/server/src/Vanguard.Server/Realtime/ProtocolFrameReader.cs @@ -0,0 +1,64 @@ +using System.Net.WebSockets; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Server.Realtime; + +/// Чтение одного кадра протокола из бинарных сообщений WebSocket. +internal static class ProtocolFrameReader +{ + public static async Task ReadAsync(WebSocket socket, CancellationToken cancellationToken) + { + var buffer = new byte[16 * 1024]; + using var stream = new MemoryStream(); + + while (true) + { + var result = await socket.ReceiveAsync(buffer.AsMemory(), cancellationToken); + + if (result.MessageType == WebSocketMessageType.Close) + return null; + + if (result.MessageType != WebSocketMessageType.Binary) + throw new ProtocolFormatException("Ожидался бинарный кадр."); + + stream.Write(buffer, 0, result.Count); + + if (stream.Length > ProtocolConstants.MaxFrameBytes) + throw new ProtocolFormatException( + $"Кадр больше {ProtocolConstants.MaxFrameBytes} байт."); + + if (result.EndOfMessage) + break; + } + + return ProtocolSerializer.DecodeClient(stream.ToArray()); + } +} + +/// Не больше N интентов в секунду с одного соединения. +internal sealed class IntentRateLimiter +{ + private readonly int _limit; + private readonly TimeProvider _time; + private DateTimeOffset _windowStart; + private int _count; + + public IntentRateLimiter(int limit, TimeProvider time) + { + _limit = limit; + _time = time; + _windowStart = time.GetUtcNow(); + } + + public bool TryAcquire() + { + var now = _time.GetUtcNow(); + if (now - _windowStart >= TimeSpan.FromSeconds(1)) + { + _windowStart = now; + _count = 0; + } + + return ++_count <= _limit; + } +} diff --git a/server/src/Vanguard.Server/Realtime/WebSocketMatchConnection.cs b/server/src/Vanguard.Server/Realtime/WebSocketMatchConnection.cs new file mode 100644 index 0000000..d9b4f6e --- /dev/null +++ b/server/src/Vanguard.Server/Realtime/WebSocketMatchConnection.cs @@ -0,0 +1,70 @@ +using System.Net.WebSockets; +using Vanguard.Server.Matches; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Server.Realtime; + +/// +/// Обёртка над WebSocket: один писатель, аккуратное закрытие и защита +/// от отправки в уже закрытый сокет. +/// +public sealed class WebSocketMatchConnection : IMatchConnection +{ + private readonly WebSocket _socket; + private readonly SemaphoreSlim _sendLock = new(1, 1); + private int _closed; + + public WebSocketMatchConnection(WebSocket socket) + { + _socket = socket; + Id = Guid.NewGuid().ToString("N"); + } + + public string Id { get; } + + public async Task SendAsync(ServerFrame frame, CancellationToken cancellationToken = default) + { + if (Volatile.Read(ref _closed) != 0) + return; + + var bytes = ProtocolSerializer.Encode(frame); + + await _sendLock.WaitAsync(cancellationToken); + try + { + if (_socket.State != WebSocketState.Open) + return; + + await _socket.SendAsync( + bytes.AsMemory(), + WebSocketMessageType.Binary, + endOfMessage: true, + cancellationToken); + } + finally + { + _sendLock.Release(); + } + } + + public async Task CloseAsync(int code, string reason) + { + if (Interlocked.Exchange(ref _closed, 1) != 0) + return; + + await _sendLock.WaitAsync(); + try + { + if (_socket.State is WebSocketState.Open or WebSocketState.CloseReceived) + await _socket.CloseOutputAsync((WebSocketCloseStatus)code, reason, CancellationToken.None); + } + catch (Exception) + { + // соединение уже могло умереть — закрывать нечего + } + finally + { + _sendLock.Release(); + } + } +} diff --git a/server/src/Vanguard.Server/Tickets/GameTicketService.cs b/server/src/Vanguard.Server/Tickets/GameTicketService.cs new file mode 100644 index 0000000..e7923f4 --- /dev/null +++ b/server/src/Vanguard.Server/Tickets/GameTicketService.cs @@ -0,0 +1,83 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using Vanguard.Server.Configuration; + +namespace Vanguard.Server.Tickets; + +/// Одноразовый тикет на подключение к конкретному месту в матче. +public sealed record GameTicket( + string Token, + string PlayerId, + string MatchId, + int Seat, + DateTimeOffset ExpiresAt); + +/// +/// Тикеты живут в памяти: TTL короткий, переживать рестарт им не нужно. +/// При переезде на нескольких инстансов сервис заменяется на Redis-реализацию. +/// +public sealed class GameTicketService +{ + private readonly ConcurrentDictionary _tickets = new(StringComparer.Ordinal); + private readonly GameOptions _options; + private readonly TimeProvider _time; + + public GameTicketService(GameOptions options, TimeProvider time) + { + _options = options; + _time = time; + } + + public GameTicket Issue(string playerId, string matchId, int seat) + { + if (seat is not (0 or 1)) + throw new ArgumentOutOfRangeException(nameof(seat), seat, "Место — 0 или 1."); + + var now = _time.GetUtcNow(); + var ticket = new GameTicket( + NewToken(), + playerId, + matchId, + seat, + now.AddSeconds(_options.TicketTtlSeconds)); + + _tickets[ticket.Token] = ticket; + RemoveExpired(now); + return ticket; + } + + /// + /// Забирает тикет. null — неизвестный, уже использованный или просроченный. + /// Тикет одноразовый: повторный Redeem того же токена не сработает. + /// + public GameTicket? Redeem(string? token) + { + if (string.IsNullOrWhiteSpace(token) || token.Length > 128) + return null; + + if (!_tickets.TryRemove(token, out var ticket)) + return null; + + return ticket.ExpiresAt > _time.GetUtcNow() ? ticket : null; + } + + public int Count => _tickets.Count; + + private void RemoveExpired(DateTimeOffset now) + { + foreach (var (token, ticket) in _tickets) + { + if (ticket.ExpiresAt <= now) + _tickets.TryRemove(token, out _); + } + } + + private static string NewToken() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + } +} diff --git a/server/src/Vanguard.Server/Vanguard.Server.csproj b/server/src/Vanguard.Server/Vanguard.Server.csproj new file mode 100644 index 0000000..761cd9d --- /dev/null +++ b/server/src/Vanguard.Server/Vanguard.Server.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + Vanguard.Server + + + + + + + + + + + + + diff --git a/server/src/Vanguard.Server/appsettings.json b/server/src/Vanguard.Server/appsettings.json new file mode 100644 index 0000000..2214025 --- /dev/null +++ b/server/src/Vanguard.Server/appsettings.json @@ -0,0 +1,30 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Game": { + "TurnSeconds": 90, + "BankSeconds": 300, + "AbsenceSeconds": 300, + "ClockSyncSeconds": 5, + "TicketTtlSeconds": 60, + "ConnectTimeoutSeconds": 5, + "HeartbeatIdleSeconds": 20, + "HeartbeatTimeoutSeconds": 20, + "HeartbeatCheckSeconds": 5, + "CompletedMatchTtlSeconds": 600, + "QueueExpirySeconds": 600, + "MmrInitialWindow": 100, + "MmrWindowStep": 50, + "MmrWindowIntervalSeconds": 5, + "MmrMaxWindow": 2000, + "PublicWsUrl": "" + }, + "Content": { + "Path": "" + } +} diff --git a/server/src/Vanguard.Shared/Protocol/ProtocolEnums.cs b/server/src/Vanguard.Shared/Protocol/ProtocolEnums.cs index 996ea9b..725ed85 100644 --- a/server/src/Vanguard.Shared/Protocol/ProtocolEnums.cs +++ b/server/src/Vanguard.Shared/Protocol/ProtocolEnums.cs @@ -115,4 +115,7 @@ public enum ProtocolErrorCode /// Внутренняя ошибка сервера. Internal = 7, + + /// Тикет валиден, но место в матче не принадлежит этому игроку. + Forbidden = 8, } diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 8ea17dc..5b3b065 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -1720,6 +1720,37 @@ public class GameEngineTests } } + [Fact] + public void Finish_Awards_Winner_And_Rejects_Later_Commands() + { + var engine = InMainPhase(); + + engine.Finish(1); + + Assert.True(engine.State.IsFinished); + Assert.Equal(1, engine.State.Winner); + Assert.True(engine.State.Players[0].IsDefeated); + Assert.IsType(engine.Events[^1]); + + var result = engine.Execute(new EndTurnCommand()); + Assert.False(result.Accepted); + Assert.Equal("Партия завершена.", result.Reason); + Assert.Empty(engine.LegalCommands()); + } + + [Fact] + public void Finish_Is_Idempotent() + { + var engine = InMainPhase(); + var before = engine.Events.Count; + + engine.Finish(0); + engine.Finish(1); + + Assert.Equal(0, engine.State.Winner); + Assert.Equal(before + 1, engine.Events.Count); + } + private static void AssertInvariants(GameState state, string context) { var ids = new HashSet(); diff --git a/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs b/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs new file mode 100644 index 0000000..db234f6 --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs @@ -0,0 +1,191 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Vanguard.Server.Api; +using Vanguard.Shared.Protocol; +using Xunit.Sdk; + +namespace Vanguard.Server.Tests; + +public class GameServerE2ETests +{ + [Fact] + public async Task Two_Clients_Play_A_Full_Match_Over_WebSocket() + { + using var factory = NewFactory(); + using var http = factory.CreateClient(); + var deck = ServerTestData.BuildDeck(); + + var firstQueue = await EnqueueAsync(http, "p1", deck); + var secondQueue = await EnqueueAsync(http, "p2", deck); + + var firstStatus = await WaitForMatchAsync(http, firstQueue.QueueId, "p1"); + var secondStatus = await WaitForMatchAsync(http, secondQueue.QueueId, "p2"); + + Assert.Equal(firstStatus.MatchId, secondStatus.MatchId); + Assert.Equal(0, firstStatus.Seat); + Assert.Equal(1, secondStatus.Seat); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + + await using var first = await TestGameClient.ConnectAsync( + factory, firstStatus.MatchId!, firstStatus.Ticket!, "p1", 0, cts.Token); + + await using var second = await TestGameClient.ConnectAsync( + factory, secondStatus.MatchId!, secondStatus.Ticket!, "p2", 0, cts.Token); + + await Task.WhenAll( + PlayAsync(first, seed: 11, cts.Token), + PlayAsync(second, seed: 22, cts.Token)); + + Assert.True(first.View.IsFinished, "Первый клиент не увидел завершение партии."); + Assert.True(second.View.IsFinished, "Второй клиент не увидел завершение партии."); + Assert.Equal(first.View.Winner, second.View.Winner); + Assert.InRange(first.View.Winner, 0, 1); + + var loser = 1 - first.View.Winner; + Assert.False(first.View.Players[first.View.Winner].IsDefeated); + Assert.True(first.View.Players[loser].IsDefeated); + + var health = await http.GetFromJsonAsync("/health"); + Assert.True(health.GetProperty("matches").GetInt32() >= 1); + } + + [Fact] + public async Task Client_Can_Reconnect_And_Resume_After_Disconnect() + { + using var factory = NewFactory(); + using var http = factory.CreateClient(); + var deck = ServerTestData.BuildDeck(); + + var firstQueue = await EnqueueAsync(http, "p1", deck); + var secondQueue = await EnqueueAsync(http, "p2", deck); + + var firstStatus = await WaitForMatchAsync(http, firstQueue.QueueId, "p1"); + var secondStatus = await WaitForMatchAsync(http, secondQueue.QueueId, "p2"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + + var first = await TestGameClient.ConnectAsync( + factory, firstStatus.MatchId!, firstStatus.Ticket!, "p1", 0, cts.Token); + + await using var second = await TestGameClient.ConnectAsync( + factory, secondStatus.MatchId!, secondStatus.Ticket!, "p2", 0, cts.Token); + + var result = await first.SubmitAsync("mulligan-1", new FinishMulliganIntent(), cts.Token); + Assert.True(result.Accepted, result.Reason); + + var lastSeq = result.EventSeq; + await first.DisposeAsync(); + + var ticketResponse = await http.PostAsJsonAsync( + "/api/game/tickets", + new TicketRequest("p1", firstStatus.MatchId)); + + if (!ticketResponse.IsSuccessStatusCode) + throw new XunitException(await ticketResponse.Content.ReadAsStringAsync()); + + var ticket = await ticketResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(ticket); + + await using var reconnected = await TestGameClient.ConnectAsync( + factory, firstStatus.MatchId!, ticket!.Ticket, "p1", lastSeq, cts.Token); + + Assert.True(reconnected.View.EventSeq >= lastSeq); + Assert.Equal(0, reconnected.Seat); + + await second.RequestSnapshotAsync(cts.Token); + Assert.Equal(reconnected.View.EventSeq, second.View.EventSeq); + Assert.Equal(reconnected.View.ActivePlayer, second.View.ActivePlayer); + Assert.False(reconnected.View.IsFinished); + } + + private static WebApplicationFactory NewFactory() => new(); + + private static async Task EnqueueAsync( + HttpClient http, + string playerId, + List deck) + { + var response = await http.PostAsJsonAsync( + "/api/game/queue", + new QueueRequest(playerId, "classic-ranked", deck)); + + if (!response.IsSuccessStatusCode) + throw new XunitException(await response.Content.ReadAsStringAsync()); + + return (await response.Content.ReadFromJsonAsync())!; + } + + private static async Task WaitForMatchAsync( + HttpClient http, + Guid queueId, + string playerId) + { + for (var attempt = 0; attempt < 200; attempt++) + { + var status = await http.GetFromJsonAsync( + $"/api/game/queue/{queueId}?playerId={playerId}"); + + if (status is null) + throw new XunitException("Очередь исчезла."); + + if (status.Status == "matched") + return status; + + if (status.Status is "failed" or "expired" or "cancelled") + throw new XunitException($"Очередь в состоянии {status.Status}: {status.Reason}"); + + await Task.Delay(50); + } + + throw new XunitException("Матч не был найден за 10 секунд."); + } + + private static async Task PlayAsync( + TestGameClient client, + int seed, + CancellationToken cancellationToken) + { + var random = new Random(seed); + var deadline = DateTime.UtcNow.AddSeconds(90); + + while (!client.View.IsFinished) + { + if (DateTime.UtcNow > deadline) + throw new XunitException( + $"Тест не доиграл партию за 90 секунд (seat {client.Seat})."); + + if (client.View.ActivePlayer == client.Seat) + { + var choices = client.View.LegalIntents + .Where(intent => intent is not ConcedeIntent) + .ToList(); + + if (choices.Count == 0) + throw new XunitException( + $"Нет легальных интентов для seat {client.Seat}, " + + $"фаза {client.View.Phase}, шаг {client.View.Step}."); + + var intent = choices[random.Next(choices.Count)]; + var intentId = Guid.NewGuid().ToString("N"); + + var result = await client.SubmitAsync(intentId, intent, cancellationToken); + if (result.Duplicate) + throw new XunitException($"Неожиданный duplicate для {intentId}."); + + if (!result.Accepted) + throw new XunitException( + $"Интент {intent.GetType().Name} отклонён: {result.Code} {result.Reason}"); + + // держимся ниже лимита 16 интентов в секунду + await Task.Delay(75, cancellationToken); + } + + await client.RequestSnapshotAsync(cancellationToken); + + if (!client.View.IsFinished && client.View.ActivePlayer != client.Seat) + await Task.Delay(20, cancellationToken); + } + } +} diff --git a/server/tests/Vanguard.Server.Tests/GameTicketServiceTests.cs b/server/tests/Vanguard.Server.Tests/GameTicketServiceTests.cs new file mode 100644 index 0000000..a6164cc --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/GameTicketServiceTests.cs @@ -0,0 +1,51 @@ +using Vanguard.Server.Tickets; + +namespace Vanguard.Server.Tests; + +public class GameTicketServiceTests +{ + [Fact] + public void Ticket_Is_Redeemed_Only_Once() + { + var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch); + var service = new GameTicketService(ServerTestData.SmallOptions(), time); + + var ticket = service.Issue("p1", "m1", 0); + + var first = service.Redeem(ticket.Token); + var second = service.Redeem(ticket.Token); + + Assert.NotNull(first); + Assert.Equal("p1", first!.PlayerId); + Assert.Equal("m1", first.MatchId); + Assert.Equal(0, first.Seat); + Assert.Null(second); + } + + [Fact] + public void Ticket_Expires_After_Ttl() + { + var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch); + var options = ServerTestData.SmallOptions(); + options.TicketTtlSeconds = 60; + + var service = new GameTicketService(options, time); + var ticket = service.Issue("p1", "m1", 1); + + time.Advance(TimeSpan.FromSeconds(61)); + + Assert.Null(service.Redeem(ticket.Token)); + } + + [Fact] + public void Unknown_Token_Is_Rejected() + { + var service = new GameTicketService( + ServerTestData.SmallOptions(), + new FakeTimeProvider(DateTimeOffset.UnixEpoch)); + + Assert.Null(service.Redeem(null)); + Assert.Null(service.Redeem("")); + Assert.Null(service.Redeem("no-such-ticket")); + } +} diff --git a/server/tests/Vanguard.Server.Tests/HeartbeatStateTests.cs b/server/tests/Vanguard.Server.Tests/HeartbeatStateTests.cs new file mode 100644 index 0000000..735ce37 --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/HeartbeatStateTests.cs @@ -0,0 +1,46 @@ +using Vanguard.Server.Realtime; + +namespace Vanguard.Server.Tests; + +public class HeartbeatStateTests +{ + private static readonly DateTimeOffset Start = new(2026, 10, 21, 12, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan Idle = TimeSpan.FromSeconds(20); + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(20); + + [Fact] + public void Server_Pings_After_Idle_And_Closes_After_Timeout() + { + var state = new HeartbeatState(Start); + + Assert.Equal(HeartbeatActionKind.None, state.Evaluate(Start, Idle, Timeout).Kind); + Assert.Equal(HeartbeatActionKind.None, + state.Evaluate(Start.AddSeconds(19), Idle, Timeout).Kind); + + var ping = state.Evaluate(Start.AddSeconds(20), Idle, Timeout); + Assert.Equal(HeartbeatActionKind.Ping, ping.Kind); + Assert.Equal(1, ping.Nonce); + + Assert.Equal(HeartbeatActionKind.None, + state.Evaluate(Start.AddSeconds(30), Idle, Timeout).Kind); + + Assert.Equal(HeartbeatActionKind.Timeout, + state.Evaluate(Start.AddSeconds(40), Idle, Timeout).Kind); + } + + [Fact] + public void Any_Frame_Resets_The_Idle_Timer() + { + var state = new HeartbeatState(Start); + + state.Evaluate(Start.AddSeconds(20), Idle, Timeout); // ping отправлен + state.Touch(Start.AddSeconds(25)); // клиент ответил + + Assert.Equal(HeartbeatActionKind.None, + state.Evaluate(Start.AddSeconds(44), Idle, Timeout).Kind); + + var next = state.Evaluate(Start.AddSeconds(45), Idle, Timeout); + Assert.Equal(HeartbeatActionKind.Ping, next.Kind); + Assert.Equal(2, next.Nonce); + } +} diff --git a/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs b/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs new file mode 100644 index 0000000..1b10144 --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs @@ -0,0 +1,309 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Vanguard.Rules.Engine; +using Vanguard.Server.Configuration; +using Vanguard.Server.Matches; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Server.Tests; + +public class MatchGrainTests +{ + private static readonly DateTimeOffset Start = new(2026, 10, 21, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task Attach_Sends_Hello_Snapshot_And_Hides_Opponent_Hand() + { + var time = new FakeTimeProvider(Start); + var grain = NewGrain(time); + var first = new FakeMatchConnection(); + var second = new FakeMatchConnection(); + + Assert.True(await grain.AttachAsync(0, "p1", first, 0)); + Assert.True(await grain.AttachAsync(1, "p2", second, 0)); + + var hello = first.All().Single(); + Assert.Equal(0, hello.PlayerIndex); + Assert.False(hello.Resumed); + + var snapshot = first.All().Single().View; + Assert.Equal(5, snapshot.Players[0].Hand.Count); + Assert.Equal(5, snapshot.Players[1].HandCount); + Assert.Empty(snapshot.Players[1].Hand); + + var clock = second.Last().Clock; + Assert.True(clock.IsRunning); + Assert.Equal(0, clock.DecisionPlayer); + } + + [Fact] + public async Task Wrong_Player_Cannot_Take_Seat() + { + var grain = NewGrain(new FakeTimeProvider(Start)); + + Assert.False(await grain.AttachAsync(0, "intruder", new FakeMatchConnection(), 0)); + Assert.False(await grain.AttachAsync(2, "p1", new FakeMatchConnection(), 0)); + } + + [Fact] + public async Task Duplicate_Intent_Is_Not_Applied_Twice() + { + var time = new FakeTimeProvider(Start); + var grain = NewGrain(time); + var first = await Attach(grain, 0, "p1"); + var second = await Attach(grain, 1, "p2"); + + var intent = new ClientIntent + { + IntentId = "intent-1", + BasedOnSeq = grain.Summary.EventSeq, + Intent = new FinishMulliganIntent(), + }; + + await grain.SubmitAsync(0, intent); + + var accepted = first.Last(); + Assert.True(accepted.Accepted); + Assert.False(accepted.Duplicate); + + var seqAfterFirst = grain.Summary.EventSeq; + var eventsAfterFirst = first.All().Count; + + await grain.SubmitAsync(0, intent); + + var duplicate = first.Last(); + Assert.True(duplicate.Accepted); + Assert.True(duplicate.Duplicate); + Assert.Equal(seqAfterFirst, grain.Summary.EventSeq); + Assert.Equal(eventsAfterFirst, first.All().Count); + Assert.True(second.All().Count == 0); + } + + [Fact] + public async Task Non_Active_Player_Is_Rejected() + { + var grain = NewGrain(new FakeTimeProvider(Start)); + var first = await Attach(grain, 0, "p1"); + var second = await Attach(grain, 1, "p2"); + var seqBefore = grain.Summary.EventSeq; + + await grain.SubmitAsync(1, new ClientIntent + { + IntentId = "p2-1", + BasedOnSeq = grain.Summary.EventSeq, + Intent = new FinishMulliganIntent(), + }); + + var result = second.Last(); + Assert.False(result.Accepted); + Assert.Equal(IntentRejectCode.NotYourTurn, result.Code); + Assert.Equal(seqBefore, grain.Summary.EventSeq); + } + + [Fact] + public async Task Concede_Works_Regardless_Of_Turn_And_Finishes_Match() + { + var sink = new RecordingResultSink(); + var grain = NewGrain(new FakeTimeProvider(Start), sink); + var first = await Attach(grain, 0, "p1"); + var second = await Attach(grain, 1, "p2"); + + // сейчас маллиган игрока 0 — сдаётся игрок 1, он не активен + await grain.SubmitAsync(1, new ClientIntent + { + IntentId = "concede", + BasedOnSeq = grain.Summary.EventSeq, + Intent = new ConcedeIntent(), + }); + + var result = second.Last(); + Assert.True(result.Accepted); + Assert.True(grain.Summary.IsFinished); + Assert.Equal(0, grain.Summary.Winner); + + Assert.Contains(first.All().SelectMany(e => e.Events), + e => e.Event is GameFinishedView { Winner: 0 }); + + var recorded = Assert.Single(sink.Results); + Assert.Equal("p1", recorded.WinnerPlayerId); + var loserId = recorded.Player0Id == recorded.WinnerPlayerId + ? recorded.Player1Id + : recorded.Player0Id; + Assert.Equal("p2", loserId); + } + + [Fact] + public async Task Intents_After_Finish_Are_Rejected() + { + var grain = NewGrain(new FakeTimeProvider(Start)); + var first = await Attach(grain, 0, "p1"); + await Attach(grain, 1, "p2"); + + await grain.SubmitAsync(1, new ClientIntent + { + IntentId = "concede", + BasedOnSeq = 1, + Intent = new ConcedeIntent(), + }); + + await grain.SubmitAsync(0, new ClientIntent + { + IntentId = "after-finish", + BasedOnSeq = grain.Summary.EventSeq, + Intent = new FinishMulliganIntent(), + }); + + var result = first.Last(); + Assert.False(result.Accepted); + Assert.Equal(IntentRejectCode.MatchFinished, result.Code); + } + + [Fact] + public async Task Turn_Clock_And_Bank_Drain_To_Defeat() + { + var time = new FakeTimeProvider(Start); + var options = ServerTestData.SmallOptions(); + options.TurnSeconds = 10; + options.BankSeconds = 20; + + var grain = NewGrain(time, options: options); + var first = await Attach(grain, 0, "p1"); + await Attach(grain, 1, "p2"); + + // 10 секунд бесплатно + 15 из банка: банк ещё жив + time.Advance(TimeSpan.FromSeconds(25)); + await grain.TickAsync(time.GetUtcNow()); + + Assert.False(grain.Summary.IsFinished); + Assert.Equal(5_000, first.Last().Clock.Bank0Ms); + + // ещё 6 секунд: банк кончился, игрок 0 проигрывает + time.Advance(TimeSpan.FromSeconds(6)); + await grain.TickAsync(time.GetUtcNow()); + + Assert.True(grain.Summary.IsFinished); + Assert.Equal(1, grain.Summary.Winner); + Assert.Contains(first.All().SelectMany(e => e.Events), + e => e.Event is GameFinishedView { Winner: 1 }); + } + + [Fact] + public async Task Absence_Of_Opponent_Ends_Match() + { + var time = new FakeTimeProvider(Start); + var options = ServerTestData.SmallOptions(); + options.AbsenceSeconds = 30; + + var grain = NewGrain(time, options: options); + var first = await Attach(grain, 0, "p1"); + + time.Advance(TimeSpan.FromSeconds(31)); + await grain.TickAsync(time.GetUtcNow()); + + Assert.True(grain.Summary.IsFinished); + Assert.Equal(0, grain.Summary.Winner); + Assert.Contains(first.All().SelectMany(e => e.Events), + e => e.Event is GameFinishedView { Winner: 0 }); + } + + [Fact] + public async Task Clock_Does_Not_Start_Until_Both_Seats_Connected() + { + var time = new FakeTimeProvider(Start); + var grain = NewGrain(time); + var first = await Attach(grain, 0, "p1"); + + time.Advance(TimeSpan.FromSeconds(20)); + await grain.TickAsync(time.GetUtcNow()); + + Assert.False(first.Last().Clock.IsRunning); + Assert.False(grain.Summary.IsFinished); + + var second = await Attach(grain, 1, "p2"); + time.Advance(TimeSpan.FromSeconds(1)); + await grain.TickAsync(time.GetUtcNow()); + + Assert.True(first.Last().Clock.IsRunning); + Assert.True(second.Last().Clock.IsRunning); + } + + [Fact] + public async Task Reconnect_Resumes_From_Event_Seq() + { + var time = new FakeTimeProvider(Start); + var grain = NewGrain(time); + var first = await Attach(grain, 0, "p1"); + var second = await Attach(grain, 1, "p2"); + + await grain.SubmitAsync(0, new ClientIntent + { + IntentId = "finish-0", + BasedOnSeq = grain.Summary.EventSeq, + Intent = new FinishMulliganIntent(), + }); + + var seq = grain.Summary.EventSeq; + await grain.DetachAsync(0, first); + + var reconnected = new FakeMatchConnection(); + Assert.True(await grain.AttachAsync(0, "p1", reconnected, seq)); + + var hello = reconnected.All().Single(); + Assert.True(hello.Resumed); + Assert.Equal(seq, hello.EventSeq); + + var events = reconnected.All().Single(); + Assert.Equal(seq + 1, events.FromSeq); + Assert.Equal(seq, events.ToSeq); + Assert.Empty(events.Events); + Assert.Empty(reconnected.All()); + + // без resumeFromSeq приходит полный снимок + var fresh = new FakeMatchConnection(); + Assert.True(await grain.AttachAsync(1, "p2", fresh, 0)); + Assert.False(fresh.All().Single().Resumed); + Assert.Single(fresh.All()); + Assert.NotEmpty(second.All()); + } + + [Fact] + public async Task New_Connection_Replaces_The_Old_One() + { + var grain = NewGrain(new FakeTimeProvider(Start)); + var old = await Attach(grain, 0, "p1"); + await Attach(grain, 1, "p2"); + + var replacement = new FakeMatchConnection(); + Assert.True(await grain.AttachAsync(0, "p1", replacement, grain.Summary.EventSeq)); + + Assert.True(old.IsClosed); + Assert.Equal(4409, old.CloseCode); + Assert.Single(replacement.All()); + } + + private static MatchGrain NewGrain( + FakeTimeProvider time, + RecordingResultSink? sink = null, + GameOptions? options = null) + { + var setup = new MatchSetup( + "match-1", + GameMode.ClassicRanked, + new MatchSeatSetup("p1", ServerTestData.BuildDeck(), 1000), + new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000)); + + return new MatchGrain( + setup, + ServerTestData.Catalog, + options ?? ServerTestData.SmallOptions(), + time, + sink ?? new RecordingResultSink(), + NullLogger.Instance); + } + + 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; + } +} diff --git a/server/tests/Vanguard.Server.Tests/MatchmakingServiceTests.cs b/server/tests/Vanguard.Server.Tests/MatchmakingServiceTests.cs new file mode 100644 index 0000000..2b99c93 --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/MatchmakingServiceTests.cs @@ -0,0 +1,221 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Vanguard.Server.Configuration; +using Vanguard.Server.Matches; +using Vanguard.Server.Matchmaking; +using Vanguard.Server.Profiles; +using Vanguard.Server.Tickets; + +namespace Vanguard.Server.Tests; + +public class MatchmakingServiceTests +{ + [Fact] + public void Two_Players_Are_Matched_And_Get_One_Time_Tickets() + { + var harness = NewHarness(); + + var first = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + var second = harness.Matchmaking.Enqueue("p2", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + + harness.Matchmaking.Matchmake(harness.Time.GetUtcNow()); + + var firstStatus = harness.Matchmaking.Poll(first.QueueId, "p1"); + var secondStatus = harness.Matchmaking.Poll(second.QueueId, "p2"); + + Assert.NotNull(firstStatus); + Assert.NotNull(secondStatus); + Assert.Equal("matched", firstStatus!.Status); + Assert.Equal("matched", secondStatus!.Status); + Assert.Equal(firstStatus.MatchId, secondStatus.MatchId); + Assert.Equal(0, firstStatus.Seat); + Assert.Equal(1, secondStatus.Seat); + Assert.Equal("p2", firstStatus.Opponent); + Assert.Equal("p1", secondStatus.Opponent); + + Assert.NotNull(firstStatus.Ticket); + Assert.NotNull(secondStatus.Ticket); + + var firstTicket = harness.Tickets.Redeem(firstStatus.Ticket); + var secondTicket = harness.Tickets.Redeem(secondStatus.Ticket); + Assert.Equal(0, firstTicket!.Seat); + Assert.Equal(1, secondTicket!.Seat); + Assert.Equal(firstStatus.MatchId, firstTicket.MatchId); + + Assert.Equal(1, harness.Registry.Count); + Assert.Equal(1, harness.Registry.ActiveCount); + Assert.Equal(0, harness.Matchmaking.QueuedCount); + } + + [Fact] + public void Ranked_And_Casual_Queues_Are_Separate() + { + var harness = NewHarness(); + + harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + var casual = harness.Matchmaking.Enqueue("p2", ServerTestData.BuildDeck(), GameMode.ClassicCasual); + + harness.Matchmaking.Matchmake(harness.Time.GetUtcNow()); + + Assert.Equal("queued", harness.Matchmaking.Poll(casual.QueueId, "p2")!.Status); + Assert.Equal(0, harness.Registry.Count); + Assert.Equal(2, harness.Matchmaking.QueuedCount); + } + + [Fact] + public void Window_Expands_While_Waiting() + { + var harness = NewHarness(); + var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + + var initial = harness.Matchmaking.Poll(ticket.QueueId, "p1")!; + Assert.Equal(100, initial.Window); + + harness.Time.Advance(TimeSpan.FromSeconds(20)); + var later = harness.Matchmaking.Poll(ticket.QueueId, "p1")!; + Assert.Equal(300, later.Window); // 100 + 4 * 50 + + harness.Time.Advance(TimeSpan.FromSeconds(200)); + var capped = harness.Matchmaking.Poll(ticket.QueueId, "p1")!; + Assert.Equal(2000, capped.Window); + } + + [Fact] + public void Invalid_Deck_Is_Rejected_At_Enqueue() + { + var harness = NewHarness(); + var deck = ServerTestData.BuildDeck(); + deck.RemoveAt(0); + + var exception = Assert.Throws( + () => harness.Matchmaking.Enqueue("p1", deck, GameMode.ClassicRanked)); + + Assert.Contains(exception.Violations, violation => violation.Code == "deck.size"); + } + + [Fact] + public void Deck_Without_Starting_Vanguard_Is_Rejected() + { + var harness = NewHarness(); + var deck = new List(); + + foreach (var printing in ServerTestData.Catalog.Printings + .Where(printing => !printing.IsParallel) + .OrderBy(printing => printing.PrintingId, StringComparer.Ordinal)) + { + if (deck.Count >= 50) + break; + + var card = ServerTestData.Catalog.CardOf(printing.PrintingId); + if (card.Grade == 0 || card.IsSentinel || card.Trigger == Vanguard.Rules.Cards.TriggerType.Heal) + continue; + + for (var i = 0; i < 4 && deck.Count < 50; i++) + deck.Add(printing.PrintingId); + } + + Assert.Equal(50, deck.Count); + + var exception = Assert.Throws( + () => harness.Matchmaking.Enqueue("p1", deck, GameMode.ClassicRanked)); + + Assert.Contains(exception.Violations, + violation => violation.Code == "deck.starting-vanguard"); + } + + [Fact] + public void Cancel_Removes_From_Queue() + { + var harness = NewHarness(); + var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + + Assert.True(harness.Matchmaking.Cancel(ticket.QueueId, "p1")); + + var status = harness.Matchmaking.Poll(ticket.QueueId, "p1")!; + Assert.Equal("cancelled", status.Status); + Assert.Equal(0, harness.Matchmaking.QueuedCount); + Assert.False(harness.Matchmaking.Cancel(ticket.QueueId, "p1")); + } + + [Fact] + public void Second_Queue_Request_Replaces_The_First() + { + var harness = NewHarness(); + var old = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + var current = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + + Assert.Equal("cancelled", harness.Matchmaking.Poll(old.QueueId, "p1")!.Status); + Assert.Equal("queued", harness.Matchmaking.Poll(current.QueueId, "p1")!.Status); + Assert.Equal(1, harness.Matchmaking.QueuedCount); + } + + [Fact] + public void Queue_Expires_After_Timeout() + { + var harness = NewHarness(); + var ticket = harness.Matchmaking.Enqueue("p1", ServerTestData.BuildDeck(), GameMode.ClassicRanked); + + harness.Time.Advance(TimeSpan.FromSeconds(harness.Options.QueueExpirySeconds + 1)); + harness.Matchmaking.Matchmake(harness.Time.GetUtcNow()); + + Assert.Equal("expired", harness.Matchmaking.Poll(ticket.QueueId, "p1")!.Status); + Assert.Equal(0, harness.Matchmaking.QueuedCount); + } + + [Fact] + public async Task Ranked_Result_Changes_Hidden_Mmr_Casual_Does_Not() + { + var profiles = new PlayerProfileStore(); + + await profiles.RecordAsync(new MatchResult( + "m1", GameMode.ClassicRanked, "winner", "loser", 0)); + + Assert.True(profiles.GetMmr("winner") > 1000); + Assert.True(profiles.GetMmr("loser") < 1000); + + var winnerAfterRanked = profiles.GetMmr("winner"); + var loserAfterRanked = profiles.GetMmr("loser"); + + await profiles.RecordAsync(new MatchResult( + "m2", GameMode.ClassicCasual, "winner", "loser", 0)); + + Assert.Equal(winnerAfterRanked, profiles.GetMmr("winner")); + Assert.Equal(loserAfterRanked, profiles.GetMmr("loser")); + } + + private static Harness NewHarness() + { + var time = new FakeTimeProvider(new DateTimeOffset(2026, 10, 21, 12, 0, 0, TimeSpan.Zero)); + var options = ServerTestData.SmallOptions(); + var profiles = new PlayerProfileStore(); + var tickets = new GameTicketService(options, time); + + var services = new ServiceCollection(); + services.AddSingleton(ServerTestData.Catalog); + services.AddSingleton(options); + services.AddSingleton(time); + services.AddSingleton(profiles); + services.AddLogging(); + + var provider = services.BuildServiceProvider(); + var registry = new MatchGrainRegistry(provider, options, NullLogger.Instance); + var matchmaking = new MatchmakingService( + ServerTestData.Catalog, + registry, + tickets, + profiles, + options, + time, + NullLogger.Instance); + + return new Harness(time, options, profiles, tickets, registry, matchmaking); + } + + private sealed record Harness( + FakeTimeProvider Time, + GameOptions Options, + PlayerProfileStore Profiles, + GameTicketService Tickets, + MatchGrainRegistry Registry, + MatchmakingService Matchmaking); +} diff --git a/server/tests/Vanguard.Server.Tests/ServerTestData.cs b/server/tests/Vanguard.Server.Tests/ServerTestData.cs new file mode 100644 index 0000000..31246bc --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/ServerTestData.cs @@ -0,0 +1,141 @@ +using Vanguard.Content; +using Vanguard.Rules.Cards; +using Vanguard.Server.Configuration; + +namespace Vanguard.Server.Tests; + +internal static class ServerTestData +{ + private static readonly Lazy LazyCatalog = new( + () => CatalogLoader.LoadDirectory(ContentPaths.Resolve(null)), + isThreadSafe: true); + + public static CardCatalog Catalog => LazyCatalog.Value; + + /// Колода без Sentinel и Heal, ровно 50 карт, до 4 копий на nameId. + public static List BuildDeck() + { + var deck = new List(); + var counts = new Dictionary(StringComparer.Ordinal); + + foreach (var printing in Catalog.Printings + .Where(p => !p.IsParallel) + .OrderBy(p => p.PrintingId, StringComparer.Ordinal)) + { + if (deck.Count >= 50) + break; + + var card = Catalog.CardOf(printing.PrintingId); + if (card.IsSentinel || card.Trigger == TriggerType.Heal) + continue; + + counts.TryGetValue(card.NameId, out var used); + var room = Math.Min(4 - used, 50 - deck.Count); + for (var i = 0; i < room; i++) + deck.Add(printing.PrintingId); + + counts[card.NameId] = used + room; + } + + Assert.Equal(50, deck.Count); + return deck; + } + + public static GameOptions SmallOptions() => new() + { + TurnSeconds = 90, + BankSeconds = 300, + AbsenceSeconds = 300, + ClockSyncSeconds = 5, + TicketTtlSeconds = 60, + ConnectTimeoutSeconds = 5, + CompletedMatchTtlSeconds = 600, + QueueExpirySeconds = 600, + MmrInitialWindow = 100, + MmrWindowStep = 50, + MmrWindowIntervalSeconds = 5, + MmrMaxWindow = 2000, + }; +} + +internal sealed class FakeTimeProvider : TimeProvider +{ + private DateTimeOffset _now; + + public FakeTimeProvider(DateTimeOffset now) => _now = now; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan delta) => _now += delta; +} + +internal sealed class FakeMatchConnection : Matches.IMatchConnection +{ + private readonly object _gate = new(); + private readonly List _frames = new(); + + public string Id { get; } = Guid.NewGuid().ToString("N"); + public bool IsClosed { get; private set; } + public int CloseCode { get; private set; } + + public IReadOnlyList Frames + { + get + { + lock (_gate) + return _frames.ToList(); + } + } + + public Task SendAsync( + Vanguard.Shared.Protocol.ServerFrame frame, + CancellationToken cancellationToken = default) + { + lock (_gate) + _frames.Add(frame); + + return Task.CompletedTask; + } + + public Task CloseAsync(int code, string reason) + { + IsClosed = true; + CloseCode = code; + return Task.CompletedTask; + } + + public T Last() where T : Vanguard.Shared.Protocol.ServerFrame + { + lock (_gate) + return _frames.OfType().Last(); + + } + + public IReadOnlyList All() where T : Vanguard.Shared.Protocol.ServerFrame + { + lock (_gate) + return _frames.OfType().ToList(); + } +} + +internal sealed class RecordingResultSink : Matches.IMatchResultSink +{ + private readonly List _results = new(); + + public IReadOnlyList Results + { + get + { + lock (_results) + return _results.ToList(); + } + } + + public Task RecordAsync(Matches.MatchResult result) + { + lock (_results) + _results.Add(result); + + return Task.CompletedTask; + } +} diff --git a/server/tests/Vanguard.Server.Tests/TestGameClient.cs b/server/tests/Vanguard.Server.Tests/TestGameClient.cs new file mode 100644 index 0000000..40f3822 --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/TestGameClient.cs @@ -0,0 +1,201 @@ +using System.Net.WebSockets; +using Microsoft.AspNetCore.Mvc.Testing; +using Vanguard.Shared.Protocol; +using Xunit.Sdk; + +namespace Vanguard.Server.Tests; + +/// +/// Минимальный клиент протокола для интеграционных тестов: +/// шлёт интенты, догоняет состояние и проверяет кадры. +/// +internal sealed class TestGameClient : IAsyncDisposable +{ + private readonly WebSocket _socket; + private readonly SemaphoreSlim _sendLock = new(1, 1); + + private TestGameClient(WebSocket socket, string playerId) + { + _socket = socket; + PlayerId = playerId; + } + + public string PlayerId { get; } + public int Seat { get; private set; } + public long LastSeq { get; private set; } + public MatchView View { get; private set; } = null!; + + public static async Task ConnectAsync( + WebApplicationFactory factory, + string matchId, + string ticket, + string playerId, + long resumeFromSeq, + CancellationToken cancellationToken) + { + var webSocketClient = factory.Server.CreateWebSocketClient(); + var socket = await webSocketClient.ConnectAsync( + new Uri($"ws://localhost/game"), cancellationToken); + + var client = new TestGameClient(socket, playerId); + + await client.SendAsync(new ClientHello + { + ProtocolVersion = ProtocolConstants.Version, + Ticket = ticket, + ClientBuild = "tests", + ResumeFromSeq = resumeFromSeq, + }, cancellationToken); + + var hello = await client.ReadUntilAsync(cancellationToken); + if (hello.MatchId != matchId) + throw new XunitException($"Ожидался матч {matchId}, пришёл {hello.MatchId}."); + + client.Seat = hello.PlayerIndex; + client.LastSeq = hello.EventSeq; + + if (hello.Resumed) + { + // после hello сервер присылает события догона и часы + var events = await client.ReadUntilAsync(cancellationToken); + if (events.ToSeq != hello.EventSeq) + throw new XunitException($"Догон дошёл до {events.ToSeq}, ожидалось {hello.EventSeq}."); + } + else + { + var snapshot = await client.ReadUntilAsync(cancellationToken); + client.View = snapshot.View; + client.LastSeq = snapshot.View.EventSeq; + } + + // после догона нужен снимок, чтобы получить MatchView + if (client.View is null) + await client.RequestSnapshotAsync(cancellationToken); + + return client; + } + + public async Task SendIntentAsync( + string intentId, + IntentPayload intent, + CancellationToken cancellationToken) + { + await SendAsync(new ClientIntent + { + IntentId = intentId, + BasedOnSeq = LastSeq, + Intent = intent, + }, cancellationToken); + } + + public async Task SubmitAsync( + string intentId, + IntentPayload intent, + CancellationToken cancellationToken) + { + await SendIntentAsync(intentId, intent, cancellationToken); + var result = await ReadUntilAsync(cancellationToken); + + if (result.IntentId != intentId) + throw new XunitException($"Ожидался результат {intentId}, пришёл {result.IntentId}."); + + return result; + } + + public async Task RequestSnapshotAsync(CancellationToken cancellationToken) + { + await SendAsync(new ClientResync + { + FromSeq = LastSeq + 1, + FullSnapshot = true, + }, cancellationToken); + + var snapshot = await ReadUntilAsync(cancellationToken); + View = snapshot.View; + LastSeq = snapshot.View.EventSeq; + } + + public async Task ReadUntilAsync(CancellationToken cancellationToken) + where T : ServerFrame + { + for (var i = 0; i < 2000; i++) + { + var frame = await ReadFrameAsync(cancellationToken); + + if (frame is T typed) + return typed; + + if (frame is ServerError error) + throw new XunitException( + $"Сервер ответил ошибкой {error.Code}: {error.Message}"); + } + + throw new XunitException($"Не дождались кадра {typeof(T).Name}."); + } + + public async Task SendAsync(ClientFrame frame, CancellationToken cancellationToken) + { + var bytes = ProtocolSerializer.Encode(frame); + + await _sendLock.WaitAsync(cancellationToken); + try + { + await _socket.SendAsync( + bytes.AsMemory(), + WebSocketMessageType.Binary, + endOfMessage: true, + cancellationToken); + } + finally + { + _sendLock.Release(); + } + } + + private async Task ReadFrameAsync(CancellationToken cancellationToken) + { + var buffer = new byte[16 * 1024]; + using var stream = new MemoryStream(); + + while (true) + { + var result = await _socket.ReceiveAsync(buffer.AsMemory(), cancellationToken); + + if (result.MessageType == WebSocketMessageType.Close) + throw new XunitException( + $"Сокет закрыт: {_socket.CloseStatus} {_socket.CloseStatusDescription}"); + + if (result.MessageType != WebSocketMessageType.Binary) + throw new XunitException("Сервер прислал текстовый кадр."); + + stream.Write(buffer, 0, result.Count); + + if (stream.Length > ProtocolConstants.MaxFrameBytes) + throw new XunitException("Кадр сервера слишком велик."); + + if (result.EndOfMessage) + break; + } + + return ProtocolSerializer.DecodeServer(stream.ToArray()); + } + + public async ValueTask DisposeAsync() + { + try + { + if (_socket.State == WebSocketState.Open) + await _socket.CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, + "bye", + CancellationToken.None); + } + catch (Exception) + { + // тестовый клиент мог уже отвалиться + } + + _socket.Dispose(); + _sendLock.Dispose(); + } +} diff --git a/server/tests/Vanguard.Server.Tests/Vanguard.Server.Tests.csproj b/server/tests/Vanguard.Server.Tests/Vanguard.Server.Tests.csproj new file mode 100644 index 0000000..37ac378 --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/Vanguard.Server.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + +