# Протокол матча Спецификация сетевого взаимодействия клиента и игрового сервера. Версия протокола — **1**. Версия растёт при несовместимом изменении кадров; клиент и сервер обязаны совпадать по версии точно, иначе handshake отклоняется. Связанные документы: - `docs/HANDOFF.md` — состояние проекта и порядок блоков; - `IMPLEMENTATION_CHECKLIST.md`, раздел 5 — план сетевого блока; - `server/src/Vanguard.Shared` — DTO, MessagePack-резолвер и проекция состояния. --- ## 1. Границы Протокол описывает **только матч**: подключение, интенты, события, состояние, таймеры и переподключение. Аккаунты, коллекция, колоды, магазин и выдача тикетов — HTTP/JSON мета-API, у него отдельная спецификация. Инварианты: - сервер авторитарный: клиент присылает намерения, а не результаты; - скрытая информация не покидает сервер; - порядок событий один на матч, `eventSeq` монотонно растёт; - повторный интент с тем же `intentId` не применяется дважды; - клиент может переподключиться на любом шаге и догнать состояние. --- ## 2. Транспорт ### 2.1. Основной вариант — WebSocket - Схема: `wss:///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` | партия закончена, соединение больше не нужно | | `Internal` | непредвиденная ошибка сервера | Коды закрытия WebSocket (диапазон приложения 4000–4999): | Код | Значение | |---|---| | `4400` | нарушение протокола (handshake, битый кадр) | | `4401` | не авторизован (тикет) | | `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 КиБ и мусорный кадр отклоняются без падения сервера.