Encounter API · спецификация

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

Живое состояние игры: сервер отдаёт снимок уровня и сигналит, когда состояние устарело. Действия игрока идут теми же кадрами, что и по REST, — ядро у обоих транспортов одно.

wss://api.en.cx/games/{gameId}/engine

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"}

Поля запроса

ПолеТипСмысл
typestringsubscribe · answer · bonus · penalty · ping; регистр не важен
req_idstringэхом возвращается во всех ответах, включая error; он же ключ идемпотентности
level_idintобязателен для answer и bonus: код с чужим уровнем отклоняется
level_numberintномер уровня (штурм)
answerstringтекст кода; пустой и длиннее 500 символов отклоняются
penalty_id, penalty_actintподсказка-штраф и действие по ней
compactboolсостояние без задания, истории и сообщений уровня

Кадры ответа

typeКогда приходитЧто делать клиенту
stateответ на subscribe, answer, bonus, penalty, а также сразу после подключениязаменить состояние целиком
eventто же, но у состояния ненулевой event: финиш, снятый уровень, автопереходзаменить состояние и отработать событие
invalidateсостояние изменил не этот клиентперезапросить состояние через after_ms
timerвместе с состоянием, если у уровня есть активный отсчётзавести локальный таймер: kind = block, timeout, help
pongответ на прикладной ping
errorbad json, unknown type, busy или ошибка выполненияпо req_id понять, к какой отправке относится
Одна задача за раз

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

04 Идемпотентность

Повтор мутации с тем же req_id в течение 5 минут возвращает прежний результат с duplicate: true и не засчитывает новую попытку. Ключ действует на пару «игра + пользователь» и общий для обоих транспортов.

ТранспортКак передатьПризнак повтора
WebSocketreq_id в кадреduplicate: true
RESTreq_id в теле, ?req_id=, X-En-Request-IdX-En-Duplicate: 1

05 Инвалидация

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

ПолеЗначение
reasonanswer — сокомандник отправил код · 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 в сборке.