VanguardReMaster/docs/server-api.md

16 KiB
Raw Permalink Blame History

HTTP API сервера

Мета-API и матчевый HTTP-слой: аккаунты, коллекция, колоды, магазин, очередь, тикеты, история матчей и реплеи. После подключения клиент переходит на WebSocket и работает по docs/protocol.md.

Базовая часть стека: ASP.NET Core 10, JWT, PostgreSQL/Redis (опционально), один процесс.


1. Запуск

$env:PATH = "$env:LOCALAPPDATA\dotnet;$env:PATH"

dotnet run --project server\src\Vanguard.Server --urls http://localhost:5080

Проверка:

Invoke-RestMethod http://localhost:5080/health

Контейнер (PostgreSQL + Redis + сервер):

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 карт можно получить так:

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:

{
  "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, статистику и состояние онбординга.
{
  "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:

{
  "items": [
    { "printingId": "TD01/001EN", "nameId": "…", "nameEn": "…", "rarity": "R", "count": 4 }
  ],
  "balances": { "gold": 0, "dust": 5, "tokens": 5 }
}

Коллекция считается по printingId и не имеет верхнего предела.

Разбор (dismantle):

{ "printingId": "BT01/050EN", "count": 2, "idempotencyKey": "uuid" }
Редкость Пыль за карту
C 5
R 10
RR 25
RRR 100
SP нельзя разобрать

Крафт (craft):

{ "printingId": "BT01/050EN", "idempotencyKey": "uuid" }
Редкость Стоимость
C 20
R 40
RR 100
RRR 400
SP нельзя скрафтить

Оба эндпоинта возвращают актуальные Balances; idempotencyKey защищает от двойного списания/начисления.

Каталог карт

GET /api/cards                       [Bearer]

Отдаёт все принты каталога для декбилдера и фильтров. Клиент объединяет их с коллекцией по printingId.

[
  {
    "printingId": "BT01/034EN",
    "nameId": "BT01-034",
    "nameEn": "Dragon Monk, Ginkai",
    "nameRu": null,
    "rarity": "C",
    "grade": 2,
    "type": "NormalUnit",
    "power": 7000,
    "shield": 5000,
    "critical": 1,
    "trigger": "None",
    "clan": "Tachikaze",
    "isSentinel": false,
    "isRestraint": false,
    "hasBoost": false,
    "hasIntercept": true,
    "isParallel": false,
    "basePrintingId": null,
    "imageKey": "bt01_034",
    "textEn": "[AUTO](VC/RC):…",
    "textRu": null
  }
]

grade — 0–3, trigger — None/Critical/Draw/Stand/Heal, clan — имя клана, rarity — C/R/RR/RRR/SP/TD. textEn/textRu — текст способностей для оверлея карты (RU может быть null).


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:

{
  "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 обязателен для покупок; повтор возвращает тот же результат и не списывает второй раз.

Ответ бустера:

{
  "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/practice               [Bearer]  { "deckId": "…" }
POST   /api/game/tickets                [Bearer]  { "matchId": "…" }

POST /api/game/practice сразу создаёт casual-матч против серверного случайного бота и возвращает ticket:

{
  "matchId": "…",
  "seat": 0,
  "ticket": "…",
  "wsUrl": "ws://…/game",
  "opponent": "Bot"
}

Бот играет легальными интентами из своего snapshot. Практика не пишет историю, не меняет MMR и не выдаёт награды.

С 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]

История:

{
  "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 остаётся прежним.

Реплей:

{
  "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
{
  "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 включается Orleans:Enabled; по умолчанию матчи идут через in-process MatchGrain, в compose — через silo.
  • Нет магазина косметики, квестов, баттлпасса, Draft, друзей и чата.
  • Rate limit: 60/мин на auth, 300/мин на остальные мутирующие запросы.
  • Dev-очередь включается только Game:AllowDevQueue=true и не для production.