Как это работает

RoadRunner «умер», и посетители воскрешают его ритуалом на тыквенной грядке. Ритуал идёт сценами. В каждой сцене тыквы проходят несколько стадий: тыква переходит в следующую, когда по ней набрали порог кликов (он растёт с числом игроков на поле — «Лимиты»), и останавливается на последней стадии сцены — её задаёт список HELLOWIN_SCENE_STAGE_MAX. Когда все тыквы дошли до неё, сцена готова, а следующую включает модератор — он же сова-рассказчик. В финале сова раскрывает реальные числа вечера. Один проход ритуала — раунд; сброс начинает следующий. Игроки ходят фигурками по лужайке и сбивают летунов. Все видят действия всех в реальном времени, а метрики уходят в публичную Grafana. Ниже — что происходит на самом деле, с именами классов, ключей, каналов и портов из кода.

Spiral Framework
PHP-фреймворк: контроллеры, DI-контейнер, конфиги, очереди, шаблоны Stempler. Здесь он — бизнес-логика.
RoadRunner
Сервер приложений на Go. Держит пулы долгоживущих PHP-воркеров и даёт им плагины: HTTP, KV, локи, очередь задач jobs, метрики, мост к Centrifugo. PHP обращается к плагинам по RPC.
Centrifugo
Сервер WebSocket-соединений и каналов (pub/sub). Держит соединения с браузерами, а решения «пускать ли», «что делать с RPC» спрашивает у приложения через proxy.
Valkey
Redis-совместимое хранилище. В нём лежат состояние ритуала и сюжета, игроки, летуны, фигурки и ключи идемпотентности переходов; AOF на volume — состояние переживает перезапуск.
Prometheus, Grafana
Prometheus сам забирает метрики с сервисов и хранит их; Grafana рисует по ним дашборд.

Кто с кем разговаривает

Перед RoadRunner нет nginx: страницы и статику отдаёт сам HTTP-плагин на localhost:8080. Его middleware идут слева направо: http_metrics, headers (заголовки безопасности), gzip, static (файлы public/ с ETag — повторный запрос получает 304) и rate_limiter (token bucket по IP — только для запросов, дошедших до PHP). RoadRunner не реверс-прокси, поэтому Centrifugo и Grafana открыты своими портами: WebSocket — :8000, дашборд — :3000. Адреса страница получает от сервера, достроенные хостом, по которому её открыли. Всё остальное живёт во внутренней сети docker compose.

Основная нагрузка — не HTTP, а RPC через WebSocket, и HTTP-лимитер их не видит. Поэтому RPCService до любого метода берёт токен из ведра игрока: оно лежит в KV process (память RoadRunner) и меняется под локом rate:{group}:{player}, потому что атомарного обновления у KV нет. Быстрые клики проходят, поток из скрипта после burst получает rate limited; модератора лимит не касается.

RoadRunner и Centrifugo связаны двумя gRPC-каналами в разные стороны. Centrifugo проксирует события клиентов (connect, rpc) в плагин centrifuge на app:10001, а приложение публикует сообщения через gRPC API Centrifugo на centrifugo:10000.

У RoadRunner три пула PHP-воркеров по два процесса php app.php: http отдаёт страницы, centrifuge отвечает на connect и RPC, jobs выполняет задачи переходов из очереди transitions (драйвер memory). Запросы и задачи RoadRunner передаёт воркеру через pipes — stdin/stdout процесса. Обратно к плагинам kv, lock, jobs, metrics и centrifuge воркер ходит по goridge RPC на 127.0.0.1:6001.

Летунов, фигурки и топ стрелков двигает время, а не запросы. Для этого есть плагин service: RoadRunner держит один долгоживущий процесс php app.php hellowin:ticker вне пулов воркеров и перезапускает его при падении. Тикер ходит к плагинам по тому же RPC, что и воркеры, а через gRPC API Centrifugo ещё и спрашивает presence канала. Вторую копию (перекрытие при рестарте) держит в стороне лок ticker — лизинг, который владелец продлевает раз в секунду.

Состояние игры — в Valkey: ритуал и сюжет, игроки и топ стрелков, летуны, фигурки. Его читает и пишет KV-плагин RoadRunner: хранилище hellowin с драйвером redis на VALKEY_ADDR, в PHP это PSR-16 кэш hellowin. Ключи идемпотентности переходов hellowin:amo:* и сохранённые ответы костей hellowin:lease:* воркеры пишут в тот же Valkey напрямую, через predis (VALKEY_DSN): пакету spiral/idempotency нужен атомарный SET NX EX, которого у KV-плагина нет. Локи (pumpkin:{id}, scene, player:{id}, flyer:{id}, walkers и другие) и очередь живут в памяти процесса RoadRunner — этого хватает, пока app один.

RoadRunner 3 · контейнер app · .rr.yaml Браузер centrifuge-js WebSocket порты хоста без прокси :8080 · :8000 · :3000 Centrifugo v6 :8000 WebSocket · :9000 /metrics :10000 gRPC API http :8080 static · gzip centrifuge proxy :10001 jobs transitions http-воркеры ×2 HomeController centrifuge ×2 ConnectService RPCService jobs ×2 TransitionPumpkin TransitionScene rpc :6001 — kv · lock · jobs.Push · metrics · centrifuge.Broadcast kv hellowin: redis lock pumpkin:{id}, scene, … metrics :2112 /metrics service: ticker hellowin:ticker один процесс, лок ticker Valkey valkey:6379 AOF + volume hellowin:round hellowin:scene hellowin:pumpkin:* hellowin:story hellowin:shot hellowin:player:* hellowin:top hellowin:flyer:* hellowin:walkers hellowin:amo:* hellowin:lease:* Grafana :3000 Prometheus :9090 HTTP WS :8000/connection/websocket WebSocket :8080 :3000 gRPC proxy :10001 connect, rpc gRPC API :10000 broadcast, presence pipes pipes pipes goridge RPC driver redis · VALKEY_ADDR predis VALKEY_DSN scrape :2112 · 5s scrape :9000/metrics PromQL → :9090
  • gRPC между Centrifugo и RoadRunner, centrifuge.Broadcast
  • в Valkey: KV-плагин и predis
  • метрики: Prometheus скрейпит, Grafana спрашивает
  • HTTP, WebSocket, pipes, RPC, jobs.Push

