# 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:  '<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
- [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:  '<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/∞/ошибке).
