VanguardReMaster/docs/server-api.md

431 lines
15 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 сервера
Мета-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` сверяется. Схема — EF-миграции
(`server/src/Vanguard.Server/Migrations`); на старте PostgreSQL выполняется
`MigrateAsync`, для InMemory — `EnsureCreated`. Новая миграция:
`dotnet ef migrations add <Name> ...`.
Redis: тикеты (`GETDEL`, TTL 60 с) и очередь (JSON + sorted set).
---
## 12. Ограничения текущего этапа
- `Storage:Provider=None` — всё в памяти, рестарт теряет данные.
История матча пишется, но реплей при `None` недоступен: строки матча нет.
Для истории и реплеев нужен `Memory` или `Postgres`.
- `EnsureCreated` используется только для InMemory; PostgreSQL — миграции.
- Один процесс: Orleans-хостинг ещё впереди; `IMatchGrain` уже отделён.
- Нет магазина косметики, квестов, баттлпасса, Draft, друзей и чата.
- Rate limit: 60/мин на auth, 300/мин на остальные мутирующие запросы.
- Dev-очередь включается только `Game:AllowDevQueue=true` и не для production.