Загрузка страницы и подключение

GET / — обычный HTTP. HomeController::index() берёт снимок состояния у SnapshotFactory: RitualStore::snapshot() одним kv.MGet читает hellowin:round, hellowin:scene, сюжет hellowin:story и hellowin:pumpkin:{id} всех тыкв, ещё один запрос — фигурки hellowin:walkers. Всё без локов: страница может отстать на клик, это не страшно. К ним добавляются раскладка из app/config/hellowin.php и числа политики, и шаблон получает снимок (Snapshot):

Затем браузер открывает WebSocket и передаёт в data подключения секрет игрока из localStorage: {playerSecret}. Centrifugo сам о пользователях ничего не знает и на connect спрашивает приложение (connect proxy). ConnectService отвечает user — публичным id игрока, вычисленным из секрета (без секрета — анонимно), списком channels: ['hellowin'] — это server-side подписка — и meta подключения (роль модератора, см. «Модератор, фигурки, летуны»). Дальше Centrifugo сам подставляет user в каждый RPC: клиент не шлёт свой id и не может выдать себя за другого. Клиент сам subscribe не вызывает: в config.json клиентские подписки и публикации запрещены, сообщения канала приходят в centrifuge.on('publication').

Тот же снимок отдаёт RPC state. Каждое событие канала несёт round: если он не совпал с раундом клиента, клиент пропустил сброс и перезапрашивает снимок; так же — после переподключения, когда события за время обрыва потеряны.

1. HTML со снимком GET / мимо static pipes kv.MGet, без лока состояние игры Snapshot: ритуал, сюжет, фигурки, раскладка, now render('home', snapshot + centrifugoUrl от хоста) 200 text/html (обратно через gzip и headers) сцена по снимку · new Centrifuge(url, {data: {playerSecret}}) 2. WebSocket ws://<host>:8000/connection/websocket connect proxy (gRPC) pipes user, channels, meta ответ подписать на hellowin (server-side) connected + subs: hellowin 3. Пересинхронизация: RPC state переподключился или пришло событие с чужим round rpc('state', {}) — по тому же WebSocket RPC proxy (gRPC) pipes SnapshotFactory::create() → kv.MGet ритуал, сюжет, фигурки {ok: true, state} ответ RPC {ok: true, state: Snapshot} перестроить сцену по снимку Браузер centrifuge-js RR middleware :8080 RR http пул воркеров HomeController → SnapshotFactory RR kv → Valkey hellowin Centrifugo :8000 RR centrifuge :10001 ConnectService RPCService
Сплошные стрелки — запросы, пунктир — ответы. Фиолетовые — путь через Centrifugo.

Что происходит при клике

Клик — не HTTP-запрос, а RPC поверх того же WebSocket: centrifuge.rpc('click', {element: 'pumpkin-3'}). Centrifugo проксирует его в RoadRunner, тот отдаёт вызов свободному воркеру из пула centrifuge, где RPCService передаёт его в ClickHandler::click().

  1. Элемент проверяется по ElementRegistry (id из app/config/hellowin.php).
  2. RitualMetrics добавляет 1 к hellowin_clicks_total.
  3. RitualStore::click() под локом pumpkin:{id} увеличивает clicks тыквы в KV (и accepted — принятые клики раунда, для разоблачения в финале) и тут же, под тем же локом, читает раунд и сцену. Лок только на эту тыкву: клики по разным тыквам друг друга не ждут.
  4. PlayerService под локом player:{id} добавляет клик в статистику игрока — user подключения; анонимный клик статистику пропускает. Сбой статистики пишется в лог и клик не ломает.
  5. ChannelPublisher публикует в канал hellowin событие click (с player, если клик не анонимный) — всем подписчикам сразу.
  6. LockstepPolicy::pumpkinTransition() решает, пора ли тыкве дальше: clicks не меньше порога тыквы (лимит pumpkin) и стадия ниже stageMax текущей сцены. Если да, TransitionQueue ставит в очередь задачу TransitionPumpkin, а hellowin_transition_jobs_pushed_total{kind="pumpkin"} растёт на 1.
  7. Ответ RPC — всегда данные: {ok: true} или {ok: false, error: 'unknown element' | 'busy' | 'internal error'}; busy — лок тыквы не удалось взять за 5 секунд.

Сам клик стадию не меняет — он только ставит задачу. Пока задача не выполнена, clicks остаётся выше порога, и каждый следующий клик ставит ещё одну такую же задачу. Дубли намеренные: их отсекает идемпотентность, а клики сверх порога сгорают. Подробно — на вкладке «Идемпотентный переход».

Свой клик браузер локально не рисует. Эффект появляется, только когда из канала приходит publication — у кликнувшего так же, как у всех. Поэтому на экране видна честная задержка круга браузер → сервер → браузер.

клик: локально ничего не рисуем rpc('click', {element}) RPC proxy (gRPC) pipes ElementRegistry::find() clicks_total +1 lock.Lock pumpkin:pumpkin-3 lockId get hellowin:pumpkin:pumpkin-3 {stage, clicks, accepted} set {stage, clicks + 1, accepted + 1} get hellowin:round, hellowin:scene round, scene lock.Release centrifuge.Broadcast gRPC API :10000 publication type:'click' та же publication эффект клика эффект клика + прогресс LockstepPolicy::pumpkinTransition(): clicks ≥ порог и stage < stageMax? да: jobs.Push TransitionPumpkin {key, round, pumpkin, toStage, player} jobs_pushed +1 RPCResponse {ok: true} ответ RPC {ok: true} каждый следующий клик сверх порога — ещё один TransitionPumpkin с тем же key, пока переход не выполнен Другие вкладки канал hellowin Браузер (клик) centrifuge-js Centrifugo :8000 RR centrifuge :10001 RPCService → ClickHandler RR metrics :2112 RR lock pumpkin:{id} RR kv Valkey RR jobs transitions под локом тыквы: read-modify-write
Оранжевая полоса на линии воркера — время, пока он держит лок pumpkin:{id}. Метрики, публикация и постановка задачи — вне лока.

Сообщения в канале — JSON-строки. Событие клика:

{"type":"click","round":…,"element":"pumpkin-3","emoji":"🕸️","clicks":…,"threshold":…,"stage":…}

