# 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 ``` Ответ 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 не начисляются, но матч попадает в историю. - 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.