VanguardReMaster/docs/protocol.md

30 KiB
Raw Blame History

Протокол матча

Спецификация сетевого взаимодействия клиента и игрового сервера. Версия протокола — 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
14 ChooseIntent index
15 ActivateAbilityIntent instanceId, discardPrintingId?
16 EndTurnIntent —
17 ConcedeIntent —

Индексы кругов: -1 — Авангард, 0..2 — задний ряд, 3..4 — передний. GuardIntent.discardPrintingId заполняется только для идеальной защиты: без него Sentinel не отменяет атаку; с ним указывается карта того же клана.

ConcedeIntent обрабатывает сервер, а не движок: матч завершается, победителем становится противник.

4.1. Обработка

  1. Сервер проверяет, что соединение прошло handshake и привязано к этому матчу.
  2. Ищет intentId в журнале идемпотентности. Нашёл — возвращает сохранённый результат с duplicate = true, состояние не меняется.
  3. Проверяет, что сейчас решает этот игрок. Во время шага защиты решает обороняющийся, а не атакующий.
  4. Переводит DTO в GameCommand и вызывает GameEngine.Execute.
  5. Пишет принятый intentId и результат в журнал.
  6. Отправляет 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. Отсутствие

  • Пропал транспорт (закрытие, ошибка, heartbeat) → матч продолжается.
  • Игрок отсутствует 5 минут подряд → авто-поражение.
  • Таймер хода при этом не останавливается: банк может кончиться раньше.
  • Оба отсутствуют → проигрывает тот, кто первым превысил 5 минут.
  • Возврат в матч сбрасывает счётчик отсутствия, но не восстанавливает потраченное время.

9. Heartbeat

  • Если от клиента 20 секунд нет любого кадра, сервер отправляет ServerPing.
  • Клиент обязан ответить ClientPong в течение 20 секунд.
  • Нет ответа 40 секунд от последнего кадра → закрытие 4408.
  • Клиент, у которого 20 секунд нет трафика, отправляет ClientPing; сервер отвечает ServerPong. Это же синхронизирует часы.
  • Любой кадр считается активностью и сбрасывает оба таймера.
  • Штатное закрытие — ClientLeave + WebSocket close 1000; матч при этом не завершается, а ждёт возвращения по общим правилам.

10. Переподключение

  1. Клиент замечает обрыв (ошибка сокета, 4408, закрытие).
  2. Ждёт по задержкам: 0, 2, 10, 30 секунд.
  3. На каждую попытку получает новый тикет через мета-API (POST /api/game/tickets, TTL 60 секунд). Тикет одноразовый.
  4. Подключается и отправляет ClientHello с resumeFromSeq = lastSeq.
  5. Сервер отвечает 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. Критерии приёмки

  1. Два клиента играют полный матч по сети: райд, призыв, атака, защита, проверки, урон, победа.
  2. Kill клиента на любом шаге: после перезапуска и переподключения состояние совпадает с сервером, eventSeq непрерывен.
  3. Дамп трафика не содержит: seed и RNG, карт колоды, карт руки противника, закрытых карт урона, вариантов чужого выбора.
  4. Повторная отправка одного intentId не меняет состояние и возвращает duplicate = true.
  5. Пропуск heartbeat закрывает соединение за 40 секунд, а отсутствие 5 минут завершает матч поражением отсутствующего.
  6. Истечение 90 секунд и банка корректно завершает партию; ServerClock приходит не реже раза в 5 секунд.
  7. Кадр больше 256 КиБ и мусорный кадр отклоняются без падения сервера.