429 lines
15 KiB
Markdown
429 lines
15 KiB
Markdown
# HTTP API сервера
|
||
|
||
Мета-API и матчевый HTTP-слой: аккаунты, коллекция, колоды, магазин,
|
||
очередь, тикеты, история матчей и реплеи. После подключения клиент
|
||
переходит на WebSocket и работает по `docs/protocol.md`.
|
||
|
||
Базовая часть стека: **ASP.NET Core 10**, JWT, PostgreSQL/Redis (опционально),
|
||
один процесс.
|
||
|
||
---
|
||
|
||
## 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
|
||
```
|
||
|
||
`.env`:
|
||
|
||
```
|
||
VANGUARD_PORT=8080
|
||
VANGUARD_WS_URL=ws://localhost:8080/game
|
||
VANGUARD_JWT_KEY=change-me-min-32-characters-long-key
|
||
```
|
||
|
||
`VANGUARD_PORT` нужен, если 8080 занят. `VANGUARD_JWT_KEY` обязателен
|
||
в production (не короче 32 символов).
|
||
|
||
Для ручной проверки колоду на 50 карт можно получить так:
|
||
|
||
```powershell
|
||
dotnet run --project server\src\Vanguard.Cli -- deck
|
||
```
|
||
|
||
Каталог карт ищется в `content/sets` вверх от каталога сборки; триал-деки
|
||
и веса бустера — в `content/trial-decks.yaml` и `content/pack_odds.yaml`.
|
||
|
||
---
|
||
|
||
## 2. Авторизация
|
||
|
||
JWT: access — 15 минут, refresh — 30 дней с ротацией. Пароли — BCrypt.
|
||
|
||
```
|
||
POST /api/auth/register { "userName": "player", "password": "password123" }
|
||
POST /api/auth/login { "userName": "player", "password": "password123" }
|
||
POST /api/auth/refresh { "refreshToken": "..." }
|
||
POST /api/auth/logout { "refreshToken": "..." } → 204
|
||
GET /api/auth/me Authorization: Bearer <access>
|
||
```
|
||
|
||
Ответ register/login/refresh:
|
||
|
||
```json
|
||
{
|
||
"userId": "…",
|
||
"userName": "player",
|
||
"accessToken": "eyJ…",
|
||
"refreshToken": "…",
|
||
"accessExpiresAt": "2026-10-21T12:15:00+00:00",
|
||
"refreshExpiresAt": "2026-11-20T12:00:00+00:00",
|
||
"onboardingCompleted": false
|
||
}
|
||
```
|
||
|
||
Правила:
|
||
|
||
- имя: 3–32 символа, буквы, цифры, `_`, `.`, `-`; уникально без учёта регистра;
|
||
- пароль: 8–128 символов;
|
||
- refresh одноразовый: при `/refresh` старый отзывается, выдаётся новый;
|
||
- повторное использование старого refresh → `401`;
|
||
- `me` возвращает балансы, MMR, XP, статистику и состояние онбординга.
|
||
|
||
```json
|
||
{
|
||
"userId": "…",
|
||
"userName": "player",
|
||
"createdAt": "2026-10-21T12:00:00+00:00",
|
||
"onboardingCompleted": true,
|
||
"selectedTrialDeckId": "td01",
|
||
"gold": 30,
|
||
"dust": 5,
|
||
"tokens": 4,
|
||
"xp": 150,
|
||
"rankedMmr": 1016,
|
||
"casualMmr": 1000,
|
||
"matches": 1,
|
||
"wins": 1
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Онбординг
|
||
|
||
```
|
||
POST /api/meta/onboarding { "deckId": "td01" } [Bearer]
|
||
```
|
||
|
||
- Выдаёт все 50 карт выбранной триал-деки в коллекцию.
|
||
- Начисляет **5 токенов** (1 токен = 1 бустер).
|
||
- Выбор делается один раз; повтор → `409`.
|
||
- Ответ: `{ "balances": { "gold": 0, "dust": 0, "tokens": 5 }, "deckId": "td01" }`.
|
||
|
||
Вторая триал-дека покупается в магазине за Gold.
|
||
|
||
---
|
||
|
||
## 4. Коллекция
|
||
|
||
```
|
||
GET /api/collection [Bearer]
|
||
POST /api/collection/dismantle [Bearer]
|
||
POST /api/collection/craft [Bearer]
|
||
```
|
||
|
||
`GET`:
|
||
|
||
```json
|
||
{
|
||
"items": [
|
||
{ "printingId": "TD01/001EN", "nameId": "…", "nameEn": "…", "rarity": "R", "count": 4 }
|
||
],
|
||
"balances": { "gold": 0, "dust": 5, "tokens": 5 }
|
||
}
|
||
```
|
||
|
||
Коллекция считается по `printingId` и не имеет верхнего предела.
|
||
|
||
Разбор (`dismantle`):
|
||
|
||
```json
|
||
{ "printingId": "BT01/050EN", "count": 2, "idempotencyKey": "uuid" }
|
||
```
|
||
|
||
| Редкость | Пыль за карту |
|
||
|---|---|
|
||
| C | 5 |
|
||
| R | 10 |
|
||
| RR | 25 |
|
||
| RRR | 100 |
|
||
| SP | нельзя разобрать |
|
||
|
||
Крафт (`craft`):
|
||
|
||
```json
|
||
{ "printingId": "BT01/050EN", "idempotencyKey": "uuid" }
|
||
```
|
||
|
||
| Редкость | Стоимость |
|
||
|---|---|
|
||
| C | 20 |
|
||
| R | 40 |
|
||
| RR | 100 |
|
||
| RRR | 400 |
|
||
| SP | нельзя скрафтить |
|
||
|
||
Оба эндпоинта возвращают актуальные `Balances`; `idempotencyKey` защищает
|
||
от двойного списания/начисления.
|
||
|
||
---
|
||
|
||
## 5. Колоды
|
||
|
||
```
|
||
GET /api/decks [Bearer]
|
||
POST /api/decks [Bearer] { "name": "...", "cards": ["..."] }
|
||
GET /api/decks/{id} [Bearer]
|
||
PUT /api/decks/{id} [Bearer] { "name": "...", "cards": ["..."] }
|
||
DELETE /api/decks/{id} [Bearer]
|
||
POST /api/decks/{id}/validate [Bearer] { "cards": ["..."] } // можно без cards — возьмёт сохранённые
|
||
POST /api/decks/{id}/share [Bearer] → { "code": "ABCD2345" }
|
||
GET /api/decks/share/{code} // публичный шаринг
|
||
```
|
||
|
||
Проверки:
|
||
|
||
- ровно 50 карт;
|
||
- ≤4 копий по `nameId`;
|
||
- ≤4 Sentinel и ≤4 Heal;
|
||
- известные принты;
|
||
- есть grade 0 без триггера для стартового Авангарда;
|
||
- **все карты есть в коллекции** (`deck.not-owned`);
|
||
- ≤30 колод на пользователя;
|
||
- имя колоды уникально у пользователя, 1–40 символов.
|
||
|
||
`validate` возвращает `{ "valid": true|false, "violations": [{ "code", "message" }] }`.
|
||
|
||
---
|
||
|
||
## 6. Магазин
|
||
|
||
```
|
||
GET /api/store [Bearer]
|
||
POST /api/store/buy/booster [Bearer] { "payment": "gold"|"token", "idempotencyKey": "uuid" }
|
||
POST /api/store/buy/trialdeck [Bearer] { "deckId": "td02", "idempotencyKey": "uuid" }
|
||
POST /api/store/redeem/token [Bearer] { "idempotencyKey": "uuid" }
|
||
```
|
||
|
||
`GET /api/store`:
|
||
|
||
```json
|
||
{
|
||
"balances": { "gold": 30, "dust": 0, "tokens": 4 },
|
||
"boosterPriceGold": 100,
|
||
"trialDecks": [
|
||
{
|
||
"id": "td01",
|
||
"name": "Blaster Blade",
|
||
"clan": "RoyalPaladin",
|
||
"cardCount": 50,
|
||
"owned": true,
|
||
"priceGold": 0,
|
||
"cards": [ { "printingId": "TD01/001EN", "count": 4 } ]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
- Бустер: **100 Gold** или **1 токен**.
|
||
- `redeem/token` — то же, что бустер за токен.
|
||
- Вторая триал-дека: **500 Gold**.
|
||
- `idempotencyKey` обязателен для покупок; повтор возвращает тот же
|
||
результат и не списывает второй раз.
|
||
|
||
Ответ бустера:
|
||
|
||
```json
|
||
{
|
||
"cards": [
|
||
{ "printingId": "BT01/050EN", "nameId": "…", "rarity": "R", "isNew": true }
|
||
],
|
||
"balances": { "gold": 0, "dust": 0, "tokens": 3 }
|
||
}
|
||
```
|
||
|
||
Состав: **4 карты C + 1 карта R+**. Веса R+:
|
||
**R 70 % / RR 22 % / RRR 7 % / SP 1 %** (`content/pack_odds.yaml`).
|
||
Duplicate protection: ключ — `printingId`, порог 4; замена внутри редкости,
|
||
при насыщении редкости — перевыбор; если насыщены все — дубль.
|
||
|
||
---
|
||
|
||
## 7. Матчмейкинг
|
||
|
||
```
|
||
POST /api/game/queue [Bearer] { "mode": "classic-ranked", "deckId": "…" }
|
||
GET /api/game/queue/{queueId} [Bearer]
|
||
DELETE /api/game/queue/{queueId} [Bearer]
|
||
POST /api/game/tickets [Bearer] { "matchId": "…" }
|
||
```
|
||
|
||
С JWT игрок определяется по токену, колода — по `deckId` (принадлежит игроку
|
||
и валидна на момент постановки). Без JWT работает dev-режим
|
||
(`playerId` + готовая колода), если `Game:AllowDevQueue=true`; в контейнере
|
||
он выключен.
|
||
|
||
Ответ очереди — `{ queueId, status: "queued", mode }`. Статус matched
|
||
содержит `matchId`, `seat`, `ticket`, `opponent`, `wsUrl`.
|
||
|
||
Подбор: две очереди, скрытый MMR, окно ±100 → +50 каждые 5 с до 2000,
|
||
ранкед меняет MMR по Эло (K = 32), casual — нет.
|
||
|
||
Тикет одноразовый, TTL 60 с, привязан к `(player, matchId, seat)`.
|
||
|
||
Dev-формат (если разрешён): `{ "playerId": "p1", "mode": "...", "deck": ["..."] }`.
|
||
|
||
---
|
||
|
||
## 8. История матчей и реплеи
|
||
|
||
```
|
||
GET /api/matches?limit=20&offset=0 [Bearer]
|
||
GET /api/matches/{matchId} [Bearer]
|
||
GET /api/replays/{matchId}?includeFrames=true [Bearer]
|
||
```
|
||
|
||
История:
|
||
|
||
```json
|
||
{
|
||
"items": [
|
||
{
|
||
"matchId": "…",
|
||
"mode": "classic-ranked",
|
||
"opponentName": "bob",
|
||
"result": "win",
|
||
"gold": 30,
|
||
"xp": 150,
|
||
"mmrBefore": 1000,
|
||
"mmrAfter": 1016,
|
||
"finishReason": "normal",
|
||
"playedAt": "2026-10-21T12:30:00+00:00",
|
||
"replayAvailable": true
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
- Запись создаётся только после подтверждённого сервером завершения матча.
|
||
- Одна запись на игрока и матч; повтор результата не дублируется.
|
||
- Награды: **победа 30 Gold / 150 XP, поражение 15 / 75**; `Gold = XP / 5`.
|
||
- За сдачу, явный выход, таймер и отсутствие Gold/XP не начисляются,
|
||
но матч попадает в историю.
|
||
- `finishReason`: `normal`, `concede`, `leave`, `timeout`, `absence`.
|
||
- MMR меняется в ранкеде; casual MMR остаётся прежним.
|
||
|
||
Реплей:
|
||
|
||
```json
|
||
{
|
||
"matchId": "…",
|
||
"mode": "classic-ranked",
|
||
"player0": "alice",
|
||
"player1": "bob",
|
||
"viewerSeat": 0,
|
||
"winner": 0,
|
||
"finishReason": "normal",
|
||
"eventHash": "…",
|
||
"events": [ { "seq": 1, "kind": "GameStarted", "event": { "firstPlayer": 0 } } ],
|
||
"frames": [ { "eventSeq": 11, "view": { "…": "MatchView" } } ]
|
||
}
|
||
```
|
||
|
||
- Реплей восстанавливается из seed, колод и журнала принятых команд.
|
||
- `events` — события, видимые этому игроку (скрытый поиск не раскрывается).
|
||
- `frames` — состояние до и после каждой принятой команды; `MatchView`
|
||
собран под зрителя, `legalIntents` пуст, часы обнулены.
|
||
- Рука противника и закрытый урон остаются скрытыми даже в реплее.
|
||
- Смотреть может только участник матча; остальным — `403`.
|
||
- `includeFrames=false` — только таймлайн событий.
|
||
|
||
---
|
||
|
||
## 9. Health
|
||
|
||
```
|
||
GET /health
|
||
```
|
||
|
||
```json
|
||
{
|
||
"status": "ok",
|
||
"matches": 3,
|
||
"activeMatches": 2,
|
||
"queued": 4,
|
||
"serverTimeMs": 1760000000000
|
||
}
|
||
```
|
||
|
||
`matches` включает завершённые матчи, которые ещё живут в памяти
|
||
(10 минут после финала — для реконнекта и просмотра результата).
|
||
|
||
---
|
||
|
||
## 10. Полный сценарий
|
||
|
||
```
|
||
POST /api/auth/register → access + refresh
|
||
POST /api/meta/onboarding {td01} → 50 карт + 5 токенов
|
||
GET /api/collection → 50 карт
|
||
POST /api/decks {cards} → колода
|
||
POST /api/store/redeem/token → бустер, 4 токена
|
||
GET /api/store → витрина
|
||
POST /api/game/queue {deckId} → queueId
|
||
GET /api/game/queue/{id} → matched + ticket
|
||
WS /game + ClientHello(ticket) → матч
|
||
GET /api/matches → история, Gold/XP
|
||
GET /api/replays/{matchId} → реплей
|
||
```
|
||
|
||
Формат WebSocket-кадров, интентов, событий, таймеров и кодов ошибок —
|
||
`docs/protocol.md`.
|
||
|
||
---
|
||
|
||
## 11. Хранилище и восстановление
|
||
|
||
| Ключ | Значения | Смысл |
|
||
|---|---|---|
|
||
| `Storage:Provider` | `None`, `Memory`, `Postgres` | `None` — матчи без сохранения, мета в памяти; `Memory` — EF InMemory; `Postgres` — постоянное |
|
||
| `Storage:ConnectionString` | строка Npgsql | обязательно для `Postgres` |
|
||
| `Redis:Configuration` | `host:port` | пусто — тикеты и очередь в памяти |
|
||
| `Redis:Prefix` | `vanguard:` | префикс ключей |
|
||
| `Jwt:SigningKey` | ≥32 символа | подпись access-токенов |
|
||
|
||
Одна БД, один `MatchDbContext`:
|
||
|
||
- `matches`, `match_intents` — заголовок и журнал матча;
|
||
- `users`, `refresh_tokens` — аккаунты и сессии;
|
||
- `collection`, `decks`, `transactions`, `booster_openings`,
|
||
`trial_deck_ownership` — коллекция, колоды, экономика, покупки;
|
||
- `match_history` — история и награды.
|
||
|
||
Восстановление матча: при старте `MatchRecoveryService` поднимает
|
||
незавершённые матчи и проигрывает принятые команды на детерминированном
|
||
движке; `eventSeq` сверяется. Схема создаётся `EnsureCreated`; миграций
|
||
пока нет, при изменении схемы тестовую БД нужно пересоздать.
|
||
|
||
Redis: тикеты (`GETDEL`, TTL 60 с) и очередь (JSON + sorted set).
|
||
|
||
---
|
||
|
||
## 12. Ограничения текущего этапа
|
||
|
||
- `Storage:Provider=None` — всё в памяти, рестарт теряет данные.
|
||
История матча пишется, но реплей при `None` недоступен: строки матча нет.
|
||
Для истории и реплеев нужен `Memory` или `Postgres`.
|
||
- `EnsureCreated`, а не миграции: схема меняется вместе с пересозданием БД.
|
||
- Один процесс: Orleans-хостинг ещё впереди; `IMatchGrain` уже отделён.
|
||
- Нет магазина косметики, квестов, баттлпасса, Draft, друзей и чата.
|
||
- Rate limit: 60/мин на auth, 300/мин на остальные мутирующие запросы.
|
||
- Dev-очередь включается только `Game:AllowDevQueue=true` и не для production.
|