# Phase 0: Слой целей в движке (goal / HUD / result) **Status:** ✅ Done (reviewed — PASS, committed) **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** frontend ## Objective Ввести в `_sim_engine.js` **«атом» игры** — декларативный блок `goal` (условие победы как булево SimExpr-выражение), вычисляемый каждый кадр, с фиксацией результата (победа/время/ попытки/звёзды), callback'ом и HUD-оверлеем. Расширить серверный гейт `validateSpec`, чтобы блок проходил валидацию. Всё аддитивно: спека без `goal` ведёт себя как раньше. ## Спека (контракт, документировать в шапке `_sim_engine.js`) ``` goal: { when: '', // SimExpr: победа, когда станет истинным (≠0) hint?: 'текст подсказки', // показывается в HUD (escape на сервере) title?: 'Цель уровня', // краткая формулировка цели для HUD hold?: 0.0, // сек, сколько условие должно держаться (деф. 0 = мгновенно) stars?: [ // 0..3 доп.условий-«звёзд» (бонусы) { when:'', label?:'...' } ], fail?: '' // опц.: мгновенный проигрыш (вышел за поле/задел шип) } ``` - `when`/`stars[].when`/`fail` — компилируются ОДИН раз при mount (как все выражения), env тот же. - Доп. env-поля для целей: `t` (время), `tries` (число reset с начала), плюс всё что уже в env (`.x/.y/.vx/.vy`, params, w/h, xmin..ymax). НЕ вводить новых небезопасных идентификаторов. - Звезда «залипает»: однажды истинное условие звезды остаётся засчитанным до reset (накопитель). ## Tasks - [x] Task 1: В шапке `_sim_engine.js` задокументировать блок `goal` (формат v1, как сделано для physics/plot). - [x] Task 2: В `prepare`/mount компилировать `goal.when`, `goal.fail`, каждое `stars[].when` через `SimExpr.compile` (хранить fn + error; кривое выражение → никогда не бросает). - [x] Task 3: Добавить состояние результата на инстанс: `_goalState = { won, failed, timeMs, attempts, starsGot:[], firstWinT }`. Сбрасывать в `reset()` (attempts++ на reset, кроме первого). - [x] Task 4: В rAF-цикле (`_renderFrame`/`_stepPhysics`-соседство) после построения env и шага: вычислить звёзды (накопить), `fail` (→ мягкий проигрыш-оверлей, не победа), `when` (учесть `hold` — таймер удержания) → при победе зафиксировать `timeMs` (мировое t или wallclock от старта play), выставить `won=true`, остановить (`pause`) и вызвать `onGoal` callback. - [x] Task 5: HUD-оверлей (DOM, поверх canvas, как слой readout): строка цели (`title`), индикатор звёзд (inline SVG звезда — заполненная/контур), подсказка (`hint`), баннер «Победа»/«Ещё раз» с кнопкой Reset. Стиль — тёмная плашка как у readout; без эмодзи. HUD появляется ТОЛЬКО при наличии `goal` в спеке. - [x] Task 6: Публичное API инстанса: `onGoal(cb)` (cb получает `getResult()`), `getResult()` → `{ won, failed, timeMs, attempts, stars:{got,total} }`, `resetResult()`. - [x] Task 7: `destroy()` снимает HUD-узлы/слушатели кнопок (нет утечек). - [x] Task 8: Сервер `backend/src/controllers/customSimController.js` `validateSpec`: разрешить ключ `goal` (объект) и `game` (объект, см. Phase 1/5) на верхнем уровне спеки; проверять `when`/`fail`/`stars[].when` через `checkExpr` (длина ≤ MAX_EXPR_LEN); `hint`/`title`/`stars[].label` — `sanitizeText` (escape + обрезка); `stars` ≤ 3; `hold` число. НЕ исполнять выражения. Обновить whitelist при необходимости. - [x] 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 - [x] Все задачи выполнены - [x] Код следует конвенциям движка (хелперы модульного уровня `_truthy`, инстанс-методы, без рисования по canvas — HUD это DOM-оверлей как readout) - [x] Аддитивность: существующие симуляции/каталог не затронуты (нет goal → `_goal=null`, HUD не создаётся, в rAF ветка `if (self._goal)` пропускается) - [x] Без эмодзи; без eval/Function (звёзды/иконки — inline SVG; выражения только через `SimExpr.compile`) - [x] 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: '', // ≤500 симв., НЕ исполняется на сервере title?: '...', hint?: '...', // sanitizeText: escape & < > + обрезка (title≤120, hint≤300) hold?: 0, // число (сек удержания when); не-число → 400 fail?: '', // ≤500 симв. stars?: [ { when:'', 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`, `.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/∞/ошибке).