# Протокол WebSocket игрового движка

Адрес: `wss://api.en.cx/games/{gameId}/engine` — тот же путь, что и у REST-режима.
Апгрейд выполняется, когда в запросе есть `Upgrade: websocket`; без него ручка работает как REST (`?json=1`).

Реализация: [internal/handlers/engine_ws_handlers.go](../internal/handlers/engine_ws_handlers.go),
[internal/services/game/engine/hub.go](../internal/services/game/engine/hub.go),
модели кадров — [internal/models/engine.go](../internal/models/engine.go).

## Подключение

| Что | Значение |
|---|---|
| Авторизация | `Authorization: Bearer <jwt>`, cookie `en_access` или `?token=<jwt>` — заголовки в браузерном WebSocket недоступны, поэтому query-параметр поддерживается специально |
| Origin | проверяется как у REST: разрешены домены `*.en.cx` |
| Ответ на апгрейд | `101 Switching Protocols`; при неверном токене — `401` обычным HTTP-ответом |
| Размер кадра от клиента | не более 4 КБ |

Сразу после апгрейда сервер сам присылает полное состояние — отдельный `subscribe` для этого не нужен. Тип первого кадра зависит от состояния игры: обычно `state`, но при ненулевом `event` (например, взнос не оплачен или уровень снят) тем же первым кадром приходит `event`. Клиент готов к работе, получив любой из двух: состояние в них одинаковое, различается только реакция на `event`.

## Keepalive и переподключение

- Сервер шлёт управляющий WebSocket-ping каждые **54 с** и ждёт pong; без кадра или pong в течение **60 с** соединение закрывается.
- Стандартный клиент отвечает на ping автоматически. Прикладной `{"type":"ping"}` тоже поддержан и отвечает `{"type":"pong"}` — он не ждёт очереди обработки и годится как проверка живости канала.
- Любой входящий кадр продлевает дедлайн чтения.
- Очередь исходящих на клиента — 64 кадра. Клиент, который не успевает читать, отключается: копить состояние живой игры бессмысленно, после переподключения оно всё равно приходит целиком.
- При обрыве: переподключиться с экспоненциальной паузой и взять состояние из первого кадра — он придёт как `state` или как `event` (см. «Подключение»). Неподтверждённую отправку кода повторять с **тем же `req_id`** — попытка не спишется дважды (см. ниже).

## Клиент → сервер

Все кадры — JSON-объекты с полем `type`. Поле `req_id` необязательно, но крайне желательно: сервер повторяет его в ответе и использует как ключ идемпотентности.

```jsonc
{"type": "subscribe", "req_id": "c-17"}                                   // перезапросить состояние
{"type": "answer",  "level_id": 812, "level_number": 3, "answer": "код раз", "req_id": "c-18"}
{"type": "bonus",   "level_id": 812, "answer": "бонус", "req_id": "c-19"}
{"type": "penalty", "penalty_id": 44, "penalty_act": 1, "req_id": "c-20"} // 1 — принять, 2 — подтвердить
{"type": "ping", "req_id": "c-21"}
```

| Поле | Тип | Примечание |
|---|---|---|
| `type` | string | `subscribe` \| `answer` \| `bonus` \| `penalty` \| `ping`; регистр не важен |
| `req_id` | string | эхом возвращается во всех ответах, включая `error`; ключ идемпотентности мутаций |
| `level_id` | int | обязателен для `answer` и `bonus`: принимается только текущий уровень игрока, иначе `reject_reason: level_changed` (в том числе для уже пройденного уровня) |
| `level_number` | int | номер уровня (штурм) |
| `answer` | string | текст кода |
| `penalty_id`, `penalty_act` | int | подсказка-штраф |

**Идемпотентность.** Повтор мутации с тем же `req_id` в течение **5 минут** возвращает прежний
результат с `duplicate: true` и не засчитывает новую попытку. Ключ действует на пару
«игра + пользователь» и общий для WebSocket и REST (в REST это поле тела, query `?req_id=`
или заголовок `X-En-Request-Id`).

В REST признак повтора приходит **заголовком ответа `X-En-Duplicate: 1`** — тело остаётся обычным
состоянием движка, чтобы форма ответа не зависела от того, повтор это или новая обработка.
В WebSocket то же самое выражено полем `duplicate: true` в кадре.

**Одна задача за раз.** Действия одного игрока обрабатываются последовательно: порядок приёма кодов
влияет на результат игры. Если предыдущее действие ещё выполняется, новое получает
`{"type":"error","message":"busy","req_id":"…"}` — это отказ поставить в очередь, а не отказ игры;
корректное поведение клиента — повторить с тем же `req_id`.

## Сервер → клиент

```jsonc
{"type": "state",      "req_id": "c-18", "event": 0, "state": { /* EngineState */ }}
{"type": "event",      "req_id": "c-18", "event": 17, "state": { /* EngineState */ }}
{"type": "state",      "req_id": "c-18", "duplicate": true, "state": { /* … */ }}
{"type": "invalidate", "after_ms": 137}
{"type": "timer",      "timer": {"kind": "help", "seconds": 240, "ref_id": 91}}
{"type": "pong",       "req_id": "c-21"}
{"type": "error",      "req_id": "c-18", "message": "bad json"}
```

