Как это работает
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 один.
- 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):
round,scene— текущий раунд и сцена;sceneStages— номер финальной сцены (длинаHELLOWIN_SCENE_STAGE_MAX),stageMax— последняя стадия тыквы в текущей сцене;threshold— порог тыквы, лимитpumpkin(«Лимиты»);limits— лимиты текущей сцены с числом игроковplayersи множителемk;elements— тыквы:id,type,x,y,size,stageиclicksс момента перехода в текущую стадию;bushes— кусты, из которых взлетают летуны;walkers— фигурки игроков на поле с их путями;- сюжет:
ready— сцена готова и ждёт модератора,finale— шаг финала (с шага разоблачения — иreveal),paused— пауза прогресса; now— серверное время в мс: по нему клиент считает смещение своих часов для летунов и фигурок.
Затем браузер открывает 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: если он не
совпал с раундом клиента, клиент пропустил сброс и перезапрашивает снимок; так же — после
переподключения, когда события за время обрыва потеряны.
Что происходит при клике
Клик — не HTTP-запрос, а RPC поверх того же WebSocket:
centrifuge.rpc('click', {element: 'pumpkin-3'}). Centrifugo проксирует его в
RoadRunner, тот отдаёт вызов свободному воркеру из пула centrifuge, где
RPCService передаёт его в ClickHandler::click().
- Элемент проверяется по
ElementRegistry(id изapp/config/hellowin.php). RitualMetricsдобавляет 1 кhellowin_clicks_total.RitualStore::click()под локомpumpkin:{id}увеличиваетclicksтыквы в KV (иaccepted— принятые клики раунда, для разоблачения в финале) и тут же, под тем же локом, читает раунд и сцену. Лок только на эту тыкву: клики по разным тыквам друг друга не ждут.PlayerServiceпод локомplayer:{id}добавляет клик в статистику игрока —userподключения; анонимный клик статистику пропускает. Сбой статистики пишется в лог и клик не ломает.ChannelPublisherпубликует в каналhellowinсобытиеclick(сplayer, если клик не анонимный) — всем подписчикам сразу.LockstepPolicy::pumpkinTransition()решает, пора ли тыкве дальше:clicksне меньше порога тыквы (лимитpumpkin) и стадия нижеstageMaxтекущей сцены. Если да,TransitionQueueставит в очередь задачуTransitionPumpkin, аhellowin_transition_jobs_pushed_total{kind="pumpkin"}растёт на 1.- Ответ RPC — всегда данные:
{ok: true}или{ok: false, error: 'unknown element' | 'busy' | 'internal error'};busy— лок тыквы не удалось взять за 5 секунд.
Сам клик стадию не меняет — он только ставит задачу. Пока задача не выполнена, clicks
остаётся выше порога, и каждый следующий клик ставит ещё одну такую же задачу. Дубли
намеренные: их отсекает идемпотентность, а клики сверх порога сгорают. Подробно — на вкладке
«Идемпотентный переход».
Свой клик браузер локально не рисует. Эффект появляется, только когда из канала приходит publication — у кликнувшего так же, как у всех. Поэтому на экране видна честная задержка круга браузер → сервер → браузер.
pumpkin:{id}.
Метрики, публикация и постановка задачи — вне лока.
Сообщения в канале — JSON-строки. Событие клика:
{"type":"click","round":…,"element":"pumpkin-3","emoji":"🕸️","clicks":…,"threshold":…,"stage":…}
Сцены, стадии тыкв и локи
Движок знает только номера: round, scene и у каждой тыквы
{stage, clicks}. Как это выглядит, решает фронтенд. Когда что двигается, решает политика —
интерфейс RitualPolicy, реализация LockstepPolicy: одна точка в коде, её можно
заменить, не трогая движок.
- Сцены нумеруются от 0 до финальной N — это длина списка
HELLOWIN_SCENE_STAGE_MAX. - У сцены s потолок стадий
stageMax(s)— элемент номер s того же списка, накопительно; у финала — тот же, что у сцены перед ним. - Если список начинается с 0, сцена 0 — бар: тыкв там нет (клик по ним —
locked,RitualPolicy::bar()), сцена готова, когда зал выпил цель бара или модератор дал отмашку, — и он выводит всех на грядку. Подробно — «Бар». - Тыква переходит в
stage + 1, набрав порог кликов — лимитpumpkin(«Лимиты»), — покаstage < stageMax(scene); переход обнуляетclicks. - Когда все тыквы дошли до
stageMax, сцена готова, но сама не переключается: событиеready, сова говорит «всё готово», и сцена ждёт модератора. Следующую включает кнопка пульта «Следующая сцена» (RPCscene.next); если зал застрял, модератор может переключить и неготовую сцену (force). Подробно — «Переход сцены». - В финальной сцене тыквы не переходят, клики только рассылаются. Модератор ведёт шаги финала, а после разоблачения начинается бесконечный «проклятый мир».
stageMax(s); кружки — тыквы.Игрок один на поле, множитель — 1: порог — 2 кликов на каждую стадию, так проходит тыква внутри сцены:
- 0старт раунда
- 1с 2 кликов
- 2ещё 2
- …по 2
- stageMaxждёт остальных
Лимиты: база, игроки, множитель
Порог тыквы — не одно число на все случаи: один игрок прокликает его за минуту, а сорок — за секунду. Поэтому все числа ритуала — клики на стадию тыквы, костра и круга, цель бара, обе цели проклятого мира, здоровье FrankenPHP — считаются одной формулой от числа игроков на поле:
лимит = max(1, ⌈k × (BASE + PER_PLAYER × (n − 1))⌉) + надбавка
- BASE и PER_PLAYER — env элемента
HELLOWIN_<X>_BASEиHELLOWIN_<X>_PER_PLAYER, X —PUMPKIN,BONFIRE,CIRCLE,BAR,CURSED_DRINKS,CURSED_ZOMBIES,BOSS_HP: сколько нужно одному и сколько прибавляет каждый следующий. Число стадий формула не трогает — только клики на стадию. - n — фигурки на поле, не меньше одной.
LimitsServiceсчитает их тикером раз в секунду и принимает новое n, когда оно продержалосьLimitsService::DEBOUNCE: зашёл и вышел — лимиты не дёргаются. - k — множитель модератора (RPC
thresholds), один на все лимиты: подогнать ритуал под зал на ходу. - Надбавка — ручная, с пульта: цель бара, цели проклятого мира, здоровье босса. Живёт до конца раунда; у кликовых порогов её нет.
Изменился любой из входов — 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-плагина нет. Без лока два воркера прочитают одно и то же
значение, и один клик пропадёт.
Локи — плагин lock RoadRunner, у каждой операции свой набор. TTL 5 секунд — лок сам
истечёт, если воркер упал; ждать занятый — до 5 секунд, дальше RitualBusyException.
| Операция | Локи | Что проверяется под локом |
|---|---|---|
клик, RitualStore::click() | pumpkin:{id} | ничего: clicks + 1, затем чтение round и scene |
переход тыквы, advancePumpkin() | pumpkin:{id} | round текущий и stage == toStage − 1 |
готовность сцены, markReady() | scene | round и scene текущие, готовность этой сцены ещё не отмечена |
переход сцены, advanceScene() | scene | round текущий и scene == toScene − 1 |
пульт: шаг финала, пауза прогресса, updateStory() | scene | сюжет до и после: событие — только если изменился |
сброс, reset() | scene и все pumpkin:{id} | round == fromRound |
статистика игрока, PlayerStore | player:{id} | ничего: счётчики игрока + 1 |
выстрел, FlyerStore::shoot() | flyer:{id} | ключ летуна ещё есть и полёт не кончился — тогда удалить |
ход и вход фигурок, WalkerStore::update() | walkers | ничего: read-modify-write одного ключа со всеми фигурками |
кружки бара: розлив, «взять», выпитые за раунд, BarStore | bar (при «взять» — внутри walkers) | сцена — бар; кружка на стойке ещё есть — её получает первый дошедший |
цель проклятого мира: кружки, зомби, добавки, CursedStore | cursed | ничего: счётчики раунда + 1; обе цели выполнены — отметка готовности под scene |
удар рапирой по FrankenPHP, BossStore | boss | босс проснулся и ещё жив — тогда здоровье − 1 |
Бар: последняя кружка и розлив партиями
Вечер начинается в баре (сцена 0): там нет тыкв, зато есть стойка, за которой сова-бармен наливает пиво. Бар показывает ещё две механики — те же лок и KV, но на кружках.
- Последнюю кружку забирает первый. Клик по стойке (RPC
bar.take) только отправляет персонажа к ней и пишет в его запись момент прибытия. Когда он наступил, тикер под локомbarодним шагом проверяет, есть ли кружка вhellowin:bar, и списывает её — как «жив ли летун» у выстрела. Двое дошли почти одновременно — лок выстраивает их по очереди: первый уходит с пивом (takeсok: true), второй застаёт пустую стойку (ok: false, сова: «кончилось»). Очереди нет — опоздавший кликает снова. - Розлив партиями. Пока на стойке есть свободные места (их столько, сколько фигурок
на поле, но не больше
HELLOWIN_BAR_MUGS), сова наливает следующую партиюHELLOWIN_BAR_POUR_MSмс: по кружке на каждыеHELLOWIN_BAR_POUR_PERсвободных мест, но не меньше одной — большой зал не ждёт пива по одной кружке. Партия решается в начале розлива: в KV лежитpouring {since, until, batch}по серверным часам, тикер RoadRunner раз в 200 мс ставит налитые кружки (mugs, не больше мест) и начинает следующую партию (pour). Все видят одну и ту же пену, а перезапуск воркеров или самого тикера ничего не теряет: состояние — в Valkey, а не в памяти процесса. - Пить можно только чокнувшейся кружкой (
drink, иначеnot clinked); выпитое считается за раунд, и когда зал выпил цель бара (её двигает модератор) — бар готов, сова зовёт всех на грядку. Переход из бара — обычныйscene.next: кружки сгорают, фигурки переходят на лужайку одним событиемscene, без ходьбы.
Рапира: слово, расколдовать, один босс на весь зал
Конец ночи показывает ещё три механики — и снова лок, KV и сервер, который решает сам.
- Слово проверяет только сервер. Клиент шлёт то, что набрал игрок (RPC
word), и узнаёт только ответ. Сервер нормализует ввод — нижний регистр, кириллица в латиницу, толькоa-z— и сравнивает с ответом с поблажкой на опечатку (префикс, расстояние Левенштейна,metaphone). Ни ответа, ни правил в браузере нет. Неверно — фигурка становится зомби, и её водит тикер сервера. - Расколдовать первым. Клик по зомби-игроку (RPC
cure) под локомwalkersснимает с него заклятие — тот же «проверить и изменить одним шагом», что у выстрела по летуну: второй кликнувший видит, что зомби уже нет. - Один босс на весь зал. Все удары рапирой (RPC
strike) идут в одну записьhellowin:bossпод одним локомboss, как клики по костру: зал конкурирует за один ресурс, и удар, отнявший последнюю единицу здоровья, — ровно один. Он и ставит в очередь переход на рассвет с ключом идемпотентности{round}:finale:7.
Раунд и сброс
Сброс начинает ритуал заново: сцена 0 (бар), у всех тыкв stage 0 и clicks 0,
round + 1, сюжет заново (пауза прогресса снимается, множитель k остаётся) и событие
reset всем. Делает это
ResetService::reset(?int $fromRound), его вызывают кнопка «Заново» на пульте модератора
(RPC reset {round} — раунд, который модератор видит) и консольная команда
hellowin:reset [--round=N]. Сервис берёт лок
scene и локи всех тыкв, поэтому ни клик, ни переход не увидят полусброшенный ритуал, и
под ними сравнивает текущий раунд с тем, который хотят завершить. Второй сброс того же раунда видит
уже новый раунд и ничего не делает — это идемпотентность по reset:{round} без отдельного
ключа: состояние и так под этими локами, а ключ в Valkey стал бы вторым источником правды о раунде.
Идемпотентный переход
Тыква набрала порог — нужен ровно один переход в следующую стадию. Но клики не останавливаются: пока
задача стоит в очереди и ждёт воркер, по тыкве успевают кликнуть ещё 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.
Почему «не более одного раза»
У пакета три гарантии, и они про разное:
| Гарантия | Как устроена | Если воркер упал посреди | Что нужно |
|---|---|---|---|
AtLeastOnce | аренда ключа на время работы; ключ закрывается после эффекта | аренда истекает, повтор выполняет эффект ещё раз — он может случиться дважды | эффект, который безопасно повторить |
AtMostOnce — выбрана | маркер пишется до эффекта и не удаляется | эффект потерян, повторы отсекаются, пока жив маркер — 0 или 1 раз | атомарный SET NX EX |
ExactlyOnce | запись о задаче и эффект в одной транзакции (inbox) | транзакция откатывается целиком, повтор выполнит эффект заново | эффект и запись в одной БД с транзакциями |
ExactlyOnce здесь недостижим: эффект — запись в KV через плагин RoadRunner плюс публикация в
Centrifugo, общей транзакции с маркером у них нет. Из оставшихся двух опаснее двойной переход: тыква
перепрыгнет стадию, событие pumpkin уйдёт дважды. Потерять переход дёшево — тыква останется
на пороге, и её дотыкают. Поэтому AtMostOnce.
Цена: если воркер упадёт между маркером и записью стадии (или лок тыквы окажется занят дольше 5 секунд),
переход потерян, а новые задачи с тем же ключом отсекаются, пока маркер жив — тыква «застревает» на
пороге. Поэтому окно короткое: retentionTtl 300 секунд, а не сутки по умолчанию. После него
следующий клик снова запускает переход, а от двойного перехода защищает доменная проверка под локом —
stage == toStage − 1: если переход на самом деле успел выполниться, повтор ничего не меняет.
Переход сцены
Сцена не переключается сама: игра идёт не на время, её ведёт модератор. Переход разбит на два шага, и каждый защищён от двойного выполнения по-своему.
-
Готовность. После каждого перехода тыквы
TransitionHandlerспрашивает у политики, не дошли ли все тыквы доstageMax(RitualPolicy::sceneReady()). Последние тыквы могут дойти почти одновременно, и тогда «да» услышат несколько воркеров. Отметку ставитRitualStore::markReady()под локомscene: в сюжетеhellowin:storyполеreadyещё не равно этой сцене — записать и вернутьtrue, уже равно —false. Поэтому событиеreadyи реплики совыlast-pumpkin(с игроком, добившим последнюю тыкву) иscene-readyуходят ровно один раз. Сама сцена остаётся прежней. -
Готовность с костром. Со сцены 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). -
Переход. Модератор нажимает «Следующая сцена» — RPC
scene.next {scene}с номером сцены, которую он видит.StoryService::nextScene()по снимку проверяет: сцена текущая, не финальная и готова (или пришёлforce) — и ставитTransitionScene. Двойной клик, две вкладки пульта или два модератора поставят две задачи, и это то же, что дубли кликов по тыкве: ключ{round}:scene:{toScene}(TransitionQueue::sceneKey()), тот же#[Idempotent]с гарантиейAtMostOnce, внутри — локscene, проверкаscene == toScene − 1и одно событиеsceneсоstageMaxновой сцены и предметом фигурок. Нажатие, пришедшее после перехода, видит, что сцена уже не та, и ничего не ставит — просто отвечает текущей.
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 допускает второй
прогон, если воркер упал до сохранения ответа или ответ истёк. Тогда прогон под локом видит свою попытку
в записи и отдаёт её грани, не бросая заново.
- Почему повтор не перебрасывает. Повтор не бросает: он получает сохранённый ответ,
а если тот пропал — грани своей попытки из записи. Новые кости даёт только следующая попытка, и не
чаще паузы
HELLOWIN_DICE_COOLDOWN_MS: перебрать попытки быстрее остальных нельзя. Сравните с переходом тыквы: там AtMostOnce (hellowin:amo:) — маркерSET NXотсекает дубль, ответ ему не нужен; здесь лизинг AtLeastOnce (hellowin:lease:) — дубль получает тот же ответ, что и первый вызов. - Первое 6-6-6 — под локом. Все броски зала проходят через одну запись
hellowin:diceпод одним локомdice, как удары по FrankenPHP. Победителя записывает тот, кто взял лок первым; второе 6-6-6 видит его и получаетnot now— как второй выстрел по уже сбитому летуну. Победа делает шаг финала готовым (markReady), и сова называет победителя. - Сова. Запасная кнопка модератора
dice.owl: первые два броска — случайные, а выпавшее 6-6-6 перебрасывается; третий — 6-6-6 наверняка. Ключа идемпотентности нет: каждое нажатие модератора — новый бросок. В число попыток зала (reveal.rolls) не идут.
Модератор, фигурки, летуны
Вокруг ритуала — живой вечер: модератор ведёт сюжет с пульта, фигурки игроков ходят по лужайке, по небу летают птицы и нечисть, а в финале сова выкладывает реальные числа. Каждая из этих механик — ещё один приём: роль в 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, сравнивает
«до» и «после» и публикует событие (и реплику совы) только при изменении.
Фигурки на поле: 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, но фигурка одна и уходит, когда закрыта последняя.
Задержка ухода нужна, чтобы перезагрузка страницы не роняла фигурку и не переносила её к краю.
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}.
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 мс. Это склейка: при полном зале полный список каждому на каждое
попадание — слишком много, а раз в интервал — один. Доска живёт весь вечер, сброс её не чистит, и
она же лежит в снимке страницы.
Разоблачение: числа вечера
На шаге финала, где сова раскрывает правду, событие 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 — кружки вечера, зомби и удары по FrankenPHP | hellowin: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:
смотреть может любой, редактировать — нет.
| Метрика | Откуда | Что значит |
|---|---|---|
hellowin_clicks_total{type, element} | ClickHandler | counter: принятые клики |
hellowin_transition_jobs_pushed_total{kind} | TransitionQueue | counter: задачи перехода в очереди, дубли включительно; kind — pumpkin или scene |
hellowin_transitions_total{kind} | TransitionHandler | counter: реально выполненные переходы |
hellowin_scene_stage | TransitionHandler, старт, сброс | 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 shoot | counter: запущенные, сбитые и улетевшие несбитыми летуны (и зомби) |
hellowin_walkers | тикер, раз в секунду | gauge: фигурки на поле |
hellowin_players, hellowin_players_registered | тикер, счёт игроков PlayerCensus | gauge: известные игроки и игроки с именем |
hellowin_online_players, hellowin_online_connections | тикер WalkService, presence канала Centrifugo | gauge: кто онлайн сейчас — уникальные игроки и все подключения, с анонимами; то же число видит модератор на пульте |
hellowin_rpc_calls_total{method, result} | RPCService | counter: каждый RPC через вебсокет; method — имя метода или unknown, result — ok или код ошибки |
hellowin_rpc_duration_seconds{method} | RPCService | histogram: сколько PHP обрабатывал RPC |
hellowin_rpc_rate_limited_total{group} | RPCService | counter: вызовы, отклонённые лимитом (rate limited), по группе ведра |
hellowin_beer_mugs_total{place} | BarService | counter: выпитые кружки; place — bar или cursed (проклятый мир) |
hellowin_beer_litres | тикер, счёт игроков PlayerCensus | gauge: выпито за вечер, литров — кружки игроков × HELLOWIN_MUG_LITRES |
rr_http_request_total, rr_http_request_duration_seconds, rr_http_workers_* | RoadRunner http | HTTP-запросы, задержка, воркеры |
centrifugo_node_num_clients, centrifugo_transport_messages_sent, centrifugo_proxy_duration_seconds_histogram | Centrifugo | подключения, отправленные сообщения, задержка 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])))