Сцены, стадии тыкв и локи

Движок знает только номера: round, scene и у каждой тыквы {stage, clicks}. Как это выглядит, решает фронтенд. Когда что двигается, решает политика — интерфейс RitualPolicy, реализация LockstepPolicy: одна точка в коде, её можно заменить, не трогая движок.

0 stageMax(0) stageMax(1) stageMax(N−1) стадия тыквы сцена 0 сцена 1 … сцена N — финал переходов нет, клики только рассылаются тыквы идут к stageMax(s) и ждут остальных стадия +1 = порог кликов (лимит pumpkin) → TransitionPumpkin все на stageMax(0) → ready, ждём scene.next → TransitionScene ready в сцене N−1 scene.next → финал N = длина списка HELLOWIN_SCENE_STAGE_MAX, stageMax(s) — его элемент номер s; потолок финала = stageMax(N−1)
Оранжевая ступенька — потолок стадий stageMax(s); кружки — тыквы.

Игрок один на поле, множитель — 1: порог — 2 кликов на каждую стадию, так проходит тыква внутри сцены:

  1. 0старт раунда
  2. 1с 2 кликов
  3. 2ещё 2
  4. …по 2
  5. stageMaxждёт остальных

Лимиты: база, игроки, множитель

Порог тыквы — не одно число на все случаи: один игрок прокликает его за минуту, а сорок — за секунду. Поэтому все числа ритуала — клики на стадию тыквы, костра и круга, цель бара, обе цели проклятого мира, здоровье FrankenPHP — считаются одной формулой от числа игроков на поле:

лимит = max(1, ⌈k × (BASE + PER_PLAYER × (n − 1))⌉) + надбавка

Изменился любой из входов — LimitsService::settle сравнивает лимиты текущей сцены с последними сказанными и, если они разошлись, публикует событие limits {players, k, burst, limits}: браузеры обновляют пороги, пульт показывает получившиеся числа. Лимит, упавший ниже накопленного, прогресс не отнимает: сервер сам ставит переход стадии (тот же идемпотентный TransitionPumpkin, что и после клика) или готовность бара и проклятого мира, не дожидаясь клика. У FrankenPHP сохраняется урон: hp = max(1, max − урон).

Тем же числом игроков растёт волна летунов, которую модератор запускает кнопкой «Пуск летунов» (RPC flyers.burst): ⌈HELLOWIN_FLYER_BURST_BASE + HELLOWIN_FLYER_BURST_PER_PLAYER × (n − 1)⌉ летунов текущего набора разом. Ни кулдауна, ни потолка: сколько раз нажал — столько волн.

Зачем лок на тыкву

Клики обрабатывают несколько воркеров параллельно (в пуле centrifuge их два), и по одной тыкве кликают многие. Увеличение счётчика — read-modify-write: прочитать {stage, clicks}, +1, записать; атомарного инкремента у KV-плагина нет. Без лока два воркера прочитают одно и то же значение, и один клик пропадёт.

Без лока: гонка С локом pumpkin:{id} (RitualStore::click) get 2 get 2 set 3 set 3 итог 3 вместо 4: клик потерян Воркер 1 pumpkin-3 RR kv clicks Воркер 2 pumpkin-3 lock.Lock → lockId lock.Lock: ждёт get 2 set 3 lock.Release лок получен get 3 set 4 lock.Release итог 4: оба клика учтены Воркер 1 pumpkin-3 RR kv clicks Воркер 2 pumpkin-3
Оранжевое — воркер держит лок, пунктирная рамка — ждёт его.

Локи — плагин lock RoadRunner, у каждой операции свой набор. TTL 5 секунд — лок сам истечёт, если воркер упал; ждать занятый — до 5 секунд, дальше RitualBusyException.

ОперацияЛокиЧто проверяется под локом
клик, RitualStore::click()pumpkin:{id}ничего: clicks + 1, затем чтение round и scene
переход тыквы, advancePumpkin()pumpkin:{id}round текущий и stage == toStage − 1
готовность сцены, markReady()sceneround и scene текущие, готовность этой сцены ещё не отмечена
переход сцены, advanceScene()sceneround текущий и scene == toScene − 1
пульт: шаг финала, пауза прогресса, updateStory()sceneсюжет до и после: событие — только если изменился
сброс, reset()scene и все pumpkin:{id}round == fromRound
статистика игрока, PlayerStoreplayer:{id}ничего: счётчики игрока + 1
выстрел, FlyerStore::shoot()flyer:{id}ключ летуна ещё есть и полёт не кончился — тогда удалить
ход и вход фигурок, WalkerStore::update()walkersничего: read-modify-write одного ключа со всеми фигурками
кружки бара: розлив, «взять», выпитые за раунд, BarStorebar (при «взять» — внутри walkers)сцена — бар; кружка на стойке ещё есть — её получает первый дошедший
цель проклятого мира: кружки, зомби, добавки, CursedStorecursedничего: счётчики раунда + 1; обе цели выполнены — отметка готовности под scene
удар рапирой по FrankenPHP, BossStorebossбосс проснулся и ещё жив — тогда здоровье − 1

Бар: последняя кружка и розлив партиями

Вечер начинается в баре (сцена 0): там нет тыкв, зато есть стойка, за которой сова-бармен наливает пиво. Бар показывает ещё две механики — те же лок и KV, но на кружках.

Рапира: слово, расколдовать, один босс на весь зал

Конец ночи показывает ещё три механики — и снова лок, KV и сервер, который решает сам.

Раунд и сброс

Сброс начинает ритуал заново: сцена 0 (бар), у всех тыкв stage 0 и clicks 0, round + 1, сюжет заново (пауза прогресса снимается, множитель k остаётся) и событие reset всем. Делает это ResetService::reset(?int $fromRound), его вызывают кнопка «Заново» на пульте модератора (RPC reset {round} — раунд, который модератор видит) и консольная команда hellowin:reset [--round=N]. Сервис берёт лок scene и локи всех тыкв, поэтому ни клик, ни переход не увидят полусброшенный ритуал, и под ними сравнивает текущий раунд с тем, который хотят завершить. Второй сброс того же раунда видит уже новый раунд и ничего не делает — это идемпотентность по reset:{round} без отдельного ключа: состояние и так под этими локами, а ключ в Valkey стал бы вторым источником правды о раунде.