| `type` | Когда приходит | Что делать клиенту |
|---|---|---|
| `state` | ответ на `subscribe`, `answer`, `bonus`, `penalty`, а также сразу после подключения, если `event` нулевой | заменить состояние целиком |
| `event` | то же, но у состояния ненулевой `event` (финиш, снятый уровень, автопереход) — в том числе первым кадром после подключения | заменить состояние и отработать `event` |
| `invalidate` | состояние изменилось не этим клиентом: действие сокомандника, автопереход, случившийся при обращении сокомандника, правка сценария автором | перезапросить состояние через `after_ms` |
| `timer` | вместе с состоянием, если у уровня есть активный отсчёт | завести локальный таймер |
| `pong` | ответ на прикладной `ping` | — |
| `error` | кадр не разобран (`bad json`), неизвестный `type` (`unknown type`), очередь занята (`busy`) или ошибка выполнения | по `req_id` понять, к какой отправке относится |

Поля кадра:

- `req_id` — эхо запроса; у `invalidate` и `timer` отсутствует, они не отвечают на запрос;
- `duplicate: true` — результат взят из журнала обработанных `req_id`: состояние отдано, попытка не списана;
- `after_ms` — сколько подождать перед перезапросом. Значение своё у каждого клиента: событие правки
  игры приходит всем сразу, и без разброса тысячи клиентов ударили бы в движок в одну миллисекунду;
- `event` — код события (перечень в `EngineState.event`, см. swagger);
- `timer.kind` — `block` (блокировка ответов), `timeout` (автопереход), `help` (подсказка, `ref_id` = `help_id`).

`invalidate` намеренно не несёт состояния: снимок игры весит килобайты, а изменение может прийти
одновременно всей игре. Инициатор действия исключается из рассылки — состояние он уже получил в ответе
на свой запрос.

## Автопереход по таймауту: перевод ленивый

**Фонового планировщика на сервере нет.** Уровень с истёкшим `timeout_expires_unix` переводится
не в момент дедлайна, а при следующем обращении к движку — запросе состояния или отправке кода.
Пока никто не обратился, игрок остаётся на просроченном уровне, и никакого пуша не будет:
переводить некому.

Отсюда обязанность клиента: **дёрнуть состояние сразу после локального `timeout_expires_unix`**
(и после возврата приложения из фона, если дедлайн прошёл в это время). Именно этот запрос
и выполнит перевод — в ответ придёт `event: 19` (`LevelAutoPassed`), а сокомандники получат
`invalidate`. Просроченные подряд уровни дожигаются одним запросом, время каждого перехода
записывается дедлайном, а не моментом обращения, поэтому опоздание клиента не искажает
статистику игры.

Практическое следствие для редкого страховочного опроса: интервал опроса — это и есть верхняя
граница задержки автоперехода. Опрос раз в 2–3 минуты означает, что уровень провисит просроченным
до двух-трёх минут, если только клиент не запросит состояние по своему локальному таймеру.

## От чего считается время на уровне

Точка отсчёта для всего на уровне — его собственная дата старта, если автор её задал в редакторе;
иначе это старт игры (штурмовая последовательность) или момент прохождения предыдущего уровня
(линейная и остальные). От неё считаются автопереход по таймауту, открытие подсказок,
а также задержка и время жизни бонусов уровня.

Последнее — отличие от ASP: там задержка бонуса в штурме отсчитывалась от старта ИГРЫ, из-за чего
бонус уровня, начинающегося позже, открывался (и мог истечь) до самого уровня. Абсолютное окно
бонуса («действует с» и «действует по») остаётся абсолютным и датой уровня не сдвигается.

## Что покрыто и что нет

Пушатся: ответы и закрытие секторов сокомандниками, смена уровня, финиш, правка сценария автором,
а также автопереход — но только когда его выполнило чужое обращение к движку (см. раздел выше).
Не пушатся отдельным сигналом: открытие подсказки по таймеру, появление и истечение бонуса —
их клиент отсчитывает сам по абсолютным меткам (`opens_at_unix`, `starts_at_unix`,
`expires_at_unix`, `timeout_expires_unix`) из состояния.

Остатки рядом с метками: секунды (`remain_seconds`, `seconds_to_start`, `seconds_left`,
`timeout_seconds_remain`) округлены **вверх** — пока срок не наступил, остаток не бывает нулём,
а открытым считается ровно то, у чего `now >= срок`. Те же остатки в миллисекундах от момента
расчёта состояния: `remain_ms` (подсказка), `starts_in_ms` / `left_ms` (бонус, уровень с датой
старта), `timeout_remain_ms` (автопереход). По ним клиент перезапрашивает состояние ровно к сроку;
у закрытого по сроку объекта поле отсутствует.

## Совместимость

Имена кадров и полей заморожены так же, как REST-контракт: установленные мобильные приложения
обновляются месяцами. Добавление поля безопасно, переименование и удаление — только через новый маршрут.
