Files
Maxim Dolgolyov d63c99cae9 chore(brand): убрать «BQ-System», оставить только LearnSpace
Бренд продукта = LearnSpace. Убрано «BQ-System»/«LearnSpace / BQ-System» из:
- банеров и комментариев запускатора/панели (control-panel/launch-server.ps1, *.bat);
- заголовка CLAUDE.md;
- планов ct-math (PLAN/README).
Путь-каталог (cd BQ-System в SETUP.md, папка на диске) и .claude-настройки — не трогаю
(это локальные пути, не брендинг). ps1 пересохранены в UTF-8 с BOM, парсинг OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:01:53 +03:00

90 KiB
Raw Permalink Blame History

LearnSpace — правила для Claude

Поиск по коду

ast-index — дефолт. ВСЕГДА первым для «найти символ по имени / usages / callers / outline». Grep/Read — только если ast-index вернул пустой результат.

vex — для поиска по смыслу, AST-паттернов, дубликатов, компактного тела символа: vex search "..." --semantic, vex similar, vex pattern, vex duplicates, vex show. Что и когда — подробно в .claude/rules/search-tools.md. (usages/callers по JS — только ast-index.)

# Найти класс/функцию/символ
ast-index class "ClassName"
ast-index symbol "functionName"

# Найти использования
ast-index usages "symbolName"

# Поиск по содержимому файла
ast-index search "keyword" --in-file "filename"

# Структура файла
ast-index outline "path/to/file.js"

# Универсальный поиск
ast-index search "query"

Grep использовать только для:

  • Поиска строковых литералов ("some text")
  • Regex-паттернов
  • Если ast-index вернул пустой результат

Git — обновление репо

После любых изменений — коммит и push:

git add <изменённые файлы>
git commit -m "тип: описание"
git push origin master
  • Коммитить только изменённые файлы (не git add -A)
  • Сообщение коммита: feat: / fix: / refactor: / style: + краткое описание на русском или английском
  • Push выполнять сразу после коммита

Стек

  • Node.js/Express backend, SQLite (better-sqlite3, sync)
  • Frontend: vanilla JS, без бандлера
  • ast-index проиндексирован: ast-index rebuild при добавлении новых файлов

Feature: Конструктор симуляций (SimForge)

Движок авторинга интерактивных 2D-симуляций из JSON-спеки (данные, НЕ код). План: plans/sim-builder/.

Phase 0 — Learnings

  • Спека = данные. Любое числовое свойство объекта = число ИЛИ строка-выражение. Выражения шарятся между людьми → движок безопасный, без eval/new Function.
  • window.SimExpr (frontend/js/labs/_sim_expr.js): токенайзер → AST → evaluate. compile(src)->{ast,fn,error}; fn(env) НИКОГДА не бросает (NaN/∞/деление на 0 → 0). Whitelist: + - * / ^ %, унарный - + !, сравнения < <= > >= == !=, логика && ||, тернарник ?:, функции sin cos tan tg ctg cot asin..arctg sqrt abs exp ln log log2 log10 floor ceil round sign min max mod atan2 pow hypot, константы pi e tau. Идентификаторы (вкл. точечные obj.x) — только из env. Парсер — расширение y=f(x) из graph.js; -2^2 == 4 (парити). Также evalSafe, compileValue, parse, tokenize, FUNCTIONS, CONSTANTS.
  • window.SimEngine.mount(host, spec) (_sim_engine.js) → { play, pause, reset, setParam, getParam, isRunning, destroy, el }. Canvas (мир→экран, равные оси, Y вверх) + KaTeX-оверлей подписей (katex.renderToString, как graph.js) + слайдеры из params[]. Выражения компилируются 1 раз в mount; в rAF — только evaluate. env = { t, <params>, w, h, xmin..ymax, <objId>.x, <objId>.y }. Объекты: point segment vector circle rect polyline path label. Формат спеки v1 — в шапке _sim_engine.js.
  • window.registerSpecSim(spec) (_sim_adapter.js): спека → манифест LabRegistry (ленивый хост #sim-spec-host-<id> в #lab-sim; stop прячет, destroy уничтожает). Так спек-сим открывается тем же путём, что рукописные ~40 (через openSim → реестр).
  • Демо customdemo_sim_demo.js, за флагом ?simdemo=1 / ?sim=customdemo / LAB_SHOW_SPEC_DEMO / localStorage lab-spec-demo=1 (ученикам не светится).
  • Подключение: 3 каркасных <script> eager после _graph_panel.js в lab.html, демо — после _register-all.js. _sim_deps.js не трогать (каркас грузится до диспетчера).

Phase 1 — Learnings

  • Новые типы объектов_sim_engine.js, формат — в шапке файла):
    • plot — график f(var) на canvas движка в мир-координатах (НЕ через GraphPanelUI — тот stacked time-series в фикс. оверлее, не y=f(x)). Поля: expr, var (деф.x), range:[a,b] (числа/выражения, деф. xmin..xmax), samples (клампится 2..2000, деф.200), trace (точка var=t пишется в trail; при trace без range статич. кривая не рисуется), color/width. Свободная переменная подставляется во временную копию env-ключа (восстанавливается после).
    • readout — живой бейдж на DOM-оверлее (_labelLayer, как label). Поля: expr, label, unit, precision (0..8, деф.2), x/y (мир-коорд.; без них — авто-столбик верх-право, счётчик _readoutSlot сбрасывается на кадр). Ошибка — мягко через SimExpr.evalSafe (AST компилируется 1 раз в prepare), показывает «—».
    • vector — новая форма origin:[ox,oy]+dx/dy (конец = origin + (dx,dy)); старая x1/y1/x2/y2 сохранена; стрелка из Ф0.
  • Drag (point/circle с drag:{param,axis,min,max,paramY}): pointer events на canvas (мышь+тач, touchAction:none); хит-тест в экранных px (допуск 16px, ближайшая ручка), приоритет ручек. axis:'xy' требует paramY. Курсор → мир через _toWorld (инверсия _toPx) → _setParamClamped (clamp по drag.min/max И по диапазону параметра из _paramRange — не полагаться на DOM-clamp слайдера). Слушатели снимаются в destroy. Drag только point/circle (вершины polyline/конец вектора — не реализовано).
  • Тестировать движок headless: vm.createContext + ручной DOM/canvas-стаб (canvas-ctx через Proxy с noop). _renderFrame рано выходит при _cw/_ch==0 — выставить вручную. setParam/drag используют new Event('input') (браузерно безопасно, в стабе нужен Event).
  • lab.html/lab-glue.js — зона параллельной сессии (Ф2 измерит. инструменты); Ф1 их НЕ трогала, работала только в _sim_engine.js/_sim_demo.js.

Phase 2 — Learnings

  • Физический режим (всё в _sim_engine.js, формат — в шапке файла): блок physics:{ enabled, gravity:{x,y}, friction?, restitution?, dt?, walls?:[...], springs?:[...] } + body:{ mass, vx, vy, fixed } на point/circle. gravity/friction/restitution/k/length/damping/mass/нач.позиция/vx/vy — число ИЛИ выражение от params (вычисляются на reset, не каждый кадр — для стабильности).
  • window.SimPhysics — экспортированный интегратор (step(state,dtFrame), integrate, resolveCollisions). Полу-неявный (симплектический) Эйлер v+=a·dt; x+=v·dt — та же математика, что _fx_motion.spring, обобщённая на N связанных тел. Фикс-шаг с накопителем (кламп dt 1/2000..1/30, кап подшагов 8, кламп скорости 1e4, вязкое трение exp(-friction·dt)) → энергия не «взрывается». Упругие столкновения круг-круг (импульс по нормали + позиционная коррекция по обратным массам) и круг-стена. Чистая функция над state, без DOM/eval — переиспользуемо headless. Отдельного файла _sim_physics.js НЕТ (нельзя подключить без правки lab.html — зона параллельной сессии); код внутри _sim_engine.js.
  • _fx_motion API не подходит для спек-движка напрямую: tween/springFactory — rAF-замыкания, тянущие ОДНО значение к цели, не связанные тела с силами. Переиспользована только их интеграционная математика (формула спринга), а не сами функции.
  • env-поля тел: <id>.x/.y/.vx/.vy берутся из СОСТОЯНИЯ интегратора и кладутся в _buildEnv ПЕРВЫМИ (до формульных центров) — это снимает forward-ref проблему однопроходного env для тел: формульный объект, ссылающийся на тело (segment x2:'ball.x'), видит актуальную позицию в том же кадре. point/circle с body рисуются из env-полей тела, а не из выражения x/y.
  • Drag тела: тело (point/circle с body, не fixed) перетаскиваемо по умолчанию (без drag-конфига). Тащишь — body.x/y = курсор, тело временно fixed в _stepPhysics; отпускаешь — body.vx/vy из сглаженной оценки скорости курсора (кламп 40 м/с). Хит-тест тела — max(16px, экранный радиус). drag-ручки Ф1 и физ-тела сосуществуют.
  • Гочи: (1) имя param e зарезервировано — это число Эйлера в SimExpr (parser проверяет CONSTANTS до env), выражение e даст 2.718, не значение param. Брать el/elast. (2) Радиус тела для коллизий: circle — мировой r; point — экранные px → мир через _scale, поэтому физика собирается в reset() ПОСЛЕ первого _fit(). (3) Слайдеры во время play меняют только env (readout/формулы), но НЕ силы/тела до reset (намеренно). На паузе при t==0 — пересборка для предпросмотра старта.
  • Headless-тест физики: виртуальные часы (vclock) синхронны с performance.now() и таймстампом rAF (иначе первый кадр получит огромный/отрицательный dt и ничего не сдвинется).

