Files
Learn_System/CLAUDE.md
T
Maxim Dolgolyov 351251d652 @
feat(quantik-game): фаза 1 — оболочка игры + физ-уровень + прогресс (MVP)

Страница /quantik монтирует уровень-спеку в SimEngine (игровой режим: HUD из
Ф0 + слайдеры закона + play/reset), на победу шлёт результат и показывает
экран успеха (звёзды/время/попытки, inline SVG). Уровень phys-artillery-1
как данные (levels.js): гравитация + запуск тела из угла/скорости, портал,
бонус-звезда. Бэкенд: миграция 076 game_progress (UNIQUE user+level),
/api/game/progress (GET свой / POST upsert best time/stars, attempts++,
auth-only, валидация входа), клиент LS.gameProgress*, пункт сайдбара.
game.test.js 13/13; npm test 251 pass/8 baseline; lint:routes 0.
Уровень проверен на реальном интеграторе (311 выигрышных комбо, 31 на 3★).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-13 15:31:25 +03:00

63 KiB
Raw Blame History

BQ-System — правила для 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.