Files
Learn_System/plans/quantik-game/phase-0-objective-layer.md
T
Maxim Dolgolyov 4b5c8077d3 @
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>
@
2026-06-13 15:13:02 +03:00

13 KiB
Raw Blame History

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) и вызвать onGoal callback.
  • 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.js validateSpec: разрешить ключ goal (объект) и game (объект, см. Phase 1/5) на верхнем уровне спеки; проверять when/fail/stars[].when через checkExpr (длина ≤ MAX_EXPR_LEN); hint/title/stars[].labelsanitizeText (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.jsvalidateSpec: разрешить 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 test 238 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) returnonGoal не задвоится. НЕ вызывайте play() в onGoal-колбэке без resetResult()/reset().
  • goal.when без hold срабатывает на ПЕРВОМ же кадре, где условие истинно (мгновенно).
  • Звёзды «залипают»: засчитываются и во время play (rAF), и на паузе/предпросмотре (_renderFrame).
  • Истинность булева = _truthy: конечное ненулевое число (SimExpr возвращает 0 при NaN/∞/ошибке).