Phase 3 — Learnings

  • Персистентность: таблица custom_sims (миграция 071), API /api/custom-sims (контроллер customSimController.js, роутер customSims.js, смонтировано в server.js после /api/materials), клиент LS.customSimsList/Get/Create/Update/Delete. Спека хранится как spec_json TEXT(JSON); парс — только на чтение/валидацию, на сервере НЕ исполняется. version ++ на каждом update со spec.
  • validateSpec(spec) — серверная защита БЕЗ исполнения (спека шарится между людьми): размер ≤200KB, specVersion=1, лимиты (params≤50/objects≤200/walls≤20/springs≤50/expr≤500симв./глубина≤8/points≤1000), whitelist типов объектов (point|segment|vector|circle|rect|polyline|path|label|plot|readout), physics-границы (restitution 0..1, dt 1/2000..1/30, body.mass>0). Строки-выражения (x/y/expr/…) НЕ парсятся (это делает безопасный SimExpr на клиенте) — проверяется только длина. Текст-поля (text/label/unit/meta/param.label/drag.param/id) обрезаются и экранируются (& < > → entities). Возврат { ok, error?, clean? } — в БД пишется clean (санитизированная).
  • Ownership-паттерн = studentMaterialsController: read-роуты auth-only (видимость own+published решает контроллер), мутации — inline requireRole('teacher','admin') + per-row проверка (owner_id === req.user.id || role==='admin' → иначе 403; нет строки → 404). НЕ blanket router.use(requireRole) — иначе ученик не увидит published.
  • lint:routes (baseline 0): :id-роуты прикрыты router-level authMiddleware (линтер видит router.use(<guard>)); read GET/PUT/DELETE /:id дополнительно помечены // @public-by-design: с указанием на ownership-проверку в хендлере (как в materials.js).
  • Тесты: setup.js строит СВОЙ Express-app и НЕ монтирует новые роуты — тест-файл должен сам app.use('/api/custom-sims', require(...)) (как lab-links.test.js). getToken(role)/inject(method,path,body,token) — готовые хелперы. seedRow(PRAGMA table_info) — для прямого посева строк, устойчив к дрейфу схемы (здесь не понадобился: пользователи создаются через getToken, спеки — через API).
  • Окружение тестов: 8 fail из 209 = 3 baseline (auth.test.js — bcrypt/JWT в тест-окружении) + 5 page-тестов (chemistry7/8-*, math5/6-page), падающих на Cannot find module 'jsdom' (devDep не установлен) — оба класса не связаны с бэкенд-фазой.

