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
|
||||
```
|
||||
- [x] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`.
|
||||
- [ ] `docker-compose.yml`: postgres, redis (AOF), meta-api, game-silo, nginx.
|
||||
- [~] `docker-compose.yml`: сейчас только game-server; postgres, redis (AOF), meta-api, nginx — со следующим блоком.
|
||||
- [ ] `docker compose up` поднимает БД и пустой API; healthcheck отвечает.
|
||||
— сервер уже поднимается и отвечает `/health`, БД ещё нет.
|
||||
- [ ] CI: сборка solution + `dotnet test` на push.
|
||||
|
||||
**Готово, когда:** `docker compose up` + `dotnet run` дают живой `/health`.
|
||||
|
|
@ -172,18 +173,22 @@
|
|||
|
||||
- [x] Спецификация протокола в `docs/protocol.md`: кадры, handshake, версия, ping/pong, reconnect.
|
||||
- [x] `Vanguard.Shared`: DTO интентов и событий, MessagePack-резолвер.
|
||||
- [ ] `IGameTicketService`: одноразовый тикет на подключение (TTL 60 с).
|
||||
- [ ] Транспорт матча: WebSocket (ASP.NET Core); резерв — TCP с префиксом длины. Accept, handshake, auth по тикету, привязка к grain.
|
||||
- [ ] `IMatchGrain`: 1 матч = 1 grain, последовательная обработка.
|
||||
- [x] `IGameTicketService`: одноразовый тикет на подключение (TTL 60 с).
|
||||
- [x] Транспорт матча: WebSocket (ASP.NET Core); резерв — TCP с префиксом длины. Accept, handshake, auth по тикету, привязка к grain.
|
||||
- [x] `IMatchGrain`: 1 матч = 1 grain, последовательная обработка.
|
||||
— реализовано in-process; Orleans-хостинг — следующий шаг, контракт отделён.
|
||||
- [x] Проекция состояния под клиента: своя рука, свой trigger, открытые зоны; **ничего** о колоде, руке противника и закрытом уроне.
|
||||
- [~] `eventSeq` + `resync` по запросу.
|
||||
- [~] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось».
|
||||
- [~] Политика отсутствия: 5 минут → авто-поражение.
|
||||
- [~] Таймер хода: 90 с + банк 5 мин.
|
||||
- [x] `eventSeq` + `resync` по запросу.
|
||||
- [x] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось».
|
||||
- [x] Политика отсутствия: 5 минут → авто-поражение.
|
||||
- [x] Таймер хода: 90 с + банк 5 мин.
|
||||
- [ ] Снапшот матча в PostgreSQL + восстановление grain после рестарта.
|
||||
- [~] Idempotency по `intentId`.
|
||||
- [x] Idempotency по `intentId`.
|
||||
- [x] HTTP-очередь и тикеты: `docs/server-api.md`.
|
||||
- [x] E2E: два клиента играют полный матч по WebSocket; обрыв и возврат проверены.
|
||||
|
||||
**Готово, когда:** два клиента играют полный матч по сети; kill процесса клиента на любом шаге восстанавливается.
|
||||
— выполнено в `Vanguard.Server.Tests`; рестарт сервера пока теряет матчи (ждёт PostgreSQL).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
21
README.md
21
README.md
|
|
@ -13,7 +13,9 @@
|
|||
| Контент-пайплайн и валидатор | готово |
|
||||
| Игровое ядро (правила) | каркас: фазы, райд, призыв, бой, драйв, урон |
|
||||
| Протокол и контракты (`docs/protocol.md`, `Vanguard.Shared`) | готово |
|
||||
| Сервер, клиент, лаунчер | не начаты |
|
||||
| Сервер: матч по сети, матчмейкинг, реконнект | готово (in-memory) |
|
||||
| Персистентность (PostgreSQL, Redis) и Orleans | не начато |
|
||||
| Клиент и лаунчер | не начато |
|
||||
|
||||
## Требования
|
||||
|
||||
|
|
@ -36,6 +38,16 @@ dotnet run --project server/src/Vanguard.Cli -- stress 1000
|
|||
dotnet test server/Vanguard.slnx
|
||||
```
|
||||
|
||||
Запуск игрового сервера:
|
||||
|
||||
```powershell
|
||||
dotnet run --project server/src/Vanguard.Server --urls http://localhost:5080
|
||||
# проверка: http://localhost:5080/health
|
||||
```
|
||||
|
||||
Контейнер: `docker compose up --build`, затем `http://localhost:8080/health`.
|
||||
HTTP-контракт очереди и тикетов — `docs/server-api.md`.
|
||||
|
||||
Ожидаемый вывод валидатора:
|
||||
|
||||
```
|
||||
|
|
@ -50,13 +62,15 @@ OK: ошибок 0, предупреждений 0
|
|||
/server/src/Vanguard.Rules ядро правил: карты, колоды, состояние игры
|
||||
/server/src/Vanguard.Content загрузка и проверка YAML-контента
|
||||
/server/src/Vanguard.Shared сетевые контракты (netstandard2.1 для Unity + net10.0)
|
||||
/server/src/Vanguard.Server ASP.NET Core: WebSocket, очередь, тикеты, матчи
|
||||
/server/src/Vanguard.Cli консольные команды
|
||||
/server/tests тесты правил и контента
|
||||
/server/tests тесты правил, контрактов и сервера (включая E2E)
|
||||
/content/sets карты в YAML
|
||||
/content/keywords.yaml ключевые слова и иконки
|
||||
/content/translations/ru.yaml русские тексты
|
||||
/tools импорт и сид переводов
|
||||
/docs правила перевода и прочие документы
|
||||
/docs протокол, API, правила перевода
|
||||
/docker-compose.yml сервер в контейнере
|
||||
```
|
||||
|
||||
`Vanguard.Rules` — чистая библиотека без Orleans, БД и сети. Это позволяет прогонять
|
||||
|
|
@ -116,4 +130,5 @@ dotnet run --project server/src/Vanguard.Cli -- translations check
|
|||
- `IMPLEMENTATION_CHECKLIST.md` — план реализации по блокам и гейтам.
|
||||
- `docs/HANDOFF.md` — состояние на конец сессии и порядок следующих шагов.
|
||||
- `docs/protocol.md` — протокол матча: кадры, интенты, события, таймеры, реконнект.
|
||||
- `docs/server-api.md` — HTTP-очередь, тикеты и `/health`.
|
||||
- `content/cards-mvp.md` — каталог карт с разбором состава сетов.
|
||||
|
|
|
|||
|
|
@ -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 предупреждений |
|
||||
| `translations check` | 94/94 |
|
||||
| `stress 1000` | 1000/1000 партий, 0 ошибок |
|
||||
| Карты | **94/94** (66 с текстом + 28 ванильных) |
|
||||
| Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы |
|
||||
| Сервер / клиент / лаунчер | не начаты |
|
||||
| Сервер | полный матч по сети, матчмейкинг, реконнект (in-memory) |
|
||||
| Клиент / лаунчер | не начаты |
|
||||
|
||||
Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям:
|
||||
боты доигрывают 1000 партий, инварианты держатся, реплей воспроизводится
|
||||
|
|
@ -68,12 +69,20 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000
|
|||
Ожидаемо:
|
||||
|
||||
```
|
||||
Пройден! : не пройдено 0, пройдено 129
|
||||
Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests)
|
||||
Пройден! : не пройдено 0, пройдено 27 (Vanguard.Server.Tests)
|
||||
OK: ошибок 0, предупреждений 0
|
||||
Переведено: 94/94
|
||||
Партий: 1000, завершено: 1000, ошибок: 0
|
||||
```
|
||||
|
||||
Сервер локально:
|
||||
|
||||
```powershell
|
||||
dotnet run --project server\src\Vanguard.Server --urls http://localhost:5080
|
||||
# GET http://localhost:5080/health
|
||||
```
|
||||
|
||||
Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||
|
||||
---
|
||||
|
|
@ -190,6 +199,44 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
|||
- Проекция вызывается для наблюдателя без побочных эффектов и не трогает
|
||||
`LegalCommands()` чужого игрока (иначе утекала бы рука решающего).
|
||||
|
||||
### 4.8. Сервер
|
||||
|
||||
`server/src/Vanguard.Server` — ASP.NET Core 10, один процесс, состояние в памяти.
|
||||
|
||||
- **HTTP**: `POST /api/game/queue`, `GET /api/game/queue/{id}`,
|
||||
`DELETE /api/game/queue/{id}`, `POST /api/game/tickets`, `GET /health`.
|
||||
Контракт — `docs/server-api.md`.
|
||||
- **Матчмейкинг**: две очереди (Classic Ranked / Casual), скрытый MMR,
|
||||
окно ±100 с расширением на 50 каждые 5 с до 2000, выбор ближайшего MMR.
|
||||
Ранкед меняет MMR по Эло (K = 32), casual — нет.
|
||||
- **Тикеты**: одноразовые, TTL 60 с, привязаны к `(playerId, matchId, seat)`;
|
||||
для реконнекта выдаётся новый тикет.
|
||||
- **`IMatchGrain`**: 1 матч = 1 экземпляр, все входы (интенты, подключения,
|
||||
тики) проходят через один семафор; внутри нет ни ASP.NET Core, ни WebSocket —
|
||||
только `IMatchConnection`. Orleans-хостинг подключается поверх контракта.
|
||||
- **WebSocket** `/game`: handshake, MessagePack, `intentId`, idempotency,
|
||||
`eventSeq`, `resync`, heartbeat (20 с простоя → ping, ещё 20 с без ответа
|
||||
→ `4408`), реконнект, захват сессии (`4409`), rate limit 16 интентов/с.
|
||||
- **Таймеры**: 90 с на решение + банк 300 с, отсутствие 300 с → поражение;
|
||||
`ServerClock` каждые 5 с.
|
||||
- **Результат**: сдача, таймер и отсутствие завершают партию через
|
||||
`GameEngine.Finish(winner)`; событие `GameFinished` уходит обоим,
|
||||
результат пишется в профили.
|
||||
- **Проекция**: своя рука, свой выбор, свои легальные интенты; у противника
|
||||
только количества и открытые зоны. `seed` и RNG не покидают сервер.
|
||||
- **Docker**: `Dockerfile` + `docker-compose.yml` поднимают сервер с контентом
|
||||
на `http://localhost:8080`.
|
||||
- **Тесты** (`Vanguard.Server.Tests`, 27):
|
||||
- тикеты: одноразовость и TTL;
|
||||
- heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром;
|
||||
- grain: attach, чужое место, duplicate intent, чужой ход, сдача,
|
||||
команды после финала, банк времени, отсутствие, старт часов,
|
||||
reconnect с `eventSeq`, захват сессии;
|
||||
- матчмейкинг: подбор, разные очереди, окно, плохая колода,
|
||||
отсутствие стартового Авангарда, отмена, замена очереди, истечение, MMR;
|
||||
- E2E: два клиента играют полный матч по WebSocket и сходятся в победителе;
|
||||
обрыв и возврат по новому тикету с догоном состояния.
|
||||
|
||||
---
|
||||
|
||||
## 5. Структура и ключевые файлы
|
||||
|
|
@ -215,12 +262,24 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
|||
/server/src/Vanguard.Shared
|
||||
/Protocol/*.cs кадры, интенты, события, view, кодек
|
||||
/Projection/*.cs проекция состояния, фильтр событий, маппинг
|
||||
/server/tests/Vanguard.Rules.Tests 129 тестов
|
||||
/server/src/Vanguard.Server
|
||||
/Program.cs ASP.NET Core, DI, WebSocket endpoint
|
||||
/Api/ очередь, тикеты, /health
|
||||
/Matchmaking/ две очереди, MMR, расширяющееся окно
|
||||
/Matches/ IMatchGrain, таймеры, тикер, регистр
|
||||
/Realtime/ WebSocket-обвязка, чтение кадров
|
||||
/Tickets/ одноразовые тикеты
|
||||
/Profiles/ скрытый MMR и статистика
|
||||
/Dockerfile
|
||||
/server/tests/Vanguard.Rules.Tests 131 тест
|
||||
/server/tests/Vanguard.Server.Tests 27 тестов, включая E2E по WebSocket
|
||||
/content/sets карты
|
||||
/content/translations/ru.yaml переводы
|
||||
/docs/translation-rules.md канон перевода
|
||||
/docs/protocol.md протокол матча, версия 1
|
||||
/docs/server-api.md HTTP-очередь, тикеты, /health
|
||||
/docs/HANDOFF.md этот файл
|
||||
/docker-compose.yml сервер в контейнере
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -240,6 +299,16 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
|||
- Бустер: 5 карт = 4 C + 1 R+; веса R 70 / RR 22 / RRR 7 / SP 1.
|
||||
- Gold за матч: 30/15/20 (победа/поражение/ничья); Gold = XP / 5.
|
||||
- Таймер хода 90 с + банк 5 мин.
|
||||
- Транспорт матча — **WebSocket** + MessagePack; TCP с префиксом длины —
|
||||
резерв для Unity, если WebSocket не подойдёт.
|
||||
- Тикет на подключение — одноразовый, TTL 60 с, передаётся в `ClientHello`,
|
||||
не в URL; reconnect получает новый тикет.
|
||||
- `playerId` в HTTP-API — dev-заглушка до JWT. Выставлять сервер в интернет
|
||||
в таком виде нельзя.
|
||||
- Состояние сервера пока in-memory: `IMatchGrain` отделён от транспорта,
|
||||
Orleans и PostgreSQL подключаются поверх.
|
||||
- Rate limit — 16 интентов в секунду на соединение.
|
||||
- Скрытый MMR: старт 1000, Эло K = 32, casual рейтинг не меняет.
|
||||
- Порядок триггеров выбирает игрок; опциональные способности спрашиваются.
|
||||
- Непрерывные эффекты пересчитываются при каждом запросе.
|
||||
- Поиск в колоде: 0 — уведомление и перемешивание, 1 — сразу, 2+ — выбор.
|
||||
|
|
@ -261,28 +330,28 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
|||
2. **[x] `Vanguard.Shared`**:
|
||||
DTO интентов и событий, MessagePack-резолвер, проекция состояния
|
||||
на каждого игрока, фильтрация скрытых событий, маппинг интентов.
|
||||
3. **[x] `Vanguard.Server`** — сетевой матч работает:
|
||||
ASP.NET Core + WebSocket, тикеты, `IMatchGrain` (in-process),
|
||||
матчмейкинг, MMR, идемпотентность, таймеры, реконнект, `/health`,
|
||||
Dockerfile и compose. Контракт HTTP — `docs/server-api.md`.
|
||||
4. **[x] Проекция состояния** подключена к грану и проверена E2E-тестом:
|
||||
своя рука и свой выбор полностью, у противника только количества
|
||||
и открытые зоны, seed и RNG не уходят.
|
||||
5. **[x] `Docker Compose`** — сервер с контентом на `http://localhost:8080`.
|
||||
|
||||
Осталось:
|
||||
Осталось в серверном направлении:
|
||||
|
||||
3. **`Vanguard.Server`**:
|
||||
- ASP.NET Core + WebSocket;
|
||||
- `IGameTicketService` (одноразовый тикет, TTL 60 с);
|
||||
- `IMatchGrain` — 1 матч = 1 grain, последовательная обработка;
|
||||
- матчмейкинг: 2 очереди (Classic Ranked / Casual);
|
||||
- скрытый MMR, расширяющееся окно;
|
||||
- идемпотентность по `intentId`.
|
||||
4. **Проекция состояния** — контракты готовы, подключить к грану:
|
||||
- своя рука и свой триггер — полностью;
|
||||
- открытые зоны — полностью;
|
||||
- колода, рука противника, закрытый урон — только количество;
|
||||
- seed и RNG не уходят.
|
||||
5. **Персистентность**:
|
||||
- PostgreSQL — снапшот матча, восстановление grain;
|
||||
6. **Orleans** — заменить in-process регистр на настоящий silo и граны.
|
||||
`IMatchGrain` уже отделён от транспорта и ASP.NET Core.
|
||||
7. **Персистентность**:
|
||||
- PostgreSQL — снапшот матча, восстановление grain после рестарта;
|
||||
- Redis — тикеты, очереди, кэш.
|
||||
6. **Docker Compose** + `/health`.
|
||||
8. **JWT и мета-API** — `playerId` перестанет быть dev-заглушкой.
|
||||
|
||||
Критерий готовности: два клиента играют полный матч по сети; kill клиента
|
||||
на любом шаге восстанавливается.
|
||||
Критерий готовности серверного блока выполнен: два клиента играют полный
|
||||
матч по сети; kill клиента на любом шаге восстанавливается через новый
|
||||
тикет и `resumeFromSeq`. Рестарт **сервера** пока теряет матчи: это
|
||||
закрывается пунктом 7.
|
||||
|
||||
### 7.2. Мета-API
|
||||
|
||||
|
|
@ -324,8 +393,10 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
|||
|
||||
1. Прочитать `docs/HANDOFF.md`, `IMPLEMENTATION_CHECKLIST.md`, `README.md`.
|
||||
2. Прогнать проверки из раздела 3.
|
||||
3. Продолжить с `Vanguard.Server`: ASP.NET Core + WebSocket, тикеты,
|
||||
`IMatchGrain`, матчмейкинг. `docs/protocol.md` и `Vanguard.Shared` уже готовы.
|
||||
3. Следующий блок — персистентность и Orleans: PostgreSQL-снапшоты,
|
||||
Redis для тикетов/очередей, замена in-process регистра на silo.
|
||||
Сетевой матч уже работает: `docs/protocol.md`, `Vanguard.Shared`,
|
||||
`Vanguard.Server`, `docs/server-api.md`.
|
||||
4. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
|
||||
5. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
|
||||
|
||||
|
|
@ -345,6 +416,20 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
|||
`net10.0`; в `netstandard2.1` (Unity) ссылки на `Vanguard.Rules` нет.
|
||||
- **Видимость.** `LegalCommands()` описывает решающего игрока. Вызывать его
|
||||
для наблюдателя нельзя — в списке есть printingId из чужой руки.
|
||||
- **Сервер: rate limit.** 16 интентов в секунду на соединение. Быстрый бот
|
||||
в E2E специально притормаживается; живой UI в лимит не упирается.
|
||||
- **Сервер: in-memory.** Очереди, тикеты, матчи и профили живут в памяти.
|
||||
Рестарт сервера теряет матчи; kill клиента и реконнект переживаются.
|
||||
- **Сервер: тесты.** E2E поднимает `WebApplicationFactory<Program>`,
|
||||
играет полный матч по WebSocket и проверяет, что победитель совпал.
|
||||
Случайные партии завершаются за ~350 действий; таймаут теста — 120 с.
|
||||
- **Сервер: таймеры.** Тик раз в секунду. После 90 с банк списывается
|
||||
по фактическому превышению; `TurnDeadlineMs` в этот момент сдвигается,
|
||||
поэтому UI должен показывать ещё и `BankMs`.
|
||||
- **Сервер: часы.** Отсчёт стартует только когда подключились оба места.
|
||||
До этого `ClockView.IsRunning = false`, но таймер отсутствия уже идёт.
|
||||
- **Сервер: проекция.** `ServerSnapshot` и `ServerEvents` собираются
|
||||
отдельно на каждое место; не переиспользовать один `MatchView` на двоих.
|
||||
- **Порядок фаз.** Вход в Основную — `EnterMainPhase()` с `OnPhaseStart`.
|
||||
- **Отложенные продолжения.** `State.Continuation` — что выполнить после паузы;
|
||||
`State.PendingQueue` + `_resolvingDepth` запрещают вложенность эффектов.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
- `docs/HANDOFF.md` — состояние проекта и порядок блоков;
|
||||
- `IMPLEMENTATION_CHECKLIST.md`, раздел 5 — план сетевого блока;
|
||||
- `docs/server-api.md` — HTTP-очередь, тикеты и `/health`;
|
||||
- `server/src/Vanguard.Shared` — DTO, MessagePack-резолвер и проекция состояния.
|
||||
|
||||
---
|
||||
|
|
@ -454,6 +455,7 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
|
|||
| `MalformedFrame` | мусор, пустой кадр, превышен размер |
|
||||
| `RateLimited` | слишком много интентов |
|
||||
| `MatchFinished` | партия закончена, соединение больше не нужно |
|
||||
| `Forbidden` | тикет валиден, но место в матче чужое |
|
||||
| `Internal` | непредвиденная ошибка сервера |
|
||||
|
||||
Коды закрытия WebSocket (диапазон приложения 4000–4999):
|
||||
|
|
@ -462,6 +464,7 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
|
|||
|---|---|
|
||||
| `4400` | нарушение протокола (handshake, битый кадр) |
|
||||
| `4401` | не авторизован (тикет) |
|
||||
| `4403` | место в матче не принадлежит игроку |
|
||||
| `4408` | heartbeat не получен |
|
||||
| `4409` | соединение заменено новым |
|
||||
| `4410` | неподдерживаемая версия протокола |
|
||||
|
|
|
|||
|
|
@ -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.Content/Vanguard.Content.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" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Vanguard.Rules.Tests/Vanguard.Rules.Tests.csproj" />
|
||||
<Project Path="tests/Vanguard.Server.Tests/Vanguard.Server.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ public sealed class GameEngine
|
|||
ulong seed,
|
||||
IReadOnlyList<string> deck0,
|
||||
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())
|
||||
.Concat(DeckValidator.Validate(catalog, deck1.ToList()))
|
||||
|
|
@ -58,7 +60,7 @@ public sealed class GameEngine
|
|||
"Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message)));
|
||||
|
||||
_scripts = scripts ?? CardScripts.Default;
|
||||
State = new GameState(catalog, seed, "Player 1", "Player 2");
|
||||
State = new GameState(catalog, seed, player0Name, player1Name);
|
||||
FillDeck(0, deck0);
|
||||
FillDeck(1, deck1);
|
||||
}
|
||||
|
|
@ -1595,6 +1597,9 @@ public sealed class GameEngine
|
|||
|
||||
public CommandResult Execute(GameCommand command)
|
||||
{
|
||||
if (State.IsFinished)
|
||||
return CommandResult.Reject("Партия завершена.");
|
||||
|
||||
// пока идёт разрешение способности или триггера, посторонние команды запрещены
|
||||
if (State.Step is Step.Trigger or Step.Ability or Step.Choice)
|
||||
{
|
||||
|
|
@ -2607,4 +2612,28 @@ public sealed class GameEngine
|
|||
State.Winner = 1 - player;
|
||||
_events.Add(new GameFinishedEvent(State.Winner.Value));
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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)
|
||||
{
|
||||
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