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> @
136 lines
13 KiB
Markdown
136 lines
13 KiB
Markdown
# 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/∞/ошибке).
|