Add a playable WebSocket match server with matchmaking and reconnect
This commit is contained in:
parent
d4e6e9d8c0
commit
99d5b95000
|
|
@ -0,0 +1,9 @@
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
client
|
||||||
|
launcher
|
||||||
|
tools
|
||||||
|
docs
|
||||||
|
server/tests
|
||||||
|
|
@ -70,8 +70,9 @@
|
||||||
/tools/import_cards.py
|
/tools/import_cards.py
|
||||||
```
|
```
|
||||||
- [x] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`.
|
- [x] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`.
|
||||||
- [ ] `docker-compose.yml`: postgres, redis (AOF), meta-api, game-silo, nginx.
|
- [~] `docker-compose.yml`: сейчас только game-server; postgres, redis (AOF), meta-api, nginx — со следующим блоком.
|
||||||
- [ ] `docker compose up` поднимает БД и пустой API; healthcheck отвечает.
|
- [ ] `docker compose up` поднимает БД и пустой API; healthcheck отвечает.
|
||||||
|
— сервер уже поднимается и отвечает `/health`, БД ещё нет.
|
||||||
- [ ] CI: сборка solution + `dotnet test` на push.
|
- [ ] CI: сборка solution + `dotnet test` на push.
|
||||||
|
|
||||||
**Готово, когда:** `docker compose up` + `dotnet run` дают живой `/health`.
|
**Готово, когда:** `docker compose up` + `dotnet run` дают живой `/health`.
|
||||||
|
|
@ -172,18 +173,22 @@
|
||||||
|
|
||||||
- [x] Спецификация протокола в `docs/protocol.md`: кадры, handshake, версия, ping/pong, reconnect.
|
- [x] Спецификация протокола в `docs/protocol.md`: кадры, handshake, версия, ping/pong, reconnect.
|
||||||
- [x] `Vanguard.Shared`: DTO интентов и событий, MessagePack-резолвер.
|
- [x] `Vanguard.Shared`: DTO интентов и событий, MessagePack-резолвер.
|
||||||
- [ ] `IGameTicketService`: одноразовый тикет на подключение (TTL 60 с).
|
- [x] `IGameTicketService`: одноразовый тикет на подключение (TTL 60 с).
|
||||||
- [ ] Транспорт матча: WebSocket (ASP.NET Core); резерв — TCP с префиксом длины. Accept, handshake, auth по тикету, привязка к grain.
|
- [x] Транспорт матча: WebSocket (ASP.NET Core); резерв — TCP с префиксом длины. Accept, handshake, auth по тикету, привязка к grain.
|
||||||
- [ ] `IMatchGrain`: 1 матч = 1 grain, последовательная обработка.
|
- [x] `IMatchGrain`: 1 матч = 1 grain, последовательная обработка.
|
||||||
|
— реализовано in-process; Orleans-хостинг — следующий шаг, контракт отделён.
|
||||||
- [x] Проекция состояния под клиента: своя рука, свой trigger, открытые зоны; **ничего** о колоде, руке противника и закрытом уроне.
|
- [x] Проекция состояния под клиента: своя рука, свой trigger, открытые зоны; **ничего** о колоде, руке противника и закрытом уроне.
|
||||||
- [~] `eventSeq` + `resync` по запросу.
|
- [x] `eventSeq` + `resync` по запросу.
|
||||||
- [~] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось».
|
- [x] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось».
|
||||||
- [~] Политика отсутствия: 5 минут → авто-поражение.
|
- [x] Политика отсутствия: 5 минут → авто-поражение.
|
||||||
- [~] Таймер хода: 90 с + банк 5 мин.
|
- [x] Таймер хода: 90 с + банк 5 мин.
|
||||||
- [ ] Снапшот матча в PostgreSQL + восстановление grain после рестарта.
|
- [ ] Снапшот матча в PostgreSQL + восстановление grain после рестарта.
|
||||||
- [~] Idempotency по `intentId`.
|
- [x] Idempotency по `intentId`.
|
||||||
|
- [x] HTTP-очередь и тикеты: `docs/server-api.md`.
|
||||||
|
- [x] E2E: два клиента играют полный матч по WebSocket; обрыв и возврат проверены.
|
||||||
|
|
||||||
**Готово, когда:** два клиента играют полный матч по сети; kill процесса клиента на любом шаге восстанавливается.
|
**Готово, когда:** два клиента играют полный матч по сети; kill процесса клиента на любом шаге восстанавливается.
|
||||||
|
— выполнено в `Vanguard.Server.Tests`; рестарт сервера пока теряет матчи (ждёт PostgreSQL).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -13,7 +13,9 @@
|
||||||
| Контент-пайплайн и валидатор | готово |
|
| Контент-пайплайн и валидатор | готово |
|
||||||
| Игровое ядро (правила) | каркас: фазы, райд, призыв, бой, драйв, урон |
|
| Игровое ядро (правила) | каркас: фазы, райд, призыв, бой, драйв, урон |
|
||||||
| Протокол и контракты (`docs/protocol.md`, `Vanguard.Shared`) | готово |
|
| Протокол и контракты (`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
|
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.Rules ядро правил: карты, колоды, состояние игры
|
||||||
/server/src/Vanguard.Content загрузка и проверка YAML-контента
|
/server/src/Vanguard.Content загрузка и проверка YAML-контента
|
||||||
/server/src/Vanguard.Shared сетевые контракты (netstandard2.1 для Unity + net10.0)
|
/server/src/Vanguard.Shared сетевые контракты (netstandard2.1 для Unity + net10.0)
|
||||||
|
/server/src/Vanguard.Server ASP.NET Core: WebSocket, очередь, тикеты, матчи
|
||||||
/server/src/Vanguard.Cli консольные команды
|
/server/src/Vanguard.Cli консольные команды
|
||||||
/server/tests тесты правил и контента
|
/server/tests тесты правил, контрактов и сервера (включая E2E)
|
||||||
/content/sets карты в YAML
|
/content/sets карты в YAML
|
||||||
/content/keywords.yaml ключевые слова и иконки
|
/content/keywords.yaml ключевые слова и иконки
|
||||||
/content/translations/ru.yaml русские тексты
|
/content/translations/ru.yaml русские тексты
|
||||||
/tools импорт и сид переводов
|
/tools импорт и сид переводов
|
||||||
/docs правила перевода и прочие документы
|
/docs протокол, API, правила перевода
|
||||||
|
/docker-compose.yml сервер в контейнере
|
||||||
```
|
```
|
||||||
|
|
||||||
`Vanguard.Rules` — чистая библиотека без Orleans, БД и сети. Это позволяет прогонять
|
`Vanguard.Rules` — чистая библиотека без Orleans, БД и сети. Это позволяет прогонять
|
||||||
|
|
@ -116,4 +130,5 @@ dotnet run --project server/src/Vanguard.Cli -- translations check
|
||||||
- `IMPLEMENTATION_CHECKLIST.md` — план реализации по блокам и гейтам.
|
- `IMPLEMENTATION_CHECKLIST.md` — план реализации по блокам и гейтам.
|
||||||
- `docs/HANDOFF.md` — состояние на конец сессии и порядок следующих шагов.
|
- `docs/HANDOFF.md` — состояние на конец сессии и порядок следующих шагов.
|
||||||
- `docs/protocol.md` — протокол матча: кадры, интенты, события, таймеры, реконнект.
|
- `docs/protocol.md` — протокол матча: кадры, интенты, события, таймеры, реконнект.
|
||||||
|
- `docs/server-api.md` — HTTP-очередь, тикеты и `/health`.
|
||||||
- `content/cards-mvp.md` — каталог карт с разбором состава сетов.
|
- `content/cards-mvp.md` — каталог карт с разбором состава сетов.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
133
docs/HANDOFF.md
133
docs/HANDOFF.md
|
|
@ -38,13 +38,14 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
|
||||||
|
|
||||||
| Проверка | Результат |
|
| Проверка | Результат |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Тесты | **129/129** |
|
| Тесты | **158/158** (131 правила/контракты + 27 сервер) |
|
||||||
| `validate` | 0 ошибок, 0 предупреждений |
|
| `validate` | 0 ошибок, 0 предупреждений |
|
||||||
| `translations check` | 94/94 |
|
| `translations check` | 94/94 |
|
||||||
| `stress 1000` | 1000/1000 партий, 0 ошибок |
|
| `stress 1000` | 1000/1000 партий, 0 ошибок |
|
||||||
| Карты | **94/94** (66 с текстом + 28 ванильных) |
|
| Карты | **94/94** (66 с текстом + 28 ванильных) |
|
||||||
| Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы |
|
| Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы |
|
||||||
| Сервер / клиент / лаунчер | не начаты |
|
| Сервер | полный матч по сети, матчмейкинг, реконнект (in-memory) |
|
||||||
|
| Клиент / лаунчер | не начаты |
|
||||||
|
|
||||||
Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям:
|
Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям:
|
||||||
боты доигрывают 1000 партий, инварианты держатся, реплей воспроизводится
|
боты доигрывают 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
|
OK: ошибок 0, предупреждений 0
|
||||||
Переведено: 94/94
|
Переведено: 94/94
|
||||||
Партий: 1000, завершено: 1000, ошибок: 0
|
Партий: 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`.
|
Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -190,6 +199,44 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
- Проекция вызывается для наблюдателя без побочных эффектов и не трогает
|
- Проекция вызывается для наблюдателя без побочных эффектов и не трогает
|
||||||
`LegalCommands()` чужого игрока (иначе утекала бы рука решающего).
|
`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. Структура и ключевые файлы
|
## 5. Структура и ключевые файлы
|
||||||
|
|
@ -215,12 +262,24 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
/server/src/Vanguard.Shared
|
/server/src/Vanguard.Shared
|
||||||
/Protocol/*.cs кадры, интенты, события, view, кодек
|
/Protocol/*.cs кадры, интенты, события, view, кодек
|
||||||
/Projection/*.cs проекция состояния, фильтр событий, маппинг
|
/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/sets карты
|
||||||
/content/translations/ru.yaml переводы
|
/content/translations/ru.yaml переводы
|
||||||
/docs/translation-rules.md канон перевода
|
/docs/translation-rules.md канон перевода
|
||||||
/docs/protocol.md протокол матча, версия 1
|
/docs/protocol.md протокол матча, версия 1
|
||||||
|
/docs/server-api.md HTTP-очередь, тикеты, /health
|
||||||
/docs/HANDOFF.md этот файл
|
/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.
|
- Бустер: 5 карт = 4 C + 1 R+; веса R 70 / RR 22 / RRR 7 / SP 1.
|
||||||
- Gold за матч: 30/15/20 (победа/поражение/ничья); Gold = XP / 5.
|
- Gold за матч: 30/15/20 (победа/поражение/ничья); Gold = XP / 5.
|
||||||
- Таймер хода 90 с + банк 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+ — выбор.
|
- Поиск в колоде: 0 — уведомление и перемешивание, 1 — сразу, 2+ — выбор.
|
||||||
|
|
@ -261,28 +330,28 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
2. **[x] `Vanguard.Shared`**:
|
2. **[x] `Vanguard.Shared`**:
|
||||||
DTO интентов и событий, MessagePack-резолвер, проекция состояния
|
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`**:
|
6. **Orleans** — заменить in-process регистр на настоящий silo и граны.
|
||||||
- ASP.NET Core + WebSocket;
|
`IMatchGrain` уже отделён от транспорта и ASP.NET Core.
|
||||||
- `IGameTicketService` (одноразовый тикет, TTL 60 с);
|
7. **Персистентность**:
|
||||||
- `IMatchGrain` — 1 матч = 1 grain, последовательная обработка;
|
- PostgreSQL — снапшот матча, восстановление grain после рестарта;
|
||||||
- матчмейкинг: 2 очереди (Classic Ranked / Casual);
|
|
||||||
- скрытый MMR, расширяющееся окно;
|
|
||||||
- идемпотентность по `intentId`.
|
|
||||||
4. **Проекция состояния** — контракты готовы, подключить к грану:
|
|
||||||
- своя рука и свой триггер — полностью;
|
|
||||||
- открытые зоны — полностью;
|
|
||||||
- колода, рука противника, закрытый урон — только количество;
|
|
||||||
- seed и RNG не уходят.
|
|
||||||
5. **Персистентность**:
|
|
||||||
- PostgreSQL — снапшот матча, восстановление grain;
|
|
||||||
- Redis — тикеты, очереди, кэш.
|
- Redis — тикеты, очереди, кэш.
|
||||||
6. **Docker Compose** + `/health`.
|
8. **JWT и мета-API** — `playerId` перестанет быть dev-заглушкой.
|
||||||
|
|
||||||
Критерий готовности: два клиента играют полный матч по сети; kill клиента
|
Критерий готовности серверного блока выполнен: два клиента играют полный
|
||||||
на любом шаге восстанавливается.
|
матч по сети; kill клиента на любом шаге восстанавливается через новый
|
||||||
|
тикет и `resumeFromSeq`. Рестарт **сервера** пока теряет матчи: это
|
||||||
|
закрывается пунктом 7.
|
||||||
|
|
||||||
### 7.2. Мета-API
|
### 7.2. Мета-API
|
||||||
|
|
||||||
|
|
@ -324,8 +393,10 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
|
|
||||||
1. Прочитать `docs/HANDOFF.md`, `IMPLEMENTATION_CHECKLIST.md`, `README.md`.
|
1. Прочитать `docs/HANDOFF.md`, `IMPLEMENTATION_CHECKLIST.md`, `README.md`.
|
||||||
2. Прогнать проверки из раздела 3.
|
2. Прогнать проверки из раздела 3.
|
||||||
3. Продолжить с `Vanguard.Server`: ASP.NET Core + WebSocket, тикеты,
|
3. Следующий блок — персистентность и Orleans: PostgreSQL-снапшоты,
|
||||||
`IMatchGrain`, матчмейкинг. `docs/protocol.md` и `Vanguard.Shared` уже готовы.
|
Redis для тикетов/очередей, замена in-process регистра на silo.
|
||||||
|
Сетевой матч уже работает: `docs/protocol.md`, `Vanguard.Shared`,
|
||||||
|
`Vanguard.Server`, `docs/server-api.md`.
|
||||||
4. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
|
4. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
|
||||||
5. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
|
5. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
|
||||||
|
|
||||||
|
|
@ -345,6 +416,20 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
`net10.0`; в `netstandard2.1` (Unity) ссылки на `Vanguard.Rules` нет.
|
`net10.0`; в `netstandard2.1` (Unity) ссылки на `Vanguard.Rules` нет.
|
||||||
- **Видимость.** `LegalCommands()` описывает решающего игрока. Вызывать его
|
- **Видимость.** `LegalCommands()` описывает решающего игрока. Вызывать его
|
||||||
для наблюдателя нельзя — в списке есть printingId из чужой руки.
|
для наблюдателя нельзя — в списке есть printingId из чужой руки.
|
||||||
|
- **Сервер: rate limit.** 16 интентов в секунду на соединение. Быстрый бот
|
||||||
|
в E2E специально притормаживается; живой UI в лимит не упирается.
|
||||||
|
- **Сервер: in-memory.** Очереди, тикеты, матчи и профили живут в памяти.
|
||||||
|
Рестарт сервера теряет матчи; kill клиента и реконнект переживаются.
|
||||||
|
- **Сервер: тесты.** E2E поднимает `WebApplicationFactory<Program>`,
|
||||||
|
играет полный матч по WebSocket и проверяет, что победитель совпал.
|
||||||
|
Случайные партии завершаются за ~350 действий; таймаут теста — 120 с.
|
||||||
|
- **Сервер: таймеры.** Тик раз в секунду. После 90 с банк списывается
|
||||||
|
по фактическому превышению; `TurnDeadlineMs` в этот момент сдвигается,
|
||||||
|
поэтому UI должен показывать ещё и `BankMs`.
|
||||||
|
- **Сервер: часы.** Отсчёт стартует только когда подключились оба места.
|
||||||
|
До этого `ClockView.IsRunning = false`, но таймер отсутствия уже идёт.
|
||||||
|
- **Сервер: проекция.** `ServerSnapshot` и `ServerEvents` собираются
|
||||||
|
отдельно на каждое место; не переиспользовать один `MatchView` на двоих.
|
||||||
- **Порядок фаз.** Вход в Основную — `EnterMainPhase()` с `OnPhaseStart`.
|
- **Порядок фаз.** Вход в Основную — `EnterMainPhase()` с `OnPhaseStart`.
|
||||||
- **Отложенные продолжения.** `State.Continuation` — что выполнить после паузы;
|
- **Отложенные продолжения.** `State.Continuation` — что выполнить после паузы;
|
||||||
`State.PendingQueue` + `_resolvingDepth` запрещают вложенность эффектов.
|
`State.PendingQueue` + `_resolvingDepth` запрещают вложенность эффектов.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
- `docs/HANDOFF.md` — состояние проекта и порядок блоков;
|
- `docs/HANDOFF.md` — состояние проекта и порядок блоков;
|
||||||
- `IMPLEMENTATION_CHECKLIST.md`, раздел 5 — план сетевого блока;
|
- `IMPLEMENTATION_CHECKLIST.md`, раздел 5 — план сетевого блока;
|
||||||
|
- `docs/server-api.md` — HTTP-очередь, тикеты и `/health`;
|
||||||
- `server/src/Vanguard.Shared` — DTO, MessagePack-резолвер и проекция состояния.
|
- `server/src/Vanguard.Shared` — DTO, MessagePack-резолвер и проекция состояния.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -454,6 +455,7 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
|
||||||
| `MalformedFrame` | мусор, пустой кадр, превышен размер |
|
| `MalformedFrame` | мусор, пустой кадр, превышен размер |
|
||||||
| `RateLimited` | слишком много интентов |
|
| `RateLimited` | слишком много интентов |
|
||||||
| `MatchFinished` | партия закончена, соединение больше не нужно |
|
| `MatchFinished` | партия закончена, соединение больше не нужно |
|
||||||
|
| `Forbidden` | тикет валиден, но место в матче чужое |
|
||||||
| `Internal` | непредвиденная ошибка сервера |
|
| `Internal` | непредвиденная ошибка сервера |
|
||||||
|
|
||||||
Коды закрытия WebSocket (диапазон приложения 4000–4999):
|
Коды закрытия WebSocket (диапазон приложения 4000–4999):
|
||||||
|
|
@ -462,6 +464,7 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
|
||||||
|---|---|
|
|---|---|
|
||||||
| `4400` | нарушение протокола (handshake, битый кадр) |
|
| `4400` | нарушение протокола (handshake, битый кадр) |
|
||||||
| `4401` | не авторизован (тикет) |
|
| `4401` | не авторизован (тикет) |
|
||||||
|
| `4403` | место в матче не принадлежит игроку |
|
||||||
| `4408` | heartbeat не получен |
|
| `4408` | heartbeat не получен |
|
||||||
| `4409` | соединение заменено новым |
|
| `4409` | соединение заменено новым |
|
||||||
| `4410` | неподдерживаемая версия протокола |
|
| `4410` | неподдерживаемая версия протокола |
|
||||||
|
|
|
||||||
|
|
@ -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 в него
|
||||||
|
не упирается; тестовый бот специально притормаживается.
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
<Project Path="src/Vanguard.Cli/Vanguard.Cli.csproj" />
|
<Project Path="src/Vanguard.Cli/Vanguard.Cli.csproj" />
|
||||||
<Project Path="src/Vanguard.Content/Vanguard.Content.csproj" />
|
<Project Path="src/Vanguard.Content/Vanguard.Content.csproj" />
|
||||||
<Project Path="src/Vanguard.Rules/Vanguard.Rules.csproj" />
|
<Project Path="src/Vanguard.Rules/Vanguard.Rules.csproj" />
|
||||||
|
<Project Path="src/Vanguard.Server/Vanguard.Server.csproj" />
|
||||||
<Project Path="src/Vanguard.Shared/Vanguard.Shared.csproj" />
|
<Project Path="src/Vanguard.Shared/Vanguard.Shared.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/Vanguard.Rules.Tests/Vanguard.Rules.Tests.csproj" />
|
<Project Path="tests/Vanguard.Rules.Tests/Vanguard.Rules.Tests.csproj" />
|
||||||
|
<Project Path="tests/Vanguard.Server.Tests/Vanguard.Server.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,9 @@ public sealed class GameEngine
|
||||||
ulong seed,
|
ulong seed,
|
||||||
IReadOnlyList<string> deck0,
|
IReadOnlyList<string> deck0,
|
||||||
IReadOnlyList<string> deck1,
|
IReadOnlyList<string> deck1,
|
||||||
IReadOnlyDictionary<string, CardScript>? scripts = null)
|
IReadOnlyDictionary<string, CardScript>? scripts = null,
|
||||||
|
string player0Name = "Player 1",
|
||||||
|
string player1Name = "Player 2")
|
||||||
{
|
{
|
||||||
var violations = DeckValidator.Validate(catalog, deck0.ToList())
|
var violations = DeckValidator.Validate(catalog, deck0.ToList())
|
||||||
.Concat(DeckValidator.Validate(catalog, deck1.ToList()))
|
.Concat(DeckValidator.Validate(catalog, deck1.ToList()))
|
||||||
|
|
@ -58,7 +60,7 @@ public sealed class GameEngine
|
||||||
"Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message)));
|
"Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message)));
|
||||||
|
|
||||||
_scripts = scripts ?? CardScripts.Default;
|
_scripts = scripts ?? CardScripts.Default;
|
||||||
State = new GameState(catalog, seed, "Player 1", "Player 2");
|
State = new GameState(catalog, seed, player0Name, player1Name);
|
||||||
FillDeck(0, deck0);
|
FillDeck(0, deck0);
|
||||||
FillDeck(1, deck1);
|
FillDeck(1, deck1);
|
||||||
}
|
}
|
||||||
|
|
@ -1595,6 +1597,9 @@ public sealed class GameEngine
|
||||||
|
|
||||||
public CommandResult Execute(GameCommand command)
|
public CommandResult Execute(GameCommand command)
|
||||||
{
|
{
|
||||||
|
if (State.IsFinished)
|
||||||
|
return CommandResult.Reject("Партия завершена.");
|
||||||
|
|
||||||
// пока идёт разрешение способности или триггера, посторонние команды запрещены
|
// пока идёт разрешение способности или триггера, посторонние команды запрещены
|
||||||
if (State.Step is Step.Trigger or Step.Ability or Step.Choice)
|
if (State.Step is Step.Trigger or Step.Ability or Step.Choice)
|
||||||
{
|
{
|
||||||
|
|
@ -2607,4 +2612,28 @@ public sealed class GameEngine
|
||||||
State.Winner = 1 - player;
|
State.Winner = 1 - player;
|
||||||
_events.Add(new GameFinishedEvent(State.Winner.Value));
|
_events.Add(new GameFinishedEvent(State.Winner.Value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Завершить партию извне: сдача, истечение времени, отсутствие игрока.
|
||||||
|
/// Победа достаётся <paramref name="winner"/>, событие пишется в общий журнал.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
using Vanguard.Rules.Decks;
|
||||||
|
|
||||||
|
namespace Vanguard.Server.Api;
|
||||||
|
|
||||||
|
public sealed record QueueRequest(string? PlayerId, string? Mode, List<string>? 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<DeckViolation>? Violations = null);
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
using Vanguard.Server.Configuration;
|
||||||
|
using Vanguard.Server.Matches;
|
||||||
|
using Vanguard.Server.Matchmaking;
|
||||||
|
using Vanguard.Server.Tickets;
|
||||||
|
|
||||||
|
namespace Vanguard.Server.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP-часть матчевого блока: очередь, тикеты, здоровье.
|
||||||
|
/// Авторизация здесь ещё dev-заглушка: игрок называется playerId.
|
||||||
|
/// JWT из мета-API встанет на это место без изменения контрактов очереди.
|
||||||
|
/// </summary>
|
||||||
|
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 '.');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
namespace Vanguard.Server.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ищет каталог content/sets: сначала явный путь из конфигурации,
|
||||||
|
/// затем вверх от каталога сборки и текущего каталога.
|
||||||
|
/// </summary>
|
||||||
|
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 в конфигурации.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
namespace Vanguard.Server.Configuration;
|
||||||
|
|
||||||
|
public enum GameMode
|
||||||
|
{
|
||||||
|
ClassicRanked = 0,
|
||||||
|
ClassicCasual = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Стабильные имена режимов на проводе. Меняются только вместе с API.</summary>
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
using Vanguard.Shared.Protocol;
|
||||||
|
|
||||||
|
namespace Vanguard.Server.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Настройки матча. Значения по умолчанию совпадают с протоколом,
|
||||||
|
/// но тесты и локальный стенд могут их уменьшить.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Стартовое окно подбора по скрытому MMR.</summary>
|
||||||
|
public int MmrInitialWindow { get; set; } = 100;
|
||||||
|
|
||||||
|
/// <summary>Насколько окно расширяется за интервал ожидания.</summary>
|
||||||
|
public int MmrWindowStep { get; set; } = 50;
|
||||||
|
public int MmrWindowIntervalSeconds { get; set; } = 5;
|
||||||
|
public int MmrMaxWindow { get; set; } = 2000;
|
||||||
|
|
||||||
|
/// <summary>Внешний адрес WebSocket. Пусто — строится из запроса.</summary>
|
||||||
|
public string PublicWsUrl { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Где лежит контент: YAML сетов.</summary>
|
||||||
|
public sealed class ContentOptions
|
||||||
|
{
|
||||||
|
/// <summary>Путь к каталогу с BT01.yaml, TD01.yaml, TD02.yaml. Пусто — искать вверх по дереву.</summary>
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
using Vanguard.Server.Configuration;
|
||||||
|
using Vanguard.Shared.Protocol;
|
||||||
|
|
||||||
|
namespace Vanguard.Server.Matches;
|
||||||
|
|
||||||
|
/// <summary>Канал доставки кадров одному месту в матче.</summary>
|
||||||
|
public interface IMatchConnection
|
||||||
|
{
|
||||||
|
string Id { get; }
|
||||||
|
|
||||||
|
Task SendAsync(ServerFrame frame, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task CloseAsync(int code, string reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Одно место в матче: игрок и его колода.</summary>
|
||||||
|
public sealed record MatchSeatSetup(string PlayerId, IReadOnlyList<string> Deck, int Mmr);
|
||||||
|
|
||||||
|
/// <summary>Всё, что нужно для создания матча.</summary>
|
||||||
|
public sealed record MatchSetup(
|
||||||
|
string MatchId,
|
||||||
|
GameMode Mode,
|
||||||
|
MatchSeatSetup Seat0,
|
||||||
|
MatchSeatSetup Seat1);
|
||||||
|
|
||||||
|
/// <summary>Снимок и итог матча для регистратуры, health и записи результата.</summary>
|
||||||
|
public sealed record MatchSummary(
|
||||||
|
string MatchId,
|
||||||
|
GameMode Mode,
|
||||||
|
string Player0Id,
|
||||||
|
string Player1Id,
|
||||||
|
bool IsFinished,
|
||||||
|
int Winner,
|
||||||
|
long EventSeq,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset? FinishedAt);
|
||||||
|
|
||||||
|
/// <summary>Результат для записи в профили. MMR меняется только в ранкеде.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Контракт матча: 1 матч = 1 grain. Все методы, меняющие состояние,
|
||||||
|
/// выполняются последовательно внутри реализации.
|
||||||
|
///
|
||||||
|
/// Сейчас grain живёт в памяти процесса. Orleans-хостинг подключается
|
||||||
|
/// поверх этого же контракта: сетевая часть и правила от него не зависят.
|
||||||
|
/// </summary>
|
||||||
|
public interface IMatchGrain
|
||||||
|
{
|
||||||
|
string MatchId { get; }
|
||||||
|
|
||||||
|
MatchSummary Summary { get; }
|
||||||
|
|
||||||
|
bool HasPlayer(string playerId);
|
||||||
|
|
||||||
|
/// <summary>Подключить место. false — место не принадлежит игроку.</summary>
|
||||||
|
Task<bool> AttachAsync(
|
||||||
|
int seat,
|
||||||
|
string playerId,
|
||||||
|
IMatchConnection connection,
|
||||||
|
long resumeFromSeq);
|
||||||
|
|
||||||
|
/// <summary>Отключить место. Отсчёт отсутствия начинается здесь.</summary>
|
||||||
|
Task DetachAsync(int seat, IMatchConnection connection);
|
||||||
|
|
||||||
|
/// <summary>Обработать интент и отправить результат и события в соединения.</summary>
|
||||||
|
Task SubmitAsync(int seat, ClientIntent intent);
|
||||||
|
|
||||||
|
/// <summary>Догнать состояние: журналом или полным снимком.</summary>
|
||||||
|
Task ResyncAsync(int seat, long fromSeq, bool fullSnapshot);
|
||||||
|
|
||||||
|
/// <summary>Один тик часов и проверки отсутствия.</summary>
|
||||||
|
Task TickAsync(DateTimeOffset now);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Один матч. Все входы — интенты, подключения и тики — проходят через
|
||||||
|
/// один семафор, поэтому состояние движка меняется строго последовательно.
|
||||||
|
/// Ничего из ASP.NET Core и WebSocket внутри не лежит: только IMatchConnection.
|
||||||
|
/// </summary>
|
||||||
|
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<string> 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<string, IntentRecord> _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<MatchGrain> _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<MatchGrain> 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<bool> 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<bool> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Vanguard.Server.Configuration;
|
||||||
|
|
||||||
|
namespace Vanguard.Server.Matches;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Регистр матчей: ровно один экземпляр grain на matchId.
|
||||||
|
/// В Orleans эту роль играет активация грана; контракт совпадает.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MatchGrainRegistry
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, IMatchGrain> _matches = new(StringComparer.Ordinal);
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly GameOptions _options;
|
||||||
|
private readonly ILogger<MatchGrainRegistry> _logger;
|
||||||
|
|
||||||
|
public MatchGrainRegistry(
|
||||||
|
IServiceProvider services,
|
||||||
|
GameOptions options,
|
||||||
|
ILogger<MatchGrainRegistry> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_options = options;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IMatchGrain Create(MatchSetup setup)
|
||||||
|
{
|
||||||
|
var grain = ActivatorUtilities.CreateInstance<MatchGrain>(_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<IMatchGrain> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
using Vanguard.Server.Configuration;
|
||||||
|
|
||||||
|
namespace Vanguard.Server.Matches;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Один общий тикер на процесс: часы и проверки отсутствия.
|
||||||
|
/// Тик раз в секунду, отправка часов клиентам — по своему интервалу.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MatchTicker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly MatchGrainRegistry _registry;
|
||||||
|
private readonly TimeProvider _time;
|
||||||
|
private readonly ILogger<MatchTicker> _logger;
|
||||||
|
|
||||||
|
public MatchTicker(
|
||||||
|
MatchGrainRegistry registry,
|
||||||
|
TimeProvider time,
|
||||||
|
ILogger<MatchTicker> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
/// <summary>Колода не прошла правила декбилдера.</summary>
|
||||||
|
public sealed class DeckRejectedException : Exception
|
||||||
|
{
|
||||||
|
public DeckRejectedException(IReadOnlyList<DeckViolation> violations)
|
||||||
|
: base("Колода не проходит проверку.")
|
||||||
|
{
|
||||||
|
Violations = violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<DeckViolation> Violations { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Две очереди, скрытый MMR и расширяющееся окно.
|
||||||
|
///
|
||||||
|
/// Состояние в памяти: до Redis этого достаточно для одного процесса.
|
||||||
|
/// Наружу MMR и колоды не отдаются — только статус, тикет и имя противника.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MatchmakingService
|
||||||
|
{
|
||||||
|
private sealed class Entry
|
||||||
|
{
|
||||||
|
public required Guid QueueId { get; init; }
|
||||||
|
public required string PlayerId { get; init; }
|
||||||
|
public required IReadOnlyList<string> 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<Guid, Entry> _entries = new();
|
||||||
|
private readonly Dictionary<GameMode, List<Guid>> _queues = new()
|
||||||
|
{
|
||||||
|
[GameMode.ClassicRanked] = new List<Guid>(),
|
||||||
|
[GameMode.ClassicCasual] = new List<Guid>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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<MatchmakingService> _logger;
|
||||||
|
private long _sequence;
|
||||||
|
|
||||||
|
public MatchmakingService(
|
||||||
|
CardCatalog catalog,
|
||||||
|
MatchGrainRegistry matches,
|
||||||
|
GameTicketService tickets,
|
||||||
|
PlayerProfileStore profiles,
|
||||||
|
GameOptions options,
|
||||||
|
TimeProvider time,
|
||||||
|
ILogger<MatchmakingService> 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<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Один проход подбора. Вызывается фоновым воркером раз в секунду.</summary>
|
||||||
|
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<Guid>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
namespace Vanguard.Server.Matchmaking;
|
||||||
|
|
||||||
|
/// <summary>Раз в секунду двигает подбор очередей.</summary>
|
||||||
|
public sealed class MatchmakingWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly MatchmakingService _matchmaking;
|
||||||
|
private readonly TimeProvider _time;
|
||||||
|
private readonly ILogger<MatchmakingWorker> _logger;
|
||||||
|
|
||||||
|
public MatchmakingWorker(
|
||||||
|
MatchmakingService matchmaking,
|
||||||
|
TimeProvider time,
|
||||||
|
ILogger<MatchmakingWorker> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Vanguard.Server.Configuration;
|
||||||
|
using Vanguard.Server.Matches;
|
||||||
|
|
||||||
|
namespace Vanguard.Server.Profiles;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Скрытый MMR и простая статистика. Наружу MMR не отдаётся:
|
||||||
|
/// клиент видит только результат матча.
|
||||||
|
///
|
||||||
|
/// Хранилище в памяти — до появления PostgreSQL. Контракт уже отделён,
|
||||||
|
/// чтобы заменить реализацию, не трогая матч.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PlayerProfileStore : IMatchResultSink
|
||||||
|
{
|
||||||
|
private const int InitialMmr = 1000;
|
||||||
|
private const int EloK = 32;
|
||||||
|
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<string, Profile> _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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<GameOptions>(builder.Configuration.GetSection("Game"));
|
||||||
|
builder.Services.Configure<ContentOptions>(builder.Configuration.GetSection("Content"));
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<GameOptions>>().Value);
|
||||||
|
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<ContentOptions>>().Value);
|
||||||
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(sp =>
|
||||||
|
{
|
||||||
|
var content = sp.GetRequiredService<ContentOptions>();
|
||||||
|
var directory = ContentPaths.Resolve(content.Path);
|
||||||
|
return CatalogLoader.LoadDirectory(directory);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<GameTicketService>();
|
||||||
|
builder.Services.AddSingleton<PlayerProfileStore>();
|
||||||
|
builder.Services.AddSingleton<IMatchResultSink>(sp => sp.GetRequiredService<PlayerProfileStore>());
|
||||||
|
builder.Services.AddSingleton<MatchGrainRegistry>();
|
||||||
|
builder.Services.AddSingleton<MatchmakingService>();
|
||||||
|
builder.Services.AddHostedService<MatchTicker>();
|
||||||
|
builder.Services.AddHostedService<MatchmakingWorker>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// каталог грузим сразу: сервер без карт бесполезен, лучше упасть на старте
|
||||||
|
app.Services.GetRequiredService<CardCatalog>();
|
||||||
|
|
||||||
|
app.UseWebSockets(new WebSocketOptions
|
||||||
|
{
|
||||||
|
KeepAliveInterval = TimeSpan.FromSeconds(30),
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGameApi();
|
||||||
|
app.Map("/game", GameWebSocketEndpoint.HandleAsync);
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
/// <summary>Точка входа доступна интеграционным тестам через WebApplicationFactory.</summary>
|
||||||
|
public partial class Program
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Точка входа матчевого WebSocket. Отвечает только за транспорт:
|
||||||
|
/// handshake, чтение кадров, лимит частоты и закрытие. Вся игра — в grain.
|
||||||
|
/// </summary>
|
||||||
|
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<GameOptions>();
|
||||||
|
var tickets = services.GetRequiredService<GameTicketService>();
|
||||||
|
var matches = services.GetRequiredService<MatchGrainRegistry>();
|
||||||
|
var time = services.GetRequiredService<TimeProvider>();
|
||||||
|
var logger = services.GetRequiredService<ILoggerFactory>().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<ClientFrame?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
namespace Vanguard.Server.Realtime;
|
||||||
|
|
||||||
|
internal enum HeartbeatActionKind
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Ping,
|
||||||
|
Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct HeartbeatAction(HeartbeatActionKind Kind, long Nonce = 0);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Состояние heartbeat одного соединения. Любой кадр клиента считается
|
||||||
|
/// активностью; если активности нет — сервер шлёт ping, а без ответа
|
||||||
|
/// закрывает соединение.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using Vanguard.Shared.Protocol;
|
||||||
|
|
||||||
|
namespace Vanguard.Server.Realtime;
|
||||||
|
|
||||||
|
/// <summary>Чтение одного кадра протокола из бинарных сообщений WebSocket.</summary>
|
||||||
|
internal static class ProtocolFrameReader
|
||||||
|
{
|
||||||
|
public static async Task<ClientFrame?> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Не больше N интентов в секунду с одного соединения.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using Vanguard.Server.Matches;
|
||||||
|
using Vanguard.Shared.Protocol;
|
||||||
|
|
||||||
|
namespace Vanguard.Server.Realtime;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обёртка над WebSocket: один писатель, аккуратное закрытие и защита
|
||||||
|
/// от отправки в уже закрытый сокет.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Vanguard.Server.Configuration;
|
||||||
|
|
||||||
|
namespace Vanguard.Server.Tickets;
|
||||||
|
|
||||||
|
/// <summary>Одноразовый тикет на подключение к конкретному месту в матче.</summary>
|
||||||
|
public sealed record GameTicket(
|
||||||
|
string Token,
|
||||||
|
string PlayerId,
|
||||||
|
string MatchId,
|
||||||
|
int Seat,
|
||||||
|
DateTimeOffset ExpiresAt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Тикеты живут в памяти: TTL короткий, переживать рестарт им не нужно.
|
||||||
|
/// При переезде на нескольких инстансов сервис заменяется на Redis-реализацию.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GameTicketService
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, GameTicket> _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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Забирает тикет. null — неизвестный, уже использованный или просроченный.
|
||||||
|
/// Тикет одноразовый: повторный Redeem того же токена не сработает.
|
||||||
|
/// </summary>
|
||||||
|
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('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>Vanguard.Server</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Vanguard.Content\Vanguard.Content.csproj" />
|
||||||
|
<ProjectReference Include="..\Vanguard.Rules\Vanguard.Rules.csproj" />
|
||||||
|
<ProjectReference Include="..\Vanguard.Shared\Vanguard.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Vanguard.Server.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -115,4 +115,7 @@ public enum ProtocolErrorCode
|
||||||
|
|
||||||
/// <summary>Внутренняя ошибка сервера.</summary>
|
/// <summary>Внутренняя ошибка сервера.</summary>
|
||||||
Internal = 7,
|
Internal = 7,
|
||||||
|
|
||||||
|
/// <summary>Тикет валиден, но место в матче не принадлежит этому игроку.</summary>
|
||||||
|
Forbidden = 8,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<GameFinishedEvent>(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)
|
private static void AssertInvariants(GameState state, string context)
|
||||||
{
|
{
|
||||||
var ids = new HashSet<int>();
|
var ids = new HashSet<int>();
|
||||||
|
|
|
||||||
|
|
@ -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<JsonElement>("/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<TicketResponse>();
|
||||||
|
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<Program> NewFactory() => new();
|
||||||
|
|
||||||
|
private static async Task<QueueAccepted> EnqueueAsync(
|
||||||
|
HttpClient http,
|
||||||
|
string playerId,
|
||||||
|
List<string> 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<QueueAccepted>())!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<QueueStatusResponse> WaitForMatchAsync(
|
||||||
|
HttpClient http,
|
||||||
|
Guid queueId,
|
||||||
|
string playerId)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; attempt < 200; attempt++)
|
||||||
|
{
|
||||||
|
var status = await http.GetFromJsonAsync<QueueStatusResponse>(
|
||||||
|
$"/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ServerHello>().Single();
|
||||||
|
Assert.Equal(0, hello.PlayerIndex);
|
||||||
|
Assert.False(hello.Resumed);
|
||||||
|
|
||||||
|
var snapshot = first.All<ServerSnapshot>().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<ServerClock>().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<ServerIntentResult>();
|
||||||
|
Assert.True(accepted.Accepted);
|
||||||
|
Assert.False(accepted.Duplicate);
|
||||||
|
|
||||||
|
var seqAfterFirst = grain.Summary.EventSeq;
|
||||||
|
var eventsAfterFirst = first.All<ServerEvents>().Count;
|
||||||
|
|
||||||
|
await grain.SubmitAsync(0, intent);
|
||||||
|
|
||||||
|
var duplicate = first.Last<ServerIntentResult>();
|
||||||
|
Assert.True(duplicate.Accepted);
|
||||||
|
Assert.True(duplicate.Duplicate);
|
||||||
|
Assert.Equal(seqAfterFirst, grain.Summary.EventSeq);
|
||||||
|
Assert.Equal(eventsAfterFirst, first.All<ServerEvents>().Count);
|
||||||
|
Assert.True(second.All<ServerIntentResult>().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<ServerIntentResult>();
|
||||||
|
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<ServerIntentResult>();
|
||||||
|
Assert.True(result.Accepted);
|
||||||
|
Assert.True(grain.Summary.IsFinished);
|
||||||
|
Assert.Equal(0, grain.Summary.Winner);
|
||||||
|
|
||||||
|
Assert.Contains(first.All<ServerEvents>().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<ServerIntentResult>();
|
||||||
|
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<ServerClock>().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<ServerEvents>().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<ServerEvents>().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<ServerClock>().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<ServerClock>().Clock.IsRunning);
|
||||||
|
Assert.True(second.Last<ServerClock>().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<ServerHello>().Single();
|
||||||
|
Assert.True(hello.Resumed);
|
||||||
|
Assert.Equal(seq, hello.EventSeq);
|
||||||
|
|
||||||
|
var events = reconnected.All<ServerEvents>().Single();
|
||||||
|
Assert.Equal(seq + 1, events.FromSeq);
|
||||||
|
Assert.Equal(seq, events.ToSeq);
|
||||||
|
Assert.Empty(events.Events);
|
||||||
|
Assert.Empty(reconnected.All<ServerSnapshot>());
|
||||||
|
|
||||||
|
// без resumeFromSeq приходит полный снимок
|
||||||
|
var fresh = new FakeMatchConnection();
|
||||||
|
Assert.True(await grain.AttachAsync(1, "p2", fresh, 0));
|
||||||
|
Assert.False(fresh.All<ServerHello>().Single().Resumed);
|
||||||
|
Assert.Single(fresh.All<ServerSnapshot>());
|
||||||
|
Assert.NotEmpty(second.All<ServerSnapshot>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<ServerHello>());
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MatchGrain>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<FakeMatchConnection> Attach(MatchGrain grain, int seat, string playerId)
|
||||||
|
{
|
||||||
|
var connection = new FakeMatchConnection();
|
||||||
|
Assert.True(await grain.AttachAsync(seat, playerId, connection, 0));
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<DeckRejectedException>(
|
||||||
|
() => 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<string>();
|
||||||
|
|
||||||
|
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<DeckRejectedException>(
|
||||||
|
() => 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<TimeProvider>(time);
|
||||||
|
services.AddSingleton<IMatchResultSink>(profiles);
|
||||||
|
services.AddLogging();
|
||||||
|
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
var registry = new MatchGrainRegistry(provider, options, NullLogger<MatchGrainRegistry>.Instance);
|
||||||
|
var matchmaking = new MatchmakingService(
|
||||||
|
ServerTestData.Catalog,
|
||||||
|
registry,
|
||||||
|
tickets,
|
||||||
|
profiles,
|
||||||
|
options,
|
||||||
|
time,
|
||||||
|
NullLogger<MatchmakingService>.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);
|
||||||
|
}
|
||||||
|
|
@ -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<CardCatalog> LazyCatalog = new(
|
||||||
|
() => CatalogLoader.LoadDirectory(ContentPaths.Resolve(null)),
|
||||||
|
isThreadSafe: true);
|
||||||
|
|
||||||
|
public static CardCatalog Catalog => LazyCatalog.Value;
|
||||||
|
|
||||||
|
/// <summary>Колода без Sentinel и Heal, ровно 50 карт, до 4 копий на nameId.</summary>
|
||||||
|
public static List<string> BuildDeck()
|
||||||
|
{
|
||||||
|
var deck = new List<string>();
|
||||||
|
var counts = new Dictionary<string, int>(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<Vanguard.Shared.Protocol.ServerFrame> _frames = new();
|
||||||
|
|
||||||
|
public string Id { get; } = Guid.NewGuid().ToString("N");
|
||||||
|
public bool IsClosed { get; private set; }
|
||||||
|
public int CloseCode { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Vanguard.Shared.Protocol.ServerFrame> 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<T>() where T : Vanguard.Shared.Protocol.ServerFrame
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return _frames.OfType<T>().Last();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<T> All<T>() where T : Vanguard.Shared.Protocol.ServerFrame
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return _frames.OfType<T>().ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RecordingResultSink : Matches.IMatchResultSink
|
||||||
|
{
|
||||||
|
private readonly List<Matches.MatchResult> _results = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<Matches.MatchResult> Results
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_results)
|
||||||
|
return _results.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RecordAsync(Matches.MatchResult result)
|
||||||
|
{
|
||||||
|
lock (_results)
|
||||||
|
_results.Add(result);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Vanguard.Shared.Protocol;
|
||||||
|
using Xunit.Sdk;
|
||||||
|
|
||||||
|
namespace Vanguard.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Минимальный клиент протокола для интеграционных тестов:
|
||||||
|
/// шлёт интенты, догоняет состояние и проверяет кадры.
|
||||||
|
/// </summary>
|
||||||
|
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<TestGameClient> ConnectAsync(
|
||||||
|
WebApplicationFactory<Program> 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<ServerHello>(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<ServerEvents>(cancellationToken);
|
||||||
|
if (events.ToSeq != hello.EventSeq)
|
||||||
|
throw new XunitException($"Догон дошёл до {events.ToSeq}, ожидалось {hello.EventSeq}.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var snapshot = await client.ReadUntilAsync<ServerSnapshot>(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<ServerIntentResult> SubmitAsync(
|
||||||
|
string intentId,
|
||||||
|
IntentPayload intent,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await SendIntentAsync(intentId, intent, cancellationToken);
|
||||||
|
var result = await ReadUntilAsync<ServerIntentResult>(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<ServerSnapshot>(cancellationToken);
|
||||||
|
View = snapshot.View;
|
||||||
|
LastSeq = snapshot.View.EventSeq;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T> ReadUntilAsync<T>(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<ServerFrame> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Vanguard.Server\Vanguard.Server.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Loading…
Reference in New Issue