Skip to content

Architecture ​

RetentionPlay is a Rust modular monolith with an authoritative backend, a thin iframe widget, a React admin, and signed server-to-server reward callbacks.

Current implemented flow ​

Components ​

  • backend (backend/) — Cargo workspace of three crates:
    • crates/api — HTTP routes, services, background jobs, auth, db.
    • crates/core — domain types (PlayerProgress, PlayContext), Dragonfly key helpers, fraud config types.
    • crates/adapters — game adapters + the data-driven SpecEngine.
  • frontend (frontend/) — React + Ant Design admin (Vite, Vitest), served by nginx that also proxies /v1 and /docs.
  • widget (widget/) — loader + current per-template bundle.js. This is implemented legacy behavior, not the target architecture for new game families.
  • deploy — Docker Swarm + Traefik stacks under start/, K8s skeleton under deploy/.

Hot / cold state (D6) ​

Postgres (player_template_progress) is the durable source of truth; Dragonfly is the working cache. On cache miss the backend reads cold, hydrates hot, and continues. If Dragonfly is down, reads fall back to Postgres (degraded header X-PlayFlow-Degraded: cold-read) and critical writes go straight to Postgres plus a hot-restore queue; if Postgres is down, writes return 503 Retry-After. See progress.

Current ingress + engine (historical D2, D3, D4) ​

A single events ingress runs FraudGate, then PlayIngressService drives the adapter (native Rust or data-driven SpecEngine) and progression, then evaluates rewards. The existing client still requires a template bundle. See SpecEngine and adapters.

Игровой слой нового поколения (реализовано) ​

Механику «Играть» задаёт projects.play_mechanic; реестр различает run (прохождение) и outcome (выбор исхода). Сервер выбирает уровень по дорожке; player_level_results и player_level_progress хранятся в Postgres. GET /v1/lobby возвращает режим показа, следующий уровень, активную партию и последний итог.

Слой не строит общий язык для всех механик. Он унифицирует только границу между браузером и сервером: создание прохождения, смысловые команды, порядковый номер, безопасный повтор, FraudGate, транзакцию и подтверждённые факты. Внутреннее состояние и правила остаются в Rust-модуле конкретной игры.

Первая игра — Match-3 (match3_v1). Браузер отправляет координаты одной перестановки, сервер рассчитывает совпадения, специальные элементы, падения, заполнения, каскады, очки и результат. Состояние прохождения хранится в Postgres (game_runs, game_run_commands); Dragonfly источником истины прохождения не является. Phaser показывает только подтверждённый ответ.

Порядок одного хода: клиент шлёт command_id, sequence, action и полезную нагрузку; сервер под блокировкой партии проверяет номер и повтор, вызывает механику, проверяет рассчитанный итог на инварианты и одной транзакцией записывает команду, новое состояние, события и прогресс. Ответ несёт новое состояние, шаги анимации и подтверждённые факты — счёт и итог клиент не присылает никогда.

Клиент собирается из games/ в widget/generated/shell/<release>/: статический вход оболочки, динамические механики и отдельный движок. Выпуск отдаётся с домена сервиса на /v1/game-assets/shell/<release>/, с заранее сжатыми .br/.gz и кешем на год. Для play_mechanic = NULL сохраняется старая сборка widget/generated/match3_v1/1/. Единственная настройка внешнего вида — загруженный владельцем фон (/v1/project-game-assets/{project_id}/{asset_id}); произвольные внешние адреса запрещены.

Текущий план платформы: docs/plan/2026-09-25/PLAN.md. Спецификации игры: docs/plan/2026-08-16/ARCHITECTURE.md и MATCH3.md.

Общие средства колеса W0 ​

