Widget
Предпросмотр доступен через /v1/admin/widget-preview на origin админки, только владельцу проекта с cookie-сессией и подтверждённой 2FA. Публичный /v1/play?mode=preview возвращает403 preview_requires_admin. Для настоящей игры с наградами origin админки автоматически не добавляется. free_play с пустым списком доменов допускает встраивание с любого сайта; режим с наградами без списка остаётся закрытым. Обрыв тела HTTP-ответа повторяется общей средой с прежним идентификатором команды, как и сбой соединения до получения заголовков.
The browser surface: a small loader plus per-template UI bundles. Located at widget/. Per-template bundles are the current implemented UI path (historical D4); the server renders a thin iframe shell.
Рядом работает второй путь — общая среда запуска и отдельный клиент полноценной игры в games/<game_id>/, связанный с серверным GameRun. Он реализован и описан ниже; подробности — docs/plan/2026-08-16/ARCHITECTURE.md.
SDK/loader 0.3.0 минифицирован; версия и хэшированные файлы — /v1/game-assets/sdk/v0-3-0/release.json. Стабильный /v1/widget.js поддерживает ETag/304 и обязательную revalidation. См. версии.
Loader
widget/loader/playflow.js (served as /v1/widget.js):
- Reads
data-*attributes from its script tag. - Calls
/v1/widget/exposure(A/B gate) then/v1/widget/config. - Mounts a sandboxed iframe to
config.play_urland applies theme.
Step 2 runs for both cohorts and is what makes the A/B measurable: services/exposure.rs writes one widget.exposure event per user per UTC day regardless of arm, and services/analytics.rs derives D1/D7 retention from those rows. Control returns show_widget: false at step 2 and stops, so any metric built on play.* events is treatment-only by construction.
See the integration view in widget embed.
Каждое выполнение loader синхронно выставляет на своём <script>retentionPlayHandle = { destroy }. data-target разрешается один раз; экземпляр добавляет собственный wrapper внутри цели либо сразу после script, не заменяя DOM соседей. Два экземпляра с одной целью независимы. SDK сохраняет свой script и до его загрузки может поставить маркер уничтожения.
destroy() идемпотентен: помечает экземпляр закрытым, отменяет его fetch через AbortController, отправляет iframe retentionplay:close версии1 на точный origin API, снимает собственные listeners рукопожатия/обновления токена/закрытия и удаляет только wrapper/iframe. Поздние ответы сессии, exposure, config и обновления токена игнорируются. Входящие сообщения loader принимает только от собственного iframe с точного origin API; переданный project_id должен совпадать. Событие retentionplay:event с event: "closed" также закрывает экземпляр. Первоначальный фокус восстанавливается лишь на подключённый элемент и только если текущий фокус остался внутри своего wrapper/iframe. Кнопка ежедневного колеса получает фокус при активации до открытия листа: это сохраняет клавиатурный вход и возврат фокуса в браузерах, где касание не фокусирует кнопку автоматически. Недоступная кнопка фокус не перехватывает.
Templates
widget/templates/<id>/bundle.js holds all gameplay UX and posts events to the API. Every bundle exposes a mount() entry consumed by the play shell. Current templates: streak_v1, quiz_v1, scratch_v1, wheel_v1, clicker_v1, memory_pairs_v1, match3_lite_v1, plus the tap_demo_v1 data-only proof.
Play shell
GET /v1/play (routes/play/shell.rs) renders one generic shell: CSP iframe, theme CSS vars, PlayFlowShell bootstrap, and loads ui_bundle_url from the manifest. Unknown templates without a bundle return 404.
Параметр mode обязателен: play — настоящая партия, preview — витрина в админке. Отсутствующее или неизвестное значение даёт 400, чтобы предпросмотр никогда не выдавали за игру.
Экранирование вставок и политика безопасности
/v1/play и /v1/admin — один origin (nginx админки проксирует /v1/*), а template_config и primary_color — значения, которые задаёт сам арендатор. До исправления SECURITY-01 их подставляли в HTML без экранирования: значение с </script> разрывало <script id="pf-config" type="application/json">, а собственный скрипт после разрыва получал cookie администратора, если ссылку на /v1/play чужого проекта открывал залогиненный админ. Теперь:
- обе JSON-вставки (
#pf-config,#pf-bootstrap) и JS-строка адреса модуля проходятjson_for_html— экранирование<,>,&, U+2028/U+2029 внутри уже сериализованного JSON, читаемоеJSON.parseбез изменений; primary_colorиtemplate_configпроверяются на записи (PATCH /v1/admin/projects/{id}) тем же правилом hex-цвета, что и параметр предпросмотра, и схемой активного шаблона — неизвестный ключ или другой тип значения отклоняются400раньше, чем попадут в HTML;Content-Security-Policyотдаётся заголовком, не<meta>(meta не умеетframe-ancestors):script-srcразрешает только скрипты с разовым nonce на ответ,'unsafe-inline'для скриптов снят,connect-srcограничен'self', аframe-ancestors— спискомallowed_originsпроекта (или'none', если список пуст) — виджет по-прежнему встраивается на сайте клиента;- cookie администратора ограничена
Path=/v1/admin(раньше —Path=/, и поэтому уходила и на/v1/play); вход и выход дополнительно снимают старый широкий cookie для браузеров, ещё не обновивших его. Изоляция пути не заменяет проверку Origin/CSRF на изменяющих admin-эндпоинтах.
Лобби
Оболочка гидратирует серверный скелет /v1/play и читает GET /v1/lobby. Кнопка data-rp-play показывает skeleton, play, continue, done, offline или unavailable. Номер берётся только из ответа сервера; партия без номера показывает «Продолжить» без цифр. При потере связи сохраняется подтверждённый номер, действие блокируется.
Лобби перечитывается при online, возврате видимой вкладки, pageshow с persisted, возврате из партии и закрытии колеса. Ошибки загрузки повторяются через 5/15/30/60 секунд, пока вкладка видна и есть сеть. Одновременные чтения объединяются. Таймер колеса использует серверные часы и перечитывает состояние на нуле не чаще раза в 30 секунд.
После входа устанавливается data-rp-ready. В простое загружаются выбранная механика и движок; при Save-Data или отсутствии сети прогрева нет. Лобби статически не импортирует Phaser. Лист настроек открывается шестерёнкой: «Звук» и «Меньше движения», ловушка фокуса, Esc, возврат фокуса на шестерёнку. Ключи retentionplay:sound и retentionplay:motion хранят '1'/'0'; при запрете хранилища настройки работают в памяти. Корень держит data-rp-motion.
Для проверок используются data-rp-screen, data-rp-ready, data-rp-level, data-rp-play, data-rp-wheel, data-rp-sheet и data-rp-switch. Проекты Playwright lobby-* и release-shell запускаются вторым прогоном с MATCH3_PROJECT_ID=$LOBBY_PROJECT_ID. «Играть» запускает Match3 по общему контракту внутри слоя stage.
Подготовленные переходы и итог G2.4
play-flow отправляет старт параллельно с монтажом механики; версии берутся из статического реестра до импорта. ID сохраняется при транспортном повторе, меняется при смене механики или перезапуске. curtain рисует «Медальон», карточку, подсказку через1с и выбор через10с. Возврат не отменяет уже отправленный старт и не ждёт бесконечно GET лобби: до свежего ответа действие недоступно и номер скрыт. После растворения играется дельта подтверждённого номера.
result-window рисует только серверный итог и строки механики. Live-победа даёт звёзды из summary.stars, бег счёта и два залпа по30 частиц; восстановление тихое. Свежий список продолжений при восстановленном итоге ожидается не более150 мс: при задержке окно уже доступно с действием «В лобби», без неподтверждённого «Дальше». Закрытие отменяет ожидание. Reduce убирает масштаб/перемещение и счётчик, звук отдельно управляется настройкой. Guard кнопок250мс, опрос награды0/1/2/4/8/23/38с с границей60с; закрытие останавливает опрос/таймеры/rAF. run_finished содержит механику и статус, проходит проверенный канал родителя.
Атрибуты: data-rp-medallion, data-rp-card-kind, data-rp-card-level, data-rp-wait-hint, data-rp-curtain-panel, data-rp-result, data-rp-live, data-rp-score, data-rp-stars, data-rp-reward-status, data-rp-action, data-rp-armed, data-rp-confetti, data-rp-particles. Новый контракт выбирается по статическому реестру до загрузки чанка: запрос старта, шторка и таймеры ожидания запускаются параллельно с загрузкой. Match3 использует общий контракт; переходы и итоги проверяют transitions.spec.ts, curtain.stand.ts и result.stand.ts. Бот терминального исхода допускает SQL только при фактическом тестовом томе retentionplay_local_check_20261001.
Потеря WebGL-контекста даёт движку3с на восстановление. Если контекст не вернулся, появляется управляемая ошибка: «Повторить» получает ту же партию через GET и заменяет потерянный движок; второй POST старта не отправляется. Этот путь действует и в старом виджете без лобби. После выхода из меню в лобби и продолжения таймеры сцены запускаются заново; завершение партии в другой вкладке восстанавливается тихо, без повторного праздника. Регрессии ввода, меню, спецэффекта после повторного входа и WebGL проверяет mechanic-recovery.stand.ts на браузерных фикстурах; SQL-приёмка выполняется отдельно.
Рукопожатие вместо токена в адресе
Токена сессии нет ни в одном адресе: он бы попал в историю браузера, в Referer и в журналы. Вместо этого окно после загрузки шлёт родителю retentionplay:ready, а loader отвечает retentionplay:bootstrap (версия 1) с токеном, базовым адресом API и настройками. До получения bootstrap окно ничего не запрашивает.
Клиент игры нового поколения
Оболочка игр (games/runtime/src/shell)
Вход shell/boot.ts вызывает createShell: читает bootstrap, создаёт машину экранов, настройки движения, общую звуковую шину, статус запросов и серверные часы. read-bootstrap.ts — единственная разрешённая точка поиска корня по документу. Match3 экспортирует модуль MechanicModule из client/src/index.ts. Оболочка монтирует лобби, создаёт или продолжает серверную партию и передаёт её экземпляру механики. Сцена отображает снимок и шаги ответа; общий итог открывает оболочка. Победа показывает цели компактными плашками, поражение — полосами оставшегося прогресса. Иконки берутся только с домена сервиса; порядок целей сохраняет порядок фишек набора, независимо от порядка ключей серверного JSON. При завершении партии в другой вкладке текущая сцена перезапускается по GET и показывает тихий итог.
Match3 v2: цели, слои и круг
Модуль объявляет supported_game_versions: [1, 2]: первая позиция дорожки и старые партии используют v1, позиции 2–15 — v2. Клиент сохраняет поддержку одиночной view.goal schema1; в schema2 отображает все view.goals. Для clear остаток равен max(required − current, 0) и считает клетки, а не слои. Цепь L2 и лёд L2 дают одну очищенную клетку после снятия последнего слоя. Цепь, лёд и дыры рисуются по серверному виду; жест по закреплённой фишке не отправляет команду.
Карточка уровня использует серверные preview.tier и preview.new_element. Обучающая карточка объясняет новый элемент; её локальная отметка просмотра относится только к интерфейсу и не подтверждает игровой прогресс. Звёзды итогового экрана берутся из серверного результата, без пересчёта по очкам или ходам.
После позиции 15 повторяются позиции 11–15 без промонаграды. Подпись круга текущей партии берётся из сохранённого level.cycle_round, а лобби описывает следующий старт через progression.cycle_round. У обычных и исторических партий сохранённое поле отсутствует. Форматы и причины отказа описаны в контракте игровых партий.
Сборка и автоматические проверки не заменяют формальную визуальную приёмку V, проверки на реальных устройствах и Safari; эти проверки принимает владелец отдельно.
Контракт mechanic-contract.ts следует docs/plan/2026-10-01/LOBBY.md §4.5: MechanicModule — модуль механики, RunEntry — серверная партия и способ входа, ResultRow — строка итогового экрана, EngineHost — общий хост Phaser. Phaser грузится динамически, создаётся один раз; freeze останавливает кадры, park также выключает сцены и скрывает холст, runScene будит цикл перед стартом.
Игровая оболочка собирает сокращённый Phaser из исходников закреплённой версии 4.2.1: базовое ядро и набор B, дополненный используемыми Ellipse, Arc и RetroFont. WebGL включён; Canvas renderer, физика, tilemap, DOMElement, видео и звуковой движок Phaser исключены. Canvas2D остаётся доступным для запекания текстур, звук обслуживает общий SoundBus. Старый виджет собирается отдельно с полным Phaser. Действующий check:budget ограничивает engine до200000 байт Brotli. Контрактный тест собирает тот же вход с теми же alias и флагами, исполняет его в Chromium и проверяет классы, фабрики add/make и загрузчики load. Имена фабрик и загрузчиков извлекаются из синтаксического дерева клиентов, включая вызовы на нескольких строках; ArcFactory сохраняет add.circle для круглых областей нажатия Match3. Общие помощники компилятора могут включать отдельный rolldown-runtime-*.js для преобразования CommonJS. Он не содержит Phaser; его размер учитывается в графах загрузки оболочки и механик. Проверка лобби разрешает это точное имя, сохраняя запрет других механик и загрузки engine до готовности лобби. Новый используемый объект требует расширения контракта, повторного измерения бюджета и проверки сцен через штатную сборку и Swarm.
В G8.1 внутренний размер холста — 420k×760k, где потолок k=min(DPR,2). Сглаживание MSAA всего WebGL-холста отключено (antialiasGL:false): в локальной трассе оно задерживало передачу кадра браузеру. Линейная фильтрация текстур и мипмапы сохраняются (antialias:true, LINEAR_MIPMAP_LINEAR); текст и статические панели сглаживаются при запекании в разрешении k. Общий пул частиц хранит настройки залпа только пока частица жива; после её завершения ссылка на профиль снимается до следующего использования. Камеры масштабируют логические координаты420×760; pointer.worldX/Y остаётся координатой поля. Текст HUD и финальной надписи создаётся с разрешениемk. Всплывающие очки и combo-баннер пока имеют разрешение1. При DPR3 холст840×1520. Включены WebGL, ограничение60 кадров/с и LINEAR_MIPMAP_LINEAR. Готовность сцены ждёт завершения параллельной компиляции шейдеров и ещё два кадра.
Фишки берутся из производного атласа512×1024: десять кадров128×128 с двухпиксельным отступом. scripts/build-match3-atlas.mjs воспроизводит его из утверждённых оригиналов; provenance.json хранит их хэши. Проверка validate:assets отклоняет изменённый источник, повреждённый выход, отсутствующий кадр или пересечение. Исходный набор сохраняется.
Новый вход загружает производные фон840×1520 и рамку780×780 — размеры для логических420×760 и390×390 приk≤2. scripts/build-match3-surfaces.mjs уменьшает существующие оригиналы через Lanczos и WebPquality80; исходные файлы сохраняются для совместимого входа. Их прежнее происхождение вложено в source_provenance, хэши источника и результата проверяет валидатор. Источник разрешён только для воспроизведения, не для client_files. Каждая сборка содержит только свои фон и рамку. HTML-скелет лобби при отсутствии загруженного фона берёт из текущего выпуска тот же runtime/background.webp с размерами840×1520.
Камера поля ограничивает вывод внутренним прямоугольником. Рамка, HUD и фон рисуются основной камерой; тряска затрагивает только поле. Излучатель частиц переиспользуется на текстуру в своей области вывода (поле или HUD), а профиль залпа сохраняет операции и гравитацию уже живущих частиц. Пул освобождается при закрытии сцены.
Гнёзда клеток используют общую процедурную текстуру. Неизменные панели HUD и усилителей запекаются в отдельные текстуры при текущемk; геометрия Graphics не обрабатывается в каждом кадре. Их Canvas-источники сохраняются в Texture Manager для восстановления WebGL, пока сцена открыта. При её закрытии эти текстуры удаляются: варианты масштаба не накапливаются между входами.
В новом входе Match3 бегущий счётчик очков использует BitmapText и один процедурный атлас цифр. Глифы копируются из прежнего шрифта с той же обводкой при текущемk; узкий неразрывный пробел сохраняет разделитель тысяч. Изменение числа обновляет геометрию, без растеризации Canvas и загрузки GPU-текстуры каждый кадр. Атлас и запись шрифта удаляются при закрытии сцены. Совместимый старый вход сохраняет прежний счётчик Text; флаг сборки исключает из него код нового атласа. Панель целей в обоих входах переиспользует значки и подписи до смены состава или порядка целей; неизменный цвет текста повторно не растеризуется.
Memory сохраняет пул из 30 карточек. Canvas для символа или метки карточки создаётся при первом показе подписи и переиспользуется до закрытия сцены. Изображения половинок замка создаются при разрушении последнего слоя. Скрытые подписи и неиспользованные половинки не готовятся при каждом входе; созданные объекты уничтожаются вместе с карточками.
После завершения анимаций, таймеров, частиц и жеста Match3 останавливает цикл. Касание, ответ сервера, возобновление и восстановление WebGL будят его. Состояние таймеров читает адаптер закреплённого Clock4.2.1; при неизвестном устройстве плагина сон запрещается.
Автокачество отслеживает p90 активных кадров за секундные окна. Два окна выше20мс запрашивают снижение2→1,5→1; десять стабильных — повышение до исходного потолка. Физический размер меняется перед следующей попыткой, без изменения GameRun. В облегчённом режиме вдвое меньше частиц и нет аддитивных ореолов выбора и спецэлементов. Проверки на настоящих устройствах и Safari остаются отдельной приёмкой G8.3.
Атрибут data-rp-screen принимает 13 состояний: loading, lobby, lobby_offline, covering, preparing, slow, revealing, mechanic, result, returning, wheel, start_error, refresh_required. data-rp-motion равен full или reduce; ключ retentionplay:motion хранит принудительный выбор (1 — уменьшить движение, 0 — полное движение), отсутствие ключа следует системной настройке. Проверка cspSatisfies разрешает требование base в обоих профилях документа, а требование wasm — только в документе wasm; иначе требуется обновление страницы.
Звук оболочки и Match3 использует один AudioContext, мастер-громкость 0,35 и компрессор. Ключ retentionplay:sound хранит 1/0; запрещённое хранилище оставляет выбор в памяти. Контекст создаётся по жесту, при скрытии вкладки приостанавливается. При pagehide без bfcache или сообщении retentionplay:close версии 1 от родителя освобождаются ресурсы; api.destroy() вызывает оболочка; на старом пути — совместимый хост Match3 (game.ts). Loader начнёт отправлять сообщение закрытия в задаче 20.5; сейчас этот путь покрыт модульным тестом.
Из games/ команда npm run check:dom проверяет исходники runtime и механик: узлы ищутся внутри корня, а не через document.querySelector*, getElementById, getElementsBy*; вставки в document.body запрещены. Проверка включена в CI. Она построчная: псевдонимы document и цепочки с переносом строки требуют проверки при ревью. Собранные файлы и виртуальные модули Vite не проверяются. Приближённый ручной эквивалент для GNU grep:
grep -rnE --include='*.ts' --include='*.js' 'document\??\.(querySelector(All)?|getElementById|getElementsBy(ClassName|TagName|TagNameNS|Name))|document\??\.body\??\.(append(Child)?|prepend|insertBefore|insertAdjacent(Element|HTML|Text)|replaceChildren|replaceWith|before|after|innerHTML|outerHTML)' runtime/src */client/src | grep -v '^runtime/src/shell/read-bootstrap\.ts:'Выпуск клиента
При заданном play_mechanic сервер использует единый выпуск /v1/game-assets/shell/<release>/. В <head> попадают CSS и <link rel="modulepreload" nonce> входа оболочки и его статических импортов. После рукопожатия вставляется модуль оболочки; механика загружается через import(). Движок не предзагружается с оболочкой. Недоступная механика показывает «Игра временно недоступна» без подключения модуля.
Ключ входа оболочки — runtime/src/shell/boot.ts, ключ механики берётся из client_entry_key паспорта. mechanics.json перечисляет механики и версии, releases.json — текущий и один предыдущий выпуск. Адреса неизменяемы и кешируются на год; варианты .br/.gz выбираются по Accept-Encoding.
При play_mechanic = NULL сохраняется старая сборка Match3: /v1/game-assets/match3_v1/1/. Сервер выбирает модуль по ключу <game_id>/<client_entry>, а не по первой записи isEntry. Предпросмотр пока также использует путь шаблона.
Клиент говорит с сервером только через POST /v1/game-runs, POST /v1/game-runs/{run_id}/commands и GET /v1/game-runs/{run_id}. Он не присылает ни поля, ни очков, ни итога. Размер окна берётся из viewport конфигурации: раскладка портретная.
Единственная настройка внешнего вида — загруженный владельцем фон. Ассеты игры отдаются с домена сервиса; произвольные внешние адреса запрещены.
Legacy template UI path
- Create
widget/templates/<id>/bundle.jswith amount()function. - Register the template in the DB (see migrations).
- The shell loads it via the manifest
ui_bundle_url; no server UI code.
scripts/ci-engine-smoke.sh checks that every bundle exposes mount.
Не добавляйте новые игры через этот старый рецепт. Новая игра использует games/runtime/, собственный Phaser-клиент, GameRun и Rust-модуль механики.
Взаимодействие и анимация
Жест разбирается чистым модулем match3_v1/client/src/input.ts, который не знает ни про Phaser, ни про правила игры. Он переводит касание в намерение и в то, насколько увести фишку за пальцем; допустимость хода по-прежнему решает только сервер.
- перетаскивание: фишка едет за пальцем, сосед — навстречу, дальше соседней клетки не уходит; у края поля сдвиг заметно меньше и хода не даёт;
- нажатие и второе нажатие остаются рабочим путём: он единственный доступен при ограниченной моторике;
pointerupoutsideзавершает жест так же, как обычное отпускание, иначе фишка осталась бы висеть, а ввод — заблокированным;- оболочка отдаёт жест игре:
touch-action: noneиoverscroll-behavior: noneвroutes/play/shell.rs. Без них вертикальное перетаскивание на телефоне уезжало бы в прокрутку страницы.
Шаги ответа проигрываются теми же спрайтами, что стоят на поле: падение и появление видны глазом, а итоговый вид сервера в конце хода остаётся страховкой от расхождения. Длинный каскад не режется, а ускоряется до предела MAX_PLAYBACK_RATE: пропущенный шаг — это очки, взявшиеся ниоткуда.
Сгорание группы и удары спецэлементов собраны из частиц и текстур, которые match3_v1/client/src/effects.ts рисует кодом при первом создании сцены и кладёт под именами с префиксом pf-fx-. Новых файлов-картинок при этом не появляется: список изображений закреплён разделом 13 MATCH3.md. Цвет искр берётся у самой фишки, ракета бьёт лучом, бомба и сфера — расходящейся волной, поэтому в цепной реакции видно, что именно сработало.
Звук и сияние
Каждый шаг ответа сервера отзывается звуком: выбор клетки — select, перестановка — swap, отказ хода — reject, сгорание группы — pop (тон растёт с каждым кругом каскада), приземление фишек — land (один раз на шаг), рождение спецэлемента — special_create, удары — rocket, bomb, sphere, баннер комбо — combo, выполненная цель — goal_done, три и меньше ходов — low_moves, перемешивание — shuffle. Звуки итога shell:win/shell:lose и кнопок общего окна принадлежат оболочке. Звук ничего не решает: он лишь сопровождает шаги, которые уже прислал сервер.
Звук синтезируется кодом: match3_v1/client/src/sound.ts хранит рецепты и фасад SoundPlayer, а общая шина runtime/src/shell/sound.ts собирает события из осцилляторов, огибающих и одного общего буфера шума через Web Audio API (браузерный интерфейс синтеза звука). Аудиофайлов нет, поэтому список ассетов раздела 13 MATCH3.md не расширялся, а звуковой менеджер Phaser отключён (audio: { noAudio: true }): второй AudioContext был бы лишним. Разблокировка — по первому жесту: браузер разрешает resume() только из обработчика настоящего события, а Phaser обрабатывает ввод в своём цикле, поэтому шина вешает нативные слушатели pointerdown, touchend и keydown на корень оболочки #pf-root, внутри которого лежит холст. Пока контекст не разблокирован или звук выключен, play молчит и ничего не ломает. Предпочтение игрока хранится в localStorage под ключом retentionplay:sound ('1'/'0'); переключатель «Звук: вкл/выкл» лежит в окне шестерёнки.
Сияние — аддитивные картинки pf-fx-glow из client/src/juice.ts, а не фильтры Phaser 4. Фильтр (enableFilters() и filters.internal.addGlow) — отдельный кадровый буфер по сырому размеру текстуры, который рисуется каждый кадр даже на стоящем поле, а дальность его ореола считается в текселях буфера и на ужатой в шесть раз фишке не видна. Ореол выбранной клетки и постоянные ореолы спецэлементов — неподвижные картинки под спрайтами, которые BoardView.syncHalos держит на фишках каждый кадр; совпавшая группа перед сгоранием сияет ореолом цвета фишки и рассыпается звёздами pf-fx-star. Поэтому WebGL и Canvas выглядят одинаково: отдельной ветки для Canvas нет, деградировать нечему.
При старте, восстановлении партии и «Играть ещё» поле сыплется сверху (BoardView.playIntro): фишки падают столбцами слева направо, всё вместе ≈ 500 мс. Вступление прерываемо любым касанием — finishIntro снимает твины и ставит фишки в центры клеток, — а ввод во время него не блокируется, поэтому жест игрока никогда не теряется. При сверке (resync) вступления нет.
До первого кадра игры оболочка (routes/play/shell.rs) показывает чистый CSS-загрузчик: золотое кольцо и «дышащий» текст, отключаемые при prefers-reduced-motion. При сбое рукопожатия класс pf-failed останавливает кольцо над текстом ошибки. Загрузчик центрируется собственным absolute-слоем, а #pf-root не трогается: Phaser сам ставит холст через autoCenter, и чужой display на родителе сдвигал бы его. После монтирования main.ts вызывает replaceChildren(), и загрузчик исчезает сам.
Правило «поле стоит на месте без жеста» сохранено: вечных анимаций нет, каждый эффект заканчивается сам и убирает свои объекты, а всё, что запущено ходом (баннеры, счётчик очков, конфетти, всплеск рамки), укладывается в 3 с после ответа сервера. Пульс выбранной клетки заканчивается после двух циклов; неподвижная подсветка остаётся до снятия выбора. Браузерный прогон ждёт покоя холста помощником settle (два совпавших снимка через 350 мс) и только потом сравнивает снимки.
Верхняя и нижняя панели
HUD показывает серверный номер уровня (без цифр при null), оставшиеся ходы, очки и цель. Панели рисуются формами из client/src/ui.ts; разметка и палитра — в client/src/theme.ts. Зона шестерёнки не меньше 44 CSS px на ширине 320 px. Клиентские XP, серия и сундук удалены. Усилители пока не применяются: нажатие отвечает «Скоро». Меню содержит «Продолжить», «Звук» и, при входе через лобби, «В лобби». Оно приостанавливает сцену, удерживает фокус и закрывается по Esc. «Меньше движения» отключает тряску и полноэкранные вспышки, финальная нота растворяется без изменения масштаба.
Фоны: слоты и нормализация
Админка загружает файл через PUT /v1/admin/projects/{id}/game-assets/{slot}/background: slot=lobby пишет lobby.background, id механики вида run из реестра пишет <mechanic>.skin.background. Колесо, старые шаблоны и произвольные пути отвергаются. PNG/JPEG/WebP до5 МиБ, 420×760–2160×3840, отношение0,50–0,65 проверяются декодированием. Габариты и пропорции проверяются до выделения пиксельного буфера с учётом EXIF-ориентации. Бюджет декодера —64 МиБ, одновременно на процесс выполняются две нормализации. Если обе заняты, загрузка получает429 rate_limited и Retry-After: 1. Сервер сохраняет исходные байты оригинала и отдельную копию WebP840×1520: EXIF-поворот применяется к пикселям копии, прозрачность сводится на #0a1226, обрезка cover по центру, качество78/70/62, предел220 КиБ. Для чёткости рекомендуется исходник не меньше840×1520. Превышение даёт normalized_too_large, сбой кодера — normalize_failed.
Ответ админки: {slot, background: {asset_id, format, width, height, bytes, sha256, original}, url, config_revision}. Сброс возвращает background:null; файлы сохраняются. Запись слота и рост play_config_revision происходят одной SQL-инструкцией, событие попадает в аудит. Публичные конфигурации исключают original; старые записи без него читаются для механики, но не используются как нормализованный фон лобби. Адрес всегда /v1/project-game-assets/{project_id}/{asset_id} на домене сервиса.
Скелет лобби
Для проекта с play_mechanic в entry_mode=lobby HTML /v1/play уже без JavaScript показывает фон, эмблему, название и неактивную «Играть»: data-rp-screen="loading", data-rp-play="skeleton". Фон — загруженный слот лобби либо встроенный фон механики из манифеста выпуска. Первый кадр соответствует утверждённому варианту В3 из G0.5. Название берётся из серверного play.title и остаётся одной строкой на ленте векторной эмблемы; полный текст сохраняется в DOM при визуальном сокращении. Слои bg/stage/lobby/overlay/modal/curtain/status/fatal, кнопка и область aria-live образуют контракт DOM. После рукопожатия лобби запускает механики через общий контракт: шторка, готовая партия, раскрытие, общий итог и возврат в лобби. Окно итога заранее читает лобби для доступных действий. После выбора «В лобби» оболочка делает новое чтение: изменения проекта и номер уровня берутся с сервера. Если предварительное чтение ещё идёт, новое начинается после него; переход показывает состояние ожидания и не задерживает раскрытие лобби ответом сети.
bootstrap.lobby содержит entry_mode, play_mechanic, lobby_wheel_enabled, background, accent, config_revision, csp_profile, allowed_parent_origins. Акцент всегда #rrggbb: заданный бренд с контрастом≥3:1 к подложке либо акцент механики. Системный #6c5ce7 считается незаданным. Лобби нового выпуска использует профиль wasm: script-src разрешает wasm-unsafe-eval, но не unsafe-eval для JavaScript. Режим только колеса, старый путь и предпросмотр используют base. В wheel_only bootstrap содержит play_mechanic: null; документ не зависит от активного шаблона партии. Заголовок CSP и csp_profile в bootstrap получают одно значение. Origin родителя сохраняется после рукопожатия в bootstrap.parentOrigin; канал событий допускает только этот origin из разрешённого списка, без *. Оболочка отправляет lobby_shown, run_finished и closed.
Клиент колеса R1
games/reward_wheel_v1/client экспортирует модуль колеса с отдельным контрактом от MechanicModule. Сервер выбирает сектор, приз и stop_fraction; клиент только рисует результат средствами DOM, Canvas2D и Web Animations. Лист daily монтируется поверх лобби и при закрытии перечитывает его; самостоятельный экран site в wheel_only не монтирует лобби и не загружает Phaser. Прогрев ежедневного колеса разрешён только при его включении, наличии сети и отсутствии Save-Data.
Допуск действия использует серверные free_available/spins_left_today и сравнение tickets с опубликованной ticket_cost из GET /v1/wheel. UUID и точное тело отправки сохраняются до POST, без токена; повтор после неопределённого ответа использует ту же попытку. Серверный pending восстанавливает непоказанный результат. Если версия сохранённого результата отличается от текущей таблицы, показывается приз без вращения по чужой раскладке. POST seen отправляется после отображения; его сбой оставляет восстановление на следующем открытии.
Экран поддерживает общий набор статусов награды pending/checking/confirmed/ delayed/unavailable/none; выдача R1 билетов и ресурсов уже зафиксирована сервером и возвращает confirmed. Опрос идёт при открытии и через1/2/4/8/23/38с, останавливается через60с или при закрытии. Destroy отменяет ожидания запросов, анимации и таймеры, освобождает listeners и игнорирует поздние ответы; уже отправленная серверная операция от закрытия не откатывается. Звук использует общую шину и AudioContext, меньше движения — настройку оболочки. Векторные секторы и локальные проверки не заменяют визуальную и аппаратную приёмку по текущему PLAN.md.
Сцена подключается к среде запуска сама
SceneBridge (runtime/src/scene-bridge.ts) доставляет ответ, сверку и ошибку в сцену. Брать сцену сразу после new Phaser.Game(...) нельзя: Phaser загружается асинхронно и в этот момент отдаёт null. Раньше эта пустая ссылка сохранялась навсегда, и клиент рисовал стартовое поле, а дальше молча игнорировал все ответы сервера. Теперь недоставленное событие считается и попадает в консоль. Runtime создаёт единственный мост; экземпляр отключается в destroy(), сцена — в SHUTDOWN. Поздние ответы отвязанной партии игнорируются. Очередь Match3 освобождает ввод после анимации и не буферизует жесты; транспорт использует паспорт client_retry (10 секунд на попытку, максимум 3 попытки).
Быстрые проверки игрового слоя
cd games
npm ci
npm run test:run
npm run typecheck
npm run validate:assets
npm run buildБраузерный прогон (npm run test:e2e) требует поднятого канонического стека и переменной MATCH3_PROJECT_ID, которую записывает scripts/e2e-stack-smoke.sh. Последовательные прогоны (WebKit включается явно):
npm run test:e2e -- --project=legacy-path
MATCH3_PROJECT_ID="$LOBBY_PROJECT_ID" npm run test:e2e -- --project='lobby-*' --project=release-shell
RP_WEBKIT=1 MATCH3_PROJECT_ID="$LOBBY_PROJECT_ID" npm run test:e2e -- --project='webkit-*'Первый проверяет проекты без play_mechanic; второй — лобби на ширинах 320, 375 и 430 px, сеть, отдельный браузерный профиль, переходы и 10 повторных входов. Третий запускает именно WebKit (browserName=webkit): Match3 v2, Memory, Parcel Pilot, колесо и смену механики. Предварительно установить браузер закреплённого Playwright: npx playwright install --with-deps chromium webkit. Проекты WebKit отсутствуют без RP_WEBKIT=1 и не входят в lobby-*. Все прогоны используют один worker; SQL-фикстуры требуют тестового тома. Подготовка demo-settings остаётся на Chromium и не считается игровым тестом WebKit. CDP-проверки кучи и сетевого throttling остаются в Chromium; профили телефона задают размер/касания, но не заменяют реальный Safari/iPhone и аппаратные замеры. Trace и автоматические снимки HTML отключены для WebKit: доказательства сохраняют только интерфейс/canvas и безопасные агрегаты. PLAYWRIGHT_NO_COPY_PROMPT=1 отключает снимок доступности host при падении opt-in прогона. Остальная диагностика error-context.md и raw-вывод остаются приватными: CI публикует лишь browser-proof.json и webkit-canvas.png.
В release-пути после сна Phaser первое касание обновляет границы холста и преобразование координат до пробуждения кадра. Это сохраняет выбранную клетку после CSS-смещения или масштаба; обновление не меняет стили и не создаёт событие resize.
В ресурсном сценарии Memory DOM, текстуры и анимации читаются перед сбором CDP-метрик: первое чтение само создаёт объекты браузерного инструмента. Обе точки сравнения учитывают эту стоимость одинаково. База остаётся после второго входа, итог — после десятого, допустимый рост JS-кучи — не более 10%. Стабильность DOM-счётчика отдельно проверяется в неподвижном лобби.
Дополнительные проверки: cd games && npm run check:dom && npm run check:budget; фикстурный браузерный стенд — npm run test:stand -- curtain.stand.ts result.stand.ts lobby.stand.ts.
Ядро Parcel Pilot и WASM
backend/crates/parcel_sim — общая целочисленная симуляция без стандартной библиотеки и зависимостей исполнения. Координаты и скорости — Q24.8, 60 тиков/с. Результат определяет серверный повтор; браузер получает то же ядро через parcel_sim_wasm. Клиент включён в ленивый реестр оболочки и паспорт с client_ready=true. Шаблон остаётся скрытым (selectable=false) до отдельной приёмки и активации; включение клиента не публикует механику. Оболочка уже разрешает WASM и в простое лобби пробует скомпилировать минимальный модуль без загрузки ядра. Результат хранится в памяти документа. Для механики с требованием wasm действие отключено до завершения проверки; при отсутствии API или отказе CSP вместо кнопки появляется «Игра не поддерживается этим браузером». Старт партии и прогрев такого клиента не отправляются. Match3, Memory и колесо работают независимо от результата. Если старый документ с профилем base получает WASM-механику, оболочка показывает «Игра обновилась. Обновите страницу» до запросов партии: обновить нужно страницу родителя, чтобы повторить рукопожатие.
Статическая сборка перед модульными проверками:
cd games
npm run build:parcel-wasm
npm run test:runНужны Cargo из backend/rust-toolchain.toml и цель wasm32-unknown-unknown (их проверяет scripts/setup-game-tools.sh --check). Скрипт собирает только модуль, копирует его в игнорируемый parcel_pilot_v1/generated/parcel_sim.wasm и проверяет предел 20 КиБ Brotli. Профиль wasm-release отдельный: panic=abort не переносится на API/worker. Docker собирает модуль стадией parcel-wasm-builder перед games-builder; URL модуля входит в выпуск игр.
FlightLoop (цикл полёта) выполняет 60 шагов/с, не больше четырёх за кадр, интерполирует два фиксированных снимка и честно учитывает задержки. Первый доверенный ввод отправляет launch; движение начинается после ответа и минимум 400 мс. Остальные доверенные касания/Space/Enter записываются в ленту. Предварительные очки и положение — из WASM; итог и прогресс — только из ответа API. Одна общая сцена Phaser и шина SoundBus используются повторно; кодовая графика служит заглушками, без загрузки якорей стиля.
Меню, скрытие документа и потеря WebGL приостанавливают полёт. Возврат включает отсчёт 1,5 с; бюджет — восемь пауз и 90 с вместе с отсчётом. Превышение бюджета закрывает попытку пустым finish(interrupted). Выход из полёта требует подтверждения и finish(quit). reducedMotion сокращает постановку, не меняя правила ядра.
Полное тело finish, включая command_id, сохраняется перед отправкой в sessionStorage по проекту, игроку и партии; токен не сохраняется. После перезагрузки клиент повторяет это тело и показывает тихий серверный итог. Полёт без сохранённого тела закрывается как interrupted, после чего оболочка создаёт новую попытку. Ошибка хранилища не мешает отправке, но восстановление после перезагрузки тогда недоступно. Повторы и обновление токена выполняет общий GameRunApi; поздние ответы после уничтожения сцены не показывают результат.
ABI — интерфейс двоичных экспортов — не использует wasm-bindgen и импортов JavaScript: pp_course_buf(words) выдаёт ограниченный буфер, pp_init(words) проверяет трассу и возвращает 0 при успехе, pp_step(tap) возвращает биты событий, pp_state_ptr() — постоянный адрес 14 слов i32 для Int32Array. Буфер можно записывать только между pp_course_buf и pp_init; инициализированную трассу менять нельзя. Ошибка инициализации сбрасывает предыдущий полёт. Горячий цикл не выделяет память. Корпус из 240 повторов сравнивает события, исходы и хэши debug/release/WASM; он проверяет совпадение сборок, а отдельные тесты проверяют правила симуляции.