lock scene, pumpkin:pumpkin-1… lock scene: ждёт get hellowin:round 1 = fromRound тыквы {0, 0}, scene 0, round 2 release всех локов publish type:'reset', round:2 локи получены get hellowin:round 2 ≠ fromRound null: раунд уже сброшен release reset:{round} без пакета идемпотентности: round сравнивается под теми же локами, что защищают состояние Вызов A hellowin:reset --round=1 RR lock scene, pumpkin:* RR kv Valkey Centrifugo канал hellowin Вызов B reset(fromRound: 1)
Оранжевое — вызов держит локи, пунктирная рамка — ждёт их.

Идемпотентный переход

Тыква набрала порог — нужен ровно один переход в следующую стадию. Но клики не останавливаются: пока задача стоит в очереди и ждёт воркер, по тыкве успевают кликнуть ещё n раз, и каждый клик ставит TransitionPumpkin снова. Это сделано нарочно. Клику не нужно знать, поставил ли кто-то задачу до него: не нужен ни флаг «переход в пути», ни его сброс, если задача потерялась (очередь в памяти, при рестарте app задачи пропадают — следующий клик просто поставит новую). Цена простоты — k + n задач на один переход, и выполниться должна одна.

Отсекает дубли пакет spiral/idempotency. Класс задачи помечен атрибутом, и IdempotencyInterceptor в цепочке interceptors.consume очереди (app/config/queue.php) берёт ключ из payload:

#[Idempotent(storage: 'transitions', key: 'payload.key')]
final class TransitionPumpkin extends JobHandler

Кто придумывает ключ

Ключ строит сервер из смысла операции: TransitionQueue::pumpkinKey() — {round}:{pumpkin}:{toStage}, например 1:pumpkin-3:2 — «в раунде 1 тыква 3 переходит в стадию 2». Все клики, которые ставят этот переход, независимо друг от друга получают один и тот же ключ. Это отличается от привычного Idempotency-Key в стиле Stripe:

Детерминированный ключ (здесь)Случайный ключ клиента (Stripe-style)
Кто придумываетсервер, из данных операцииклиент, UUID на одну попытку действия
Где передаётсяв payload задачи, поле keyзаголовок Idempotency-Key запроса
Кто дублируетразные клики разных людей, не знающие друг о другетот же клиент при повторе: таймаут, обрыв сети
Что защищаемодно изменение состояния: переход тыквы на стадиюодну попытку запроса: платёж не спишется дважды
Два разных намерения с одним ключомневозможны: тот же ключ — тот же переходошибка клиента: сервер сверяет запрос с первым

В Valkey ключ попадает с префиксом хранилища и областью — именем обработчика, чтобы ключи разных задач не столкнулись: hellowin:amo:App\Application\Hellowin\Job\TransitionPumpkin::handle:1:pumpkin-3:2.

Как отсекается дубль

Хранилище transitions в app/config/idempotency.php — RedisAtMostOnceConfig с префиксом hellowin:amo: и retentionTtl: 300. Перед вызовом обработчика драйвер одной атомарной командой SET {ключ} M:{token} NX EX 300 занимает ключ. Записал — это первая доставка, обработчик выполняется. Ключ уже был — дубль: обработчик не вызывается вовсе, интерсептор возвращает null, и задача подтверждается (ACK) — не падает и не возвращается в очередь. Маркер не удаляется ни при успехе, ни при ошибке и исчезает сам по TTL.

1. Дубли намеренные: один ключ на k + n задач клик push key=1:pumpkin-3:2 клик push key=1:pumpkin-3:2 … ×n тот же key ×n 2. Первая доставка выполняет, остальные отсекаются задача 1 задача 2 (параллельно) SET … NX EX 300 OK: маркер записан SET … NX EX 300 nil: ключ занят дубль: обработчик не вызывается null → ACK TransitionPumpkin → TransitionHandler::pumpkin() lock.Lock pumpkin:pumpkin-3 round текущий и stage == toStage − 1? set stage = toStage, clicks = 0 lock.Release publish type:'pumpkin' — ровно один раз transitions_total +1 · все на stageMax? → markReady() под локом scene ACK задачи 3…n: SET NX → nil → ACK, пока жив маркер Клики сверх порога ClickHandler TransitionQueue RR jobs transitions jobs-воркер A IdempotencyInterceptor jobs-воркер B IdempotencyInterceptor Valkey hellowin:amo:* RR lock + kv pumpkin:{id} Centrifugo канал hellowin окно риска: падение здесь — переход потерян до истечения TTL
Сплошные стрелки — вызовы, пунктир — ответы и ACK. Пунктирная рамка на воркере A — окно между маркером и эффектом.

Почему «не более одного раза»

У пакета три гарантии, и они про разное:

ГарантияКак устроенаЕсли воркер упал посредиЧто нужно
AtLeastOnceаренда ключа на время работы; ключ закрывается после эффектааренда истекает, повтор выполняет эффект ещё раз — он может случиться дваждыэффект, который безопасно повторить
AtMostOnce — выбранамаркер пишется до эффекта и не удаляетсяэффект потерян, повторы отсекаются, пока жив маркер — 0 или 1 разатомарный SET NX EX
ExactlyOnceзапись о задаче и эффект в одной транзакции (inbox)транзакция откатывается целиком, повтор выполнит эффект зановоэффект и запись в одной БД с транзакциями

ExactlyOnce здесь недостижим: эффект — запись в KV через плагин RoadRunner плюс публикация в Centrifugo, общей транзакции с маркером у них нет. Из оставшихся двух опаснее двойной переход: тыква перепрыгнет стадию, событие pumpkin уйдёт дважды. Потерять переход дёшево — тыква останется на пороге, и её дотыкают. Поэтому AtMostOnce.

Цена: если воркер упадёт между маркером и записью стадии (или лок тыквы окажется занят дольше 5 секунд), переход потерян, а новые задачи с тем же ключом отсекаются, пока маркер жив — тыква «застревает» на пороге. Поэтому окно короткое: retentionTtl 300 секунд, а не сутки по умолчанию. После него следующий клик снова запускает переход, а от двойного перехода защищает доменная проверка под локом — stage == toStage − 1: если переход на самом деле успел выполниться, повтор ничего не меняет.

Переход сцены

