581 lines
32 KiB
Markdown
581 lines
32 KiB
Markdown
# Протокол матча
|
||
|
||
Спецификация сетевого взаимодействия клиента и игрового сервера.
|
||
Версия протокола — **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?` |
|
||
| 16 | `EndTurnIntent` | — |
|
||
| 17 | `ConcedeIntent` | — |
|
||
|
||
Индексы кругов: `-1` — Авангард, `0..2` — задний ряд, `3..4` — передний.
|
||
`damageIndex` — индекс карты урона в порядке отображения (сначала закрытые,
|
||
затем открытые); `-1` — авто.
|
||
`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 КиБ и мусорный кадр отклоняются без падения сервера.
|