Бренд продукта = 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>
90 KiB
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/ localStoragelab-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_motionAPI не подходит для спек-движка напрямую: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_jsonTEXT(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). НЕ blanketrouter.use(requireRole)— иначе ученик не увидит published. - lint:routes (baseline 0):
:id-роуты прикрыты router-levelauthMiddleware(линтер видитrouter.use(<guard>)); readGET/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 хранит в UIrange_a/range_bотдельно и материализуетсяnormalizePlotForSpec→range:[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) vsdata-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, извлекает IIFELabCustomиз 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_accessallowlist'ом 'sim' — тот гейтит ТОЛЬКО legacylab_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-range→range_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-блок +_cardHtmlactions + делегат + 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 relayPOST /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(rawcustom:<dbid>), НЕ реестровый id. Обработчикapply_sim_stateберёт_simStateRegistry[_autoSim], а_autoSim— это сырой URL-paramcustom:<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)(зум к экранной точке — мир-точка под курсором инвариантна; кламп_zoom0.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 --checkOK; 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 нет. ⛔ В DOMstyle.cssTextпользовательские цвета НЕ кладутся (это_drawLabel/_drawReadout— НЕ трогались в P2). - Новые поля стиля спеки (контракт для P4-контролов):
opacity0..1;lineStylesolid|dashed|dotted;width(0 → у circle/rect только заливка);fill/fillColor;gradient:[c0,c1](приоритетнее fill, верт. по bbox, полигон — только приclosed);glow:true/shadow:'#c'/shadow:{blur}/glowColor/glowBlur(деф. ВЫКЛ);pointStylefilled|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 --checkOK; 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 (samples200/макс 2000),_applyStrokeдаёт dash/opacity/glow/round-стыки. Каждая кривая в своёмctx.save()/restore(), легенда — на внешнем уровне → состояние не протекает. - Новые хелперы модульного уровня (рядом с
_dashFor/_opacity):_markerStyle(v)(none|dot|ring),_fillAlpha(color,a)(hex→rgba для заливки). - Верификация P3:
node --checkOK; 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 --checksim-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обнуляет историю;_restoreSnapshot→renderPanels+scheduleRemount(гочи: захватитьthis._selObjIdв локальную переменную — иначеthisтеряется в колбэке.some()). - Верификация P5:
node --checkOK; эмодзи/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:auto→inst.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[].when→checkExpr(длина ≤500, НЕ исполняются);title/hint/stars[].label→sanitizeText(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 test238 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.js→window.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:
/quantik→quantik.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(iconrocket) в группе 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 migrate076 применяется чисто;npm test251 pass / 8 baseline fail (3 auth + 5 jsdom page-тестов — пре-существующие; game.test.js 13/13 PASS);lint:routes247 :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:routesbaseline 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)(Σ звёзд во всех уровнях с меньшимorder≥level.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-мерцание, seededmulberry32), линии-связи<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 test259/251 pass/8 baseline fail (без новых падений);lint:routesbaseline 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+(b−a)·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 = ymax−ymin(высота вьюпорта) иenv.w— поэтому param с именемh(планировался под вершину модуляa·|x−h|+1) затирался:abs(x−h)видел 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·(x−5)²+k, модульa·|x−m|+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) — узлы рисуются по метаданным, тип спеки карте безразличен.unlockStars9/11/13/15/17 ≤ 18 (макс звёзд 6 физ-уровней) → нет дедлока (даже только физ-главы дают 18 ≥ 17).QuantikGame.start→SimEngine.mountтот же; спец-вайринг управления НЕ нужен (те же слайдеры).tintHeroSpecтинтует point-героя наcurve.runX/runYштатно. quantik.html: бейдж темы стал per-level (level.subject→Физика/Алгебра) — аддитивно, idqg-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. E2EQuantikGame.start→onGoal на graph-line-7 → won 2/2. Смоуки удалены.npm test261/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).unlockStars19/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·(x−5)²+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 test261/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 /:id403 — отдельной защиты не потребовалось. - ⚠️ ПАРАЛЛЕЛЬНАЯ СЕССИЯ на ветке правит 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-tripbuildSpec→loadFromSim→buildSpec—deepEqualgoal+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 }. Безgoal→null(не уровень). Глава по умолчанию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_idTEXT≤120, двоеточие проходит, бэкенд НЕ менялся. - Верификация Ф5:
node --checkвсех изменённых JS + inline обоих HTML — OK; headless vm-смоук (РЕАЛЬНЫЕ_sim_expr+sim-builder+levels, DOM-стаб) 7/7: blank без goal/game; материализация goal+game; round-tripdeepEqual; non-game sim не включает слой;validateловит пустой/битыйwhen;customToLevelмаппинг + дефолты + null-для-non-game — удалён. Бэкенд-тестtests/quantik-authoring.test.js6/6 (создание game-уровня, чужой draft→403, published виден, share→game_level_shared+/quantik-ссылка+авто-публикация, >3 звезды→400).npm test267/259 pass / 8 baseline fail (без новых); lint:routes 0. Эмодзи/eval/new Function — 0 (новый UI — inline SVG.ic, выражения — толькоSimExpr).