Сцена не переключается сама: игра идёт не на время, её ведёт модератор. Переход разбит на два шага, и каждый защищён от двойного выполнения по-своему.

  1. Готовность. После каждого перехода тыквы TransitionHandler спрашивает у политики, не дошли ли все тыквы до stageMax (RitualPolicy::sceneReady()). Последние тыквы могут дойти почти одновременно, и тогда «да» услышат несколько воркеров. Отметку ставит RitualStore::markReady() под локом scene: в сюжете hellowin:story поле ready ещё не равно этой сцене — записать и вернуть true, уже равно — false. Поэтому событие ready и реплики совы last-pumpkin (с игроком, добившим последнюю тыкву) и scene-ready уходят ровно один раз. Сама сцена остаётся прежней.
  2. Готовность с костром. Со сцены 2 готовых тыкв мало: в центре грядки ещё костёр, один элемент на весь зал. Пока тыквы сцены не готовы, клик по нему отвечает locked и никуда не идёт; последняя тыква вместо готовности открывает костёр (отметка в сюжете под тем же локом scene, реплика bonfire-open). Дальше все бьют в один лок bonfire — отсюда ответы busy — и после порога костра (лимит bonfire) ставят горсть дублей одной задачи TransitionPumpkin с ключом {round}:bonfire:{toStage}; выполняется одна, это видно по kind="bonfire" в метриках. Сцену делает готовой переход костра на его последнюю стадию сцены (HELLOWIN_BONFIRE_STAGE_MAX) — тот же markReady(), реплики bonfire-done и scene-ready. Так же в финальной сцене зал рисует магический круг (лок circle, ключ {round}:circle:{toStage}): замкнутый круг делает финал готовым, и только тогда «Шаг финала» поднимает FrankenPHP (без готовности — лишь с force).
  3. Переход. Модератор нажимает «Следующая сцена» — RPC scene.next {scene} с номером сцены, которую он видит. StoryService::nextScene() по снимку проверяет: сцена текущая, не финальная и готова (или пришёл force) — и ставит TransitionScene. Двойной клик, две вкладки пульта или два модератора поставят две задачи, и это то же, что дубли кликов по тыкве: ключ {round}:scene:{toScene} (TransitionQueue::sceneKey()), тот же #[Idempotent] с гарантией AtMostOnce, внутри — лок scene, проверка scene == toScene − 1 и одно событие scene со stageMax новой сцены и предметом фигурок. Нажатие, пришедшее после перехода, видит, что сцена уже не та, и ничего не ставит — просто отвечает текущей.
1. Готовность: две последние тыквы добиты одновременно последняя тыква → stageMax, sceneReady(): да и эта тоже: sceneReady(): да markReady(round, 1): lock scene не отмечена → story.ready = 1 → true markReady(round, 1) уже отмечена → false publish ready · owl last-pumpkin · owl scene-ready ready кнопка «Следующая сцена» ждёт нажатия 2. Переход: модератор нажал кнопку дважды scene.next {scene: 1} то же ещё раз RPC proxy ×2 + meta подключения роль из meta · сцена 1 готова → TransitionScene, оба раза key 1:scene:2 ×2 {ok, scene: 2} {ok, scene: 2} ×2 задача 1 задача 2 (параллельно) SET … NX EX 300 OK SET … NX EX 300 nil → дубль, ACK lock scene · round текущий и scene == 1? scene = 2, ready снят lock.Release publish scene {stageMax, item} · owl scene-2 scene: 2 две тыквы и два нажатия — одна отметка ready и один переход: лок scene и ключ {round}:scene:{toScene} Модератор пульт в браузере Centrifugo канал hellowin RPCService → StoryService RR jobs transitions jobs-воркер A #[Idempotent] jobs-воркер B #[Idempotent] Valkey hellowin:amo:* RR lock + kv scene, hellowin:story
Оранжевое — воркер держит лок scene. В фазе 1 воркеры выполняют TransitionPumpkin, в фазе 2 — TransitionScene.

Зачем в ключе раунд

После сброса ритуал проходит те же переходы заново: тыква 3 снова идёт в стадию 2. Без раунда ключ совпал бы со старым, и пока жив старый маркер, новый переход отсекался бы как дубль. С раундом это новая операция: 2:pumpkin-3:2. А задачи прошлого раунда, оставшиеся в очереди, ничего не меняют, даже если пройдут маркер: доменная проверка видит, что раунд уже другой.

Что видно в Grafana

В разделе «Transitions: queue and idempotency» дашборда — панели «Jobs pushed vs transitions applied» (в минуту и всего, по kind), «Duplicates dropped» = pushed − transitions и «Pumpkin stages». Чем активнее кликают по тыкве на пороге, тем больше разница. Маркеры можно увидеть и в самом Valkey, вместе с оставшимся TTL:

docker compose exec valkey valkey-cli --scan --pattern 'hellowin:amo:*'
docker compose exec valkey valkey-cli ttl 'hellowin:amo:App\Application\Hellowin\Job\TransitionPumpkin::handle:1:pumpkin-3:2'

Кости: идемпотентность с сохранённым ответом

Перед восстанием в финале зал бросает три кости, пока кто-то не выбросит 6-6-6 — версию PHP, которой никогда не было. Бросает сервер: клиент шлёт RPC roll {attempt} с номером своей попытки, а в ответ и событием roll всем приходят кости. Задача та же, что у перехода тыквы, — повтор запроса не должен дать второй эффект, — но дубль перехода можно просто отбросить, а повтору броска нужен ответ: те же кости. Поэтому ответ попытки сохраняется, и повтор получает его.

Честный бросок

Кости — настоящий случайный выбор: DiceRoller трижды берёт Randomizer::getInt(1, 6) поверх системного CSPRNG — то же, что random_int. Предсказать, на какой попытке выпадет 6-6-6, не может никто, и сервер тоже. Но и посчитать те же кости второй раз нельзя: бросок тянется один раз, а результат нужно запомнить.

Ключ и сохранённый ответ

