283 lines
11 KiB
Markdown
283 lines
11 KiB
Markdown
# HTTP API сервера
|
||
|
||
Служебный HTTP-слой матчевого блока: очередь, тикеты на подключение и `/health`.
|
||
После подключения клиент переходит на WebSocket и работает по `docs/protocol.md`.
|
||
|
||
Базовая часть стека: **ASP.NET Core 10**, один процесс.
|
||
Матчи и очередь сохраняются в PostgreSQL/Redis, если они настроены; без
|
||
внешних сервисов сервер работает in-memory (удобно для тестов).
|
||
|
||
---
|
||
|
||
## 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
|
||
```
|
||
|
||
Контейнер (PostgreSQL + Redis + сервер):
|
||
|
||
```powershell
|
||
docker compose up --build
|
||
# http://localhost:8080/health
|
||
```
|
||
|
||
Порт и внешний адрес WebSocket задаются в `.env`:
|
||
|
||
```
|
||
VANGUARD_PORT=8080
|
||
VANGUARD_WS_URL=ws://localhost:8080/game
|
||
```
|
||
|
||
`VANGUARD_PORT` нужен, если 8080 занят (на тестовом стенде — 18080).
|
||
|
||
Для ручной проверки колоду на 50 карт можно получить так:
|
||
|
||
```powershell
|
||
dotnet run --project server\src\Vanguard.Cli -- deck
|
||
```
|
||
|
||
Каталог карт ищется в `content/sets` вверх от каталога сборки. Если контент
|
||
лежит в другом месте, задайте `Content:Path` (или `Content__Path` в окружении).
|
||
|
||
---
|
||
|
||
## 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. Хранилище и восстановление
|
||
|
||
### 7.1. Конфигурация
|
||
|
||
| Ключ | Значения | Смысл |
|
||
|---|---|---|
|
||
| `Storage:Provider` | `None`, `Memory`, `Postgres` | `None` — без сохранения, `Memory` — EF InMemory, `Postgres` — постоянное |
|
||
| `Storage:ConnectionString` | строка Npgsql | обязательно для `Postgres` |
|
||
| `Redis:Configuration` | `host:port` | пусто — тикеты и очередь в памяти |
|
||
| `Redis:Prefix` | `vanguard:` | префикс ключей |
|
||
|
||
В контейнере переменные задаются как `Storage__Provider`,
|
||
`Storage__ConnectionString`, `Redis__Configuration`.
|
||
|
||
### 7.2. Матчи в PostgreSQL
|
||
|
||
- `matches` — заголовок: игроки, колоды, seed, часы, банки, статус, `eventSeq`.
|
||
- `match_intents` — журнал: `intentId`, место, команда, результат, код отказа.
|
||
- На каждый интент пишется строка журнала и обновляется заголовок.
|
||
- После рестарта `MatchRecoveryService` поднимает незавершённые матчи:
|
||
создаёт движок с сохранённым seed и проигрывает принятые команды заново.
|
||
`GameFinished`, ожидания выбора и продолжения восстанавливаются реплеем,
|
||
делегаты никуда не сериализуются.
|
||
- `eventSeq` сверяется: если реплей разошёлся со снапшотом, матч пропускается
|
||
с ошибкой в логе.
|
||
- Банк и дедлайн сохраняются раз в ~5 секунд; авария теряет не больше тика.
|
||
- Завершённые матчи остаются историей и при старте в память не поднимаются.
|
||
|
||
### 7.3. Redis
|
||
|
||
- Тикеты: ключ `vanguard:ticket:{token}`, TTL 60 с, изъятие через `GETDEL`
|
||
(одноразовость сохраняется между инстансами).
|
||
- Очередь: запись `vanguard:queue:entry:{id}` (JSON) и sorted set
|
||
`vanguard:queue:{mode}` по номеру постановки. При старте очередь
|
||
загружается обратно.
|
||
- Состояние профилей (MMR) пока остаётся в памяти: это закроет мета-API
|
||
вместе с аккаунтами.
|
||
|
||
---
|
||
|
||
## 8. Ограничения текущего этапа
|
||
|
||
- PostgreSQL/Redis опциональны. По умолчанию (`Storage:Provider=None`)
|
||
матчи, тикеты и очередь живут только в памяти.
|
||
- Профили и MMR пока не персистентны: после рестарта рейтинг начинается
|
||
с 1000. Матчи и очередь при этом восстанавливаются.
|
||
- Один процесс: горизонтальное масштабирование и Orleans-хостинг ещё впереди.
|
||
- `IMatchGrain` уже отделён от ASP.NET Core и WebSocket: замена на Orleans
|
||
grain не затронет транспорт и правила.
|
||
- Нет JWT, коллекций и наград — это следующие блоки.
|
||
- Rate limit: 16 интентов в секунду с одного соединения. Живой UI в него
|
||
не упирается; тестовый бот специально притормаживается.
|