Phase 4 — Learnings

  • Билдер = frontend/sim-builder.html + frontend/js/sim-builder.js (логика модульна: html держит только разметку/стили/bootstrap). window.SimBuilder.create({host,previewHost,panelHost,toolbarHost}) -> Builder. Состояние Builder.st; _uid на объектах/стенах/пружинах — UI-метка, вырезается в buildSpec(). Доступ teacher/admin: LS.initPage(){isTeacher,isAdmin} → редирект /dashboard (паттерн live-quiz.html).
  • Подключение движка тем же путём, что lab.html: <script src="/js/labs/_sim_expr.js"> + _sim_engine.js. Гочи маршрутизации: /js мапится на корневой js/ (api.js/sidebar.js/mobile.js/notifications.js), а в нём НЕТ labs/ → запрос /js/labs/* и /js/sim-builder.js проваливается на express.static(frontendDir) и отдаёт frontend/js/.... Это уже работающий механизм (lab.html), не трогать server.js.
  • Генерация спеки: buildSpec() → JSON v1. stripObj() убирает _uid/пустые поля. plot хранит в UI range_a/range_b отдельно и материализуется normalizePlotForSpecrange:[a,b] (границы — число ИЛИ выражение). stripObj переопределён в конце IIFE на plot-aware версию — работает т.к. buildSpec вызывает её в рантайме (function-declaration binding мутабелен). Числовые поля хранятся «как введено» (число/строка) — SimExpr.compileValue ест оба, серверная validateSpec не парсит.
  • Выражения = только SimExpr (без eval/Function): SimExpr.compile(v).error → inline-ошибка у поля; FUNCTIONS/CONSTANTSобычные объекты (ключи=имена, не Set) → палитра через Object.keys. exprError() пропускает чистые числа и пустые строки.
  • Запрет имени param: не только e (число Эйлера), но и pi/E/PI/tau/t/w/h (служебные env-переменные движка) — иначе слайдер затрёт системное имя.
  • Drag-on-preview: переиспользует геометрию движка — inst.canvas + inst._toWorld(px,py) (px относит. canvas getBoundingClientRect). Пишет x/y (point/circle/label/readout/rect) или x2/y2 (segment/vector). Только на паузе (!inst.isRunning()), чтобы не конфликтовать со встроенным drag/анимацией движка.
  • Клиентская валидация зеркалит серверную (Ф3 лимиты) и показывает дружелюбную модалку-список ДО запроса — экономит round-trip и даёт понятные ошибки вместо сырого 400.
  • Сайдбар — аддитивно: пункт /sim-builder в js/sidebar.js в группе G('practice',...) после /lab, паттерн { cls:'sb-teacher-only', hidden:!isTch }. isActive('/sim-builder') подсвечивает на странице (clean URL без .html). НЕ ломает остальной сайдбар.
  • Верификация без jsdom: headless-смоук — vm.createContext + ручной DOM/window/Blob-стаб (canvas getContext-заглушка, setTimeout no-op чтобы debounce не стрелял), грузим _sim_expr.js+sim-builder.js, дёргаем buildSpec()/validate()/loadFromSim() напрямую (рендер не нужен для логики). 23/23.

Phase 5 — Learnings

  • id-неймспейс custom: гочи LabRegistry. LabRegistry.get/has обрезают часть после : (_baseId), т.к. встроенные используют base:arg (emfield:E, stereo:cube). Поэтому регистрировать custom:42 НЕЛЬЗЯ — has('custom:42') искал бы _byId['custom']. Решение: в реестре id без двоеточия customsim_<dbid>, а наружу (deep-link/клик/data-open) — custom:<dbid>. Конвертация одной функцией LabCustom.resolveId через хук в начале openSim (lab-init.js, +7 строк).
  • Ленивый манифест-заглушка вместо ранней загрузки spec. На старте /lab грузим только мету (customSimsList, без spec) и регистрируем заглушку с асинхронным open(). При первом открытии: ensureSpec(dbid) (customSimGet, кэш+дедуп) → registerSpecSim(spec) (Ф0-адаптер) заменяет заглушку на месте (LabRegistry.register сохраняет позицию по тому же id) → setActive(real) + real.open(ctx). Дисп. в openSim уже умеет Promise-возврат open() (Ф3). Повторное открытие — синхронно (реальный манифест в реестре). Движок _sim_* уже eager (Ф0) → ленивый файл не нужен, _sim_deps.js не трогаем.
  • Аддитивность в чужих файлах: вся логика — в новом IIFE window.LabCustom в КОНЦЕ lab-glue.js; в существующий код добавлены только хуки: renderSims() merge +&& !m._custom (1 терм) + вызов renderSection; init зовёт init(). Секция «Мои симуляции» (#custom-sim-section) создаётся динамически в #lab-home — без правок lab.html/CSS (тот же приём, что _loadRelated в Ф-каталоге). Карточки переиспользуют .sim-card/.sim-cat/.sim-preview; бейджи/кнопки — inline-стиль + SVG .ic (без эмодзи).
  • Owner-only действия: owner_id === user.id (user из LS.initPage(), поле id — канон всего фронта, ср. t.createdBy === user.id в theory.html). Edit → location.href='/sim-builder?id='+dbid; Delete → LS.customSimDelete + убрать карточку. Делегированный клик по контейнеру секции: data-act (edit/del, stopPropagation) vs data-open (открыть). Видимость draft/published обеспечивает сервер Ф3 (список = свои+чужие published).
  • Embed/Ф7 заметка: для ?sim=custom:* открытие отложено до LabCustom.init() (и в обычном, и в embed-режиме). _loadRelated('customsim_<id>') дергает /api/lab/sims/.../related (404, тихо). LabRegistry не имеет unregister → удалённая custom остаётся заглушкой в реестре (карточки нет, ensureSpec вернёт 404). Источник spec для доски (Ф7): LabCustom.ensureSpec(dbid).
  • Смоук на РЕАЛЬНОМ registry/adapter: harness грузит настоящие _registry.js+_sim_adapter.js в vm-контекст, стабит только SimEngine/LS/DOM, извлекает IIFE LabCustom из lab-glue по маркеру и прогоняет init→open→del. Гочи стаба: реальный код проверяет window.LS (api.js ставит и window.LS, и глобал LS) — в стабе надо ставить ОБА; document.getElementById стаба должен находить и динамически appendChild-нутые элементы (регистрировать по id в appendChild). 22/22.

Phase 6 — Learnings

  • Раздача классу = доступ + уведомление, НЕ копия. Ключевое отличие от «Моих материалов» (shareMaterial): там оригинал ПРИВАТНЫЙ, поэтому каждому ученику делается независимая КОПИЯ. У custom-sim published И ТАК видна всем в каталоге (list/get отдают published любому; custom-sim НЕ гейтится content_access allowlist'ом 'sim' — тот гейтит ТОЛЬКО legacy lab_sims). Поэтому share = (1) авто-публикация status→published, (2) адресное уведомление ученикам класса. Копия и запись content_access избыточны. Решение зафиксировано в CONTEXT.md.
  • Долговечное уведомление: pushNotif, НЕ sse.emit. materials.share шлёт emit(uid, {...}) (только SSE, теряется если оффлайн) — там персистентность даёт сама копия. Для share без копии нужен durable канал: require('../utils/notifications').pushNotif(uid, type, message, link) — пишет в таблицу notifications И шлёт SSE. Ссылка /lab?sim=custom:<id> (Ф5 deep-link).
  • lab_sim_links.sim_id — TEXT (см. мигр.043), поэтому курикулумные связи custom переиспользуют ту же таблицу с sim_id='custom:<id>' — отдельная таблица не нужна. Связями СВОЕЙ симуляции рулит владелец/admin (а не только admin как у lab_sims в lab.js — custom-sim принадлежит учителю). DELETE симуляции должен чистить её связи вручную (у lab_sim_links нет FK на custom_sims). /api/lab/links?kind=...&ref_id= (обратный поиск) джойнит lab_sims — для custom не сработает (отдельный bulk-эндпоинт — остаток).
  • Шаблоны = данные в JS, не код/файл. TEMPLATES (массив спек v1) прямо в sim-builder.js; «Создать из шаблона» собирает синтетический sim-объект { id:null, status:'draft', spec, title, cat } и зовёт существующий loadFromSim → simId сбрасывается в null + history.replaceState('/sim-builder'), чтобы первое «Сохранить» создало запись. loadFromSim уже корректно раскладывает plot-rangerange_a/range_b (Ф4) — шаблоны с графиками round-trip без потерь.
  • publish-toggle через PUT status. Снять с публикации = customSimUpdate(id, { status:'draft' }) (контроллер Ф3 уже принимает status в update). В билдере для уже сохранённой sim — setStatus (без полного save, не бампит version зря); в каталоге — кнопка publish/unpublish на owner-карточке.
  • clone-источник: своя любая ИЛИ чужая published (чужой draft → 403). Кнопка «Клонировать к себе» — только на чужой published-карточке и только для teacher/admin (_isTeacherUser()). Копируется spec_json как есть (уже санитизирован при сохранении оригинала), status=draft, version=1, title += ' (копия)'.
  • Аддитивность сохранена: lab-glue.js правлен только внутри IIFE LabCustom (ICON-блок + _cardHtml actions + делегат + 3 новые функции + экспорт); lab.html/classroom.html не тронуты. Кнопки — inline-стиль + SVG .ic, без эмодзи.

Phase 7 — Learnings

  • Доска грузит sim в IFRAME, НЕ монтирует движок напрямую. Ключевое открытие: onSimOpen(simId) в classroom.html просто ставит cr-sim-frame.src = /lab?embed=1&sim=<simId>. Значит custom-sim на доску = переиспользование Ф5-пути: iframe /lab?embed=1&sim=custom:<id> сам монтирует SimEngine через LabCustom.init→openSim→registerSpecSim. Никакого прямого SimEngine.mount в классруме — план («смонтировать SimEngine в container доски») был неточен, фактический конвейер чище.
  • Синхрон состояния — обобщённый мост sim_state/apply_sim_state (postMessage), НЕ per-sim код в классруме. Каждая встроенная sim в embed зовёт _registerSimState(id, getState, applyState) + _startStateEmit(id) (lab-glue.js, top-level). Учительский iframe постит {type:'sim_state',state} родителю → classroom relay POST /sim/state → SSE → ученик постит {type:'apply_sim_state',state} в свой iframe → _simStateRegistry[_autoSim].applyState. Custom-sim просто подключается к тому же реестру: _bridgeCustomSimState(real) с getState={params,running} / applyState=setParam+play/pause поверх real.instance() (SimEngine: .params, setParam, isRunning, play, pause).
  • Ключ реестра состояния = _autoSim (raw custom:<dbid>), НЕ реестровый id. Обработчик apply_sim_state берёт _simStateRegistry[_autoSim], а _autoSim — это сырой URL-param custom:<dbid> (двоеточие!), хотя в LabRegistry sim лежит под customsim_<dbid> (resolveId). Регистрировать мост надо под _autoSim, иначе ученик не применит state. Гоча неочевидная.
  • simId с двоеточием ломал бэкенд-валидацию. simOpen валидировал ^[a-z0-9_-]{1,40}$ — двоеточие в custom:5 не проходило. Добавлена ветка ^custom:(\d+)$ + проверка доступа (own|published|admin → иначе 404/403). Доступ дублируется на GET /custom-sims/:id (ensureSpec в iframe) — две линии обороны, чужой draft не утечёт.
  • Закрытие = frame.src='about:blank' сносит весь iframe-документ (SimEngine, rAF, listeners, _simStateRegistry) — явный destroy() в классруме не нужен, чисто по построению. Смена sim — тот же сброс src + новый load.
  • classroom.html (8240 строк) — искать через vex по DOM-id (cr-sim-picker-grid, cr-sim-frame), затем точечный Read. ast-index НЕ индексирует inline-<script> в HTML (символы crOpenSimPicker и т.п. → пусто); vex тоже не парсит тела inline-функций. Для тел функций в HTML — Grep tool (документированный escape-hatch ast-index.md: «ONLY when ast-index returns empty»). Проверка инлайна: извлечь <script> без src в temp .js → node --check → удалить.

SimForge improvements — P1 (Рабочее поле) — Learnings

Раунд полировки сверх фаз 0–7. План: plans/sim-builder/IMPROVEMENTS.md. Всё в frontend/js/labs/_sim_engine.js (один движок → эффект и в билдере, и в /lab, и на доске).

  • Первопричина «съехало вправо»: _build раскладывал root как display:flex с фикс-панелью width:260px СЛЕВА + stage справа → у пустой/новой sim панель всё равно занимала 260px, сцена смещалась. Фикс — раскладка, НЕ _fit (_fit был корректен): root(relative) → stage(position:absolute;inset:0, canvas+labels на всю площадь) + контролы как плавающая overlay-панель (position:absolute;left/top:10px;z-index:5;pointer-events:auto, сворачивается _togglePanel, есть только при наличии params) + бар кнопок вида (right/bottom:10px). Пустое место сцены под панелью доступно для pan (pointer-events:auto только на карточке). sim-builder.html НЕ потребовался — старый CSS .sbu-preview .sim-spec-root{position:absolute;inset:0} уже растягивает новый full-bleed root.
  • Transform-модель (zoom/pan): _fit() считает БАЗУ _baseScale/_baseOffX/_baseOffY (центрированный fit по viewport) и ЭФФЕКТИВНЫЙ _scale/_offX/_offY (его используют _toPx/_toWorld — сигнатуры без изменений). _zoom — пользовательский множитель к базе; _viewLocked — был ли zoom/pan (тогда ресайз СОХРАНЯЕТ мир-центр+zoom, не сбрасывает вид). Публичное API вида: inst.fitView() / inst.resetView() (оба → центрированный viewport). Внутреннее: _zoomAt(lx,ly,factor) (зум к экранной точке — мир-точка под курсором инвариантна; кламп _zoom 0.1..50×), _setupZoomPan() (колесо {passive:false} + pan на pointer events), _visibleWorld(W,H) (видимые мир-границы для сетки/осей с учётом zoom/pan).
  • Pan vs drag-ручек — приоритет хит-теста: хит-тест ручек/тел вынесен из замыкания _setupDrag в общий метод _pickHandleAt(lx,ly). Drag-листенеры регистрируются ПЕРВЫМИ (если _hasHandles), pan — после; _onPanDown стартует pan, только если !_dragging && !_pickHandleAt(...) → ручка/тело всегда побеждает. Курсор сцены grab (пустое место паним), grabbing при pan.
  • Сетка адаптивна к zoom: _niceStep(targetPx) завязан на _scale (мир→px), шаги 1/2/5·10^n; _drawGrid рисует minor(~34px) + major(×5) через всю видимую область (_visibleWorld); линии округляются к .5px (резкость, без «ступенек»). _drawAxes — оси X/Y (прижимаются к краю canvas, если 0 вне видимой области) + числовые подписи делений (светлый текст + тень на тёмном фоне, хелперы _axisNum/_stepDecimals) + маркер origin (0,0).
  • destroy снимает wheel-листенер + pan-листенеры (_onWheel/_onPanDown/_onPanMove/_onPanUp) и ResizeObserver — утечек нет.
  • Иконки кнопок (_chevIcon/_fitIcon/_resetViewIcon) — inline SVG .ic-стиль (без эмодзи). Вычислений выражений в P1 нет → eval/Function не вводились.
  • Верификация P1: node --check OK; headless-смоук (ручной DOM/canvas-стаб + РЕАЛЬНЫЕ _sim_expr.js+_sim_engine.js, грузятся через require) 40/40: центрирование пустой спеки, zoom-инвариант курсора + кламп, pan-сдвиг _off, приоритет ручек над pan, drag-ручка пишет param, подписи-оверлей следуют zoom/pan (позиционируются по _toPx), fit/reset вида, ресайз сохраняет вид, рендер всех 10 типов объектов без throw, destroy снимает все canvas-листенеры. Стаб баланса addEventListener/removeEventListener доказывает отсутствие утечек.
  • На P2 (графика объектов): расширять _drawObject/_drawTrail/_arrowHead/_drawPlot и чтение стилей в _prepareObjects (там уже читаются color/fill/width).

SimForge improvements — P2 (Качество графики объектов) — Learnings

Всё в frontend/js/labs/_sim_engine.js. Расширено чтение стилей в _prepareObjects + применение в _drawObject.

  • Два хелпера вместо повтора в каждой ветке: _applyStroke(ctx,o) ставит globalAlpha=opacity, lineWidth=width, lineJoin/lineCap='round', setLineDash по lineStyle (хелпер _dashFor, паттерн масштабируется от width), и glow→shadowColor/shadowBlur (если o.glow). _fillStyleFor(ctx,o,x0,y0,x1,y1) строит линейный градиент gradient:[c0,c1] по переданному bbox (try/catch — мусорный цвет падает на fillColor) или возвращает сплошной fillColor/null. Каждая ветка _drawObject обёрнута в свой ctx.save()/restore() → состояние (alpha/dash/shadow/join) НЕ протекает между объектами.
  • Безопасность цвета: все новые цветовые поля (включая стопы gradient, glowColor/shadow) идут ТОЛЬКО в canvas-стоки (fillStyle/strokeStyle/createLinearGradient+addColorStop/shadowColor) — canvas игнорит мусор, XSS нет. В DOM style.cssText пользовательские цвета НЕ кладутся (это _drawLabel/_drawReadout — НЕ трогались в P2).
  • Новые поля стиля спеки (контракт для P4-контролов): opacity 0..1; lineStyle solid|dashed|dotted; width (0 → у circle/rect только заливка); fill/fillColor; gradient:[c0,c1] (приоритетнее fill, верт. по bbox, полигон — только при closed); glow:true/shadow:'#c'/shadow:{blur}/glowColor/glowBlur (деф. ВЫКЛ); pointStyle filled|hollow|cross|ring; trailFade(деф.true)/trailWidth(1.6)/trailLen(2000,макс 5000). Полные дефолты — IMPROVEMENTS.md Handoff P2.
  • Стрелки векторов: _arrowHead(ctx,a,b,color,width) — заполненный «барбед»-треугольник (вырез у основания, не «галочка»), длина _arrowHeadLen(width)=max(9,width*3.2)px; тело линии укорочено на длину головы (headLen*0.9), голова всегда сплошная (setLineDash([]) перед ней). Точки _drawPoint(ctx,o,px,py,r) — 4 стиля; filled-деф. = заполненный кружок + тонкая белая обводка (если не glow). Трассы _drawTrail(ctx,pts,o) — при trailFade рисуется ПОСЕГМЕНТНО (alpha 0.08→0.68 от хвоста к голове, «комета»), иначе одной полупрозрачной линией.
  • Палитра по умолчанию DEFAULT_PALETTE (8 холодно-ярких тонов) — циклически [i % 8] в _prepareObjects, только если color не задан в спеке; явный color сохраняется.
  • Верификация P2: node --check OK; headless-смоук (vm + DOM/canvas-стаб со счётчиками вызовов и проверкой баланса save/restore + РЕАЛЬНЫЕ _sim_expr.js+_sim_engine.js) 23/23: 18-объектная спека (все типы + все новые поля) ×4 кадра без throw; ctx не протекает (depth=0, globalAlpha→1, shadowBlur→0, lineDash→[] после кадра); setLineDash/createLinearGradient/fill/stroke/arc вызваны; поля прочитаны; палитра+явный color; трасса накоплена; destroy чист. Эмодзи нет (скан: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только в комментарии стр.15.
  • На P3 (графики/диаграммы): _drawPlot уже зовёт _applyStroke. Расширять _drawPlot — оси-деления plot, несколько кривых, заливка под кривой, маркеры (переиспользовать _drawPoint), легенда. Хелперы _applyStroke/_fillStyleFor/_drawPoint готовы к переиспользованию.

SimForge improvements — P3 (Графики/диаграммы) — Learnings

Всё в frontend/js/labs/_sim_engine.js. Расширен _drawPlot + ветка type==='plot' в _prepareObjects. Оси/сетка/подписи уже из P1 — в P3 не дублировались.

  • Несколько кривых. Нормализуются в prep.curves[] с приоритетом источника: curves:[{...}]exprs:['sin(x)','x^2'] → одиночный expr (легаси, обратная совместимость). Каждой кривой свой цвет: явный color или DEFAULT_PALETTE[i%8]. prep.exprFn оставлен = первой кривой (нужен trace-режиму _accumPlotTrace).
  • Поля кривой (curves[i]): expr, color, label(→легенда), width, lineStyle(solid|dashed|dotted), opacity(0..1), fill(true→полупрозр. цвет кривой / строка цвета), marker(none|dot|ring). Не заданные наследуют plot-уровень (width/lineStyle/opacity). Plot-уровневые fill/marker — дефолт для всех кривых.
  • Заливка под кривой_fillUnderCurve(ctx,pts,baseY): между кривой и осью y=0 (baseY клиппится к canvas), посегментно — разрывы у не-finite точек НЕ сливаются в один полигон. fill:true_fillAlpha(color,0.18) (#RGB/#RRGGBB→rgba; прочие форматы как есть, alpha через globalAlpha).
  • Маркеры узлов_drawCurveMarkers переиспользует _drawPoint (dot→filled, ring→hollow), прорежены ~28px по экрану (не сотни точек на 200 сэмплах).
  • Легенда_drawLegend на canvas (НЕ DOM): тёмная плашка (roundRect с фолбэком на fillRect) + цветной свотч (strokeStyle цвета кривой) + светлый fillText. Верх-право, не наезжает на бар кнопок вида. Авто при наличии label; legend:false отключает. Пользовательский цвет — только canvas-сток; текст легенды — фикс. светлый.
  • Качество кривой — пропуск не-finite (разрывы через started=false), переиспользован equidistant sampling (samples 200/макс 2000), _applyStroke даёт dash/opacity/glow/round-стыки. Каждая кривая в своём ctx.save()/restore(), легенда — на внешнем уровне → состояние не протекает.
  • Новые хелперы модульного уровня (рядом с _dashFor/_opacity): _markerStyle(v) (none|dot|ring), _fillAlpha(color,a) (hex→rgba для заливки).
  • Верификация P3: node --check OK; headless vm-смоук (canvas-стаб со счётчиком save/restore + РЕАЛЬНЫЕ _sim_expr+_sim_engine) 10/10: легаси одиночный expr, exprs[], curves[]+fill+marker+legend, наследование plot-уровня, не-finite (1/x, tan) без throw, legend:false, trace±range, fillUnder+markers с null-разрывами, регресс point/vector/circle/rect — все PASS; ctx сбалансирован (depth→0, нет restore-underflow). Эмодзи нет (только пре-существующие → в комментариях); eval=0. Temp-смоук удалён.
  • На P4 (билдер): дать полям контролы — список кривых (add/del, expr+color+label+width+lineStyle+opacity+fill+marker), plot-уровневые fill/marker, тумблер легенды.

SimForge improvements — P4 (UI билдера + контролы стиля) — Learnings

Всё в frontend/sim-builder.html (CSS) + frontend/js/sim-builder.js (логика). _sim_engine.js/js/api.js/lab.* НЕ тронуты — билдер только генерит спеку, которую движок P2/P3 уже рисует.

  • Контролы стиля = data-driven хелперы (рядом с field/miniField): colorCtl(label,attr,val,clearable) (нативный <input type=color> + текст + опц.очистка), rangeCtl (слайдер 0..1 для opacity), selectCtl (lineStyle/pointStyle/marker). Блок «Стиль» в каждом объекте — Builder.styleBlock(o), набор полей решает STYLE_FOR[type] ({opacity,line,point,glow,grad}).
  • Цвет: текст — источник истины, не нативный пикер. Нативный <input type=color> умеет только #rrggbb; rgba()/named он бы потерял. Поэтому пикер лишь ПИШЕТ в текстовое поле и диспатчит input (его ловит основной data-of/data-cvf-обработчик). Builder.wireColorControls(row) связывает пикер↔текст↔очистку. toHexColor(v) приводит #rgb/#rrggbb→#rrggbb (иначе #000000) для нативного пикера. Очистка (fill/trailColor) = пустая строка → stripObj выбрасывает → «нет заливки».
  • Round-trip как чинили range в Ф4: дефолты НЕ сериализуем. stripObj.isDefaultStyle(k,v) выбрасывает hidden, glow:false, lineStyle:'solid', pointStyle:'filled', opacity:1, trail/closed:false. Спека минимальна, а save→load→save идемпотентен (loadFromSim восстанавливает дефолты из контролов). Селекты хранят дефолтную строку в st, но она не уходит в спеку. Проверено vm-смоуком.
  • Plot теперь — кривые. UI-модель plot = {var,range_a/b,samples,trace,legend,plotFill,plotMarker,curves:[{_uid,expr,color,label,width,lineStyle,opacity,fill(bool),fillColor,marker}]}. plotEditor+curveEditor рисуют, loadPlot (spec→UI: curves[]exprs[]→легаси expr; легаси plot-level width/lineStyle/opacity наследуются кривой), normalizePlotForSpec+stripCurve (UI→spec). Одиночная «простая» кривая (только expr+color, без plot-fill/marker) → легаси {expr,color}, иначе curves:[...] — не ломает обратную совместимость. legend:false эмитится только при выкл (движок включает легенду авто при label). Валидация: каждая кривая + границы range через SimExpr.compile.
  • z-order / видимость / дублирование — чисто в билдере (движок не трогали): z-order = порядок массива st.objects/st.plots (кнопки вверх/вниз свапают, крайние disabled). Видимость hidden:true — билдерский флаг, buildSpec фильтрует hidden из спеки (движок про hidden не знает). Дублирование — JSON.parse(JSON.stringify(o)) + новый _uid + id+'_copy', вставка после оригинала. Аналогично у plot.
  • Новые ICON (inline SVG .ic, без эмодзи): up/down/copy/eye/eyeOff/clearX. Новые CSS-классы в ls.css-стиле; заголовок объекта flex-wrap + 26px-кнопки; медиа ≤920px (была) + новый ≤560px (поля/стили в один столбец).
  • Верификация P4: node --check sim-builder.js + извлечённого инлайна html — OK; эмодзи/eval/new Function — 0 (скан кодпойнтов обоих файлов); headless vm-смоук (DOM/SimExpr-стаб) 27+12+2 PASS: стили объекта в спеке, round-trip объектов ×2 идемпотентен, plot с 2 кривыми (все поля) + round-trip ×2, легаси-одиночная→легаси-форма, hidden исключён, z-order=порядок, дефолты-стрип, шаблонные легаси-plot save→load→save стабильны. Temp удалены. git status: только sim-builder.html и sim-builder.js.
  • На P5 (прямое манипулирование + история): drag сейчас только x/y point/circle/label/readout/rect + конец segment/vector (bindPreviewDrag через inst._toWorld). Расширять до всех типов + snap-к-сетке + выравнивание (нужны хит-тесты/ручки в _sim_engine.js). Undo/redo: this.st сериализуем JSON → стек снапшотов в Builder, restore + renderPanels/scheduleRemount.

SimForge improvements — P5 (Прямое манипулирование + история) — ФИНАЛ раунда — Learnings

Всё в frontend/js/sim-builder.js. _sim_engine.js НЕ тронут — вопреки прогнозу IMPROVEMENTS, хук в движке не понадобился: _toWorld/_toPx/_niceStep(targetPx) уже публичны на инстансе, их хватает для хит-теста/перевода координат/шага сетки прямо из билдера.

  • Ручки вместо «drag только x/y» (bindPreviewDrag переписан). handlesOf(obj) строит список ручек {label, blocked, wx, wy, set(x,y)} по типу: point/circle/label/readout/rect → одна ручка (x,y); segment/vector → origin(x1,y1) + end (x2,y2 ИЛИ, если у объекта dx/dy без x2/y2 — origin+dx/dy: ручка пишет dx=x-x1, dy=y-y1); polyline/path → по ручке на каждую числовую вершину points (её set ре-парсит JSON-строку и пишет свой индекс). pickHandle — ближайшая незаблокированная ручка в 14px (через _toPx). pointerdown-режимы: handle (драг ручки), place (единств. ручка — клик СТАВИТ точку, сохранён исходный смысл), body (несколько ручек — относительный сдвиг всех от стартовой мир-точки), none.
  • Выражения не затираются. numField(obj,key) → число, либо null если значение — строка-выражение (не парсится как число) → ручка blocked (не двигается; молча в спеку не пишется). refreshObjFields расширен на x1/y1/x2/y2/dx/dy/points.
  • Snap-к-сетке = шаг движка. Тумблер в тулбаре (_snap, toggleSnap, ICON.grid; активность — инлайн SNAP_ACTIVE_CSS, без зависимости от CSS-класса). При вкл координаты округляются к inst._niceStep(34) (минорный шаг видимой сетки; fallback 0.5), при выкл — round2. Выравнивание к чужим координатам/осям не делалось (бонус; snap достаточно — частично).
  • Undo/Redo без библиотек. Снапшот = JSON.stringify(this.st) (this.st уже сериализуемо). pushHistory снимает ДО мутации (без дублей верхушки; чистит redo; глубина _undoMax=50). Гранулярность правки поля: snapField снимает ОДИН снапшот на сессию (флаг _fieldSnapTaken сбрасывается на focusin поля; первый input/change снимает) → Ctrl+Z откатывает значение целиком, не посимвольно. Структурные операции (add/del/z-order/dup/hide/тумблеры — объекты/plot/curve/wall/spring/физика) — снапшот сразу. Drag — один на сессию (pushHistory в pointerdown; no-op-снапшот без изменений откатывается в end()). Кнопки undo/redo (SVG .ic) + клавиши Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (bindKeyboardShortcuts на document, вешается один раз, игнорит фокус в INPUT/TEXTAREA/SELECT). loadFromSim обнуляет историю; _restoreSnapshotrenderPanels+scheduleRemount (гочи: захватить this._selObjId в локальную переменную — иначе this теряется в колбэке .some()).
  • Верификация P5: node --check OK; эмодзи/eval/new Function — 0 (скан кодпойнтов); headless vm-смоук (DOM/SimExpr/SimEngine-стаб с линейным _toPx/_toWorld) 38/38 PASS: drag point/circle, оба конца segment, vector origin+dx/dy, вершина polyline, body-move polyline и segment, snap к 0.5, выражение-поле не затирается, undo/redo drag и onAdd, лимит стека, round-trip buildSpec идемпотентен ×2, no-op-drag не плодит историю. Temp удалён. git status: тронут только sim-builder.js (_sim_engine.js в статусе — чужой коммит «goal/game» параллельной сессии, мной не редактировался).

Feature: Квантик — Законы Мира (игра)

2D физика-головоломка поверх SimForge. План: plans/quantik-game/. Уровень = спека SimForge + блок goal.

Phase 0 — Learnings (Слой целей в движке)

  • «Атом» игры = верхнеуровневый блок goal в спеке (формат — в шапке _sim_engine.js): goal:{ when, title?, hint?, hold?:0, fail?, stars?:[{when,label?}] } (звёзд ≤3). Аддитивно: нет goal_goal=null, HUD не создаётся, в rAF ветка if(self._goal) пропускается → поведение спеки без goal не меняется (нет накладных вычислений побед, нет DOM-узлов).
  • Компиляция один раз через SimExpr.compile(src).fn (как все выражения движка; кривое выражение → fn возвращает 0, не бросает). Истинность булева = _truthy (модульный хелпер): конечное ненулевое число. Без eval/Function.
  • Env цели = весь env кадра + ЕДИНСТВЕННЫЙ доп.идентификатор tries (= attempts). Не вводить других новых идентификаторов — контракт безопасности шаренных выражений. env.tries ставится и в _evalGoal (rAF), и в _renderFrame (star-accumulation на паузе/предпросмотре) для консистентности.
  • Оценка в rAF-кадре: _evalGoal(self._buildEnv(), dt) ПОСЛЕ _stepPhysics, ДО _renderFrame. Порядок: накопить звёзды (залипают до reset) → fail (мягкий проигрыш, приоритет, НЕ победа) → when с учётом hold (таймер _goalHoldT копит мировые секунды; условие пропало → сброс таймера). Победа → timeMs = max(1, round(t*1000)) (мировое t, детерминизм), won=true, pause(), _fireGoal() (onGoal один раз).
  • onGoal не задваивается: победа делает pause() внутри кадра; уже-заквигованный следующий rAF выходит по if(!self._running) return. Повторный play() после победы не перезапускает (уже won, paused).
  • attempts: инкрементится только на пользовательском reset() (флаг _goalInited — первый авто-reset при mount НЕ считается). resetResult() сбрасывает результат, но attempts сохраняет (НЕ попытка).
  • HUD = DOM-оверлей (НЕ canvas), стиль _readoutBadgeCss (тёмная плашка). Контейнеры pointer-events:none (не крадёт pan/drag), кнопка «Ещё раз» — pointer-events:autoinst.reset(). Звёзды — inline SVG (_starIcon: заполненная #FBBF24 / контур), без эмодзи. destroy() снимает click-слушатель кнопки + removeChild HUD-узлов (баланс add/remove; узлы и так внутри inst.el, который удаляется — belt-and-suspenders).
  • Публичное API инстанса: onGoal(cb) (chainable), getResult(){won,failed,timeMs,attempts,stars:{got,total}} (без goal → null), resetResult(). Полный перезапуск уровня = reset() (физика+время+attempts++).
  • Сервер customSimController.validateSpec: goal (объект) + game (резерв Ф1/5) разрешены на верхнем уровне. when/fail/stars[].whencheckExpr (длина ≤500, НЕ исполняются); title/hint/stars[].labelsanitizeText (escape & < > + обрезка); stars>3 → 400; hold не-число → 400. cat='game' уже в CATS. Санитизированный goal/game пишется в clean.
  • Верификация P0: node --check обоих файлов OK; headless vm-смоук (ручной DOM/canvas-стаб + РЕАЛЬНЫЕ _sim_expr.js+_sim_engine.js, rAF-очередь степается вручную, performance.now() = виртуальные часы) 40/40 PASS: when→win+timeMs>0, звёзды копятся+залипают+сброс на reset, fail без won, hold требует удержания + сброс при лапсе, спека без goal без HUD/без throw, onGoal ровно 1 раз, destroy баланс add/remove, серверный validateSpec (escape/>3 звезды/длина/hold/без-goal). npm test 238 pass / 8 baseline fail; lint:routes 0. Temp удалён. Эмодзи/eval/new Function — 0 (new Function только в пре-существующем комментарии стр.15).
  • На Phase 1: использовать onGoal/getResult/resetResult; HUD включается сам наличием goal. Уровни хранятся в custom_sims (cat='game'). game{}-блок зарезервирован под мета (узел карты/мир/XP).

Phase 1 — Learnings (Оболочка игры + 1 уровень + прогресс)

  • Сквозной MVP-срез играбелен. Страница /quantik (frontend/quantik.html + frontend/js/game/quantik-game.js): QuantikGame.start({host, level})SimEngine.mount(host, level.spec)inst. «Игровой режим» НЕ требует флага — HUD из Ф0 появляется сам по наличию goal в спеке; управление = собственные слайдеры params движка + play/reset (внутри inst.el). Победа: inst.onGoal(res => { LS.gameProgressSubmit(level.id, {time_ms:res.timeMs, stars:res.stars.got}); showSuccess(res); }).
  • Уровни = ДАННЫЕ, встроенные (MVP). frontend/js/game/levels.jswindow.QuantikLevels.{list,get,LEVELS}. Запись { id, title, subject?, hint?, spec }, id==level_id. Один уровень phys-artillery-1: physics-гравитация + body-запуск (point с body.vx='v*cos(theta*pi/180)', vy='v*sin(...)'), портал-цель (goal.when:'hypot(ball.x-PX,ball.y-PY)<R'), бонус-звезда (stars[].when), fail при промахе за поле. Подобран ПРОХОДИМЫМ в пределах слайдеров (θ 10..80°, v 5..20 м/с; портал x=8, дальность v²·sin2θ/g ≈ 6..10 м). custom_sims cat='game' остаётся для авторённых уровней (Ф5) — реестр тогда станет асинхронным со слиянием.
  • API прогресса: таблица game_progress (мигр.076, UNIQUE(user_id,level_id), user_id ON DELETE CASCADE), контроллер gameController.js + роутер routes/game.js (router.use(authMiddleware) → lint:routes 0), смонтировано в server.js после /api/custom-sims. GET /api/game/progress{progress:[…]}; POST {level_id,time_ms,stars} → upsert best (min time / max stars) + attempts++. Валидация: level_id строка ≤120, time_ms/stars неотрицательные ЦЕЛЫЕ (Number.isInteger, отвергает дробь/NaN/∞), stars 0..3. Прогресс всегда req.user — нет межпользовательских роутов, ownership-проверка не нужна. Клиент LS.gameProgressList()/LS.gameProgressSubmit(levelId,{time_ms,stars}) (стиль customSim*-врапперов в js/api.js).
  • Маршрутизация без правок server.js: /quantikquantik.html через express.static(frontendDir,{extensions:['html']}) (как все clean URL). /js/game/* и /js/labs/* отдаются тем же static (гоча /js→корневой js/ касается только api.js/sidebar.js, не подпапок). Подключение движка — копия sim-builder.html: /js/labs/_sim_expr.js + /js/labs/_sim_engine.js.
  • Экран успеха = DOM-оверлей страницы .qg-overlay (НЕ HUD движка), QuantikGame.buildSuccessOverlay(state) строит карточку: звёзды inline SVG (заполн./контур, без эмодзи), время/звёзды/попытки, «Ещё раз» (убрать оверлей + inst.reset()) / «Дальше» (disabled-заглушка MVP — Ф2 активирует). CSS .qg-* в <style> quantik.html. Кнопки — классы btn-primary/btn-ghost (НЕ ls-btn-* — таких в ls.css нет).
  • Сайдбар: /quantik (icon rocket) в группе practice ПЕРЕД /sim-builder, БЕЗ hidden (видно ученикам — это игра, в отличие от teacher-only sim-builder). isActive('/quantik') подсвечивает на clean URL.
  • Доступ страницы: LS.initPage() (без {requireLogin:false}) сам редиректит на /login если не авторизован и возвращает null → бутстрап выходит. Любой авторизованный играет.
  • Верификация P1: node --check всех новых/изменённых JS — OK; npm run migrate 076 применяется чисто; npm test 251 pass / 8 baseline fail (3 auth + 5 jsdom page-тестов — пре-существующие; game.test.js 13/13 PASS); lint:routes 247 :id-роутов, 0 unprotected (baseline 0). Эмодзи в коде нет (флагуются только / в комментариях — конвенция проекта); eval/new Function — 0. Спека без goal по-прежнему работает (Ф0 не задет).
  • На Phase 2 (карта/мир/XP): реестр уровней расширяемый (добавить запись в LEVELS); game_progress-API готов; экран успеха buildSuccessOverlay переиспользуем (расширить «следующим уровнем», активировать «Дальше»); при смене уровня без перезагрузки — inst.destroy() перед новым mount.

Phase 2 — Learnings (Карта-созвездие + мир физ-уровней + XP/скины)

  • Phase 2 = FRONTEND-ONLY (осознанное решение): XP/уровень игрока агрегируются на КЛИЕНТЕ из game_progress (Ф1), скин — localStorage. Без новых таблиц/роутов/миграций → lint:routes baseline 0 не тронут, npm test ровно как в Ф1 (259 tests / 251 pass / 8 baseline fail). Перенос XP на сервер позже тривиален — те же чистые функции progress-logic.js.
  • Чистая логика в отдельном модуле frontend/js/game/progress-logic.js (window.QuantikProgress, без DOM/сети/eval — тестируемо в изоляции): isUnlocked(level,map,levels) (Σ звёзд во всех уровнях с меньшим orderlevel.unlockStars; порог в ДАННЫХ уровня), computeXp(звёзды·100+40/пройден), playerLevel(xp) (квадратичная шкала xpForLevel(L)=240·(L-1)L/2), groupByChapter, nextPlayable, fromProgressList, starsFor/starsToUnlock/nodeStatus. Гоча тестов: assert.deepEqual через vm-границу сравнивает массивы РАЗНЫХ реалмов (прототипы ≠) → ложный fail; сравнивать через JSON.stringify.
  • Карта frontend/js/game/map.js (window.QuantikMap.create({host,headerHost,onPlay,getSkin,onSkin})->{render(progressMap),destroy()}): созвездия по главам (groupByChapter), узлы — <button class="qm-node qm-{locked|available|completed}">, позиция в % через layoutNodes (зигзаг-дуга), статус из nodeStatus. Звёздное небо — SVG <circle class="qm-tw"> (CSS-мерцание, seeded mulberry32), линии-связи <line>. Поэтапное появление — staggerReveal (.qm-pre.qm-in, setTimeout 70 мс). Тип спеки уровня карте безразличен — читает только метаданные → Ф3 граф-уровни = НОВАЯ глава без правок map.js.
  • Метаданные уровня (Ф2): { id, title, chapter, order, unlockStars?, par_ms?, hint, spec }. Главы — QuantikLevels.CHAPTERS ({key,title,subtitle,accent}). 6 уровней: Кинематика (артиллерия/перелёт-через-стену/отскок-от-стены) + Динамика (маятник-на-пружине/орбита-в-колодце/гравиманёвр). По 2 звезды: кристалл (stars[0]) + норматив времени t*1000<=par_ms (stars[1] — par-звезда выражается через мировое t, идентификатор tries для неё НЕ нужен).
  • Физика «силовых» уровней через ПРУЖИНУ (движок не имеет central-gravity): маятник — пружина к якорю-точке с короткой length (растянута → сильный возврат) + горизонтальный толчок; орбита — пружина к центру с length:0 (== гармонический осциллятор F=-k·r == эллиптические орбиты); гравиманёвр — гравитация вниз + пружина-«колодец» к центру. k/толчок/сила = params-слайдеры.
  • Скин: тинт без исполнения. tintHeroSpec(spec,key) — глубокая JSON-копия спеки (данные!), переписывает color/glowColor/trailColor объекта id:'ball' цветом из PetSprite.PALETTES[key]. localStorage ключ quantik-skin (валидируется при чтении). Скин тинтует и героя, и нарратора (PetSprite.render(...,colorKey,...)). Гейты — массив SKIN_GATES (needStars/needXp).
  • Нарратор = PetSprite.render(level,mood,[],skin,0,'none') на карте-шапке (mood по уровню игрока), интро (buildIntro, happy) и успехе (buildSuccessOverlay, ecstatic при всех звёздах≥2 / happy при ≥1). quantik.html грузит /js/pet-sprite.js (как dashboard/pet).
  • Навигация (inline-bootstrap quantik.html): 2 вида #qg-map-view/#qg-level-view (класс .show). showMap перезагружает прогресс (LS.gameProgressList) → map.render. openLevel→интро→launchLevel→onGoal→успех→onNext(nextPlayable)|onMap. Смена уровня ВСЕГДА через destroyLevel() (=inst.destroy()) до нового mount (гоча Ф1). Deep-link ?level= открывает только разблокированный.
  • Per-level winnability обязательна (как Ф1): harness грузит РЕАЛЬНЫЕ _sim_expr.js+_sim_engine.js в vm, свипует слайдеры через движок, проверяет getResult().won. Гоча OOM: переиспользовать ОДИН inst через reset() по сотням комбо ТЕЧЁТ (накопление через goal-state/bodyById-замыкания) → mount+destroy() СВЕЖИЙ inst на каждое комбо (leak-proof). Headless _renderFrame рано выходит при _cw/_ch==0 (рендер не нужен, физика/_evalGoal идут в play-кадре независимо); для point-радиуса в физике выставить inst._scale. Виртуальные часы синхронны с performance.now()/rAF-timestamp. Результат: ВСЕ 6 winnable, у всех достижимы обе звезды (combos: artillery 28/196, arc 5/196, bounce 92/343, pendulum 189/196, orbit 94/196, gravimanёvr 170/343).
  • Верификация P2: node --check всех новых/изменённых JS + inline-<script> quantik.html — OK; смоуки (логика 16/16, рендер карты/оверлеев 7/7, winnability 6/6) зелёные и удалены; npm test 259/251 pass/8 baseline fail (без новых падений); lint:routes baseline 0. Эмодзи//eval/new Function — 0 (звёзды UI — inline SVG; в комментариях заменён на «зв.»).

Phase 3 — Learnings (Граф-уровни: движение по f(x) + зоны)

  • «Бегунок по кривой» — поле runner на plot, НЕ новый тип объекта. plot.runner:{duration?:8, hold?:true} превращает ПЕРВУЮ кривую plot в дорожку. Движок в _buildEnv (ДО формульных центров, после физ-тел) кладёт <plotId>.runX (= a+(ba)·clamp(t/duration,0,1) по range кривой), <plotId>.runY (= f(runX) ТОЙ ЖЕ скомпил. cv.exprFn, что рисует кривую → видимая кривая и путь героя идентичны), <plotId>.runDone (1 при t≥duration). Само-ссылку снимает разделение: герой = ОБЫЧНЫЙ point с x:'curve.runX', y:'curve.runY' (glow+trail, визуал P2), а f компилируется один раз и питает И кривую, И бегунок — точка НЕ ссылается на собственный x в одном проходе env. hold:true оставляет бегунок на конце (иначе зацикливание по time.loop). Кинематический проход (без физики) — герой не тело.
  • Зоны — type:'zone' + булево env-поле <zoneId>.hit, БЕЗ предикатов в грамматике. {type:'zone', id, shape:'rect'|'circle', kind:'forbidden'|'target'|'collect', track?:'ball', x,y,w,h|r, color?, label?}. Движок считает <zoneId>.hit (1/0) в _buildEnv последним (нужна актуальная позиция героя из тела/формулы) через _zoneHit(z,env) (геометрия в мире). goal.when/fail/stars[].when ссылаются на поле (when:'gate.hit', fail:'pit.hit'). Никаких inzone(...) в синтаксис SimExpr — контракт выражений закрыт, добавляются только именованные env-поля (та же модель безопасности, что t/tries из Ф0). Рисует _drawZone (forbidden=красный пунктир, target=зелёный, collect=золотой пунктир) — цвета ТОЛЬКО в canvas-стоки (fillStyle/strokeStyle), XSS нет. Зона НЕ кладёт <id>.x/.y как центр (hasCenter пропущен для type==='zone' — это область, не точка).
  • ГОЧА имён param (повтор Ф4 SimForge, укусила здесь): t/w/h/pi/e/E/PI/tau зарезервированы движком. _buildEnv ставит env.h = ymaxymin (высота вьюпорта) и env.w — поэтому param с именем h (планировался под вершину модуля a·|xh|+1) затирался: abs(xh) видел h=10 (высота), а не значение слайдера → 0 решающих комбинаций. Фикс — переименовать в m. При добавлении граф-уровней проверять имена коэффициентов против этого списка. (Сетка-смоук solvability ловит такую ошибку как «0 wins» — обязательна.)
  • Контент: глава functions (5 уровней) через хелперы-данные. road(exprStr,a,b,dur) (plot+runner, id 'curve'), graphHero() (point ball на curve.runX/runY), rectZone/circZone(id,kind,...), startMarker. Уровни: луч a·x+b, синус A·sin(k·x), парабола a·(x5)²+k, модуль a·|xm|+1, экспонента c·e^(r·x). time:{duration,loop:false} синхронизирован с runner.duration. Управление = обычные params-слайдеры коэффициентов (крутишь → кривая+путь перестраиваются live); свободный ввод выражения не понадобился. Звёзды: collect-зона + доп. условие формы кривой (sticky через механизм stars Ф0).
  • Карта/запуск без правок map.js (подтверждён хэндофф Ф2): глава functions в CHAPTERS (key/title/subtitle/accent) — узлы рисуются по метаданным, тип спеки карте безразличен. unlockStars 9/11/13/15/17 ≤ 18 (макс звёзд 6 физ-уровней) → нет дедлока (даже только физ-главы дают 18 ≥ 17). QuantikGame.startSimEngine.mount тот же; спец-вайринг управления НЕ нужен (те же слайдеры). tintHeroSpec тинтует point-героя на curve.runX/runY штатно. quantik.html: бейдж темы стал per-level (level.subject→Физика/Алгебра) — аддитивно, id qg-pill.
  • Сервер validateSpec (customSimController.js): zone в OBJECT_TYPES + поля. zone.track санитизируется как id; plot.runner.duration — checkExpr (длина). Готовит авторённые граф-уровни Ф5. x/y/w/h/r зон проходят общий expr-loop. Тест custom-sims.test.js +2 (приём zone+runner спеки; отказ unknown type при разрешённой zone) → 26/26.
  • Верификация Ф3: node --check всех изменённых JS + inline-<script> quantik.html — OK; headless vm-смоук (РЕАЛЬНЫЕ _sim_expr.js+_sim_engine.js+levels.js, DOM/canvas-стаб + виртуальные часы): per-level solvability (сетка коэффициентов 625 комбо/уровень) — line 59/625, sine 290/625, parab 88/625, abs 231/625, exp 36/625, у КАЖДОГО найден full-star комбо; logic — правильная f→победа без forbidden, плоская f→fail (зашёл в forbidden), zone.hit флипается по позиции, runX/runY/runDone корректны, регресс всех типов + физики без throw, ctx сбалансирован → 29/29. E2E QuantikGame.start→onGoal на graph-line-7 → won 2/2. Смоуки удалены. npm test 261/253 pass / 8 baseline fail (без новых); lint:routes 0. Эмодзи/eval/new Function — 0 (только пре-существующие →/ в комментариях; зоны/звёзды — canvas/inline SVG).

Phase 4 — Learnings (Квантовые способности + SR-комнаты)

  • Все три способности — через БЕЗОПАСНУЮ модель спеки, движок НЕ тронут (engine touch = 0). План допускал поле tunnelable у стены в _sim_engine.js, но фактически не понадобилось: туннелирование = forbidden-зона wall + fail:'wall.hit && tunnel<1', где tunnel — обычный param (не слайдер). По умолчанию tunnel отсутствует в env → SimExpr трактует неизвестный идентификатор как 0 → tunnel<1 истинно → стена сплошная. Способность зовёт inst.setParam('tunnel',1)_buildEnv спредит ВСЕ this.params в env (стр.1193) → fail видит tunnel=1 → стена проницаема. Суперпозиция = чистый контент (2 тела ball+ball2, goal.when с обоими). Прицел = пауза-тоггл (inst.pause/play) над пунктир-plot. Ни новой грамматики SimExpr, ни новых типов объектов, ни правок движка.
  • setParam для НЕ-слайдер-параметра работает штатно: ставит this.params[name], слайдера нет → на паузе ре-рендерит. Значение переживает кадр (спредится в env). НО reset физики НЕ трогает tunnel (он не нач.условие тела) — поэтому tunnel надо ставить ПОСЛЕ reset() (в харнессе и в resetAbilities). tunnelUsed-флаг + сброс tunnel→0 на новую попытку/mount → заряд тратится один раз за попытку.
  • Энергия — клиентский ресурс, чистая логика (window.QuantikEnergy). localStorage ключ quantik-energy (целое 0..99). getEnergy/setEnergy/grantEnergy/spendEnergy/canSpend/rewardForQuality/onEnergyChange. TUNNEL_COST=3; награда rewardForQuality: q=5(Легко)→2, q=4(Знаю)→1, иначе 0 (та же шкала, что flashcards.html). spendEnergy атомарен (не хватило → false, без списания). onEnergyChange-подписки обновляют HUD без перезагрузки (панель подписывается в mountBar, отписывается в destroy — без утечки).
  • SR-комната = РЕЮЗ серверного SR, НЕ iframe и НЕ дубль расписания. QuantikAbilities.openRestRoom — своя модалка в стиле игры: LS.fcListDecks() → авто-выбор колоды с макс. due_count (одна → сразу учить; несколько → пикер) → LS.fcStudySession(deckId) (отдаёт {cards,total_due}) → лицо→Показать ответ→оценки (Снова0/Трудно3/Знаю4/Легко5) → LS.fcReview(cardId,quality) (отдаёт {ok,graduated,...}; graduated=false → re-queue в пределах сессии через RQ_GAP, как flashcards.html). «Знаю/Легко» начисляют энергию ОПТИМИСТИЧНО (до ответа сети). Пусто (нет колод / нет due / SR недоступен) → дружелюбное окно + ссылка /flashcards. Картинка карты — только свой /uploads/flashcards/... (regex-гейт), текст escape.
  • Клиентские врапера SR в js/api.js: fcStudySession(deckId) = GET /flashcards/decks/${id}/study, fcReview(cardId,quality) = POST /flashcards/cards/${id}/review {quality} — стиль блока fcListDecks/fcCreateDeck/fcAddCard. Контроллер flashcardController.getStudySession/submitReview уже существовал (Tier-1 SR, мигр.074) — бэкенд не трогался, lint:routes/тесты неизменны.
  • tintHeroSpec (quantik-game.js) тинтует ball И ball2: ball — цвет скина, ball2 — осветлённый «фантом» (lighten(color,0.42), hex→белый). Авторские id ВНЕ ball/ball2 скином не тинтуются (Phase 5 при желании расширит список). Панель способностей оборачивает inst.destroy (снимает бар) — аддитивно, без правки lifecycle движка.
  • Глава quantum (L12–L16) появляется на карте без правок map.js (контракт Ф2 подтверждён 3-й раз): groupByChapter+Levels.chapter метадата-driven. CHAPTERS.quantum (accent #C4B5FD). unlockStars 19/20/22/24/26 ≤ кумулятив макс-звёзд всех уровней меньшего order (по 3 звезды/уровень: 18 физ + 15 граф = 33 до L12 ≥ 19) → нет дедлока (проверено цепочкой). isUnlocked считает звёзды по ВСЕМ уровням с меньшим глобальным order, не по главе.
  • Активация способностей — по СОДЕРЖИМОМУ спеки, не по флагу уровня: levelHasTunnel(level) = слово tunnel в goal.fail/when/stars[].when; levelHasAim(level) = на сцене plot с id:'aim' ИЛИ lineStyle:'dashed'. Кнопка появляется только если уместна. Контракт для авторского UI Ф5.
  • ГОЧА харнесса solvability (физ-уровни): mount планирует ОТЛОЖЕННЫЙ rAF, который делает _fit+reset(+autoplay). Если не «слить» его ДО своего play(), он выстрелит в середине прогона, вызовет reset→pause→cancelAnimationFrame и убьёт кадровый цикл (тело стоит на старте, t=0, 0 wins у ЗАВЕДОМО решаемого уровня). Фикс: после mount слить отложенный callback БЕЗ продвижения часов, затем pause(), конфиг params, reset(), play(), гнать кадры с виртуальными часами (8.33мс/кадр, performance.now синхронен с таймстампом rAF). Headless-смоук физики обязан гнать РЕАЛЬНУЮ физику (SimPhysics экспортится из _sim_engine.js).
  • Контент-фикс L16 (поймал sweep): монета (5,6) r0.7 у параболы a·(x5)²+k (вершина в (5,k)) собиралась при 5.3<k<6.7, а 2-я звезда требует k≥6.8взаимоисключающие → full-star недостижим. Сдвинул монету на (5,6.9) r0.85 → пересечение с k≥6.8 есть → full-star достижим (a-0.25/k7.2). Урок: проверять full-star reachability sweep'ом, а не только «есть ли победа».
  • Верификация Ф4: node --check всех изменённых JS + inline quantik.html — OK; headless vm-смоук (РЕАЛЬНЫЕ _sim_expr+_sim_engine+levels+progress-logic+quantik-abilities, DOM/canvas-стаб + виртуальный rAF-клок): энергия grant/spend/reward/clamp/notify; суперпозиция-when требует ОБА тела; tunnel флипает fail (вкл. absent→0); per-level solvability (L12 52 win, L13/L14/L15/L16 ≥3 win + full-star у всех 5; L15/L16 БЕЗ tunnel = 0 win → гейт работает); регресс 11 существующих уровней mount+step без throw → 48/48, удалён. npm test 261/253 pass / 8 baseline fail (без новых); lint:routes 0. Эмодзи/eval/new Function в UI — 0 ( U+26D4 — только в комментариях, пре-существующая конвенция всего кодбейза; способности — inline SVG .ic).

Phase 5 — Learnings (Авторинг уровней в sim-builder + раздача классу)

  • Бэкенд почти не понадобился — Ф0/Ф3/Ф6 уже всё дали. validateSpec уже пропускал goal/game (Ф0), CATS уже содержал 'game', share/clone/links/per-row-ownership/GET /:id (own|published|admin) — Ф6. Единственная серверная правка: в share() для cat==='game' переключить ссылку на /quantik?level=custom:<id> + тип game_level_shared (иначе /lab?sim=…+sim_shared); ответ дополнен link. Доступ к чужому draft (deep-link/embed-утечка) закрыт ТЕМ ЖЕ GET /:id 403 — отдельной защиты не потребовалось.
  • ⚠️ ПАРАЛЛЕЛЬНАЯ СЕССИЯ на ветке правит sim-builder.js/.html → все правки строго АДДИТИВНЫЕ. В sim-builder.js тронуто минимум существующих строк: по 1 врезке в blankState(+блок game), loadFromSim(+st.game=loadGame(...)), buildSpec(+материализация при st.game.enabled), renderPanels(+sectionGame()), validate(+проверка goal-выражений), wirePanels(+блок game-листенеров перед renderLatexPreviews), onAdd(+ветка 'star'), _open(+game:false). НОВЫЕ методы/функции: sectionGame, playGame, модульные loadGame/buildGoal/buildGameMeta. HTML — только +CSS-блок .sbu-game-fields/.sbu-star/.sbu-star-hdr/.sbu-stars-list. Никаких переформатирований/перестановок — минимизирует merge-конфликты.
  • Игровой слой ⇄ UI = st.game = { enabled, when,title,hint,hold,fail, stars:[{when,label}], chapter,order,par_ms }. Хранит «как введено» (строки/числа), как plot-range в Ф4. buildGoal/buildGameMeta материализуют → spec.goal/spec.game (числа коэрсятся: hold/order/par_ms; пустые поля выкидываются; звёзды clamp ≤3). loadGame(spec.goal,spec.game) включает слой, если присутствует goal ИЛИ game. Выключенный enabled → goal/game НЕ эмитятся → обычная симуляция ведёт себя ровно как раньше. Round-trip buildSpec→loadFromSim→buildSpecdeepEqual goal+game (доказано смоуком).
  • «Играть» = монтировать SimEngine в модалке, НЕ открывать /quantik. На странице sim-builder уже загружены _sim_expr+_sim_engine; HUD/победа/звёзды активируются САМИ наличием блока goal (Ф0 движка) — QuantikGame не нужен, доп. скрипт-тегов нет. Тестирует ЧЕРНОВИК без сохранения/сети. Инстанс уничтожается на закрытии модалки (кнопка + m.onClose, если поддерживается). Если goal.when пуст — тост-подсказка, модалку не открываем.
  • QuantikLevels стал асинхронным (контракт Ф1 исполнен). ensureCustom() (Promise, кэш _customPromise): LS.customSimsList() → фильтр cat==='game' (список БЕЗ spec) → LS.customSimGet(id) каждой → customToLevel(row). list()=LEVELS.concat(CUSTOM), get(id) ищет в обоих. getAsync(id) для deep-link: в кэше → синхронно; иначе custom:<dbid>LS.customSimGet(dbid) (сервер-доступ own|published|admin), резолвнутый уровень подмешивается в CUSTOM (повторное открытие/«Дальше» синхронны). Встроенные уровни — offline, как раньше.
  • Запись авторённого уровня (customToLevel): { id:'custom:<dbid>', dbid, title, chapter:(game.chapter||'custom'), order:(game.order|| 1000+dbid), unlockStars:(game.unlockStars||0), par_ms, subject, hint:(goal.hint), spec, _custom:true }. Без goalnull (не уровень). Глава по умолчанию custom (новая CHAPTERS.custom, accent #F472B6) — map.js рисует автоматически (метадата-driven, не тронут, контракт Ф2 подтверждён в 4-й раз). order дефолт 1000+dbid ставит custom-уровни ПОСЛЕ встроенных в сортировке.
  • Deep-link ?level=custom:<id> открывается БЕЗ гейта unlockStars (получатель ссылки/автор заходит прямо в уровень); встроенный ?level=<id> — через isUnlocked как раньше. quantik.html: Promise.all([loadProgress(), ensureCustom()]) до первого map.render, deep-link через getAsync. Прогресс по custom-уровням: gameProgressSubmit('custom:<dbid>',…)game_progress.level_id TEXT≤120, двоеточие проходит, бэкенд НЕ менялся.
  • Верификация Ф5: node --check всех изменённых JS + inline обоих HTML — OK; headless vm-смоук (РЕАЛЬНЫЕ _sim_expr+sim-builder+levels, DOM-стаб) 7/7: blank без goal/game; материализация goal+game; round-trip deepEqual; non-game sim не включает слой; validate ловит пустой/битый when; customToLevel маппинг + дефолты + null-для-non-game — удалён. Бэкенд-тест tests/quantik-authoring.test.js 6/6 (создание game-уровня, чужой draft→403, published виден, share→game_level_shared+/quantik-ссылка+авто-публикация, >3 звезды→400). npm test 267/259 pass / 8 baseline fail (без новых); lint:routes 0. Эмодзи/eval/new Function — 0 (новый UI — inline SVG .ic, выражения — только SimExpr).