Ключ попытки — {round}:roll:{player}:{attempt}: раунд, публичный id игрока и номер попытки. DiceService::roll выполняет бросок через хранилище dice пакета spiral/idempotency — лизинг RedisLeaseConfig с гарантией AtLeastOnce. Первый вызов с ключом бросает и сохраняет ответ {attempt, dice} на HELLOWIN_DICE_IDEMPOTENCY_TTL с. Повтор того же ключа — двойной клик, переподключение, ретрай после потерянного ответа — получает сохранённый ответ: нового броска нет, события и счёта тоже. Отказ (busy, not now, bad attempt) — не ответ: FailurePolicy::Release освобождает ключ, и попытку можно бросить, когда отказ уйдёт.

Грани последней попытки игрока ещё и лежат в hellowin:dice под локом dice — одной записью с номером попытки и временем нового броска. Это страховка: AtLeastOnce допускает второй прогон, если воркер упал до сохранения ответа или ответ истёк. Тогда прогон под локом видит свою попытку в записи и отдаёт её грани, не бросая заново.

Браузер roll {attempt: n} RPC хранилище dice {round}:roll:{player}:{n} spiral/idempotency, AtLeastOnce TTL HELLOWIN_DICE_IDEMPOTENCY_TTL есть ответ ответа нет DiceService::roll лок dice hellowin:dice → last последняя попытка и её грани первое 6-6-6 — winner под тем же локом: второе видит его, not now n = last + 1 n = last иначе Повтор: ответ уже сохранён тот же {attempt, dice} — без броска двойной клик, переподключение, потерянный ответ; ни лока, ни события, ни счёта: не перебросить Новый бросок random_int × 3 → кости, ответ сохранён запись {attempt, at, dice}, событие roll всем, rolls + 1; не чаще HELLOWIN_DICE_COOLDOWN_MS Ответ потерян, попытка в записи воркер упал до сохранения или ответ истёк: грани из hellowin:dice, без броска и события bad attempt {last} пропустить попытку нельзя: клиент берёт last + 1 и бросает заново ответ попытки — в хранилище dice (hellowin:lease:*) на HELLOWIN_DICE_IDEMPOTENCY_TTL с; грани последней попытки — ещё и в hellowin:dice
Зелёное — новый эффект, пунктир — тот же ответ без эффекта, красное — отказ; оранжевое — работа под локом.

Модератор, фигурки, летуны

Вокруг ритуала — живой вечер: модератор ведёт сюжет с пульта, фигурки игроков ходят по лужайке, по небу летают птицы и нечисть, а в финале сова выкладывает реальные числа. Каждая из этих механик — ещё один приём: роль в meta подключения, presence канала, общие часы сервера, «первый под локом», счёт из состояния без лишних записей.

Роль модератора

Модератор открывает страницу с ?moderator=<секрет>; страница кладёт секрет в localStorage, убирает его из адреса и передаёт в data подключения рядом с секретом игрока. ConnectService сравнивает его с HELLOWIN_MODERATOR_SECRET (hash_equals) и при совпадении кладёт {moderator: true} в meta подключения. Meta хранит Centrifugo, клиенту она не видна, а в каждый RPC proxy Centrifugo добавляет её сам — так настроено rpc.proxy.include_connection_meta в config.json. Клиент шлёт в RPC только данные и не может назначить себе роль запросом. Пустой секрет в env — роли нет ни у кого.

RPCService читает роль из meta и на методы пульта (scene.next, finale.step, owl.say, flyer.special, flyers.burst, pause, thresholds, reset) без неё отвечает forbidden ещё до разбора данных. thresholds {k} умножает все лимиты на k («Лимиты») — подогнать ритуал под зал на ходу; k лежит в сюжете и переживает сброс. pause {on} — пауза прогресса: задача сцены замирает, а зал живёт дальше. RPC, которые двигают задачу (click по тыквам, костру и кругу, drink, strike, roll), не отказывают: действие проходит, событие уходит всем, но с counted: false — клики и стадии стоят, кружки не идут в цель, здоровье босса прежнее, 6-6-6 не выигрывает, готовность сцены не наступает. Игроки паузу не видят — она только на пульте; летунов, зомби и выстрелы пауза не трогает. Снятая пауза догоняет своё тем же пересчётом, что и снизившийся лимит, а сброс снимает её сам. Роль — не в user: модератор может быть и игроком со своим секретом, а user — это публичный id игрока в событиях и presence. Каждый метод пульта безопасно повторить: StoryService меняет сюжет hellowin:story под локом scene, сравнивает «до» и «после» и публикует событие (и реплику совы) только при изменении.

1. Подключение: секрет в data, роль в meta connect {data: {playerSecret, moderatorSecret}} connect proxy: data hash_equals(HELLOWIN_MODERATOR_SECRET, …) user, channels, meta {moderator: true} meta хранится здесь, клиенту не уходит connected (без meta) 2. RPC пульта: meta добавляет Centrifugo, не клиент rpc('pause', {on: true}) RPC proxy + meta {moderator: true} метод пульта, роль есть → StoryService {ok: true, on: true} {ok: true, on: true} rpc('pause', …) RPC proxy + meta {} роли нет → forbidden, данные не разбираются {ok: false, error: 'forbidden'} forbidden клиент не может назначить себе роль: в RPC он шлёт только данные, meta приходит от Centrifugo Модератор браузер с секретом Игрок без секрета Centrifugo include_connection_meta RR centrifuge ConnectService · RPCService
Сплошные стрелки — запросы, пунктир — ответы. Фиолетовые — вызовы proxy из Centrifugo.

Фигурки на поле: presence и тикер

Игрок, собравший персонажа на экране входа (RPC register с именем и look), — фигурка на лужайке. Кто сейчас на странице, сервер не считает сам: у канала hellowin включён presence Centrifugo ("presence": true в config.json, клиентам он закрыт). Каждое подключение подписано на канал сервером, поэтому presence — это все открытые вкладки с user = публичный id игрока, и запись пропадает сама, как только соединение закрылось.

Раз в секунду тикер (WalkService::sync()) спрашивает presence через gRPC API Centrifugo (CentrifugoPresence), сводит записи к набору игроков и под локом walkers сравнивает его с ключом hellowin:walkers: у присутствующих обновляет seenAt, игроки с look без фигурки выходят на поле (событие walker), а фигурки, чьих игроков нет дольше HELLOWIN_WALK_LEAVE_AFTER мс, уходят (leave). Несколько вкладок — несколько записей одного user, но фигурка одна и уходит, когда закрыта последняя. Задержка ухода нужна, чтобы перезагрузка страницы не роняла фигурку и не переносила её к краю.