economy::grant_tx и spend_tx изменяют баланс и журнал Postgres внутри переданной транзакции. Они не коммитят внешнюю транзакцию и не создают эффект-токены или кеш Dragonfly. Повтор ключа сверяет игрока, ресурс, сумму, причину и операцию; локальный savepoint отменяет неудачную попытку. Билеты wheel_ticket начинают с0 и доступны внутреннему сервису без включения публичной экономики. Списание и приз используют разные ключи.

project_day проверяет пояс по pg_timezone_names, считает календарные сутки/DST в SQL и откладывает смену до полуночи старого пояса. next_free_at учитывает действующий в будущем пояс и использованную дату. Это внутренние функции; W1 использует их для HTTP-настройки календаря и фактического права на бесплатное вращение.

Серверный start_id колеса выводится из SHA256 имени механики, строк проекта и игрока и канонического UUID вращения; клиентский spin_id не используется как ключ общего старта. Общий outcome_is_flagged вычисляет входной флаг или сильный сигнал в памяти до решения шлюза для этого же исхода.

Сервер колеса R1 (W1) ​

services/wheel читает актуальную опубликованную версию под блокировкой проекта. Блокировки игрока сериализуют права и билетный баланс. Одна транзакция сохраняет списание права, отдельные проводки списания/приза, закрытый GameRun, результат wheel_spins, фрод-флаги и события. Ошибка энтропии, инварианта или баланса откатывает всё. Точный повтор UUID/тела возвращает сохранённый ответ до лимитов; другой запрос с тем же UUID отклоняется. Восстановление результата доступно после выключения колеса.

Новая серверная победа обычной механики выдаёт один билет внутри транзакции команды, максимум3по общим проектным суткам; повтор, replay, проигрыш и результат колеса билетов победы не дают. wheel.spun учитывается в MAU; анонимные игроки исключены из оплачиваемой квоты. Билеты нельзя выдавать старыми публичными API экономики или настроить как обычный ресурс.

Админский /wheel редактирует черновик, публикует неизменяемую версию, сверяя ожидаемый хеш содержимого под блокировкой публикации, показывает серверные проценты, статистику и чистую симуляцию10000. Фрод-лимиты новых вращений —6/минуту и30/час. R1 выдаёт билеты и ресурсы; запасы, материальные заявки и бюджеты относятся к R2. Клиентский допуск вычисляется совместно по паспорту механики и готовому выпуску с поддержкой версии правил. Переключатели проекта дополнительно требуют опубликованную таблицу соответствующего колеса; публикация сама показ не включает. Подробный HTTP-контракт — wheel.

services/wheel/lobby.rs читает краткие права и незакрытый показ результата одним согласованным SQL-запросом без записи и блокировок. Выключенное колесо не добавляет этого запроса к чтению лобби. Расчёт местного дня учитывает отложенную смену зоны; полный HTTP-ответ колеса возвращает фактическую опубликованную ticket_cost, а не предполагаемую цену одного билета.

Клиент games/reward_wheel_v1/client отображает готовый серверный исход: daily — лист поверх лобби, site в wheel_only — самостоятельный экран. Это модуль колеса с собственным lifecycle, без команд обычной партии. Оболочка wheel_only использует CSP base и не требует активного шаблона или механики Play. Старые партии сохраняют свои пути чтения и команд. Loader владеет внешним wrapper/iframe и каналом сообщений; оболочка и клиент колеса освобождают собственные таймеры, анимации и ожидания при закрытии.

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

Observability ​

GET /v1/status exposes engine + D6 metrics (hot_read_latency_ms, sync_lag_seconds, cold_sync_last_success, dlq_depth, webhook_retry_queue_depth, table_sizes). Fraud decisions emit structured fraud_decision logs.

Источники решений ​

Текущая архитектура игрового слоя находится в docs/plan/2026-08-16/ARCHITECTURE.md. Исторические D1–D9 перенесены в docs/archive/plan/2026-06-24/DECISIONS.md и кратко разобраны в архитектурных решениях. Единственный активный трекер — docs/plan/2026-09-25/PLAN.md.

Internal & integration documentation