32 KiB
Протокол матча
Спецификация сетевого взаимодействия клиента и игрового сервера. Версия протокола — 1. Версия растёт при несовместимом изменении кадров; клиент и сервер обязаны совпадать по версии точно, иначе handshake отклоняется.
Связанные документы:
docs/HANDOFF.md— состояние проекта и порядок блоков;IMPLEMENTATION_CHECKLIST.md, раздел 5 — план сетевого блока;docs/server-api.md— HTTP-очередь, тикеты и/health;server/src/Vanguard.Shared— DTO, MessagePack-резолвер и проекция состояния.
1. Границы
Протокол описывает только матч: подключение, интенты, события, состояние, таймеры и переподключение. Аккаунты, коллекция, колоды, магазин и выдача тикетов — HTTP/JSON мета-API, у него отдельная спецификация.
Инварианты:
- сервер авторитарный: клиент присылает намерения, а не результаты;
- скрытая информация не покидает сервер;
- порядок событий один на матч,
eventSeqмонотонно растёт; - повторный интент с тем же
intentIdне применяется дважды; - клиент может переподключиться на любом шаге и догнать состояние.
2. Транспорт
2.1. Основной вариант — WebSocket
- Схема:
wss://<host>/gameв production,ws://только локально. - Каждый бинарный кадр WebSocket — ровно один кадр протокола (один объект MessagePack). Текстовые кадры не принимаются.
- Ping/pong WebSocket-уровня не считается активностью игрока: приложение
использует свои кадры
ClientPing/ServerPing(см. раздел 9). - Тикет не передаётся в URL и не пишется в логи: он уходит внутри
ClientHello.
2.2. Резервный вариант — TCP
Тот же payload можно нести поверх сырого TCP. Формат кадра:
+----------------+---------------------------+
| длина, 4 байта | payload, длина байт |
| big-endian | MessagePack |
+----------------+---------------------------+
Длина считается только по payload и не включает сами 4 байта. Максимум —
ProtocolConstants.MaxFrameBytes (256 КиБ). TCP-вариант не заменяет
WebSocket в MVP: он нужен, если Unity-клиент упрётся в ограничения
WebSocket-транспорта. Логика handshake, кадров и таймеров идентична.
2.3. Лимиты
| Параметр | Значение |
|---|---|
| Максимальный кадр | 256 КиБ до десериализации |
| Интентов в секунду с соединения | 16 |
| Тикет на подключение | 60 с, одноразовый |
| Сжатие MessagePack | не используется в v1 |
Кадр больше лимита или мусор вместо MessagePack → ServerError(MalformedFrame)
и закрытие 4400. Сервер не пытается «починить» такой кадр.
3. Кадры
Кадр — объект MessagePack с union-дискриминатором. На проводе union выглядит
как массив из двух элементов: [ключ, тело]. Ключи зафиксированы и не меняются.
3.1. Клиент → сервер
| Ключ | Кадр | Назначение |
|---|---|---|
| 0 | ClientHello |
первый кадр: версия, тикет, догон |
| 1 | ClientIntent |
игровое действие |
| 2 | ClientResync |
запрос догона или полного снимка |
| 3 | ClientPing |
проверка связи и часов |
| 4 | ClientPong |
ответ на ServerPing |
| 5 | ClientLeave |
явный выход: поражение и завершение матча |
ClientHello:
| Поле | Тип | Смысл |
|---|---|---|
protocolVersion |
int | ProtocolConstants.Version |
ticket |
string | одноразовый тикет |
clientBuild |
string | версия клиента для логов |
resumeFromSeq |
long | последний применённый eventSeq, 0 — без догона |
ClientIntent:
| Поле | Тип | Смысл |
|---|---|---|
intentId |
string | GUID, уникальный в рамках матча |
basedOnSeq |
long | eventSeq, на котором построен интент |
intent |
union | полезная нагрузка (раздел 4) |
ClientResync:
| Поле | Тип | Смысл |
|---|---|---|
fromSeq |
long | с какого номера догонять |
fullSnapshot |
bool | true — прислать полный снимок |
ClientPing: nonce, clientTimeMs. ClientPong: nonce.
ClientLeave: reason.
3.2. Сервер → клиент
| Ключ | Кадр | Назначение |
|---|---|---|
| 0 | ServerHello |
принятое подключение |
| 1 | ServerSnapshot |
полное состояние под игрока |
| 2 | ServerEvents |
порция событий |
| 3 | ServerIntentResult |
результат интента |
| 4 | ServerClock |
синхронизация таймеров |
| 5 | ServerPing |
проверка связи |
| 6 | ServerPong |
ответ на ClientPing |
| 7 | ServerError |
ошибка протокола |
ServerHello:
| Поле | Тип | Смысл |
|---|---|---|
protocolVersion |
int | версия сервера |
matchId |
string | идентификатор матча |
playerIndex |
int | место игрока: 0 или 1 |
resumed |
bool | true — догон журналом, false — будет снимок |
eventSeq |
long | текущий последний номер события |
serverTimeMs |
long | UTC, миллисекунды |
ServerSnapshot: view — MatchView (раздел 6).
ServerEvents: fromSeq, toSeq, events[] (seq + event).
ServerIntentResult: intentId, accepted, code, reason, eventSeq,
duplicate.
ServerClock: clock (ClockView), eventSeq.
ServerPing: nonce, serverTimeMs. ServerPong: nonce, serverTimeMs.
ServerError: code, message, fatal, eventSeq.
4. Интенты
Полезная нагрузка повторяет команды движка один в один. Union-ключи:
| Ключ | Интент | Поля |
|---|---|---|
| 0 | MulliganIntent |
handIndexes[] |
| 1 | FinishMulliganIntent |
— |
| 2 | RideIntent |
printingId |
| 3 | SkipRideIntent |
— |
| 4 | CallIntent |
printingId, slot |
| 5 | MoveIntent |
fromSlot, toSlot |
| 6 | EnterBattleIntent |
— |
| 7 | AttackIntent |
attackerSlot, targetSlot, boosterSlot |
| 8 | GuardIntent |
printingId, discardPrintingId? |
| 9 | InterceptIntent |
slot |
| 10 | PassGuardIntent |
— |
| 11 | ResolveTriggerIntent |
powerTarget, effectTarget |
| 12 | SelectTriggerIntent |
index |
| 13 | ResolveAbilityIntent |
accept, damageIndex? |
| 14 | ChooseIntent |
index |
| 15 | ActivateAbilityIntent |
instanceId, discardPrintingId?, damageIndex?, damageIndexes? |
| 16 | EndTurnIntent |
— |
| 17 | ConcedeIntent |
— |
Индексы кругов: -1 — Авангард, 0..2 — задний ряд, 3..4 — передний.
damageIndex — индекс карты урона в порядке отображения (сначала закрытые,
затем открытые); -1 — авто.
GuardIntent.discardPrintingId заполняется только для идеальной защиты:
без него Sentinel не отменяет атаку; с ним указывается карта того же клана.
ConcedeIntent обрабатывает сервер, а не движок: матч завершается,
победителем становится противник.
4.1. Обработка
- Сервер проверяет, что соединение прошло handshake и привязано к этому матчу.
- Ищет
intentIdв журнале идемпотентности. Нашёл — возвращает сохранённый результат сduplicate = true, состояние не меняется. - Проверяет, что сейчас решает этот игрок. Во время шага защиты решает обороняющийся, а не атакующий.
- Переводит DTO в
GameCommandи вызываетGameEngine.Execute. - Пишет принятый
intentIdи результат в журнал. - Отправляет
ServerIntentResult, затемServerEventsс событиями, порождёнными интентом (включая автоматические разрешения).
Интенты одного игрока обрабатываются строго в порядке прихода. Ждать
ServerIntentResult перед отправкой следующего не обязательно, но клиенту
проще: при отказе не придётся откатывать оптимистичный UI.
4.2. intentId
- GUID в виде строки, генерируется клиентом заново для каждого действия.
- Уникален в рамках матча и игрока. Повтор после обрыва связи — тот же id.
- Журнал идемпотентности живёт весь матч: интентов за партию сотни, память не проблема. Чистить его по TTL не нужно.
4.3. basedOnSeq
Это последний eventSeq, который клиент применил. Сервер не отклоняет
интент только из-за отставания: состояние авторитарно, интент проверяется
по нему. Поле нужно для диагностики и для решения «пора слать resync».
Если интент отклонён, а basedOnSeq меньше текущего eventSeq, клиент
обязан запросить ClientResync. Если сервер видит, что интент ссылается
на уже недоступное состояние (например, карта давно ушла из руки), он
возвращает IntentRejectCode.Stale.
4.4. Причины отказа
| Код | Значение |
|---|---|
None |
принят |
Illegal |
движок отклонил действие, причина в reason |
NotYourTurn |
сейчас решает другой игрок |
MatchFinished |
партия завершена |
NotSupported |
интент не поддержан этой версией |
RateLimited |
превышен лимит частоты |
Malformed |
пустой или слишком длинный intentId, битые поля |
Stale |
клиент отстал, нужен resync |
Отказ никогда не меняет состояние. reason — человекочитаемая строка для
лога и отладки; клиент не должен разбирать её, только показывать.
5. События и eventSeq
eventSeq — номер события в общем журнале матча. Нумерация начинается
с 1 (GameStarted). Журнал неизменяем и живёт весь матч.
Событие, скрытое от конкретного игрока, всё равно занимает номер. Поэтому
в ServerEvents номера могут идти с пропусками: это не потеря, а скрытое
событие. Клиент обязан:
- применять события строго по возрастанию
seq; - не считать пропуск ошибкой;
- обновлять
lastSeqпоtoSeq(а не по последнему видимому событию); - при
seq <= lastSeqигнорировать событие как повтор.
События одного интента приходят одним кадром ServerEvents. Сервер также
может слать события без интента — например, авто-поражение по таймеру.
Скрытые события (уходят только владельцу):
CardSearchedView— поиск в колоде;SearchFailedView— неудачный поиск вместе со строкой запроса.
Остальные события открыты обоим игрокам: райд, призыв, перемещение, атака, поддержка, защита, проверки, урон, уничтожение, сброс, душа, возврат в колоду, срабатывание и разрешение способностей.
6. Проекция состояния
MatchView собирается отдельно для каждого игрока. Это не «общее
состояние с флагами», а два разных объекта: то, что не положено видеть,
в объект просто не попадает.
| Данные | Свои | Противник |
|---|---|---|
| Рука | карты полностью | только handCount |
| Колода | только deckCount |
только deckCount |
| Урон лицом вниз | только faceDownDamage |
только faceDownDamage |
| Урон лицом вверх | карты | карты |
| Авангард, Арьергард | карты | карты |
| Душа, сброс, зона триггера, круг защиты | карты | карты |
| Сила и крит | эффективные, посчитаны сервером | эффективные |
Ожидание выбора (Choice) |
полностью | не отправляется |
Ожидание способности (Ability) |
полностью | не отправляется |
Порядок триггеров (Order) |
полностью | не отправляется |
Ожидание триггера (Trigger) |
полностью | полностью (проверка открыта) |
| Легальные интенты | свои | только «сдаться» |
| Seed, RNG | никогда | никогда |
MatchView содержит:
| Поле | Смысл |
|---|---|
matchId, eventSeq |
идентификатор и последний применённый номер |
you |
для кого собрано состояние |
activePlayer |
кто принимает решения |
turnNumber, phase, step |
ход и фаза |
isFinished, winner |
итог (winner = -1, пока игра идёт) |
players[2] |
PlayerView обоих игроков |
battle |
текущий бой или null |
trigger |
ожидающий триггер, если есть |
choice, ability, order |
личные ожидания, только для решающего |
opponentChoosing |
противник сейчас что-то выбирает (без деталей) |
clock |
таймеры (раздел 8) |
legalIntents |
что этот игрок может отправить прямо сейчас |
legalIntents собирается из GameEngine.LegalCommands(), но только для
игрока, который сейчас решает. Наблюдателю чужой список не отправляется:
в нём встречаются printingId из руки решающего. Сдача доступна всегда, пока
партия не закончена, поэтому ConcedeIntent есть в списке у обоих.
PlayerView:
| Поле | Смысл |
|---|---|
index, name |
место и имя |
handCount, deckCount |
количество, не содержимое |
hand |
свои карты; у противника пустой список |
vanguard, rearGuards[5] |
поле, пустой круг — null |
guardian, soul, drop, trigger |
открытые зоны |
faceDownDamage |
число закрытых карт урона |
faceUpDamage |
открытые карты урона |
isDefeated |
поражение |
CardView содержит instanceId, printingId, rested, faceUp,
basePower, power, baseCritical, critical. Тексты карт клиент берёт
из своего каталога: сервер не дублирует контент в каждом событии.
7. Resync
Клиент запрашивает догон, когда:
- пропустил события (например, после долгой заморозки процесса);
- получил
IntentRejectCode.Stale; - подозревает рассинхрон (не сходится
eventSeq).
ClientResync { fromSeq, fullSnapshot }:
fullSnapshot = true→ сервер отвечаетServerSnapshot;- иначе, если журнал доступен →
ServerEventsсо всеми видимыми событиями начиная сfromSeq; - если журнал недоступен (например, grain восстановлен из снапшота) →
ServerSnapshot.
ServerEvents в ответ на resync может быть пустым: fromSeq > toSeq значит,
что клиент уже в актуальном состоянии.
После ServerSnapshot клиент заменяет локальное состояние целиком и
выставляет lastSeq = view.eventSeq. Догонять снимок событиями не нужно.
8. Таймеры
8.1. Ход
- На каждое решение даётся 90 секунд бесплатного времени.
- Дальше тратится банк 5 минут на игрока за матч.
- Банк не пополняется. Неизрасходованные 90 секунд в банк не переходят.
- Банк исчерпан → этот игрок проигрывает.
- Таймер идёт и во время выбора целей, защиты, порядка триггеров и подтверждения способности: это тоже решения.
Отсчёт начинается, когда игрок становится activePlayer. Смена активного
игрока (в том числе переход хода защиты к обороняющемуся) начинает новый
отсчёт 90 секунд.
ClockView:
| Поле | Смысл |
|---|---|
decisionPlayer |
кто сейчас решает |
turnDeadlineMs |
когда истекают 90 секунд (UTC, мс); 0 — таймер стоит |
bank0Ms, bank1Ms |
остаток банка каждого игрока |
isRunning |
идёт ли отсчёт |
serverTimeMs |
время сервера для расчёта смещения |
Сервер шлёт ServerClock каждые 5 секунд и сразу после смены активного
игрока. Клиент считает оставшееся время от turnDeadlineMs с поправкой
на разницу часов, а не от момента получения кадра.
При исчерпании времени сервер сам завершает партию, добавляет
GameFinishedView и рассылает его обоим. Интент проигравшего после этого
отклоняется с MatchFinished.
8.2. Сдача, выход и отсутствие
- Сдача (
ConcedeIntent) — немедленное поражение сдавшегося. - Явный выход (
ClientLeave) — немедленное поражение вышедшего; матч завершается, сопернику уходитGameFinished. - Разрыв (закрытие сокета, ошибка, heartbeat) — матч продолжается: у игрока есть 5 минут на возврат. Если он не вернулся — поражение.
- Таймер хода при разрыве не останавливается: банк может кончиться раньше.
- Оба отсутствуют → проигрывает тот, кто первым превысил 5 минут.
- Возврат в матч сбрасывает счётчик отсутствия, но не восстанавливает потраченное время. После явного выхода возврата в игру нет — только просмотр результата.
9. Heartbeat
- Если от клиента 20 секунд нет любого кадра, сервер отправляет
ServerPing. - Клиент обязан ответить
ClientPongв течение 20 секунд. - Нет ответа 40 секунд от последнего кадра → закрытие
4408. - Клиент, у которого 20 секунд нет трафика, отправляет
ClientPing; сервер отвечаетServerPong. Это же синхронизирует часы. - Любой кадр считается активностью и сбрасывает оба таймера.
- Штатное закрытие —
ClientLeave+ WebSocket close1000.ClientLeaveзавершает матч поражением вышедшего. Если клиент закрыл сокет безClientLeave, это считается разрывом: матч ждёт возвращения 5 минут.
10. Переподключение
- Клиент замечает обрыв (ошибка сокета,
4408, закрытие). - Ждёт по задержкам: 0, 2, 10, 30 секунд.
- На каждую попытку получает новый тикет через мета-API
(
POST /api/game/tickets, TTL 60 секунд). Тикет одноразовый. - Подключается и отправляет
ClientHelloсresumeFromSeq = lastSeq. - Сервер отвечает
ServerHello.
Далее:
resumed = true→ сразуServerEventsс пропущенными событиями;resumed = false→ServerSnapshot, затем обычная игра;- если игрок уже отсутствовал 5 минут, матч завершён: сервер присылает
ServerHelloс актуальнымeventSeq, затем снимок сisFinished = true.
После четырёх неудачных попыток клиент показывает экран «не удалось подключиться» с кнопкой ручного повтора, но продолжает считать время отсутствия. Матч на сервере при этом не удаляется до истечения 5 минут.
10.1. Захват сессии
Если для места уже есть живое соединение, новое ClientHello с валидным
тикетом заменяет его: старое закрывается кодом 4409, новое получает
ServerHello. Это защищает от ситуации «старый сокет ещё не умер, а клиент
уже переподключился». Тикет при этом всё равно одноразовый.
10.2. Восстановление grain
После рестарта сервера матч поднимается из снапшота PostgreSQL. Журнал
событий может быть недоступен: тогда resumed = false, и клиент получает
ServerSnapshot. Хранить весь журнал в БД не обязательно — снимок и
eventSeq достаточны для продолжения.
11. Ошибки и коды закрытия
ServerError несёт code, message, fatal, eventSeq. Если fatal,
сервер закрывает соединение сразу после кадра.
| Код | Когда |
|---|---|
UnsupportedVersion |
версия клиента не равна версии сервера |
InvalidTicket |
тикет неизвестен, использован или чужой |
TicketExpired |
прошло больше 60 секунд |
MatchNotFound |
матч не найден или удалён |
MalformedFrame |
мусор, пустой кадр, превышен размер |
RateLimited |
слишком много интентов |
MatchFinished |
партия закончена, соединение больше не нужно |
Forbidden |
тикет валиден, но место в матче чужое |
Internal |
непредвиденная ошибка сервера |
Коды закрытия WebSocket (диапазон приложения 4000–4999):
| Код | Значение |
|---|---|
4400 |
нарушение протокола (handshake, битый кадр) |
4401 |
не авторизован (тикет) |
4403 |
место в матче не принадлежит игроку |
4408 |
heartbeat не получен |
4409 |
соединение заменено новым |
4410 |
неподдерживаемая версия протокола |
4413 |
превышен лимит частоты |
4500 |
внутренняя ошибка |
Причина закрытия — короткая ASCII-строка. Секреты и содержимое руки в неё не попадают.
12. Схема MessagePack
- Все DTO —
[MessagePackObject]с целочисленными[Key]; на проводе это массивы, а не словари. - Union —
[Union(ключ, тип)]на абстрактном кадре или полезной нагрузке. Union-ключи и ключи полей никогда не переиспользуются. - Новые поля добавляются только в конец с новым номером ключа. Старый клиент получит для них значение по умолчанию, новый клиент переживёт отсутствие поля.
MessagePackSecurity.UntrustedData: глубина и размер структур ограничены.- Typeless-режим запрещён: он позволяет данным выбирать тип при десериализации.
- Строки интернируются форматтером:
printingIdповторяется в событиях. - Сжатие LZ4 не используется: кадры малы, а совместимость важнее.
Кодек — Vanguard.Shared.Protocol.ProtocolSerializer. Резолвер —
ProtocolResolver; он собирает StandardResolver (включая сгенерированные
форматтеры) и интернирование строк.
13. Сценарии
13.1. Первое подключение
клиент сервер
| POST /api/game/tickets (HTTP) |
|---------------------------------------->|
| { ticket, matchId, expiresAt } |
|<----------------------------------------|
| |
| WS /game |
|---------------------------------------->|
| ClientHello(version, ticket, 0) |
|---------------------------------------->|
| ServerHello(resumed=false, seq=N)
|<----------------------------------------|
| ServerSnapshot(view, seq=N)
|<----------------------------------------|
13.2. Ход
| ClientIntent(id, seq, RideIntent) |
|---------------------------------------->|
| ServerIntentResult(id, accepted=true, seq=M)
|<----------------------------------------|
| ServerEvents(seq=M-2..M)
|<----------------------------------------|
Если интент отклонён, ServerEvents не приходит: состояние не менялось.
13.3. Обрыв и возврат
| X связь пропала |
| |
| (0 с) новый тикет, WS, Hello(resume=N) |
|---------------------------------------->|
| ServerHello(resumed=true, seq=M)
|<----------------------------------------|
| ServerEvents(N+1..M)
|<----------------------------------------|
13.4. Resync после рассинхрона
| ClientResync(fromSeq=N, fullSnapshot=false)
|---------------------------------------->|
| ServerEvents(N..M) (или ServerSnapshot)
|<----------------------------------------|
14. Критерии приёмки
- Два клиента играют полный матч по сети: райд, призыв, атака, защита, проверки, урон, победа.
- Kill клиента на любом шаге: после перезапуска и переподключения состояние
совпадает с сервером,
eventSeqнепрерывен. - Дамп трафика не содержит: seed и RNG, карт колоды, карт руки противника, закрытых карт урона, вариантов чужого выбора.
- Повторная отправка одного
intentIdне меняет состояние и возвращаетduplicate = true. - Пропуск heartbeat закрывает соединение за 40 секунд, а отсутствие
5 минут завершает матч поражением отсутствующего.
ConcedeIntentиClientLeaveдают немедленное поражение; сетевой разрыв оставляет окно на возврат. - Истечение 90 секунд и банка корректно завершает партию;
ServerClockприходит не реже раза в 5 секунд. - Кадр больше 256 КиБ и мусорный кадр отклоняются без падения сервера.