вкладка 1 вкладка 2 presence канала фигурка на поле тикер, раз в секунду перезагрузка walker leave HELLOWIN_WALK_LEAVE_AFTER без единого подключения провал короче задержки — фигурка стоит на месте две вкладки — две записи presence одного user, а фигурка одна; уходит, когда закрыта последняя
Прямоугольники — открытые соединения и записи presence, оранжевая линия — фигурка на поле, засечки — тики. Длины условные: задержка — HELLOWIN_WALK_LEAVE_AFTER.

Почему не connect proxy: в момент connect клиент ещё не подписан на канал и своё событие walker не получил бы. Почему не refresh proxy с продлением подключений: уход был бы виден только по истечении срока, а каждое продление — лишний вызов и запись в KV. Presence — готовый учёт подключений, который Centrifugo сам чистит при разрыве. Сбой опроса presence пишется в лог, а фигурки остаются: недоступный presence не значит, что все ушли.

Путь фигурки считает сервер

Клик по земле — только намерение: RPC move {x, y}. Координаты кадр за кадром никто не шлёт. WalkService::move() приводит цель в зону — прямоугольник лужайки walk.area минус эллипс грядки walk.patch из app/config/hellowin.php (клик в грядку — на её край), берёт начало там, где фигурка сейчас по её текущему пути и серверным часам, и строит ломаную: напрямую, если отрезок не задевает грядку, иначе по касательной к эллипсу, увеличенному на 3 %, по его дуге и по касательной к цели, — со стороны, где путь короче. Длительность — длина ломаной, делённая на HELLOWIN_WALK_SPEED. Под локом walkers путь записывается в hellowin:walkers, а в канал уходит событие move с Walk {path, startedAt, arrivesAt}.

walk.area — лужайка грядка walk.patch сюда фигурки не заходят напрямую — через грядку откуда: где фигурка сейчас по Walk и серверным часам цель move {x, y} обход по дуге: сторона короче, шаг дуги ≤ 15° пунктир — эллипс + 3 %: хорды дуги не задевают грядку клик в грядку → на край по лучу из центра Walk {path: [откуда, …, цель], startedAt, arrivesAt} · время = длина / HELLOWIN_WALK_SPEED
Оранжевая ломаная — путь из события move; его одинаково анимируют все клиенты.

Анимацию задаёт сервер, а не клиент кликнувшего. Каждый браузер по now из снимка знает смещение своих часов от серверных и ставит фигурку в точку пути, где она должна быть в этот момент серверного времени. Поэтому все видят одно и то же движение, а вкладка, открытая посреди хода, сразу рисует фигурку на середине пути. Новый move во время хода начинается из текущей точки.

Чоканье по клику в проклятом мире — тот же путь плюс отложенная проверка: RPC clink ведёт фигурку к цели и кладёт рядом с ней в hellowin:walkers запись «жду встречи к dueAt», а тикер каждые 200 мс под локом walkers снимает наступившие записи и, если цель не ушла, публикует ровно одно событие clink с тостом и временем встречи.

Летуны и зомби: очки — первому

Летунов запускает тикер: случайно раз в HELLOWIN_FLYER_INTERVAL мс, вид — из набора текущей сцены HELLOWIN_FLYER_KINDS, изредка редкий (HELLOWIN_FLYER_RARE), ещё модератор может выпустить особого кнопкой или целую волну («Пуск летунов», «Лимиты»). Траекторию сервер присылает готовой: событие flyer с точками, глубиной, очками и временем launchedAt / expiresAt, а в KV — ключ hellowin:flyer:{id} с TTL и индекс летящих. Зомби проклятого мира — те же летуны (kind: zombie, свой интервал HELLOWIN_ZOMBIE_INTERVAL), только идут по лужайке от края грядки наружу, — поэтому выстрел, очки и метрики у них общие. Пауза прогресса тикер не останавливает: летуны и зомби идут, попадание засчитывается игроку, только подбитый на паузе зомби не идёт в цель проклятого мира.

Клик по летуну — RPC shoot {flyer}. Под локом flyer:{id}: ключ ещё есть, по летуну можно стрелять и полёт не кончился — ключ удаляется, это попадание: очки игроку, одно событие shot, метрика. Следующий выстрел ключа уже не найдёт — промах. Каждый тик тикер тем же способом учитывает улетевших: ключ ещё есть — удалить и засчитать как улетевшего. Ключ удаляет ровно один из них, поэтому летун не бывает и сбит, и улетел. Сервер принимает выстрел до expiresAt + 500 мс, а клиент не рисует летуна, чьё время по серверным часам уже вышло.

Очки собираются в топ стрелков вечера — hellowin:top под локом top (TopService). Попадание пишет туда не «+N», а абсолютные очки игрока, которые вернула его запись, и берёт max(старое, новое): повтор и гонка дважды не посчитают. Сам выстрел ничего не публикует — доску шлёт тикер событием top {board}, когда она изменилась, и не чаще HELLOWIN_TOP_INTERVAL мс. Это склейка: при полном зале полный список каждому на каждое попадание — слишком много, а раз в интервал — один. Доска живёт весь вечер, сброс её не чистит, и она же лежит в снимке страницы.

lock flyer:{id} lock flyer:{id}: ждёт get: ключ есть, летун ещё летит delete — попадание release publish shot {points, player} · очки игроку · flyers_shot +1 лок получен get ключа нет промах: {ok: true, hit: false} летун сбит ровно один раз: ключ удаляет первый под локом; тикер учитывает улетевших так же RPC shoot игрок 1, первый RR lock flyer:{id} RR kv hellowin:flyer:{id} RPC shoot игрок 2, следом Centrifugo канал hellowin
Оранжевое — воркер держит лок, пунктирная рамка — ждёт его.

Разоблачение: числа вечера

На шаге финала, где сова раскрывает правду, событие finale и снимок несут reveal — реальные числа раунда. Отдельных счётчиков на каждый клик для этого нет: всё следует из состояния в Valkey, которое и так пишется под локами. RevealBuilder считает их заново при каждой сборке.

