feat(quantik-game): фаза 0 — слой целей в движке (goal/HUD/result) Декларативный блок goal в спеке SimForge (булево SimExpr-условие победы), вычисляемый каждый кадр: фиксация результата (победа/время/попытки/звёзды), callback onGoal, HUD-оверлей (цель/звёзды/подсказка/баннер, inline SVG). API инстанса: onGoal/getResult/resetResult. Серверный validateSpec пропускает goal/game (длина выражений + escape текста, без исполнения). Аддитивно: спека без goal ведёт себя как раньше. Смоук 40/40; npm test 238 pass/8 baseline; lint:routes 0. План фичи (7 фаз) + CONTEXT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
13 KiB
Phase 0: Слой целей в движке (goal / HUD / result)
Status: ✅ Done (reviewed — PASS, committed) Parent plan: PLAN.md Domain: frontend
Objective
Ввести в _sim_engine.js «атом» игры — декларативный блок goal (условие победы как
булево SimExpr-выражение), вычисляемый каждый кадр, с фиксацией результата (победа/время/
попытки/звёзды), callback'ом и HUD-оверлеем. Расширить серверный гейт validateSpec, чтобы
блок проходил валидацию. Всё аддитивно: спека без goal ведёт себя как раньше.
Спека (контракт, документировать в шапке _sim_engine.js)
goal: {
when: '<bool expr>', // SimExpr: победа, когда станет истинным (≠0)
hint?: 'текст подсказки', // показывается в HUD (escape на сервере)
title?: 'Цель уровня', // краткая формулировка цели для HUD
hold?: 0.0, // сек, сколько условие должно держаться (деф. 0 = мгновенно)
stars?: [ // 0..3 доп.условий-«звёзд» (бонусы)
{ when:'<bool expr>', label?:'...' }
],
fail?: '<bool expr>' // опц.: мгновенный проигрыш (вышел за поле/задел шип)
}
when/stars[].when/fail— компилируются ОДИН раз при mount (как все выражения), env тот же.- Доп. env-поля для целей:
t(время),tries(число reset с начала), плюс всё что уже в env (<obj>.x/.y/.vx/.vy, params, w/h, xmin..ymax). НЕ вводить новых небезопасных идентификаторов. - Звезда «залипает»: однажды истинное условие звезды остаётся засчитанным до reset (накопитель).
Tasks
- Task 1: В шапке
_sim_engine.jsзадокументировать блокgoal(формат v1, как сделано для physics/plot). - Task 2: В
prepare/mount компилироватьgoal.when,goal.fail, каждоеstars[].whenчерезSimExpr.compile(хранить fn + error; кривое выражение → никогда не бросает). - Task 3: Добавить состояние результата на инстанс:
_goalState = { won, failed, timeMs, attempts, starsGot:[], firstWinT }. Сбрасывать вreset()(attempts++ на reset, кроме первого). - Task 4: В rAF-цикле (
_renderFrame/_stepPhysics-соседство) после построения env и шага: вычислить звёзды (накопить),fail(→ мягкий проигрыш-оверлей, не победа),when(учестьhold— таймер удержания) → при победе зафиксироватьtimeMs(мировое t или wallclock от старта play), выставитьwon=true, остановить (pause) и вызватьonGoalcallback. - Task 5: HUD-оверлей (DOM, поверх canvas, как слой readout): строка цели (
title), индикатор звёзд (inline SVG звезда — заполненная/контур), подсказка (hint), баннер «Победа»/«Ещё раз» с кнопкой Reset. Стиль — тёмная плашка как у readout; без эмодзи. HUD появляется ТОЛЬКО при наличииgoalв спеке. - Task 6: Публичное API инстанса:
onGoal(cb)(cb получаетgetResult()),getResult()→{ won, failed, timeMs, attempts, stars:{got,total} },resetResult(). - Task 7:
destroy()снимает HUD-узлы/слушатели кнопок (нет утечек). - Task 8: Сервер
backend/src/controllers/customSimController.jsvalidateSpec: разрешить ключgoal(объект) иgame(объект, см. Phase 1/5) на верхнем уровне спеки; проверятьwhen/fail/stars[].whenчерезcheckExpr(длина ≤ MAX_EXPR_LEN);hint/title/stars[].label—sanitizeText(escape + обрезка);stars≤ 3;holdчисло. НЕ исполнять выражения. Обновить whitelist при необходимости. - Task 9: Headless-смоук (vm + ручной DOM/canvas-стаб + РЕАЛЬНЫЕ
_sim_expr.js+_sim_engine.js, как в P1–P3): (а) спека сgoal.whenдостигает победы →getResult().won,timeMs>0; (б) звёзды накапливаются и не сбрасываются до reset; (в)failставит failed без won; (г)holdтребует удержания; (д) спека БЕЗ goal — поведение без изменений, HUD не создан; (е)onGoalзовётся один раз; (ж) destroy снимает HUD-слушатели (баланс add/remove). Удалить temp-смоук после прогона. → 40/40 PASS, удалён.
Files to Modify/Create
frontend/js/labs/_sim_engine.js— блок goal: документация, компиляция, eval в цикле, HUD, API.backend/src/controllers/customSimController.js—validateSpec: разрешитьgoal/game.- (temp) headless-смоук — создать, прогнать, удалить.
Acceptance Criteria
- Спека с
goalпоказывает цель/звёзды/победу;getResult()корректен;onGoalсрабатывает. - Спека без
goalрендерится и ведёт себя ровно как раньше (нет HUD, нет накладных вычислений побед). validateSpecпропускает корректныйgoal, режет переразмер/длинные выражения, экранирует текст.node --checkобоих файлов OK; headless-смоук зелёный; эмодзи нет;eval/new Functionнет.cd backend && npm test— без новых регрессий;npm run lint:routes— без новых ошибок.
Notes
- Время победы: предпочесть мировое
t(детерминизм, headless-тест), плюс можно хранить wallclock. attempts= числоreset()(первый mount/авто-reset не считать попыткой; считать пользовательские).- HUD не должен перехватывать pan/drag сцены вне своих интерактивных элементов (pointer-events: none на контейнере, auto — на кнопках), как сделано с overlay-панелью в P1.
- Не вводить новые env-идентификаторы помимо
t/tries— безопасность контракта выражений.
Review Checklist
- Все задачи выполнены
- Код следует конвенциям движка (хелперы модульного уровня
_truthy, инстанс-методы, без рисования по canvas — HUD это DOM-оверлей как readout) - Аддитивность: существующие симуляции/каталог не затронуты (нет goal →
_goal=null, HUD не создаётся, в rAF веткаif (self._goal)пропускается) - Без эмодзи; без eval/Function (звёзды/иконки — inline SVG; выражения только через
SimExpr.compile) - Build (node --check) и тесты проходят (смоук 40/40;
npm test238 pass / 8 baseline fail; lint:routes 0)
Handoff to Next Phase
Движковое API цели (для Phase 1)
Всё в frontend/js/labs/_sim_engine.js. var inst = SimEngine.mount(host, spec):
inst.onGoal(cb)— подписка.cbполучаетgetResult()и вызывается РОВНО ОДИН РАЗ при первой победе (послеpause()). Возвращаетinst(chainable). Можно подписать несколько cb.inst.getResult()→{ won:bool, failed:bool, timeMs:number, attempts:number, stars:{got:number, total:number} }. Для спеки БЕЗgoalвозвращаетnull— проверяйте на null перед использованием.inst.resetResult()— сбросить результат как новый уровень (won/failed/звёзды/таймер обнуляются), но НЕ считается попыткой (attempts сохраняется). Для перезапуска уровня используйтеinst.reset()(это И сбрасывает физику/время И инкрементит attempts как пользовательскую попытку).
Как включить «игровой режим» / HUD
HUD появляется автоматически, как только в спеке есть верхнеуровневый блок goal. Отдельного
флага «game mode» в движке НЕТ. Никакого вызова не нужно — mount() сам создаёт HUD при наличии goal.
HUD = DOM-оверлей внутри inst.el (контейнер pointer-events:none, кнопка «Ещё раз» — pointer-events:auto,
дёргает inst.reset()). Спрятать HUD = убрать goal из спеки.
Как измеряется timeMs
Мировое время t от старта уровня (детерминизм для headless/реплеев): при победе
timeMs = max(1, round(t*1000)). Это НЕ wallclock — паузы/тротлинг rAF не влияют. firstWinT
(сырое мировое t победы) хранится внутри в _goalState, наружу отдаётся только timeMs.
Форма блока goal/game (что сервер теперь принимает)
validateSpec (backend/src/controllers/customSimController.js) пропускает на верхнем уровне:
goal: {
when: '<bool SimExpr>', // ≤500 симв., НЕ исполняется на сервере
title?: '...', hint?: '...', // sanitizeText: escape & < > + обрезка (title≤120, hint≤300)
hold?: 0, // число (сек удержания when); не-число → 400
fail?: '<bool SimExpr>', // ≤500 симв.
stars?: [ { when:'<bool SimExpr>', label?:'...' } ] // ≤3 (иначе 400); label sanitizeText ≤120
}
game?: { ... } // зарезервирован под мета-слой Ф1/5; проходит как есть
// (под общими лимитами размер/глубина), НЕ исполняется
Категория cat='game' уже в CATS (math/phys/chem/bio/game) — расширять не нужно.
Env-контракт цели (безопасность)
Выражения goal.when/fail/stars[].when видят ВЕСЬ env кадра (params, t, w/h, xmin..ymax,
<obj>.x/.y/.vx/.vy) ПЛЮС единственный доп.идентификатор tries (= attempts). НЕ вводить
новых идентификаторов в env цели — это контракт безопасности шаренных выражений.
Гочи для Phase 1
- Победа ставит
pause()внутри rAF-кадра; следующий queued-кадр выходит поif (!self._running) return→onGoalне задвоится. НЕ вызывайтеplay()вonGoal-колбэке безresetResult()/reset(). goal.whenбезholdсрабатывает на ПЕРВОМ же кадре, где условие истинно (мгновенно).- Звёзды «залипают»: засчитываются и во время play (rAF), и на паузе/предпросмотре (
_renderFrame). - Истинность булева =
_truthy: конечное ненулевое число (SimExpr возвращает 0 при NaN/∞/ошибке).