VanguardReMaster/docs/server-api.md

283 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 в него
не упирается; тестовый бот специально притормаживается.