ЧислоОткудаПочему так
clicks — обработанные кликиΣ accepted по hellowin:pumpkin:{id}accepted растёт под тем же локом, что и clicks, но не обнуляется при переходе
jobs — задачи, прошедшие очередьΣ stage + sceneкаждая стадия и каждая сцена — ровно одна выполненная задача перехода
burned — сгоревшие кликиclicks − Σ spent − Σ min(clicks тыквы, порог)переход тратит ровно порог, действовавший при его выполнении, и прибавляет его к spent тыквы; остальное отбросила идемпотентность или пришло после готовности тыквы, кроме кликов, ещё копящихся к переходу
shot — сбитые летуны и зомбиhellowin:shot — {round, count} под локом shotединственный отдельный счётчик: выстрелы редки, лок не мешает; счёт другого раунда читается как 0
drinks, slain, damage — кружки вечера, зомби и удары по FrankenPHPhellowin:drinks, hellowin:cursed, hellowin:boss — каждый с roundсчётчики бара, проклятого мира и босса уже есть ради игры; запись другого раунда читается как пустая
lastPumpkin — кто добил последнюю тыквуlastPumpkin в hellowin:storyигрок из задачи, которая отметила готовность сцены; null, если клик был анонимным или сцену переключили force

Сброс раунда обнуляет эти числа без отдельного кода: accepted, stage, scene и сюжет сбрасываются вместе с ритуалом, а счёт сбитых привязан к раунду.

Метрики

При старте воркера HellowinBootloader объявляет коллекторы в плагине metrics и выставляет gauge из состояния в Valkey — оно переживает перезапуск RoadRunner. Дальше RitualMetrics вызывает metrics.Add и metrics.Set по RPC из клика, постановки задачи, перехода и сброса, а PlayMetrics — из выстрела и тикера (летуны, фигурки, игроки). Ошибка метрик только пишется в лог: клик из-за неё не падает. RoadRunner отдаёт всё на :2112/metrics вместе со своими rr_http_* (middleware http_metrics в .rr.yaml; там же gzip, который сжимает и ответы PHP, и статику — он стоит перед static, потому что RoadRunner 3 выполняет middleware слева направо).

Prometheus раз в 5 секунд скрейпит app:2112 и centrifugo:9000/metrics (внутренний порт Centrifugo: наружу открыт только WebSocket на 8000). Grafana получает datasource и дашборд «Hellowin» через provisioning и пускает анонимно в роли Viewer: смотреть может любой, редактировать — нет.

RitualMetrics ClickHandler: clicks TransitionQueue: pushed TransitionHandler: transitions + gauges boot, ResetService: gauges из Valkey RR metrics :2112 /metrics + rr_http_* Prometheus :9090 хранит 15d Grafana :3000 дашборд «Hellowin» Centrifugo :9000/metrics Браузер :3000 Add, Set :6001 scrape 5s PromQL scrape 5s anonymous Viewer pull-модель: Prometheus сам приходит за метриками, приложение никуда их не отправляет
Стрелки показывают, кто инициирует запрос; данные по scrape-стрелкам идут обратно.
МетрикаОткудаЧто значит
hellowin_clicks_total{type, element}ClickHandlercounter: принятые клики
hellowin_transition_jobs_pushed_total{kind}TransitionQueuecounter: задачи перехода в очереди, дубли включительно; kind — pumpkin или scene
hellowin_transitions_total{kind}TransitionHandlercounter: реально выполненные переходы
hellowin_scene_stageTransitionHandler, старт, сбросgauge: текущая сцена
hellowin_roundстарт, сбросgauge: текущий раунд
hellowin_pumpkin_stage{element}TransitionHandler, старт, сбросgauge: стадия каждой тыквы
hellowin_flyers_launched_total{depth}, hellowin_flyers_shot_total{depth}, hellowin_flyers_escaped_total{depth}тикер, RPC shootcounter: запущенные, сбитые и улетевшие несбитыми летуны (и зомби)
hellowin_walkersтикер, раз в секундуgauge: фигурки на поле
hellowin_players, hellowin_players_registeredтикер, счёт игроков PlayerCensusgauge: известные игроки и игроки с именем
hellowin_online_players, hellowin_online_connectionsтикер WalkService, presence канала Centrifugogauge: кто онлайн сейчас — уникальные игроки и все подключения, с анонимами; то же число видит модератор на пульте
hellowin_rpc_calls_total{method, result}RPCServicecounter: каждый RPC через вебсокет; method — имя метода или unknown, result — ok или код ошибки
hellowin_rpc_duration_seconds{method}RPCServicehistogram: сколько PHP обрабатывал RPC
hellowin_rpc_rate_limited_total{group}RPCServicecounter: вызовы, отклонённые лимитом (rate limited), по группе ведра
hellowin_beer_mugs_total{place}BarServicecounter: выпитые кружки; place — bar или cursed (проклятый мир)
hellowin_beer_litresтикер, счёт игроков PlayerCensusgauge: выпито за вечер, литров — кружки игроков × HELLOWIN_MUG_LITRES
rr_http_request_total, rr_http_request_duration_seconds, rr_http_workers_*RoadRunner httpHTTP-запросы, задержка, воркеры
centrifugo_node_num_clients, centrifugo_transport_messages_sent, centrifugo_proxy_duration_seconds_histogramCentrifugoподключения, отправленные сообщения, задержка proxy-вызовов в RoadRunner

Счётчики живут в процессе RoadRunner и обнуляются при его перезапуске, gauge восстанавливаются из Valkey. Разделы дашборда: Game — клики, сцена, раунд, онлайн; Transitions: queue and idempotency — задачи против выполненных переходов, отсечённые дубли, стадии тыкв; Centrifugo — соединения, сообщения, proxy-вызовы; RoadRunner — HTTP и воркеры.

Запросы с дашборда, например:

# клики в секунду, всего и по тыквам
sum(rate(hellowin_clicks_total[1m]))
sum by (element) (rate(hellowin_clicks_total[1m]))

# отсечённые дубли: задачи в очереди минус выполненные переходы
sum by (kind) (hellowin_transition_jobs_pushed_total) - sum by (kind) (hellowin_transitions_total)

# p95 задержки proxy-вызовов Centrifugo → RoadRunner
histogram_quantile(0.95, sum by (le, type) (rate(centrifugo_proxy_duration_seconds_histogram_bucket[1m])))

Открыть дашборд в Grafana · Вернуться к тыквам