15 KiB
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 защищает
от двойного списания/начисления.
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/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]
История:
{
"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-хостинг ещё впереди;
IMatchGrainуже отделён. - Нет магазина косметики, квестов, баттлпасса, Draft, друзей и чата.
- Rate limit: 60/мин на auth, 300/мин на остальные мутирующие запросы.
- Dev-очередь включается только
Game:AllowDevQueue=trueи не для production.