VanguardReMaster/docs/protocol.md

582 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Протокол матча
Спецификация сетевого взаимодействия клиента и игрового сервера.
Версия протокола — **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` — авто. `DamageDisplayOrder` в движке и клиентская
зона урона используют тот же порядок.
`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. Сдача, выход и отсутствие
- **Сдача** (`ConcedeIntent`) — немедленное поражение сдавшегося.
- **Явный выход** (`ClientLeave`) — немедленное поражение вышедшего;
матч завершается, сопернику уходит `GameFinished`.
- **Разрыв** (закрытие сокета, ошибка, heartbeat) — матч продолжается:
у игрока есть **5 минут** на возврат. Если он не вернулся — поражение.
- Таймер хода при разрыве не останавливается: банк может кончиться раньше.
- Оба отсутствуют → проигрывает тот, кто первым превысил 5 минут.
- Возврат в матч сбрасывает счётчик отсутствия, но не восстанавливает
потраченное время. После явного выхода возврата в игру нет — только
просмотр результата.
---
## 9. Heartbeat
- Если от клиента 20 секунд нет **любого** кадра, сервер отправляет
`ServerPing`.
- Клиент обязан ответить `ClientPong` в течение 20 секунд.
- Нет ответа 40 секунд от последнего кадра → закрытие `4408`.
- Клиент, у которого 20 секунд нет трафика, отправляет `ClientPing`;
сервер отвечает `ServerPong`. Это же синхронизирует часы.
- Любой кадр считается активностью и сбрасывает оба таймера.
- Штатное закрытие — `ClientLeave` + WebSocket close `1000`. `ClientLeave`
завершает матч поражением вышедшего. Если клиент закрыл сокет без
`ClientLeave`, это считается разрывом: матч ждёт возвращения 5 минут.
---
## 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 минут завершает матч поражением отсутствующего. `ConcedeIntent` и
`ClientLeave` дают немедленное поражение; сетевой разрыв оставляет
окно на возврат.
6. Истечение 90 секунд и банка корректно завершает партию; `ServerClock`
приходит не реже раза в 5 секунд.
7. Кадр больше 256 КиБ и мусорный кадр отклоняются без падения сервера.