Протокол WebSocket игрового движка
Живое состояние игры: сервер отдаёт снимок уровня и сигналит, когда состояние устарело. Действия игрока идут теми же кадрами, что и по REST, — ядро у обоих транспортов одно.
01 Подключение
Адрес совпадает с REST-режимом движка: апгрейд выполняется, когда в запросе есть Upgrade: websocket, иначе та же ручка работает как REST с ?json=1.
| Авторизация | Authorization: Bearer <jwt>, cookie en_access или ?token=<jwt>. Query-параметр поддержан специально: браузерный WebSocket не умеет слать заголовки |
|---|---|
| Origin | проверяется как у REST — разрешены домены *.en.cx |
| Ответ | 101 Switching Protocols; при неверном токене — обычный HTTP 401 |
| Размер кадра | не более 4 КБ от клиента |
Сервер сам присылает кадр state с текущим состоянием — отдельный subscribe для этого не нужен.
02 Keepalive и переподключение
- Сервер шлёт управляющий ping каждые 54 с и ждёт pong; без кадра или pong в течение 60 с соединение закрывается.
- Стандартный клиент отвечает на ping сам. Прикладной
{"type":"ping"}тоже поддержан и отвечаетpongвне очереди обработки — годится как проверка живости канала. - Любой входящий кадр продлевает дедлайн чтения.
- Очередь исходящих на клиента — 64 кадра. Клиент, который не успевает читать, отключается: копить состояние живой игры бессмысленно, после переподключения оно приходит целиком.
- При обрыве: переподключиться с экспоненциальной паузой и взять состояние из первого кадра. Неподтверждённую отправку кода повторять с тем же
req_id— попытка не спишется дважды.
03 Кадры
клиент → сервер
{"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"}
compact: true в любом кадре запроса состояния отдаёт его без задания, истории и сообщений уровня — то же, что ?compact=1 в REST.
сервер → клиент
{"type": "state", "req_id": "c-18",
"event": 0, "state": { /* EngineState */ }}
{"type": "event", "event": 17,
"state": { /* EngineState */ }}
{"type": "state", "duplicate": true, …}
{"type": "invalidate", "after_ms": 137,
"reason": "answer", "level_id": 812,
"user_id": 163483}
{"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 | string | subscribe · answer · bonus · penalty · ping; регистр не важен |
| req_id | string | эхом возвращается во всех ответах, включая error; он же ключ идемпотентности |
| level_id | int | обязателен для answer и bonus: код с чужим уровнем отклоняется |
| level_number | int | номер уровня (штурм) |
| answer | string | текст кода; пустой и длиннее 500 символов отклоняются |
| penalty_id, penalty_act | int | подсказка-штраф и действие по ней |
| compact | bool | состояние без задания, истории и сообщений уровня |
Кадры ответа
| type | Когда приходит | Что делать клиенту |
|---|---|---|
| state | ответ на subscribe, answer, bonus, penalty, а также сразу после подключения | заменить состояние целиком |
| event | то же, но у состояния ненулевой event: финиш, снятый уровень, автопереход | заменить состояние и отработать событие |
| invalidate | состояние изменил не этот клиент | перезапросить состояние через after_ms |
| timer | вместе с состоянием, если у уровня есть активный отсчёт | завести локальный таймер: kind = block, timeout, help |
| pong | ответ на прикладной ping | — |
| error | bad json, unknown type, busy или ошибка выполнения | по req_id понять, к какой отправке относится |
Действия игрока обрабатываются последовательно: порядок приёма кодов влияет на результат игры. Если предыдущее ещё выполняется, новое получает {"type":"error","message":"busy"} — это отказ поставить в очередь, а не отказ игры. Правильная реакция — повторить с тем же req_id.
04 Идемпотентность
Повтор мутации с тем же req_id в течение 5 минут возвращает прежний результат с duplicate: true и не засчитывает новую попытку. Ключ действует на пару «игра + пользователь» и общий для обоих транспортов.
| Транспорт | Как передать | Признак повтора |
|---|---|---|
| WebSocket | req_id в кадре | duplicate: true |
| REST | req_id в теле, ?req_id=, X-En-Request-Id | X-En-Duplicate: 1 |
05 Инвалидация
invalidate намеренно не несёт состояния: снимок игры весит килобайты, а изменение может прийти одновременно всей игре. Инициатор действия из рассылки исключается — состояние он уже получил в ответе на свой запрос.
| Поле | Значение |
|---|---|
| reason | answer — сокомандник отправил код · level_changed — уровень сменился · timeout — серверный автопереход · scenario_changed — автор правит сценарий |
| level_id | уровень, которого касается изменение |
| user_id | инициатор действия |
| after_ms | сколько подождать перед перезапросом. Значение своё у каждого клиента: правка игры приходит всем сразу, и без разброса тысячи клиентов ударили бы в движок в одну миллисекунду |
06 Что покрыто и что нет
Пушится: ответы и закрытие секторов сокомандниками, смена уровня, финиш, серверный автопереход по таймауту, правка сценария автором.
Не пушится отдельным сигналом: открытие подсказки по таймеру, появление и истечение бонуса — их клиент отсчитывает сам по абсолютным меткам из состояния: opens_at_unix, starts_at_unix, expires_at_unix, timeout_expires_unix. Серверное время ответа — в server_unix.
Остатки в секундах (remain_seconds, seconds_to_start, seconds_left, timeout_seconds_remain) округлены вверх: пока срок не наступил, остаток не бывает нулём, открыто ровно то, у чего now >= срок. Те же остатки в миллисекундах от момента расчёта состояния: remain_ms, starts_in_ms, left_ms, timeout_remain_ms — по ним клиент перезапрашивает состояние ровно к сроку.
07 Совместимость
Имена кадров и полей заморожены так же, как REST-контракт: установленные мобильные приложения обновляются месяцами. Добавление поля безопасно; переименование и удаление — только через новый маршрут, и за этим следит контрактный тест формы EngineState в сборке.