11 KiB
HTTP API сервера
Служебный HTTP-слой матчевого блока: очередь, тикеты на подключение и /health.
После подключения клиент переходит на WebSocket и работает по docs/protocol.md.
Базовая часть стека: ASP.NET Core 10, один процесс. Матчи и очередь сохраняются в PostgreSQL/Redis, если они настроены; без внешних сервисов сервер работает in-memory (удобно для тестов).
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
Порт и внешний адрес WebSocket задаются в .env:
VANGUARD_PORT=8080
VANGUARD_WS_URL=ws://localhost:8080/game
VANGUARD_PORT нужен, если 8080 занят (на тестовом стенде — 18080).
Для ручной проверки колоду на 50 карт можно получить так:
dotnet run --project server\src\Vanguard.Cli -- deck
Каталог карт ищется в content/sets вверх от каталога сборки. Если контент
лежит в другом месте, задайте Content:Path (или Content__Path в окружении).
2. Авторизация
Пока это dev-заглушка: игрок представляется строкой playerId.
Никаких токенов и паролей нет, поэтому сервер нельзя выставлять в интернет
как есть.
Когда появится мета-API, playerId будет браться из JWT, а контракты очереди
и тикетов не изменятся. Ограничения на формат: 1–64 символа, буквы, цифры,
_, ., -.
3. Очередь
POST /api/game/queue
Поставить игрока в очередь.
{
"playerId": "p1",
"mode": "classic-ranked",
"deck": ["BT01/001EN", "BT01/002EN", "..."]
}
mode:classic-rankedилиclassic-casual.deck: ровно 50printingId. Проверяются те же правила, что и в декбилдере: 50 карт, ≤4 поnameId, ≤4 Sentinel, ≤4 Heal, известные принты и хотя бы одна карта grade 0 без триггера для стартового Авангарда.
Ответ 200:
{
"queueId": "5b5c7a4e-9d5a-4f7b-a0d8-6f1b2c3d4e5f",
"status": "queued",
"mode": "classic-ranked"
}
Ответ 400 при плохой колоде:
{
"error": "deck.invalid",
"message": "Колода не проходит проверку.",
"violations": [
{ "code": "deck.size", "message": "В колоде 49 карт, требуется 50." }
]
}
Повторный запрос от того же playerId отменяет его предыдущую очередь
в любом режиме: один игрок — одна активная очередь.
GET /api/game/queue/{queueId}?playerId=p1
Статус очереди. Клиент опрашивает, пока не получит matched.
{
"queueId": "5b5c7a4e-9d5a-4f7b-a0d8-6f1b2c3d4e5f",
"status": "matched",
"mode": "classic-ranked",
"waitedSeconds": 7,
"window": 100,
"matchId": "9f2c...",
"seat": 0,
"ticket": "c2VjcmV0...",
"opponent": "p2",
"reason": null,
"wsUrl": "ws://localhost:5080/game"
}
status:
| Значение | Смысл |
|---|---|
queued |
ждёт противника, window — текущее окно MMR |
matched |
матч найден, ticket — одноразовый тикет |
cancelled |
игрок отменил очередь или встал в новую |
expired |
очередь истекла (10 минут) |
failed |
не удалось создать матч |
wsUrl заполняется только для matched. Он строится из Game:PublicWsUrl
или из текущего запроса. Тикет живёт 60 секунд: если клиент не успел
подключиться, он запрашивает новый через POST /api/game/tickets с matchId.
Подбор:
- две независимые очереди — ranked и casual;
- скрытый MMR, стартовое окно ±100;
- окно расширяется на 50 каждые 5 секунд, максимум 2000;
- пара выбирается по ближайшему MMR в пределах взаимных окон;
- кто встал раньше — садится за место 0.
MMR наружу не отдаётся. В ранкеде после матча он меняется по Эло (K = 32), в casual — нет.
DELETE /api/game/queue/{queueId}?playerId=p1
Отменить очередь. 204 — отменена, 409 — уже не активна.
4. Тикеты
POST /api/game/tickets
Выдать новый одноразовый тикет на подключение. Нужен для переподключения после обрыва: старый тикет уже израсходован.
{ "playerId": "p1", "matchId": "9f2c..." }
Ответ 200:
{
"ticket": "c2VjcmV0...",
"matchId": "9f2c...",
"seat": 0,
"wsUrl": "ws://localhost:5080/game",
"expiresAt": "2026-10-21T12:01:00+00:00"
}
- TTL — 60 секунд, тикет одноразовый.
- Тикет привязан к
(playerId, matchId, seat). - Если матч уже завершён, тикет всё равно выдаётся: клиент может подключиться и посмотреть итоговое состояние.
404— матч не найден,403— игрок не сидит в этом матче.
5. Health
GET /health
{
"status": "ok",
"matches": 3,
"activeMatches": 2,
"queued": 4,
"serverTimeMs": 1760000000000
}
matches включает завершённые матчи, которые ещё живут в памяти
(10 минут после финала — для реконнекта и просмотра результата).
6. Полный сценарий
POST /api/game/queue → queueId
GET /api/game/queue/{id} → matched + ticket + wsUrl
WS /game → ClientHello(ticket)
← ServerHello + ServerSnapshot
→ ClientIntent(...)
← ServerIntentResult + ServerEvents
...
обрыв
POST /api/game/tickets → новый ticket
WS /game → ClientHello(ticket, resumeFromSeq)
← ServerHello + ServerEvents (догон)
Формат WebSocket-кадров, интентов, событий, таймеров и кодов ошибок —
docs/protocol.md.
7. Хранилище и восстановление
7.1. Конфигурация
| Ключ | Значения | Смысл |
|---|---|---|
Storage:Provider |
None, Memory, Postgres |
None — без сохранения, Memory — EF InMemory, Postgres — постоянное |
Storage:ConnectionString |
строка Npgsql | обязательно для Postgres |
Redis:Configuration |
host:port |
пусто — тикеты и очередь в памяти |
Redis:Prefix |
vanguard: |
префикс ключей |
В контейнере переменные задаются как Storage__Provider,
Storage__ConnectionString, Redis__Configuration.
7.2. Матчи в PostgreSQL
matches— заголовок: игроки, колоды, seed, часы, банки, статус,eventSeq.match_intents— журнал:intentId, место, команда, результат, код отказа.- На каждый интент пишется строка журнала и обновляется заголовок.
- После рестарта
MatchRecoveryServiceподнимает незавершённые матчи: создаёт движок с сохранённым seed и проигрывает принятые команды заново.GameFinished, ожидания выбора и продолжения восстанавливаются реплеем, делегаты никуда не сериализуются. eventSeqсверяется: если реплей разошёлся со снапшотом, матч пропускается с ошибкой в логе.- Банк и дедлайн сохраняются раз в ~5 секунд; авария теряет не больше тика.
- Завершённые матчи остаются историей и при старте в память не поднимаются.
7.3. Redis
- Тикеты: ключ
vanguard:ticket:{token}, TTL 60 с, изъятие черезGETDEL(одноразовость сохраняется между инстансами). - Очередь: запись
vanguard:queue:entry:{id}(JSON) и sorted setvanguard:queue:{mode}по номеру постановки. При старте очередь загружается обратно. - Состояние профилей (MMR) пока остаётся в памяти: это закроет мета-API вместе с аккаунтами.
8. Ограничения текущего этапа
- PostgreSQL/Redis опциональны. По умолчанию (
Storage:Provider=None) матчи, тикеты и очередь живут только в памяти. - Профили и MMR пока не персистентны: после рестарта рейтинг начинается с 1000. Матчи и очередь при этом восстанавливаются.
- Один процесс: горизонтальное масштабирование и Orleans-хостинг ещё впереди.
IMatchGrainуже отделён от ASP.NET Core и WebSocket: замена на Orleans grain не затронет транспорт и правила.- Нет JWT, коллекций и наград — это следующие блоки.
- Rate limit: 16 интентов в секунду с одного соединения. Живой UI в него не упирается; тестовый бот специально притормаживается.