53 Commits

Author SHA1 Message Date
Maxim Dolgolyov 143ae23216 fix(ctmath): срезать провенанс-префикс [ЦТ YYYY · XN] из текста заданий
48 заданий год-пачек (ЦТ 2017/2021) при оцифровке получили в начале text_html
тег вида «[ЦТ 2017 · A1]» — мусор для ученика в тренажёре. cleanup_ctmath_bank.js
теперь срезает ведущий тег [ЦТ|ЦЭ|РТ|ДРТ YYYY …] (узкий паттерн, не трогает
матскобки внутри $…$, не обнуляет пустой результат). Идемпотентно.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:37:29 +03:00
Maxim Dolgolyov dbfcfa41ec fix(ctmath): расширить выпадающий список вариантов под длинные подписи
Селект «Вариант» использовал .mk-input (узкий, под число) → подпись
«РТ-2024/25 · этап I» обрезалась. Задал width:auto/min-width:14rem/max-width:100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:33:40 +03:00
Maxim Dolgolyov 9a13a19e63 feat(ctmath): человекочитаемые подписи вариантов-пробников
Вместо «Вариант 101/102/103» (технические номера) показываем источник:
«РТ-2024/25 · этап I/II/III». examVariantLabel() в exam-prep.js — единый
источник подписи: listVariants (пикер/dropdown) + variant_label в ответе
mock/:id (строка прохождения и результата). Номера в БД остаются 101+
(нужны для фильтра-диапазона [101;1999] и провенанса). math9 — fallback
«Вариант N» (не затронут). Новые варианты (104+) — дописывать в VARIANT_LABEL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:31:45 +03:00
Maxim Dolgolyov 68817cc612 fix(ctmath): чистка банка — год-пачки убраны из пикера пробников
- exam-prep.js: MOCK_VARIANT_RANGE — для ctmath показываем как пробники
  только чистые 30-задачные варианты [101;1999]; год-пачки (variant=год
  2011-2024 и 0, до 114 задач) остаются пулом для тренажёра по темам,
  но скрыты из пикера/mock-start/просмотра вариантов. math9 (1..80) не затронут
  (диапазон только для ctmath).
- mock.js: пикер «По варианту» — выпадающий список реальных вариантов
  (через listVariants) вместо number-input 1..N; раньше для ctmath он
  предлагал 1..18 и не доходил до 101 → пробник по варианту не запускался.
- cleanup_ctmath_bank.js: идемпотентный скрипт — ретайр битого id=1419
  (mc с противоречивым ответом → long), variants_count → 3 (чистых вариантов).
- seed_*: variants_count считается по диапазону [101;1999] (консистентно с роутом).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:22:32 +03:00
Maxim Dolgolyov 6cd0a81d88 feat(ctmath): пробник РТ-2024/25 Этап III Вариант 1 (variant=103)
Завершающий пробник РТ-2024/25 (полный охват: тела вращения, сфера,
производная, сечения, параметрически сложные задачи). По 1 варианту на Этап.
1 чертёж из PDF (три окружности, А2). KaTeX-рендер 30/30, self-сверка ответов.
РТ-2024/25 оцифрован целиком: Этапы I/II/III = variants 101/102/103.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:01:38 +03:00
Maxim Dolgolyov 2af560b7c4 feat(ctmath): пробник РТ-2024/25 Этап II Вариант 1 (variant=102)
Чистый 30-задачный пробник Этапа II (другой набор тем, чем Этап I:
обратные тригфункции, логарифмы, производная, стереометрия). По 1 варианту
на Этап (правило «без повторов»). 3 чертежа из PDF (параллельные прямые,
панель из 5 графиков для y=|x|, график функции). KaTeX-рендер 30/30, self-сверка.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:34:53 +03:00
Maxim Dolgolyov 98894e31ad feat(ctmath): эталонный пробник РТ-2024/25 Этап I Вариант 1 (variant=101)
Первый чистый 30-задачный вариант-пробник для exam-prep ctmath (А1–А10 + В1–В20),
в отличие от год-пачек (variant=год). Идемпотентный seed (dry-run/--apply),
3 чертежа вырезаны из PDF (хорда/график/L-поле). Проверено: KaTeX-рендер 30/30,
self-сверка ответов через checkAnswerServer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:08:19 +03:00
Maxim Dolgolyov e9fe4dabb9 fix(stereo): прямой угол (90°) рисуется квадратиком, а не дугой
В инструменте «∠ рёбер» общий рисовальщик _drawAngleArc всегда чертил дугу,
включая случай 90° — должен быть квадратный маркер прямого угла.

- _drawAngleArc: при |angle−90|<0.5° рисует угловой «квадратик» (p1=center+
  n1·r, p3=center+(n1+n2)·r, p2=center+n2·r, r=radius·0.7) вместо дуги.
  Подпись «∠ABC = 90.0°» и лучи угла рисуются отдельно в обработчике —
  не затронуты. Для не-прямых углов поведение прежнее (дуга).

Верификация: node --check OK; headless-смоук 10/10 (90° → 3-точечный квадрат
с верной геометрией в любой плоскости; 89.6° в допуске → квадрат; 60/88/130°
→ дуга; полный поток _onEdgeAngleClick на угле куба → квадрат); эмодзи/eval/
new Function — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:20:09 +03:00
Maxim Dolgolyov ce99c15895 feat(stereo): мастер-тумблер «Фигура» — скрыть тело с поля
Не было способа убрать само тело со сцены. Добавил тумблер «Фигура» в
начале секции «Отображение»: скрывает грани, рёбра, вершины и подписи тела,
оставляя сетку/оси и ВСЕ построения, точки, сечения и выделения — удобно
работать с конструкциями на «пустом» поле.

- StereoSim: флаг showFigure (деф. true) + toggleFigure(v) — переключает
  _figGroup.visible/_labelGroup.visible (флаг переживает _clearGroup, поэтому
  фигура остаётся скрытой и после перестроения при смене параметров). При
  смене типа фигуры (setFigure) тело снова показывается.
- Панель: st-toggle-row #stg-figure; диспетчер stereoToggleSt('figure');
  setStereoFigure возвращает тумблер в «вкл» для новой фигуры.

Верификация: node --check OK; headless-смоук 13/13 (деф. видна; скрытие
прячет fig+labels, но grid/construct/poly/point-группы остаются; перестроение
сохраняет скрытие; обратное включение; setFigure ре-показывает; dispose);
эмодзи/eval/new Function — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:14:53 +03:00
Maxim Dolgolyov 1f461e96fd feat(stereo): выделение цветом — многоугольник по точкам (с палитрой)
Новый инструмент «Многоугольник по точкам» (секция «Выделение цветом»):
кликаешь точки/вершины по контуру → «Замкнуть» (или клик по первой точке)
→ область заливается полупрозрачным цветом + контур + вершины. Палитра из
6 цветов (свотчи), переключается. Можно выделить треугольник/грань/сечение
из выбранных точек, чтобы подсветить «фигуру по точкам».

- StereoSim: _polyMode/_polyPicks/_polyHighlights/_polyColor + _polyGroup;
  setPolyMode (взаимоисключение с другими инструментами), setPolyColor,
  closePoly (≥3 точек), removeLastPolyPick, clearPoly, _onPolyClick
  (авто-замыкание кликом по первой вершине), _rebuildPoly/_drawPolyHighlight/
  _drawPolyPreview (превью: пунктир + крупная 1-я точка-подсказка). Пикинг
  вершин/точек через _pickConstructPoint. Сброс в setFigure, очистка в dispose.
- Панель: секция «Выделение цветом» (кнопка, палитра .st-sw, Замкнуть/
  Отменить точку/Очистить, #poly-hint); glue stereoPolyMode/Color/Close/
  Undo/Clear; интеграция в _stereoDeactivateTools. CSS палитры в lab.css.

Верификация: node --check OK; headless-смоук 21/21 (режим+взаимоисключение,
пик→замыкание, дефолт/выбранный цвет, авто-замыкание по 1-й точке, требование
≥3, undo точки/выделения, clear, setFigure-сброс, dispose, счётчики
fill+контур+вершины); эмодзи/eval/new Function — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:02:06 +03:00
Maxim Dolgolyov 5e6effa8cd feat(stereo): тумблер показа длин соединённых отрезков
В инструменте «Соединить» подпись длины у каждого отрезка рисовалась всегда.
Добавил переключатель «Длины отрезков» (секция «Инструменты»): прячет только
подписи длин, сами отрезки и точки остаются.

- StereoSim: флаг showConnectionLengths (деф. true), гард в
  _rebuildPointVisuals, метод toggleConnectionLengths(on). Предпочтение
  переживает смену фигуры (не сбрасывается в setFigure).
- Панель: st-toggle-row #stg-connlen + glue stereoToggleConnLen.

Верификация: node --check OK; headless-смоук 8/8 (деф. вкл, подпись
гейтится флагом, линия/маркеры сохраняются, предпочтение переживает
setFigure); эмодзи/eval/new Function — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:52:26 +03:00
Maxim Dolgolyov 601f584181 feat(stereo): сворачиваемый аккордеон панели управления (UX)
Панель за фазы A–C разрослась до ~14 всегда-раскрытых секций (длинный
скролл, тяжело ориентироваться). Сделал её удобнее:

- _stereoInitPanel() (вызов из _openStereo, идемпотентно) оборачивает
  контролы каждой секции в .st-acc-body; заголовки .gp-section-title →
  кликабельные .st-acc-hdr с шевроном; состояние секций в localStorage.
- Тройку фигурных секций (Многогранники/Правильные/Тела вращения) слил в
  одну «Фигуры» (под-метки .st-sublabel). По умолчанию открыты «Фигуры» и
  «Параметры», остальное свёрнуто.
- Кнопки «Развернуть всё / Свернуть всё» (stereoAccAll), клавиатура
  (Enter/Space на заголовке), role=button/tabindex.
- Только раскладка: ни один контрол/обработчик не изменён (узлы лишь
  перемещены в тело секции). Затронуты stereo.js + lab.css.

Верификация: node --check OK; headless DOM-смоук (мини-DOM + реальный
stereo.js в vm) 22/22: 12 сворачиваемых секций, тройка фигур слита (2
под-метки внутри «Фигуры»), пары заголовок→тело, дефолт-открытие,
тоггл+персист, развернуть/свернуть всё, идемпотентная переинициализация,
ни одна строка контролов не потеряна. Эмодзи/eval/new Function — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:48:08 +03:00
Maxim Dolgolyov 9547a20875 feat(stereo): B — умные точки (деление m:n, координаты, перетаскивание)
Фаза B раунда «Конструктор» (умные точки для построений).

B1 — деление отрезка m:n: задаёшь m,n, кликаешь 2 точки A,B → точка делит
AB как AM:MB = m:n (t=m/(m+n)), создаётся как точка-построение M,N,K…
B2 — точка по координатам: поля x/y/z + кнопка → addPointAt.
B3 — перетаскивание построенных точек мышью: drag в плоскости, обращённой
к камере (нормаль фиксируется на старте), приоритет над орбитой; снапшот
истории на старте → undo откатывает весь drag. Непараметрично: downstream-
объекты за перетаскиванием не следуют (параметрический граф — бэклог).

- StereoSim: setDivideMode/setDivideRatio (+ ветка в _onConstructClick),
  addPointAt; setDragPointMode/_pickCPointAt/_beginCPointDrag/_rayPlaneHit/
  _dragCPointWithRay/_dragCPointAt/_endCPointDrag; pointer-хендлеры
  (down=начать drag, move=тащить, up=завершить); сброс в setFigure;
  интеграция в _stereoDeactivateTools.
- Панель: блок «Точки» (кнопки Деление/Тащить, поля m:n, поля x,y,z +
  «Точка (x,y,z)»); glue stereoDivideMode/DivideRatio/AddCoordPoint/
  DragPointMode.

Верификация: node --check OK; headless-смоук 25/25 (деление 1:1/1:2/3:1,
координатная точка + отказ NaN, ray∩plane вкл. parallel/behind, drag begin→
move→end с проверкой позиции и снапшота истории + undo, взаимоисключение
режимов, setFigure-сброс, dispose); эмодзи/eval/new Function — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:28:22 +03:00
Maxim Dolgolyov 24403718bf feat(stereo): C1+C3 — плоскость как сечение + «натуральная величина»
Фаза C раунда «Конструктор» (C2 покрыта Фазой A, C4 отложена).

C1 — любую построенную плоскость можно показать сечением тела: клик по
плоскости в дереве (нормальный режим) → setSectionPlane: заливка
многоугольника + подписи вершин K,L,M… + площадь и периметр в readout-
панели. Удаление плоскости / очистка / смена фигуры сбрасывают сечение.

C3 — «Натуральная величина» сечения (getTrueShape): многоугольник сечения
разворачивается в свою плоскость (ортонормированный базис от нормали) с
сохранением истинных длин → 2D-SVG мини-панель со штриховкой (pattern),
подписями вершин, длинами сторон и S/P. Появляется автоматически при
активном сечении.

- StereoSim: _sectionPlaneId, setSectionPlane, _activeSectionPolygon,
  _sectionVertexLabel, getTrueShape; _drawPlaneObject заливает+подписывает
  активное сечение; getReadout добавляет S/P; getConstructions отдаёт
  sectionId + per-plane section; pickConstructObject в нормальном режиме
  тогглит сечение по плоскости.
- Панель: контейнер #construct-trueshape + подсказка; glue
  _stereoUpdateTrueShape (SVG-рендер) вызывается из _stereoUpdateUI; строки
  плоскостей в дереве всегда кликабельны, тег «(сечение)».

Верификация: node --check OK; headless-смоук 26/26 (квадрат y=2: S=16,P=16;
readout/дерево/тоггл; true-shape длины K,L,M,N=4, площадь=16; сохранение
длин и площади для прямого И наклонного сечения; 2D-shoelace=S; удаление/
очистка/setFigure сбрасывают сечение; dispose); эмодзи/eval/new Function — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:15:22 +03:00
Maxim Dolgolyov 9382b063aa feat(stereo): A3 — параллели/перпендикуляры + общий undo/redo построений
Фаза A3 раунда «Конструктор». Построения через точку, опираясь на объект:
- lpar: прямая ∥ выбранной прямой;
- lperp: прямая ⟂ выбранной плоскости (вдоль нормали);
- ppar: плоскость ∥ выбранной плоскости;
- pperp: плоскость ⟂ выбранной прямой (= плоскость по точке+нормали,
  через _createPlaneFromPointNormal — мост к Фазе C).
Поток: кнопка op → выбор опоры в дереве → клик точки.

Общий undo/redo конструкторного слоя: JSON-снапшоты _undoStack/_redoStack
(кап 60), хуки _pushHistory в create/remove/clear; Ctrl+Z / Ctrl+Shift+Z /
Ctrl+Y + кнопки «Отменить»/«Вернуть». Видимость объекта — не шаг истории.

- StereoSim: setRelMode/_pickRelRef/_onRelClick/_createPlaneFromPointNormal;
  _snapshot/_pushHistory/_restoreSnapshot/undo/redo/canUndo/canRedo;
  pickConstructObject диспатчит rel/intersect; getConstructions отдаёт
  relMode + selected по опоре; _lastConstructMsg → flash в подсказку.
  Сброс rel/истории в setFigure, очистка в clearConstructions.
- Панель: 4 кнопки (∥/⟂ прямая/плоск.) + «Отменить»/«Вернуть»; интеграция в
  _stereoDeactivateTools; glue stereoRelMode/HistUndo/HistRedo; дерево —
  строки выбираемы и в rel-режиме.

Верификация: node --check OK; headless-смоук 30/30 (4 rel-операции с
проверкой параллельности направлений/нормалей, гард типа опоры, undo/redo
одиночный/многошаговый/redo-сброс/clear-undoable/vis-не-шаг/кап, setFigure-
сброс истории, dispose); эмодзи/eval/new Function — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:07:43 +03:00
Maxim Dolgolyov abd1af2653 feat(stereo): A2 — пересечения построений + интерактивное дерево объектов
Фаза A2 раунда «Конструктор». Пересечения как list-based операция:
- прямая ∩ плоскость → точка (_cpoints, имена M,N,K…);
- плоскость ∩ плоскость → прямая;
- прямая ∩ прямая → точка либо «скрещиваются»/«параллельны».
Точки-пересечения пикабельны — по ним строятся новые прямые/плоскости.

- StereoSim: setIntersectMode/pickConstructObject (выбор 2 объектов),
  _computeIntersection + _intersectLinePlane/_intersectPlanePlane/
  _intersectLineLine, _createCPoint/_drawCPointObject/_cpointLabel,
  removeConstruction(id)/toggleConstructionVis(id), getConstructions
  переписан в дерево (id/type/hidden/selected/info), _pickConstructPoint
  теперь учитывает точки-пересечения. Сброс в setFigure, очистка/clear.
- Панель: кнопка «Пересечение»; список — интерактивные строки (выбор для
  пересечения, глаз=видимость, ×=удаление) через glue stereoIntersectMode/
  ConstructSelect/ConstructVis/ConstructDelete; интеграция в _stereoDeactivateTools.

Верификация: node --check OK; headless-смоук 34/34 (точная геометрия
line∩plane / plane∩plane / line∩line, параллельные/скрещ. без объекта,
list-pick поток, гард точки, дерево, видимость/удаление/remove-last,
setFigure-сброс, dispose); эмодзи/eval/new Function — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:59:20 +03:00
Maxim Dolgolyov 53ac45bccd feat(stereo): конструкторное ядро A1 — прямые и плоскости как объекты
Фаза A раунда «Конструктор» (под ученика-самоучку). Прямая по 2 точкам
(имена a,b,c…) и плоскость по 3 точкам (имена α,β,γ…) как именованные
объекты сцены. Плоскость рисует полупрозрачный квад + пунктирную рамку +
сечение тела этой плоскостью (через _sliceByPlane) — сразу осмысленна.

- StereoSim: _lines/_planes (сериализуемые {x,y,z}), _constructGroup,
  setLineMode/setPlaneMode, _onConstructClick, _createLine/_createPlane,
  _rebuildConstructions/_drawLineObject/_drawPlaneObject, removeLast/clear,
  getConstructions (с уравнением плоскости). Сброс в setFigure, очистка в
  dispose, перерисовка подписей в toggleLabels, счётчик в info().
- Панель «Построения» в labs-bodies.html + glue (stereoLineMode/PlaneMode/
  ConstructUndo/Clear, _stereoUpdateConstructList); интеграция в
  _stereoDeactivateTools и _stereoUpdateUI.
- План: Фазы A и C в plans/STEREO_3D_IMPROVEMENT.md.

Верификация: node --check OK; headless-смоук 35/35 (создание/имена/нормаль/
коллинеарность/rebuild/summary/remove-last/clear/click-путь/setFigure-сброс/
dispose); эмодзи/eval/new Function — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:27:27 +03:00
Maxim Dolgolyov 477d47e9e6 feat(admin): тумблер фичи для «Квантик» (паритет с другими играми)
У Квантика не было фиче-флага — его нельзя было выключить, и он всегда висел
в сайдбаре (даже у учеников без класса). Добавлено по образцу остальных игр:
- adminController.updateFeatures: 'quantik' в whitelist (PATCH принимает флаг).
- games.js: пункт «Квантик: Законы Мира» в GAME_FEATURES и FS_FEATURES
  (тумблер в админке → Игры; пишет feature_quantik_enabled).
- api.js hideDisabledFeatures: quantik -> ['/quantik','/quantik.html'] (скрытие
  из сайдбара при выключении) + '/quantik' в classOnlyHrefs/classOnlyPaths
  (скрыт у учеников без класса, как прочие игры).

Миграция не нужна: флаг «неявно включён», пока админ не выключит (features[key]
!== false => включено). Требует Ctrl+F5 (фронт).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:00:23 +03:00
Maxim Dolgolyov 56fc15418e feat(sidebar): скрывать ссылки exam-prep при выключенном/недоступном треке 2026-06-15 14:19:38 +03:00
Maxim Dolgolyov 6fed18f819 feat(admin): тумблер вкл/выкл для экзамен-модулей (exam-prep)
Не было UI для управления exam_tracks.enabled (только флаг в БД, ставился
миграцией). Добавлена админ-секция «Экзамен-модули»:
- backend exam-prep.js: GET /admin/tracks (все треки, вкл. выключенные, + число
  заданий) и PATCH /admin/track (exam_key, enabled), обе requireRole('admin').
  Пути без :examKey, чтобы не задеть гейт content_access.
- frontend: секция sections/exams.js (список треков + переключатель enabled),
  вкладка в admin.html (admin-only через ADMIN_ONLY_TABS, locked для не-админов),
  регистрация в admin.js (ROUTE_TO_SECTION).

Выключенный трек скрыт у учеников и пропадает из каталога прав доступа (тот
берёт exam_tracks WHERE enabled=1). Доступ ученикам по-прежнему в «Доступ · контент».
Требует перезапуска бэкенда + Ctrl+F5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:32:01 +03:00
Maxim Dolgolyov 1cf8083c0e docs(ct-math): IDEAS.md - идеи по улучшению модуля по всем направлениям 2026-06-15 12:15:04 +03:00
Maxim Dolgolyov 8091b48e1c fix(ct-math): практика возвращала меньше count + перенос заголовков в навигации урока
1) exam-prep practice (strategy=random) возвращал около 0.6 от count: функция
   distributeByDifficulty раскладывает count по 5 уровням сложности, а у трека
   ctmath задания только уровней 1-3 (уровни 4-5 пустые) -> часть выборки терялась
   (20 -> 12, 15 -> 10, 10 -> 6). В pickRandomByDifficulty добавлен добор до count
   из доступных уровней. Трек math9 не затронут (там добор не требуется).
2) lesson.html: .lesson-nav-btn-title был inline-span, поэтому max-width и ellipsis
   игнорировались и длинные заголовки вылезали за кнопку. Добавлен display:block.

Бэкенд-правка требует перезапуска сервера; фронт-правка видна после Ctrl+F5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:09:50 +03:00
Maxim Dolgolyov 4b23d768f2 fix(ct-math): литеральные угловые скобки в формулах уроков ломали KaTeX
Блок formula вставляет tex в HTML без экранирования, поэтому литеральная
"меньше"-скобка (напр. в "0 le r lt d") принималась браузером за HTML-тег и
формула не рендерилась (показывался сырой $$...$$). Заменено на \lt и \gt
(KaTeX рендерит их как отношения).

- seed_ctmath_lessons_rest.js: исправлены 4 формулы в исходнике (числа,
  модуль, показ/лог равносильности, производная-монотонность).
- fix_ctmath_formula_lt.js: фикс уже залитых блоков курса 13 (dry/--apply).
  Флешкарты не затронуты (mathHtmlFC через textContent экранирует сам).

Запись (UPDATE 4 блоков) запускает пользователь.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:05:47 +03:00
Maxim Dolgolyov a982628d04 feat(ct-math): уроки всех остальных блоков (48-55) + 4 колоды флешкарт формул
- seed_ctmath_lessons_rest.js — 8 уроков по PLAN: числа, преобразования,
  уравнения (квадратные/рацион/модуль + показ/лог/иррац+рационализация),
  функции+производная, прогрессии/текстовые, планиметрия, параметры.
  Курс 13 теперь покрывает все 9 секций (15 уроков, lessons.id=41-55).
- seed_ctmath_flashcards.js — 4 колоды формул (тригонометрия/стереометрия/
  логарифмы-степени/производная, 49 карт, flashcard_decks.id=11-14, владелец admin).
- Форматы блоков/карт сверены с рендером (lesson.html $…$/$$; flashcards $…$/\(\)/\[\]).
  Применены seed-скриптами; JSON валиден (0 битых).
- README: статус контента.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:48:39 +03:00
Maxim Dolgolyov 623fbde38b feat(ct-math): уроки стереометрии (44-47) + скрипт мини-фикса 866/1248
- backend/scripts/seed_ctmath_lessons_stereo.js — 4 урока блока «Стереометрия»
  по PILOT_STEREOMETRY (расположение/сечения, многогранники, тела вращения,
  координатный метод В20) в курс 13; применён (lessons.id=44-47, 60 блоков).
- backend/scripts/fix_ctmath_misc.js — точечный фикс exam_tasks id=866
  (варианты-прямые в норму) и id=1248 (битый источник → long); dry/--apply,
  идемпотентен. Запись блокируется авто-режимом — запускает пользователь.
- README: статус (уроки стерео, сайдбар, остаток).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:36:56 +03:00
Maxim Dolgolyov 1bc0cc247a docs(ct-math): постфикс инлайн-вариантов применён (213 задач, осталось ~3) 2026-06-15 11:11:57 +03:00
Maxim Dolgolyov 9b1abb83f8 fix(ct-math): варианты ответа из текста → нормальный opts_json (mc ctmath)
У части mc-задач ЦТ (формат РИКЗ «укажите номер») список ответов был вшит
в текст («1) 44; 2) 22; …»), а opts содержали лишь цифры-указатели — рисовалось
«а) 1, б) 2…» + значения строкой. Скрипт fix_ctmath_inline_opts.js вытаскивает
список из текста в opts_json (метка=цифра, текст=значение), пересчитывает answer,
очищает текст. Последовательный парсер сохраняет ';' внутри значений (интервалы).
Dry: 281 кандидат → 213 чинятся чисто, 68 нестандартных пропущены (без порчи).

Запись (UPDATE 213) — запускает пользователь (--apply), как и прочие записи в БД.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:06:42 +03:00
Maxim Dolgolyov c79effa16a feat(ct-math): пункт сайдбара «Подготовка к ЦЭ/ЦТ» → /exam-prep/ctmath
Навигация exam-prep не динамическая — пункты прописываются вручную. Добавил
ссылку на модуль ctmath рядом с «Экзамен 9» (группа «Контент»). Поэтому ранее
модуль не появлялся в панели, хотя открывался по прямому адресу.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:32:52 +03:00
Maxim Dolgolyov 3a20ac8a6e docs(ct-math): модуль ctmath поднят — 723 задания в exam_tasks (/exam-prep/ctmath)
Миграция 077 применена (пользователем вручную) + конвертер залил 723 задания
ЦТ-11 из банка questions в exam_tasks (exam_key='ctmath'): 525 mc + 191 open +
7 long, дерево тем 41 (9+32), variants_count=15. Проверка: осиротевших
subtopic 0, неконвертированных делимитеров 0. Модуль на /exam-prep/ctmath.

- BUILD_ON_QUESTIONS.md §0a / README: статус «применено», что осталось
  (content_access, сайдбар, фикс id=1248).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:27:08 +03:00
Maxim Dolgolyov fd26efca53 feat(ct-math): конвертер questions→exam_tasks для отдельного модуля ctmath (dry-готов)
- backend/scripts/seed_ctmath_exam_tasks.js — переносит размеченные вопросы
  ЦТ-11 из банка questions в exam_tasks (exam_key='ctmath') для отдельного
  модуля exam-prep. Dry по умолчанию, запись только с --apply.
  Правила сверены с exam-prep: MC-метки кириллица а..д (answer=метка);
  open числовой/дробь/пара иначе long; делимитеры \( \)→$, \[ \]→$$;
  subtopic=slug из 077; variant=год; multi/multiple пропуск.
  Dry-run: 733 вопроса → 723 (525 mc + 191 open + 7 long), выборка корректна.
- BUILD_ON_QUESTIONS.md: решение «ЦТ = отдельный модуль» + план + dry-результат.

Запись в БД (применение 077 + вставка 723) — ожидает явной санкции пользователя.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:56:43 +03:00
Maxim Dolgolyov 31719b2e79 feat(ct-math): уроки блока «Тригонометрия» (3 урока в курсе ЦЭ/ЦТ)
- backend/scripts/seed_ctmath_lessons_trig.js — идемпотентный seed 3 уроков по
  PILOT_TRIGONOMETRY в секцию «Тригонометрия» курса 13:
  круг и значения (lessons.id=41, 18 блоков, А3), тождества и формулы (id=42,
  19 блоков, А8/В4), уравнения и отбор корней (id=43, 15 блоков, В15).
  Форматы блоков сверены с рендером frontend/lesson.html (heading/text/formula/
  callout/sim trigcircle/flashcard/quiz/matching/ordering/accordion/table;
  math $…$/$$…$$; data JSON валиден). Уроки — в DRAFT-курсе (ученикам не видны).
- BUILD_ON_QUESTIONS.md / README: статус (блок «Тригонометрия» готов).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:41:15 +03:00
Maxim Dolgolyov 228bd885ed feat(ct-math): диагностический тест из реальных вопросов банка (tests.id=164)
- backend/scripts/seed_ctmath_diagnostic.js — идемпотентный сбор ОДНОГО test
  «Диагностика ЦЭ/ЦТ — Математика» из размеченных вопросов ЦТ-11 (в осн. 2024):
  5 single (базовые) + 10 fill-blank (средние/сложные), по 1 на ключевую тему.
  Новых вопросов не авторит. Применён: test id=164, 15 вопросов, лимит 40 мин.
  Выдать = assignment с test_id=164.
- BUILD_ON_QUESTIONS.md / README: отметка о готовой диагностике, статус.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:16:27 +03:00
Maxim Dolgolyov c3816baf99 feat(ct-math): каркас курса ЦЭ/ЦТ на банке questions (темы + draft-курс + секции)
- backend/scripts/seed_ctmath_course.js — идемпотентный аддитивный seed:
  +6 тем (Преобразование выражений/Модуль/Иррациональные ур./Показательные ур./
  Производная/Параметры), DRAFT-курс «ЦЭ/ЦТ — Математика» + 9 секций.
  Применён на живой БД: course id=13 (is_published=0), topics 72-77, sections 27-35.
  Существующие данные не тронуты; повторный запуск ничего не дублирует.
- BUILD_ON_QUESTIONS.md: уточнения инспекции банка (year=2025 = «Экзамен 9»,
  без тем; реальный ЦТ-11 = ~733 размеч., Часть B = fill-blank → гоча mode='ct')
  + блок «Состояние реализации».
- README: статус каркаса.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:10:22 +03:00
Maxim Dolgolyov 055a6cd1a4 docs(ct-math): пивот плана на существующий банк questions (1753 задания ЦЭ/ЦТ)
Контент ЦЭ/ЦТ по математике уже в БД (questions, subject_id=3, 1753 задания
2011–2025, seed_math_ct*.js) — курс строим на нём через tests/assignments
(готовый mode='ct') и courses, а не через exam-prep/exam_tasks.

- plans/ct-math/BUILD_ON_QUESTIONS.md — новый основной тех-документ: схема
  questions/topics/tests/assignments, режимы ct/topic, таксономия и её доведение,
  каркас курса, диагностика из реальных вопросов, прогресс, порядок работ
- примечания-пивот в PLAN (§6/§8), TOPICS_SEED, DIGITIZATION_SPEC (помечены
  вторичными: exam-prep — опция, оцифровка уже сделана), пилотах, README
- difficulty приведён к шкале банка 1–3

Миграция 077 оставлена как опция exam-prep, в БД не применяется.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:56:33 +03:00
Maxim Dolgolyov 7eb6cb2da0 docs(ct-math): план подготовки к ЦЭ/ЦТ по математике + миграция дерева тем
- plans/ct-math: модульная программа (карта теста А1–А10/В1–В20, 9 блоков
  и ~32 модуля, 3 уровня, маппинг на exam-prep платформы), 2 пилота
  (тригонометрия, стереометрия), seed дерева тем, спецификация оцифровки
  заданий РТ/ЦТ, инвентарь материалов
- backend: миграция 077 — трек ctmath + exam_topics (9 разделов, 32 подтемы),
  валидирована in-memory node:sqlite; на живую БД НЕ применялась

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:26:43 +03:00
Maxim Dolgolyov c9a00d105e @
merge: SimForge + Квантик — Законы Мира → master

Вливает конструктор симуляций (SimForge) и игру «Квантик: Законы Мира»
(фазы 0–5) в master. master был прямым предком feature/sim-builder —
мерж чистый, без конфликтов.
@
2026-06-14 20:21:19 +03:00
Maxim Dolgolyov 082a1ed010 @
docs(quantik-game): план завершён — фича смержена в feature/sim-builder

Статус  Complete; финальный чек-лист отмечен (merge dabb370).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 17:31:52 +03:00
Maxim Dolgolyov dabb3706fe @
merge: Квантик — Законы Мира (образовательная 2D-игра, фазы 0–5)

Игра-головоломка на движке SimForge: слой goal (условие победы = безопасное
SimExpr) + HUD; страница /quantik с картой-созвездием, 16 уровней в 4 главах
(физика/графики/квантовые способности), прогресс (game_progress), XP/скины,
нарратор-Квантик; граф-уровни (plot.runner + zone), квантовые способности
(суперпозиция/коллапс/туннель) + SR-комната флешкарт; авторинг уровней в
sim-builder + раздача классу + deep-link. Движок/бэкенд расширены аддитивно.

Финальное ревью: READY TO MERGE (0 блокеров). Security: SECURE (0 critical).
Фаза 6 (лидерборд) не реализована (решение пользователя). Тесты — baseline,
lint:routes 0.
@
2026-06-14 17:29:27 +03:00
Maxim Dolgolyov 69df2f8190 @
chore(quantik-game): полировка по финальному ревью + security-review

Финальное ревью: READY TO MERGE (0 блокеров). Security: SECURE (0 critical).
Применены дешёвые фиксы из ревью:
- validateSpec: блок game{} санитизируется ПОИМЁННО (chapter/subject →
  sanitizeText, order/par_ms/unlockStars → проверка типа, неизвестные ключи
  отбрасываются) — закрыт латентный хранимый XSS (раньше clean.game=spec.game).
- quantik.html: @media (prefers-reduced-motion) делает анимации мгновенными
  (не выключает — иначе forwards-появление узлов оставило бы их скрытыми).
- progress-logic.js: фикс комментария isUnlocked (сумма звёзд по ВСЕМ уровням
  с меньшим глобальным order, а не «той же главы»).
План: Ф6 (лидерборд/гонка) удалена (Amendment 1, решение пользователя);
финальные гейты отмечены; deferred-бэклог зафиксирован.
Затронутые тесты 45/45; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 17:00:13 +03:00
Maxim Dolgolyov c780b6fd96 @
feat(quantik-game): фаза 5 — авторинг игровых уровней в sim-builder + раздача

Учитель собирает игровой уровень без кода: новая (аддитивная, сворачиваемая)
панель в sim-builder задаёт блок goal (when/title/hint/hold/fail) + до 3
звёзд + game-мету (chapter/order/par_ms); выражения проверяются inline через
SimExpr.compile (без eval). buildSpec/loadFromSim — round-trip без потерь
(goal/game пишутся только при включённом слое; обычная sim не меняется).
Кнопка «Играть» монтирует черновик в SimEngine-модалке (HUD цели из Ф0).
QuantikLevels стал async: подмешивает custom_sims cat=game (свои+
published) в реестр (custom:<dbid>), offline-safe, строки без goal
отбрасываются; deep-link /quantik?level=custom:<id> с серверной проверкой
доступа (own|published → иначе 403/404), мимо геймплейного гейта unlockStars.
Раздача классу — реюз share Ф6 (game-aware ссылка + durable pushNotif).
Правки sim-builder строго аддитивны (параллельная сессия). npm test 259/8
baseline; quantik-authoring 6/6; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 16:09:10 +03:00
Maxim Dolgolyov 8db8171b97 @
fix(pet-sprite): уникальные id градиентов спрайта — фикс «пропадающего» тела

uid градиента питомца строился детерминированно (pg+level+mood+colorKey),
поэтому два питомца с одинаковыми параметрами на одной странице получали
совпадающие id. url(#id) заливки тела резолвился в чужой градиент (часто в
display:none-вьюхе) → тело без заливки, видны только контур/усики/аура.
Проявлялось «случайно» — только при совпадении параметров (нарратор на
карте vs на экране победы в /quantik). Теперь uid — глобальный счётчик
(pg1, pg2, …), коллизий нет. Чинит и /pet, и /dashboard, и игру.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 10:59:17 +03:00
Maxim Dolgolyov 6e33be3de1 @
fix(quantik-game): отображать заработанные звёзды на узлах карты и экране победы

Правило .ic в ls.css (fill:none; stroke:currentColor) перебивало
presentation-атрибуты fill/stroke в starSvg → заработанные звёзды
рисовались как пустые (CSS-свойства приоритетнее presentation-атрибутов).
Цвета звёзд теперь задаются inline style (приоритетнее класса) и в map.js,
и в quantik-game.js. Заодно звезда главы становится сплошной золотой.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 10:43:18 +03:00
Maxim Dolgolyov 0b1925fd3b @
feat(quantik-game): фаза 4 — квантовые способности + SR-комнаты

Глава-созвездие quantum (L12–L16) и фирменные механики — всё через
безопасную модель спеки, движок и бэкенд НЕ тронуты (engine touch = 0):
- Суперпозиция: два тела ball+ball2, goal.when требует ОБА (зеркальный
  закон). Туннелирование: forbidden-зона wall + fail wall.hit && tunnel<1;
  способность тратит энергию → setParam(tunnel,1). Коллапс/прицел: пунктир-
  plot предсказанной траектории на паузе.
- Энергия — клиентский ресурс (localStorage quantik-energy, QuantikEnergy).
- SR-комната: мини-сессия повторения флешкарт в модалке (НЕ iframe),
  LS.fcStudySession/fcReview; «Знаю/Легко» дают энергию; текст карт
  экранируется, картинки — по regex-вайтлисту.
Все 5 уровней проверены на реальном движке (2★ достижимы; суперпозиция
требует оба тела; туннель-гейт блокирует без заряда). npm test 253/8
baseline; lint:routes 0; цепочка разблокировки проходима.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 10:29:35 +03:00
Maxim Dolgolyov 978448d99b @
feat(quantik-game): фаза 3 — граф-уровни (движение по f(x)) + зоны

Новый тип уровня: Квантик едет по кривой y=f(x), которую игрок собирает
слайдерами коэффициентов, проходя сквозь зоны-препятствия. Движок
(аддитивно): plot.runner → env-поля curve.runX/runY/runDone (f компилится
1 раз, питает И кривую, И бегунок-героя, без само-ссылки); type zone
(forbidden/target/collect) → булево env-поле zone.hit. Грамматика
выражений ЗАКРЫТА — никаких inzone()-предикатов, только именованные
env-поля (модель t/tries из Ф0), без eval. Глава-созвездие functions из
5 уровней (луч/синус/парабола/модуль/экспонента), разблокировка 9/11/13/
15/17 (цепочка проходима). validateSpec принимает zone+runner. Все 5
уровней независимо проверены на движке (2★ достижимы). npm test 253/8
baseline; custom-sims 26/26; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-13 17:07:33 +03:00
Maxim Dolgolyov 02ab886bee merge: SimForge (конструктор + улучшения + тумблер + руководство) в quantik-game 2026-06-13 16:32:30 +03:00
Maxim Dolgolyov 0f3e12426a @
feat(quantik-game): фаза 2 — карта-созвездие + мир + XP/скины (MVP-мир)

Одиночный уровень → играбельный мир: карта-созвездие из 6 физ-уровней
(2 главы, нарастающая сложность), разблокировка по звёздам, клиентский
XP/уровень игрока, пикер из 8 скинов (тинт героя+нарратора), нарратор
PetSprite на интро/победе (mood по звёздам). Навигация карта→интро→игра→
успех→карта/дальше; кнопка «Дальше» пересчитывает nextPlayable после
дозагрузки прогресса (фикс stale-hasNext). Логика прогресса — чистый
модуль progress-logic.js (unlock/XP/группировка). Только фронт, без
бэкенда: XP агрегируется из game_progress (Ф1). Каждый уровень проверен
на реальном движке (выигрываем + обе звезды достижимы); цепочка
разблокировки доказуемо проходима. npm test 251/8 baseline; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-13 16:24:31 +03:00
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
Maxim Dolgolyov 34afdafcb1 docs(teacher-guide): глава 21 «Конструктор симуляций» — подробное руководство + актуализация навигации 2026-06-13 15:31:03 +03:00
Maxim Dolgolyov 225e252e3c feat(sim-builder): тумблер «Конструктор симуляций» в админке (feature_sim_builder_enabled) — гейт авторинга + скрытие/редирект 2026-06-13 15:22:59 +03:00
Maxim Dolgolyov 4b5c8077d3 @
feat(quantik-game): фаза 0 — слой целей в движке (goal/HUD/result)

Декларативный блок goal в спеке SimForge (булево SimExpr-условие победы),
вычисляемый каждый кадр: фиксация результата (победа/время/попытки/звёзды),
callback onGoal, HUD-оверлей (цель/звёзды/подсказка/баннер, inline SVG).
API инстанса: onGoal/getResult/resetResult. Серверный validateSpec
пропускает goal/game (длина выражений + escape текста, без исполнения).
Аддитивно: спека без goal ведёт себя как раньше. Смоук 40/40; npm test
238 pass/8 baseline; lint:routes 0. План фичи (7 фаз) + CONTEXT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-13 15:13:02 +03:00
Maxim Dolgolyov 6743dfcbce feat(sim-builder): улучшение P5 — прямое манипулирование (drag всех типов, snap) + undo/redo в билдере 2026-06-13 15:08:09 +03:00
Maxim Dolgolyov b6f854fc77 feat(sim-builder): улучшение P4 — UI билдера: color-пикеры, контролы стиля, редактор кривых, z-order/дубль/видимость 2026-06-13 14:46:14 +03:00
Maxim Dolgolyov 69e219ae8c feat(sim-builder): улучшение P3 — графики: несколько кривых, заливка под кривой, маркеры, легенда 2026-06-13 14:26:36 +03:00
77 changed files with 12010 additions and 158 deletions
+115
View File
@@ -158,3 +158,118 @@ git push origin master
- **Палитра по умолчанию** `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` обнуляет историю; `_restoreSnapshot``renderPanels`+`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: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 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.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` (icon `rocket`) в группе practice ПЕРЕД `/sim-builder`, БЕЗ `hidden` (видно ученикам — это игра, в отличие от teacher-only sim-builder). `isActive('/quantik')` подсвечивает на clean URL.
- **Доступ страницы**: `LS.initPage()` (без `{requireLogin:false}`) сам редиректит на `/login` если не авторизован и возвращает null → бутстрап выходит. Любой авторизованный играет.
- **Верификация P1**: `node --check` всех новых/изменённых JS — OK; `npm run migrate` 076 применяется чисто; `npm test` 251 pass / 8 baseline fail (3 auth + 5 jsdom page-тестов — пре-существующие; **game.test.js 13/13 PASS**); `lint:routes` 247 :id-роутов, 0 unprotected (baseline 0). Эмодзи в коде нет (флагуются только `→`/`⛔` в комментариях — конвенция проекта); eval/new Function — 0. Спека без goal по-прежнему работает (Ф0 не задет).
- **На Phase 2 (карта/мир/XP)**: реестр уровней расширяемый (добавить запись в `LEVELS`); `game_progress`-API готов; экран успеха `buildSuccessOverlay` переиспользуем (расширить «следующим уровнем», активировать «Дальше»); при смене уровня без перезагрузки — `inst.destroy()` перед новым mount.
### Phase 2 — Learnings (Карта-созвездие + мир физ-уровней + XP/скины)
- **Phase 2 = FRONTEND-ONLY** (осознанное решение): XP/уровень игрока агрегируются на КЛИЕНТЕ из `game_progress` (Ф1), скин — localStorage. Без новых таблиц/роутов/миграций → `lint:routes` baseline 0 не тронут, `npm test` ровно как в Ф1 (259 tests / 251 pass / 8 baseline fail). Перенос XP на сервер позже тривиален — те же чистые функции `progress-logic.js`.
- **Чистая логика в отдельном модуле `frontend/js/game/progress-logic.js`** (`window.QuantikProgress`, без DOM/сети/eval — тестируемо в изоляции): `isUnlocked(level,map,levels)` (Σ звёзд во всех уровнях с меньшим `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-мерцание, seeded `mulberry32`), линии-связи `<line>`. Поэтапное появление — `staggerReveal` (`.qm-pre``.qm-in`, setTimeout 70 мс). Тип спеки уровня карте безразличен — читает только метаданные → Ф3 граф-уровни = НОВАЯ глава без правок map.js.
- **Метаданные уровня (Ф2)**: `{ id, title, chapter, order, unlockStars?, par_ms?, hint, spec }`. Главы — `QuantikLevels.CHAPTERS` (`{key,title,subtitle,accent}`). 6 уровней: Кинематика (артиллерия/перелёт-через-стену/отскок-от-стены) + Динамика (маятник-на-пружине/орбита-в-колодце/гравиманёвр). По 2 звезды: кристалл (`stars[0]`) + норматив времени `t*1000<=par_ms` (`stars[1]` — par-звезда выражается через мировое `t`, идентификатор `tries` для неё НЕ нужен).
- **Физика «силовых» уровней через ПРУЖИНУ** (движок не имеет central-gravity): маятник — пружина к якорю-точке с короткой `length` (растянута → сильный возврат) + горизонтальный толчок; орбита — пружина к центру с `length:0` (== гармонический осциллятор `F=-k·r` == эллиптические орбиты); гравиманёвр — гравитация вниз + пружина-«колодец» к центру. k/толчок/сила = params-слайдеры.
- **Скин: тинт без исполнения.** `tintHeroSpec(spec,key)` — глубокая JSON-копия спеки (данные!), переписывает `color/glowColor/trailColor` объекта `id:'ball'` цветом из `PetSprite.PALETTES[key]`. localStorage ключ **`quantik-skin`** (валидируется при чтении). Скин тинтует и героя, и нарратора (`PetSprite.render(...,colorKey,...)`). Гейты — массив `SKIN_GATES` (needStars/needXp).
- **Нарратор = `PetSprite.render(level,mood,[],skin,0,'none')`** на карте-шапке (mood по уровню игрока), интро (`buildIntro`, happy) и успехе (`buildSuccessOverlay`, ecstatic при всех звёздах≥2 / happy при ≥1). `quantik.html` грузит `/js/pet-sprite.js` (как dashboard/pet).
- **Навигация (inline-bootstrap quantik.html)**: 2 вида `#qg-map-view`/`#qg-level-view` (класс `.show`). `showMap` перезагружает прогресс (`LS.gameProgressList`) → `map.render`. `openLevel→интро→launchLevel→onGoal→успех→onNext(nextPlayable)|onMap`. **Смена уровня ВСЕГДА через `destroyLevel()` (=`inst.destroy()`)** до нового mount (гоча Ф1). Deep-link `?level=` открывает только разблокированный.
- **Per-level winnability обязательна** (как Ф1): harness грузит РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js` в `vm`, свипует слайдеры через движок, проверяет `getResult().won`. Гоча OOM: **переиспользовать ОДИН `inst` через `reset()` по сотням комбо ТЕЧЁТ** (накопление через goal-state/bodyById-замыкания) → mount+`destroy()` СВЕЖИЙ inst на каждое комбо (leak-proof). Headless `_renderFrame` рано выходит при `_cw/_ch==0` (рендер не нужен, физика/`_evalGoal` идут в `play`-кадре независимо); для point-радиуса в физике выставить `inst._scale`. Виртуальные часы синхронны с `performance.now()`/rAF-timestamp. Результат: ВСЕ 6 winnable, у всех достижимы обе звезды (combos: artillery 28/196, arc 5/196, bounce 92/343, pendulum 189/196, orbit 94/196, gravimanёvr 170/343).
- **Верификация P2**: `node --check` всех новых/изменённых JS + inline-`<script>` quantik.html — OK; смоуки (логика 16/16, рендер карты/оверлеев 7/7, winnability 6/6) зелёные и удалены; `npm test` 259/251 pass/8 baseline fail (без новых падений); `lint:routes` baseline 0. Эмодзи/`★`/eval/new Function — 0 (звёзды UI — inline SVG; в комментариях `★` заменён на «зв.»).
### Phase 3 — Learnings (Граф-уровни: движение по f(x) + зоны)
- **«Бегунок по кривой» — поле `runner` на `plot`, НЕ новый тип объекта.** `plot.runner:{duration?:8, hold?:true}` превращает ПЕРВУЮ кривую plot в дорожку. Движок в `_buildEnv` (ДО формульных центров, после физ-тел) кладёт `<plotId>.runX` (= `a+(ba)·clamp(t/duration,0,1)` по range кривой), `<plotId>.runY` (= f(runX) ТОЙ ЖЕ скомпил. `cv.exprFn`, что рисует кривую → видимая кривая и путь героя идентичны), `<plotId>.runDone` (1 при t≥duration). **Само-ссылку снимает разделение**: герой = ОБЫЧНЫЙ `point` с `x:'curve.runX', y:'curve.runY'` (glow+trail, визуал P2), а f компилируется один раз и питает И кривую, И бегунок — точка НЕ ссылается на собственный x в одном проходе env. `hold:true` оставляет бегунок на конце (иначе зацикливание по `time.loop`). Кинематический проход (без физики) — герой не тело.
- **Зоны — `type:'zone'` + булево env-поле `<zoneId>.hit`, БЕЗ предикатов в грамматике.** `{type:'zone', id, shape:'rect'|'circle', kind:'forbidden'|'target'|'collect', track?:'ball', x,y,w,h|r, color?, label?}`. Движок считает `<zoneId>.hit` (1/0) в `_buildEnv` **последним** (нужна актуальная позиция героя из тела/формулы) через `_zoneHit(z,env)` (геометрия в мире). `goal.when/fail/stars[].when` ссылаются на поле (`when:'gate.hit'`, `fail:'pit.hit'`). ⛔ **Никаких `inzone(...)` в синтаксис SimExpr** — контракт выражений закрыт, добавляются только именованные env-поля (та же модель безопасности, что `t`/`tries` из Ф0). Рисует `_drawZone` (forbidden=красный пунктир, target=зелёный, collect=золотой пунктир) — цвета ТОЛЬКО в canvas-стоки (fillStyle/strokeStyle), XSS нет. Зона НЕ кладёт `<id>.x/.y` как центр (`hasCenter` пропущен для `type==='zone'` — это область, не точка).
- **ГОЧА имён param (повтор Ф4 SimForge, укусила здесь): `t/w/h/pi/e/E/PI/tau` зарезервированы движком.** `_buildEnv` ставит `env.h = ymaxymin` (высота вьюпорта) и `env.w` — поэтому param с именем `h` (планировался под вершину модуля `a·|xh|+1`) затирался: `abs(xh)` видел h=10 (высота), а не значение слайдера → 0 решающих комбинаций. Фикс — переименовать в `m`. **При добавлении граф-уровней проверять имена коэффициентов против этого списка.** (Сетка-смоук solvability ловит такую ошибку как «0 wins» — обязательна.)
- **Контент: глава `functions` (5 уровней) через хелперы-данные.** `road(exprStr,a,b,dur)` (plot+runner, id 'curve'), `graphHero()` (point ball на curve.runX/runY), `rectZone/circZone(id,kind,...)`, `startMarker`. Уровни: луч `a·x+b`, синус `A·sin(k·x)`, парабола `a·(x5)²+k`, модуль `a·|xm|+1`, экспонента `c·e^(r·x)`. `time:{duration,loop:false}` синхронизирован с `runner.duration`. Управление = обычные `params`-слайдеры коэффициентов (крутишь → кривая+путь перестраиваются live); свободный ввод выражения не понадобился. Звёзды: collect-зона + доп. условие формы кривой (sticky через механизм stars Ф0).
- **Карта/запуск без правок map.js** (подтверждён хэндофф Ф2): глава `functions` в `CHAPTERS` (key/title/subtitle/accent) — узлы рисуются по метаданным, тип спеки карте безразличен. `unlockStars` 9/11/13/15/17 ≤ 18 (макс звёзд 6 физ-уровней) → **нет дедлока** (даже только физ-главы дают 18 ≥ 17). `QuantikGame.start``SimEngine.mount` тот же; спец-вайринг управления НЕ нужен (те же слайдеры). `tintHeroSpec` тинтует point-героя на `curve.runX/runY` штатно. quantik.html: бейдж темы стал per-level (`level.subject`→Физика/Алгебра) — аддитивно, id `qg-pill`.
- **Сервер `validateSpec` (customSimController.js): `zone` в OBJECT_TYPES + поля.** `zone.track` санитизируется как id; `plot.runner.duration` — checkExpr (длина). Готовит авторённые граф-уровни Ф5. x/y/w/h/r зон проходят общий expr-loop. Тест custom-sims.test.js +2 (приём zone+runner спеки; отказ unknown type при разрешённой zone) → 26/26.
- **Верификация Ф3**: `node --check` всех изменённых JS + inline-`<script>` quantik.html — OK; headless vm-смоук (РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`+`levels.js`, DOM/canvas-стаб + виртуальные часы): **per-level solvability** (сетка коэффициентов 625 комбо/уровень) — line 59/625, sine 290/625, parab 88/625, abs 231/625, exp 36/625, у КАЖДОГО найден full-star комбо; **logic** — правильная f→победа без forbidden, плоская f→fail (зашёл в forbidden), zone.hit флипается по позиции, runX/runY/runDone корректны, регресс всех типов + физики без throw, ctx сбалансирован → 29/29. E2E `QuantikGame.start`→onGoal на graph-line-7 → won 2/2. Смоуки удалены. `npm test` 261/253 pass / 8 baseline fail (без новых); lint:routes 0. Эмодзи/eval/new Function — 0 (только пре-существующие →/⛔ в комментариях; зоны/звёзды — canvas/inline SVG).
### Phase 4 — Learnings (Квантовые способности + SR-комнаты)
- **Все три способности — через БЕЗОПАСНУЮ модель спеки, движок НЕ тронут (engine touch = 0).** План допускал поле `tunnelable` у стены в `_sim_engine.js`, но фактически не понадобилось: **туннелирование** = `forbidden`-зона `wall` + `fail:'wall.hit && tunnel<1'`, где `tunnel` — обычный param (не слайдер). По умолчанию `tunnel` отсутствует в env → SimExpr трактует неизвестный идентификатор как 0 → `tunnel<1` истинно → стена сплошная. Способность зовёт `inst.setParam('tunnel',1)``_buildEnv` спредит ВСЕ `this.params` в env (стр.1193) → `fail` видит `tunnel=1` → стена проницаема. **Суперпозиция** = чистый контент (2 тела `ball`+`ball2`, `goal.when` с обоими). **Прицел** = пауза-тоггл (`inst.pause/play`) над пунктир-`plot`. Ни новой грамматики SimExpr, ни новых типов объектов, ни правок движка.
- **`setParam` для НЕ-слайдер-параметра работает штатно**: ставит `this.params[name]`, слайдера нет → на паузе ре-рендерит. Значение переживает кадр (спредится в env). НО reset физики НЕ трогает `tunnel` (он не нач.условие тела) — поэтому `tunnel` надо ставить ПОСЛЕ `reset()` (в харнессе и в `resetAbilities`). `tunnelUsed`-флаг + сброс `tunnel→0` на новую попытку/mount → заряд тратится один раз за попытку.
- **Энергия — клиентский ресурс, чистая логика (`window.QuantikEnergy`).** localStorage ключ **`quantik-energy`** (целое 0..99). `getEnergy/setEnergy/grantEnergy/spendEnergy/canSpend/rewardForQuality/onEnergyChange`. `TUNNEL_COST=3`; награда `rewardForQuality`: q=5(Легко)→2, q=4(Знаю)→1, иначе 0 (та же шкала, что flashcards.html). `spendEnergy` атомарен (не хватило → false, без списания). `onEnergyChange`-подписки обновляют HUD без перезагрузки (панель подписывается в mountBar, отписывается в destroy — без утечки).
- **SR-комната = РЕЮЗ серверного SR, НЕ iframe и НЕ дубль расписания.** `QuantikAbilities.openRestRoom` — своя модалка в стиле игры: `LS.fcListDecks()` → авто-выбор колоды с макс. `due_count` (одна → сразу учить; несколько → пикер) → `LS.fcStudySession(deckId)` (отдаёт `{cards,total_due}`) → лицо→`Показать ответ`→оценки (Снова0/Трудно3/Знаю4/Легко5) → `LS.fcReview(cardId,quality)` (отдаёт `{ok,graduated,...}`; `graduated=false` → re-queue в пределах сессии через RQ_GAP, как flashcards.html). «Знаю/Легко» начисляют энергию ОПТИМИСТИЧНО (до ответа сети). Пусто (нет колод / нет due / SR недоступен) → дружелюбное окно + ссылка `/flashcards`. Картинка карты — только свой `/uploads/flashcards/...` (regex-гейт), текст escape.
- **Клиентские врапера SR в `js/api.js`**: `fcStudySession(deckId)` = GET `/flashcards/decks/${id}/study`, `fcReview(cardId,quality)` = POST `/flashcards/cards/${id}/review` `{quality}` — стиль блока `fcListDecks/fcCreateDeck/fcAddCard`. Контроллер `flashcardController.getStudySession`/`submitReview` уже существовал (Tier-1 SR, мигр.074) — бэкенд не трогался, lint:routes/тесты неизменны.
- **`tintHeroSpec` (quantik-game.js) тинтует `ball` И `ball2`**: ball — цвет скина, ball2 — осветлённый «фантом» (`lighten(color,0.42)`, hex→белый). Авторские id ВНЕ `ball`/`ball2` скином не тинтуются (Phase 5 при желании расширит список). Панель способностей оборачивает `inst.destroy` (снимает бар) — аддитивно, без правки lifecycle движка.
- **Глава `quantum` (L12–L16) появляется на карте без правок map.js** (контракт Ф2 подтверждён 3-й раз): `groupByChapter`+`Levels.chapter` метадата-driven. `CHAPTERS.quantum` (accent `#C4B5FD`). `unlockStars` 19/20/22/24/26 ≤ кумулятив макс-звёзд всех уровней меньшего `order` (по 3 звезды/уровень: 18 физ + 15 граф = 33 до L12 ≥ 19) → **нет дедлока** (проверено цепочкой). `isUnlocked` считает звёзды по ВСЕМ уровням с меньшим глобальным `order`, не по главе.
- **Активация способностей — по СОДЕРЖИМОМУ спеки, не по флагу уровня**: `levelHasTunnel(level)` = слово `tunnel` в `goal.fail/when/stars[].when`; `levelHasAim(level)` = на сцене `plot` с `id:'aim'` ИЛИ `lineStyle:'dashed'`. Кнопка появляется только если уместна. Контракт для авторского UI Ф5.
- **ГОЧА харнесса solvability (физ-уровни): mount планирует ОТЛОЖЕННЫЙ rAF, который делает `_fit`+`reset`(+autoplay).** Если не «слить» его ДО своего `play()`, он выстрелит в середине прогона, вызовет `reset→pause→cancelAnimationFrame` и убьёт кадровый цикл (тело стоит на старте, `t=0`, 0 wins у ЗАВЕДОМО решаемого уровня). Фикс: после mount слить отложенный callback БЕЗ продвижения часов, затем `pause()`, конфиг params, `reset()`, `play()`, гнать кадры с виртуальными часами (8.33мс/кадр, `performance.now` синхронен с таймстампом rAF). Headless-смоук физики обязан гнать РЕАЛЬНУЮ физику (`SimPhysics` экспортится из `_sim_engine.js`).
- **Контент-фикс L16 (поймал sweep)**: монета `(5,6)` r0.7 у параболы `a·(x5)²+k` (вершина в `(5,k)`) собиралась при `5.3<k<6.7`, а 2-я звезда требует `k≥6.8`**взаимоисключающие → full-star недостижим**. Сдвинул монету на `(5,6.9)` r0.85 → пересечение с `k≥6.8` есть → full-star достижим (a-0.25/k7.2). **Урок: проверять full-star reachability sweep'ом, а не только «есть ли победа».**
- **Верификация Ф4**: `node --check` всех изменённых JS + inline quantik.html — OK; headless vm-смоук (РЕАЛЬНЫЕ `_sim_expr`+`_sim_engine`+`levels`+`progress-logic`+`quantik-abilities`, DOM/canvas-стаб + виртуальный rAF-клок): энергия grant/spend/reward/clamp/notify; суперпозиция-`when` требует ОБА тела; tunnel флипает fail (вкл. absent→0); per-level solvability (L12 52 win, L13/L14/L15/L16 ≥3 win + full-star у всех 5; L15/L16 БЕЗ tunnel = 0 win → гейт работает); регресс 11 существующих уровней mount+step без throw → **48/48**, удалён. `npm test` 261/253 pass / 8 baseline fail (без новых); lint:routes 0. Эмодзи/eval/new Function в UI — 0 (`⛔` U+26D4 — только в комментариях, пре-существующая конвенция всего кодбейза; способности — inline SVG `.ic`).
### Phase 5 — Learnings (Авторинг уровней в sim-builder + раздача классу)
- **Бэкенд почти не понадобился — Ф0/Ф3/Ф6 уже всё дали.** `validateSpec` уже пропускал `goal`/`game` (Ф0), `CATS` уже содержал `'game'`, `share`/`clone`/`links`/per-row-ownership/`GET /:id` (own|published|admin) — Ф6. Единственная серверная правка: в `share()` для `cat==='game'` переключить ссылку на `/quantik?level=custom:<id>` + тип `game_level_shared` (иначе `/lab?sim=…`+`sim_shared`); ответ дополнен `link`. Доступ к чужому draft (deep-link/embed-утечка) закрыт ТЕМ ЖЕ `GET /:id` 403 — отдельной защиты не потребовалось.
- **⚠️ ПАРАЛЛЕЛЬНАЯ СЕССИЯ на ветке правит sim-builder.js/.html → все правки строго АДДИТИВНЫЕ.** В sim-builder.js тронуто минимум существующих строк: по 1 врезке в `blankState`(+блок `game`), `loadFromSim`(+`st.game=loadGame(...)`), `buildSpec`(+материализация при `st.game.enabled`), `renderPanels`(+`sectionGame()`), `validate`(+проверка goal-выражений), `wirePanels`(+блок game-листенеров перед `renderLatexPreviews`), `onAdd`(+ветка `'star'`), `_open`(+`game:false`). НОВЫЕ методы/функции: `sectionGame`, `playGame`, модульные `loadGame`/`buildGoal`/`buildGameMeta`. HTML — только +CSS-блок `.sbu-game-fields/.sbu-star/.sbu-star-hdr/.sbu-stars-list`. **Никаких переформатирований/перестановок** — минимизирует merge-конфликты.
- **Игровой слой ⇄ UI = `st.game = { enabled, when,title,hint,hold,fail, stars:[{when,label}], chapter,order,par_ms }`.** Хранит «как введено» (строки/числа), как plot-range в Ф4. `buildGoal`/`buildGameMeta` материализуют → `spec.goal`/`spec.game` (числа коэрсятся: hold/order/par_ms; пустые поля выкидываются; звёзды clamp ≤3). `loadGame(spec.goal,spec.game)` включает слой, если присутствует goal ИЛИ game. **Выключенный `enabled` → goal/game НЕ эмитятся** → обычная симуляция ведёт себя ровно как раньше. Round-trip `buildSpec→loadFromSim→buildSpec``deepEqual` goal+game (доказано смоуком).
- **«Играть» = монтировать `SimEngine` в модалке, НЕ открывать /quantik.** На странице sim-builder уже загружены `_sim_expr`+`_sim_engine`; HUD/победа/звёзды активируются САМИ наличием блока `goal` (Ф0 движка) — `QuantikGame` не нужен, доп. скрипт-тегов нет. Тестирует ЧЕРНОВИК без сохранения/сети. Инстанс уничтожается на закрытии модалки (кнопка + `m.onClose`, если поддерживается). Если `goal.when` пуст — тост-подсказка, модалку не открываем.
- **`QuantikLevels` стал асинхронным (контракт Ф1 исполнен).** `ensureCustom()` (Promise, кэш `_customPromise`): `LS.customSimsList()` → фильтр `cat==='game'` (список БЕЗ spec) → `LS.customSimGet(id)` каждой → `customToLevel(row)`. `list()=LEVELS.concat(CUSTOM)`, `get(id)` ищет в обоих. **`getAsync(id)`** для deep-link: в кэше → синхронно; иначе `custom:<dbid>``LS.customSimGet(dbid)` (сервер-доступ own|published|admin), резолвнутый уровень подмешивается в `CUSTOM` (повторное открытие/«Дальше» синхронны). Встроенные уровни — offline, как раньше.
- **Запись авторённого уровня (`customToLevel`)**: `{ id:'custom:<dbid>', dbid, title, chapter:(game.chapter||'custom'), order:(game.order|| 1000+dbid), unlockStars:(game.unlockStars||0), par_ms, subject, hint:(goal.hint), spec, _custom:true }`. Без `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_id` TEXT≤120, двоеточие проходит, бэкенд НЕ менялся.
- **Верификация Ф5**: `node --check` всех изменённых JS + inline обоих HTML — OK; headless vm-смоук (РЕАЛЬНЫЕ `_sim_expr`+`sim-builder`+`levels`, DOM-стаб) 7/7: blank без goal/game; материализация goal+game; round-trip `deepEqual`; non-game sim не включает слой; `validate` ловит пустой/битый `when`; `customToLevel` маппинг + дефолты + null-для-non-game — удалён. Бэкенд-тест `tests/quantik-authoring.test.js` 6/6 (создание game-уровня, чужой draft→403, published виден, share→`game_level_shared`+`/quantik`-ссылка+авто-публикация, >3 звезды→400). `npm test` 267/259 pass / 8 baseline fail (без новых); lint:routes 0. Эмодзи/eval/new Function — 0 (новый UI — inline SVG `.ic`, выражения — только `SimExpr`).
+103
View File
@@ -0,0 +1,103 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
cleanup_ctmath_bank.js — точечная чистка банка exam-prep ctmath.
Что делает (идемпотентно):
1. id=1248 (вычисление 5^lg2·2^lg5): дефектная задача (варианты «а» и «д»
одинаковы, верного ответа нет) — уже переведена в 'long'; чистим
литеральное answer="null" → NULL.
2. id=1419 (var 2024, «укажите номера пар»): битый mc — сохранённый ответ «а»
(«3 и 4») противоречит решению («4 и 5»), причём «4 и 5» вообще нет среди
вариантов; единственная подходящая пара — №4, ни один mc-вариант не верен.
Ретайрим в 'long' (self-check): убирается из авто-проверки тренажёра/пробника
(там берутся только mc/open), но текст и разбор сохраняются.
3. variants_count трека ctmath → число «чистых» вариантов-пробников (variant≥101),
чтобы шапка («N вариантов») соответствовала пикеру (год-пачки скрыты роутом).
Год-пачки (variant=год) НЕ удаляются — они остаются пулом задач для тренажёра
по темам (он отбирает по subtopic). «Указательные» opts (["1","1"]…) НЕ трогаем —
они рабочие (ученик выбирает номер).
Запуск: node backend/scripts/cleanup_ctmath_bank.js [--apply]
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
// Чистые варианты-пробники: 3-значные [101;1999]; год-пачки — 4-значные годы
// (≥2011) и 0 — исключены. Совпадает с MOCK_VARIANT_RANGE.ctmath в routes/exam-prep.js.
const MOCK_LO = 101, MOCK_HI = 1999;
const db = new DatabaseSync(path.join(__dirname, '..', 'data', 'learnspace.db'));
const get = (sql, ...a) => db.prepare(sql).get(...a);
console.log(`\n=== cleanup_ctmath_bank (${APPLY ? 'APPLY' : 'DRY-RUN'}) ===\n`);
const actions = [];
// 1. id=1248 answer="null" → NULL
const t1248 = get(`SELECT id, task_type, answer FROM exam_tasks WHERE id=1248 AND exam_key=?`, EXAM);
if (t1248 && t1248.answer === 'null') {
actions.push({ desc: `id=1248: answer "null" → NULL (тип ${t1248.task_type})`,
run: () => db.prepare(`UPDATE exam_tasks SET answer=NULL WHERE id=1248`).run() });
} else {
console.log(`• id=1248: пропуск (answer=${t1248 ? JSON.stringify(t1248.answer) : 'нет строки'})`);
}
// 2. id=1419 битый mc → long, answer/opts NULL
const t1419 = get(`SELECT id, task_type FROM exam_tasks WHERE id=1419 AND exam_key=?`, EXAM);
if (t1419 && t1419.task_type === 'mc') {
actions.push({ desc: `id=1419: битый mc → 'long' (answer/opts → NULL, текст и разбор сохраняются)`,
run: () => db.prepare(`UPDATE exam_tasks SET task_type='long', answer=NULL, opts_json=NULL WHERE id=1419`).run() });
} else {
console.log(`• id=1419: пропуск (тип ${t1419 ? t1419.task_type : 'нет строки'})`);
}
// 2b. Срезать провенанс-префикс [ЦТ YYYY · XN] из начала текста задания
// (в чистых вариантах 101+ его нет; для консистентности убираем из год-пачек).
// Паттерн узкий: [ + ЦТ|ЦЭ|РТ|ДРТ + год + … + ]; математические скобки внутри $…$ не задеваются.
const reTag = /^\s*\[(?:ЦТ|ЦЭ|РТ|ДРТ)\s+\d{4}[^\]]*\]\s*/;
const prefixed = db.prepare(`SELECT id, text_html FROM exam_tasks WHERE exam_key=? AND TRIM(text_html) LIKE '[%'`).all(EXAM)
.filter(r => reTag.test(r.text_html))
.map(r => ({ id: r.id, clean: r.text_html.replace(reTag, '') }))
.filter(p => p.clean.trim().length > 0); // не обнуляем задачу
if (prefixed.length) {
actions.push({ desc: `срезать провенанс-префикс [ЦТ … ] у ${prefixed.length} заданий`,
run: () => { const upd = db.prepare(`UPDATE exam_tasks SET text_html=? WHERE id=?`); for (const p of prefixed) upd.run(p.clean, p.id); } });
} else {
console.log('• провенанс-префиксы: пропуск (не найдено)');
}
// 3. variants_count = число чистых вариантов (≥101)
const cleanCnt = get(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN ? AND ?`, EXAM, MOCK_LO, MOCK_HI).c;
const curCnt = get(`SELECT variants_count vc FROM exam_tracks WHERE exam_key=?`, EXAM).vc;
if (curCnt !== cleanCnt) {
actions.push({ desc: `exam_tracks.variants_count: ${curCnt}${cleanCnt} (чистых вариантов [${MOCK_LO};${MOCK_HI}])`,
run: () => db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(cleanCnt, EXAM) });
} else {
console.log(`• variants_count: пропуск (уже ${curCnt})`);
}
console.log(`\nК применению (${actions.length}):`);
actions.forEach(a => console.log(' - ' + a.desc));
if (!actions.length) { console.log('\nНечего менять — всё уже в нужном состоянии.\n'); db.close(); process.exit(0); }
if (!APPLY) {
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/cleanup_ctmath_bank.js --apply\n');
db.close(); process.exit(0);
}
db.exec('BEGIN');
try {
for (const a of actions) a.run();
db.exec('COMMIT');
console.log(`\n✓ Применено изменений: ${actions.length}.\n`);
} catch (e) {
db.exec('ROLLBACK');
console.error('\n✗ Ошибка, откат:', e.message);
process.exitCode = 1;
}
db.close();
+29
View File
@@ -0,0 +1,29 @@
'use strict';
/*
* Фикс: блок formula вставляет tex в HTML БЕЗ экранирования ($$...$$), поэтому
* литеральные '<' / '>' в формуле браузер принимает за HTML-тег → KaTeX не рендерит.
* Заменяем литеральные '<' → '\lt', '>' → '\gt' в tex всех formula-блоков курса 13
* (KaTeX их рендерит как отношения). Идемпотентно. dry по умолчанию, запись --apply.
* node backend/scripts/fix_ctmath_formula_lt.js [--apply]
*/
const db = require('../src/db/db');
const APPLY = process.argv.includes('--apply');
const rows = db.prepare(`SELECT lb.id, lb.lesson_id, lb.data FROM lesson_blocks lb
JOIN lessons l ON l.id=lb.lesson_id WHERE l.course_id=13 AND lb.type='formula'`).all();
const upd = db.prepare('UPDATE lesson_blocks SET data=? WHERE id=?');
let changed = 0;
for (const r of rows) {
let d; try { d = JSON.parse(r.data); } catch { continue; }
if (!d.tex || !/[<>]/.test(d.tex)) continue;
const before = d.tex;
d.tex = d.tex.replace(/</g, '\\lt ').replace(/>/g, '\\gt ');
changed++;
console.log(`block ${r.id} (lesson ${r.lesson_id}):`);
console.log(' было:', before);
console.log(' стало:', d.tex);
if (APPLY) upd.run(JSON.stringify(d), r.id);
}
console.log(`\n${APPLY ? 'Обновлено' : '(dry) к обновлению'}: ${changed} формул.`);
if (!APPLY) console.log('Запись: --apply');
+85
View File
@@ -0,0 +1,85 @@
'use strict';
/*
* Фикс mc-задач ctmath, где варианты ответа вшиты в текст («1) 44; 2) 22; …»),
* а opts_json содержит лишь цифры-указатели. Вытаскивает список из текста в
* нормальный opts_json (метка=цифра, текст=значение), пересчитывает answer,
* очищает текст. Только для чисто распознанных случаев (иначе пропуск).
* node backend/scripts/fix_ctmath_inline_opts.js # dry: статистика+выборка
* node backend/scripts/fix_ctmath_inline_opts.js --apply # запись (UPDATE)
*/
const db = require('../src/db/db');
const APPLY = process.argv.includes('--apply');
// Разбор инлайн-списка "1) v1; 2) v2; … N) vN."
// Последовательный: режем значение только по " ; (n+1)) " следующего номера,
// поэтому ';' внутри значений (интервалы вида (-6;9)) сохраняются.
function parseInline(text) {
const m1 = text.match(/(^|[\s:>(])1\)\s/);
if (!m1) return null;
const start = m1.index + m1[1].length; // позиция "1)"
const stem = text.slice(0, start).replace(/[\s:]+$/, '').trim();
if (!stem) return null;
let rest = text.slice(start);
const h1 = /^1\)\s*/;
if (!h1.test(rest)) return null;
rest = rest.replace(h1, ''); // "1)" снимаем один раз
const pairs = [];
let n = 1;
while (true) {
const nextRe = new RegExp('\\s*;?\\s*' + (n + 1) + '\\)\\s');
const nm = rest.match(nextRe);
let val;
if (nm) { val = rest.slice(0, nm.index); rest = rest.slice(nm.index + nm[0].length); }
else { val = rest; rest = ''; } // последний пункт
val = val.replace(/[;.\s]+$/, '').trim();
if (!val) return null;
pairs.push([String(n), val]);
if (!nm) break;
n++;
}
if (pairs.length < 2) return null;
return { stem, pairs };
}
const rows = db.prepare("SELECT id, text_html, opts_json, answer FROM exam_tasks WHERE exam_key='ctmath' AND task_type='mc'").all();
const stat = { total: rows.length, candidate: 0, fixed: 0, skip_notdigit: 0, skip_parse: 0, skip_count: 0, skip_answer: 0 };
const updates = [];
for (const r of rows) {
let opts; try { opts = JSON.parse(r.opts_json); } catch { continue; }
const texts = opts.map(p => String(p[1]).replace(/\$/g, '').trim());
const isDigitPtr = texts.length >= 2 && texts.every(x => /^[1-9][0-9]?$/.test(x));
if (!isDigitPtr) { stat.skip_notdigit++; continue; }
stat.candidate++;
const parsed = parseInline(r.text_html);
if (!parsed) { stat.skip_parse++; continue; }
if (parsed.pairs.length !== opts.length) { stat.skip_count++; continue; }
// correctDigit = указатель, на который ссылается текущий answer
const ai = opts.findIndex(p => String(p[0]).toLowerCase() === String(r.answer).toLowerCase());
const correctDigit = ai >= 0 ? String(opts[ai][1]).replace(/\$/g, '').trim() : null;
if (!correctDigit || !/^[1-9][0-9]?$/.test(correctDigit) || Number(correctDigit) > parsed.pairs.length) { stat.skip_answer++; continue; }
const newOpts = JSON.stringify(parsed.pairs); // [["1","44"],...]
updates.push({ id: r.id, text: parsed.stem, opts: newOpts, answer: correctDigit, _old: r.text_html, _newpairs: parsed.pairs });
stat.fixed++;
}
console.log(APPLY ? '[APPLY]' : '[DRY-RUN]', 'mc всего', stat.total);
console.log('Статистика:', JSON.stringify(stat));
console.log('\n— Выборка (3) —');
for (const u of updates.slice(0, 3)) {
console.log(`\n id=${u.id}`);
console.log(' было text:', u._old.replace(/\s+/g, ' ').slice(0, 120));
console.log(' стало text:', u.text.replace(/\s+/g, ' ').slice(0, 90));
console.log(' стало opts:', u.opts.slice(0, 160), '| answer:', u.answer);
}
if (!APPLY) { console.log('\nDRY-RUN: запись НЕ выполнялась. Запись: --apply'); process.exit(0); }
const upd = db.prepare('UPDATE exam_tasks SET text_html=@text, opts_json=@opts, answer=@answer WHERE id=@id');
let n = 0;
for (const u of updates) { upd.run({ id: u.id, text: u.text, opts: u.opts, answer: u.answer }); n++; }
console.log(`\nОбновлено ${n} задач.`);
+45
View File
@@ -0,0 +1,45 @@
'use strict';
/*
* Точечная полировка 2 mc-задач ctmath:
* - id=866: варианты-прямые вшиты в середину текста, opts = цифры-указатели →
* нормальный opts_json + чистый текст (answer сохраняем = 4).
* - id=1248: битый источник (нет верного варианта, опции не сходятся) → 'long'.
* Идемпотентно (проверяет текущее состояние). dry по умолчанию, запись --apply.
*/
const db = require('../src/db/db');
const APPLY = process.argv.includes('--apply');
const t866 = db.prepare('SELECT id,task_type,answer,opts_json FROM exam_tasks WHERE id=866').get();
const t1248 = db.prepare('SELECT id,task_type FROM exam_tasks WHERE id=1248').get();
const plan = [];
if (t866 && t866.task_type === 'mc') {
// opts уже нормальные? (значения не цифры-указатели)
let o = []; try { o = JSON.parse(t866.opts_json); } catch {}
const isDigit = o.length && o.every(p => /^[1-9]$/.test(String(p[1]).trim()));
if (isDigit) {
plan.push({
id: 866,
set: {
text_html: 'A16. Какая из прямых пересекает график функции $y=x^4-3x^2+11x$ в 11 добавочных точках?',
opts_json: JSON.stringify([['1', '$y=-3$'], ['2', '$y=-1{,}5$'], ['3', '$y=0$'], ['4', '$y=4k$'], ['5', '$y=2$']]),
answer: '4',
},
});
} else console.log('id=866 уже не цифровой — пропуск');
} else console.log('id=866 нет или уже не mc — пропуск');
if (t1248 && t1248.task_type === 'mc') {
plan.push({ id: 1248, set: { task_type: 'long', answer: null } });
} else console.log('id=1248 нет или уже не mc — пропуск');
console.log(APPLY ? '[APPLY]' : '[DRY-RUN]', 'к изменению:', plan.map(p => p.id).join(', ') || '(нет)');
for (const p of plan) console.log(' id', p.id, '→', JSON.stringify(p.set).slice(0, 160));
if (!APPLY) { console.log('DRY-RUN: запись НЕ выполнялась. Запись: --apply'); process.exit(0); }
for (const p of plan) {
const cols = Object.keys(p.set);
const sql = `UPDATE exam_tasks SET ${cols.map(c => c + '=@' + c).join(', ')} WHERE id=@id`;
db.prepare(sql).run({ ...p.set, id: p.id });
}
console.log('Обновлено:', plan.length);
+93
View File
@@ -0,0 +1,93 @@
'use strict';
/*
* Каркас курса «ЦЭ/ЦТ — Математика» на существующем банке questions.
* План: plans/ct-math/ (BUILD_ON_QUESTIONS.md).
* ИДЕМПОТЕНТЕН и АДДИТИВЕН: добавляет недостающие темы (topics),
* создаёт DRAFT-курс (is_published=0) + 9 секций. Существующие данные не трогает.
* Запуск: node backend/scripts/seed_ctmath_course.js (применить)
* node backend/scripts/seed_ctmath_course.js --dry (только показать план)
*/
const db = require('../src/db/db');
const DRY = process.argv.includes('--dry');
const MATH_ID = 3;
// 1) Недостающие темы под модульную карту (см. BUILD_ON_QUESTIONS §3)
const NEW_TOPICS = [
'Преобразование выражений',
'Модуль',
'Иррациональные уравнения',
'Показательные уравнения',
'Производная',
'Параметры',
];
// 2) Секции курса = 9 блоков (PLAN §3)
const SECTIONS = [
'Числа и вычисления',
'Алгебраические преобразования',
'Уравнения и неравенства',
'Функции и производная',
'Тригонометрия',
'Прогрессии и текстовые задачи',
'Планиметрия',
'Стереометрия',
'Продвинутое и комбинированное',
];
const COURSE_TITLE = 'ЦЭ/ЦТ — Математика';
const COURSE_DESC = 'Подготовка к ЦЭ/ЦТ по математике: 30 заданий (часть А — А1–А10, часть В — В1–В20). Теория по темам, тренажёр на банке заданий прошлых лет, карточки формул, пробные варианты.';
function topicExists(name) {
return db.prepare('SELECT id FROM topics WHERE subject_id=? AND LOWER(name)=LOWER(?)').get(MATH_ID, name);
}
function adminId() {
const u = db.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id LIMIT 1").get()
|| db.prepare('SELECT id FROM users ORDER BY id LIMIT 1').get();
return u && u.id;
}
let addedTopics = 0, skippedTopics = 0;
console.log(DRY ? '[DRY-RUN] план изменений:' : '[APPLY] вношу изменения:');
console.log('\n— Темы (topics) —');
for (const name of NEW_TOPICS) {
const ex = topicExists(name);
if (ex) { console.log(` есть: ${name} (id ${ex.id})`); skippedTopics++; continue; }
if (DRY) { console.log(` + добавить: ${name}`); addedTopics++; continue; }
const id = db.prepare('INSERT INTO topics (subject_id,name) VALUES (?,?)').run(MATH_ID, name).lastInsertRowid;
console.log(` + добавлено: ${name} (id ${id})`);
addedTopics++;
}
console.log('\n— Курс (courses) —');
let course = db.prepare("SELECT id,is_published FROM courses WHERE subject_slug='math' AND title=?").get(COURSE_TITLE);
let courseId;
if (course) {
courseId = course.id;
console.log(` есть курс «${COURSE_TITLE}» (id ${courseId}, ${course.is_published ? 'published' : 'draft'})`);
} else if (DRY) {
console.log(` + создать DRAFT-курс «${COURSE_TITLE}» (created_by=${adminId()})`);
} else {
const by = adminId();
// cover_emoji не указываем — применится дефолт схемы; в коде эмодзи не вводим
courseId = db.prepare(
'INSERT INTO courses (subject_slug,title,description,is_published,created_by) VALUES (?,?,?,0,?)'
).run('math', COURSE_TITLE, COURSE_DESC, by).lastInsertRowid;
console.log(` + создан DRAFT-курс «${COURSE_TITLE}» (id ${courseId}, created_by=${by})`);
}
console.log('\n— Секции (course_sections) —');
if (!courseId && DRY) {
SECTIONS.forEach((t, i) => console.log(` + секция [${i + 1}] ${t}`));
} else if (courseId) {
SECTIONS.forEach((title, i) => {
const ex = db.prepare('SELECT id FROM course_sections WHERE course_id=? AND title=?').get(courseId, title);
if (ex) { console.log(` есть: [${i + 1}] ${title} (id ${ex.id})`); return; }
if (DRY) { console.log(` + секция [${i + 1}] ${title}`); return; }
const id = db.prepare('INSERT INTO course_sections (course_id,title,order_index) VALUES (?,?,?)').run(courseId, title, i + 1).lastInsertRowid;
console.log(` + секция [${i + 1}] ${title} (id ${id})`);
});
}
console.log(`\nИтог: темы +${addedTopics} (есть ${skippedTopics}); курс id=${courseId || '(dry)'}; секций ${SECTIONS.length}.`);
console.log(DRY ? 'DRY-RUN: ничего не записано.' : 'Готово. Курс создан как ЧЕРНОВИК (is_published=0) — ученикам не виден до публикации.');
+93
View File
@@ -0,0 +1,93 @@
'use strict';
/*
* Входная диагностика для курса «ЦЭ/ЦТ — Математика».
* Собирает ОДИН test из РЕАЛЬНЫХ размеченных вопросов ЦТ-11 (banks 20112024):
* по 1 заданию на ключевую тему, смесь уровней (single 🟢 → fill-blank 🔴).
* Новых вопросов НЕ авторит — только группирует существующие.
* ИДЕМПОТЕНТЕН: если test с таким title есть — не дублирует.
* Запуск: node backend/scripts/seed_ctmath_diagnostic.js (применить)
* node backend/scripts/seed_ctmath_diagnostic.js --dry (показать выбор)
*/
const db = require('../src/db/db');
const DRY = process.argv.includes('--dry');
const MATH_ID = 3;
const TITLE = 'Диагностика ЦЭ/ЦТ — Математика';
const DESC = 'Входная диагностика: задания по ключевым темам (от базовых до сложных) для определения уровня и приоритетных тем подготовки к ЦЭ/ЦТ.';
// Слоты: тема (по имени) + предпочтительный тип + уровень-зонд.
// Исключаем набор year=2025 («Экзамен 9»): берём только размеченные ЦТ-11 (year<=2024).
const SLOTS = [
['Теория чисел', 'single', 'base'],
['Арифметика и степени', 'single', 'base'],
['Квадратные уравнения', 'single', 'base'],
['Тригонометрия', 'single', 'base'],
['Числовые промежутки', 'single', 'base'],
['Словесные задачи', 'fill-blank', 'mid'],
['Прогрессии', 'fill-blank', 'mid'],
['Функции', 'fill-blank', 'mid'],
['Геометрия', 'fill-blank', 'mid'],
['Окружность и круг', 'single', 'mid'],
['Стереометрия', 'fill-blank', 'mid'],
['Логарифмы', 'fill-blank', 'hard'],
['Неравенства', 'fill-blank', 'hard'],
['Уравнения', 'fill-blank', 'hard'],
['Показательные неравенства','fill-blank', 'hard'],
];
function topicId(name) {
const r = db.prepare('SELECT id FROM topics WHERE subject_id=? AND LOWER(name)=LOWER(?)').get(MATH_ID, name);
return r && r.id;
}
function adminId() {
const u = db.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id LIMIT 1").get()
|| db.prepare('SELECT id FROM users ORDER BY id LIMIT 1').get();
return u && u.id;
}
// Кандидаты по теме: сперва предпочт. тип, потом любой; только размеченные ЦТ-11 (year<=2024 или not null),
// исключая набор «Экзамен 9» (source_type='экзамен 9'); 2024 в приоритете, затем свежие.
function candidates(tid, type) {
const order = "ORDER BY (year=2024) DESC, year DESC, id";
const base = `SELECT id, type, year, substr(text,1,70) AS t FROM questions
WHERE subject_id=${MATH_ID} AND topic_id=${tid}
AND (source_type IS NULL OR source_type <> 'экзамен 9')`;
const pref = db.prepare(`${base} AND type=? ${order} LIMIT 8`).all(type);
const any = db.prepare(`${base} ${order} LIMIT 8`).all();
// предпочт. тип впереди, затем остальные (для фолбэка)
const seen = new Set(pref.map(r => r.id));
return [...pref, ...any.filter(r => !seen.has(r.id))];
}
const used = new Set();
const picks = [];
for (const [name, type, level] of SLOTS) {
const tid = topicId(name);
if (!tid) { console.log(` [skip] нет темы: ${name}`); continue; }
const cand = candidates(tid, type).find(r => !used.has(r.id));
if (!cand) { console.log(` [skip] нет вопросов: ${name}`); continue; }
used.add(cand.id);
picks.push({ name, level, ...cand });
}
console.log(DRY ? '[DRY-RUN] выбранные вопросы диагностики:' : '[APPLY] диагностика:');
const mark = { base: 'базовый', mid: 'средний', hard: 'сложный' };
picks.forEach((p, i) => console.log(
` ${String(i + 1).padStart(2)}. [${mark[p.level]}] ${p.name} | qid ${p.id} (${p.type}, ${p.year || '—'}) — ${p.t.replace(/\s+/g, ' ')}`
));
console.log(`\nВсего отобрано: ${picks.length} заданий.`);
const existing = db.prepare("SELECT id FROM tests WHERE subject_slug='math' AND title=?").get(TITLE);
if (existing) {
console.log(`\nТест «${TITLE}» уже существует (id ${existing.id}) — не дублирую.`);
} else if (DRY) {
console.log(`\nDRY-RUN: тест НЕ создан. Будет создан с ${picks.length} вопросами.`);
} else {
const by = adminId();
const testId = db.prepare(
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, created_by) VALUES (?,?,?,?,?,?)'
).run(TITLE, 'math', DESC, 1, 40, by).lastInsertRowid;
const ins = db.prepare('INSERT INTO test_questions (test_id, question_id, order_index) VALUES (?,?,?)');
picks.forEach((p, i) => ins.run(testId, p.id, i));
console.log(`\nСоздан тест «${TITLE}» (id ${testId}, ${picks.length} вопросов, лимит 40 мин).`);
console.log('Выдать классу/ученику: assignment с test_id=' + testId + ' (mode неважен, test_id перекрывает выбор).');
}
+149
View File
@@ -0,0 +1,149 @@
'use strict';
/*
* Конвертер: размеченные вопросы ЦТ-11 из банка `questions` (subject_id=3)
* → `exam_tasks` для отдельного модуля exam-prep (exam_key='ctmath').
*
* По умолчанию DRY (только чтение, печать выборки и статистики).
* Запись ТОЛЬКО с флагом --apply (и только если применён трек 077).
* node backend/scripts/seed_ctmath_exam_tasks.js # dry: выборка+статистика
* node backend/scripts/seed_ctmath_exam_tasks.js --apply # запись
*
* Правила (сверены с exam-prep, см. plans/ct-math/BUILD_ON_QUESTIONS.md):
* - тип: single/true_false → 'mc'; fill-blank/short_answer → 'open' (если ответ
* числовой/дробь/пара) иначе 'long'; multi/multiple → пропуск (exam-prep mc = radio).
* - opts_json: [["а","html"],...] кириллические метки; answer(mc)=метка верного.
* - answer(open): очищенный числовой/дробь/пара; проверка на клиенте численная.
* - математика: \( \) → $ , \[ \] → $$ (exam-prep KaTeX знает только $/$$).
* - subtopic = slug из exam_topics(077); difficulty 1..3; variant=year.
*/
const db = require('../src/db/db');
const APPLY = process.argv.includes('--apply');
const MATH_ID = 3, EXAM_KEY = 'ctmath';
const LABELS = ['а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з'];
// flat topic name → [section slug, subtopic slug] (slugs из миграции 077)
const TOPIC_MAP = {
'Теория чисел': ['numbers', 'num-divisibility'],
'Арифметика и степени': ['expressions', 'expr-powers-roots'],
'Квадратные уравнения': ['equations', 'eq-quadratic'],
'Тригонометрия': ['trigonometry', 'trig-identities'],
'Тригонометрические уравнения':['trigonometry', 'trig-equations'],
'Прогрессии': ['word-sequences', 'seq-progressions'],
'Словесные задачи': ['word-sequences', 'word-problems'],
'Неравенства': ['equations', 'eq-rational'],
'Уравнения': ['equations', 'eq-rational'],
'Функции': ['functions', 'fn-properties'],
'Логарифмы': ['equations', 'eq-logarithmic'],
'Показательные неравенства': ['equations', 'eq-exponential'],
'Геометрия': ['planimetry', 'plan-triangles'],
'Стереометрия': ['stereometry', 'ster-basics'],
'Окружность и круг': ['planimetry', 'plan-circle'],
'Числовые промежутки': ['equations', 'eq-linear'],
'Подобные фигуры': ['planimetry', 'plan-quadrilaterals'],
'Парабола': ['functions', 'fn-graphs'],
'Статистика и диаграммы': ['advanced', 'adv-combined'],
};
// \( \) → $ ; \[ \] → $$ (replacement-функции, чтобы $ не интерпретировался)
function conv(s) {
return String(s || '')
.replace(/\\\(/g, () => '$').replace(/\\\)/g, () => '$')
.replace(/\\\[/g, () => '$$').replace(/\\\]/g, () => '$$');
}
// численная проверяемость ответа (зеркало answer-check.js exam-prep)
function isNumericAnswer(s) {
if (s == null) return false;
const t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
if (/^-?\d+(?:\.\d+)?$/.test(t)) return true; // число
if (/^-?\d+(?:\.\d+)?\/-?\d+(?:\.\d+)?$/.test(t)) return true; // дробь
const parts = String(s).replace(/\$/g, '').split(/[;]|\sи\s/).map(x => x.trim()).filter(Boolean);
if (parts.length === 2 && parts.every(p => /^-?\d+(?:[.,]\d+)?(?:\/-?\d+)?$/.test(p.replace(/\s/g, '')))) return true; // пара
return false;
}
function cleanAnswer(s) { return String(s || '').trim().replace(/\$/g, '').replace(/\s+/g, ' ').trim(); }
const rows = db.prepare(`
SELECT q.id, q.text, q.type, q.difficulty, q.year, q.explanation, q.image, q.correct_text,
COALESCE(t.name,'') AS topic_name
FROM questions q LEFT JOIN topics t ON t.id = q.topic_id
WHERE q.subject_id = ? AND q.topic_id IS NOT NULL
AND (q.source_type IS NULL OR q.source_type <> 'экзамен 9')
ORDER BY q.year, q.id
`).all(MATH_ID);
const optStmt = db.prepare('SELECT text, is_correct, order_index FROM options WHERE question_id=? ORDER BY order_index, id');
const out = [];
const stat = { mc: 0, open: 0, long: 0, skip_multi: 0, skip_notopic: 0, skip_noopts: 0, open_demoted_long: 0 };
const perVariant = {};
for (const q of rows) {
const map = TOPIC_MAP[q.topic_name];
if (!map) { stat.skip_notopic++; continue; }
const [topic, subtopic] = map;
const opts = optStmt.all(q.id);
const variant = q.year || 0;
let task_type, opts_json = null, answer = null;
if (q.type === 'single' || q.type === 'true_false') {
if (!opts.length) { stat.skip_noopts++; continue; }
task_type = 'mc';
opts_json = JSON.stringify(opts.map((o, i) => [LABELS[i] || String(i + 1), conv(o.text)]));
const ci = opts.findIndex(o => o.is_correct);
answer = ci >= 0 ? (LABELS[ci] || String(ci + 1)) : null;
stat.mc++;
} else if (q.type === 'fill-blank' || q.type === 'short_answer') {
const corr = (opts.find(o => o.is_correct) || {}).text || q.correct_text || '';
if (isNumericAnswer(corr)) { task_type = 'open'; answer = cleanAnswer(corr); stat.open++; }
else { task_type = 'long'; answer = null; stat.long++; stat.open_demoted_long++; }
} else { // multi / multiple
stat.skip_multi++; continue;
}
// solution_html (NOT NULL): объяснение + строка ответа
let sol = conv(q.explanation || '');
if (answer && task_type !== 'long') sol += `<div class="sol-ans">Ответ: ${task_type === 'mc' ? answer + ')' : '$' + answer + '$'}</div>`;
if (!sol.trim()) sol = '<div class="sol-ans">См. решение в источнике.</div>';
const figure = q.image ? `<img src="${String(q.image)}" alt="" loading="lazy" style="max-width:100%">` : null;
perVariant[variant] = (perVariant[variant] || 0) + 1;
out.push({
exam_key: EXAM_KEY, variant, task_idx: perVariant[variant],
task_type, text_html: conv(q.text), figure_html: figure, opts_json, answer,
solution_html: sol, topic, subtopic, difficulty: q.difficulty || 1, _qid: q.id, _tn: q.topic_name,
});
}
console.log(APPLY ? '[APPLY]' : '[DRY-RUN]', `вход: ${rows.length} размеченных вопросов; к вставке: ${out.length}`);
console.log('Статистика типов:', JSON.stringify(stat));
console.log('Заданий по годам (variant):', JSON.stringify(perVariant));
const bySub = {};
out.forEach(o => { bySub[o.subtopic] = (bySub[o.subtopic] || 0) + 1; });
console.log('По подтемам:', JSON.stringify(bySub));
console.log('\n— Выборка (по одному mc / open / long) —');
for (const tp of ['mc', 'open', 'long']) {
const s = out.find(o => o.task_type === tp);
if (!s) continue;
console.log(`\n[${tp}] qid=${s._qid} тема="${s._tn}" → ${s.topic}/${s.subtopic} variant=${s.variant} diff=${s.difficulty}`);
console.log(' text :', s.text_html.replace(/\s+/g, ' ').slice(0, 160));
if (s.opts_json) console.log(' opts :', s.opts_json.slice(0, 200));
console.log(' answ :', s.answer);
console.log(' sol :', s.solution_html.replace(/\s+/g, ' ').slice(0, 140));
}
if (!APPLY) { console.log('\nDRY-RUN: запись НЕ выполнялась. Для записи: --apply (после применения миграции 077).'); process.exit(0); }
// ── APPLY ──
const track = db.prepare("SELECT exam_key FROM exam_tracks WHERE exam_key=?").get(EXAM_KEY);
if (!track) { console.error('Нет трека ctmath (миграция 077 не применена). Сначала примените 077.'); process.exit(1); }
const already = db.prepare("SELECT COUNT(*) c FROM exam_tasks WHERE exam_key=?").get(EXAM_KEY).c;
if (already > 0) { console.error(`В exam_tasks уже есть ${already} задач ctmath — повторная вставка отменена (избегаем дублей).`); process.exit(1); }
const ins = db.prepare(`INSERT INTO exam_tasks
(exam_key,variant,task_idx,task_type,text_html,figure_html,opts_json,answer,solution_html,topic,subtopic,difficulty)
VALUES (@exam_key,@variant,@task_idx,@task_type,@text_html,@figure_html,@opts_json,@answer,@solution_html,@topic,@subtopic,@difficulty)`);
let n = 0;
for (const o of out) { ins.run({ exam_key:o.exam_key, variant:o.variant, task_idx:o.task_idx, task_type:o.task_type, text_html:o.text_html, figure_html:o.figure_html, opts_json:o.opts_json, answer:o.answer, solution_html:o.solution_html, topic:o.topic, subtopic:o.subtopic, difficulty:o.difficulty }); n++; }
// обновим метаданные трека
const variants = Object.keys(perVariant).length;
db.prepare("UPDATE exam_tracks SET variants_count=? WHERE exam_key=?").run(variants, EXAM_KEY);
console.log(`\nВставлено ${n} задач в exam_tasks (ctmath). variants_count=${variants}.`);
+87
View File
@@ -0,0 +1,87 @@
'use strict';
/*
* Колоды карточек формул для подготовки к ЦЭ/ЦТ (интервальное повторение).
* flashcard_decks(user_id,title,description,color) + flashcard_cards(deck_id,front,back,order_idx).
* Математика — KaTeX inline $…$ (страница флешкарт рендерит \( \), \[ \], $ $; НЕ $$).
* Идемпотентно: колода с таким title у владельца не создаётся повторно.
* node backend/scripts/seed_ctmath_flashcards.js [--dry]
*/
const db = require('../src/db/db');
const DRY = process.argv.includes('--dry');
const owner = (db.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id LIMIT 1").get()
|| db.prepare('SELECT id FROM users ORDER BY id LIMIT 1').get()).id;
const DECKS = [
{ title: 'ЦТ · Тригонометрия — формулы', color: '#9B5DE5', cards: [
['Определения через единичную окружность', '$\\cos\\alpha=x,\\ \\sin\\alpha=y$ (координаты точки)'],
['Основное тригонометрическое тождество', '$\\sin^2\\alpha+\\cos^2\\alpha=1$'],
['$1+\\operatorname{tg}^2\\alpha$', '$\\dfrac{1}{\\cos^2\\alpha}$'],
['$1+\\operatorname{ctg}^2\\alpha$', '$\\dfrac{1}{\\sin^2\\alpha}$'],
['$\\sin(\\alpha\\pm\\beta)$', '$\\sin\\alpha\\cos\\beta\\pm\\cos\\alpha\\sin\\beta$'],
['$\\cos(\\alpha\\pm\\beta)$', '$\\cos\\alpha\\cos\\beta\\mp\\sin\\alpha\\sin\\beta$'],
['$\\sin 2\\alpha$', '$2\\sin\\alpha\\cos\\alpha$'],
['$\\cos 2\\alpha$', '$\\cos^2\\alpha-\\sin^2\\alpha=1-2\\sin^2\\alpha=2\\cos^2\\alpha-1$'],
['Понижение степени: $\\sin^2\\alpha$', '$\\dfrac{1-\\cos 2\\alpha}{2}$'],
['Область значений $\\arcsin x$', '$\\left[-\\tfrac{\\pi}{2};\\tfrac{\\pi}{2}\\right]$'],
['Область значений $\\arccos x$', '$[0;\\ \\pi]$'],
['$\\sin x=a$ — корни', '$x=(-1)^n\\arcsin a+\\pi n$'],
['$\\cos x=a$ — корни', '$x=\\pm\\arccos a+2\\pi n$'],
['$\\operatorname{tg} x=a$ — корни', '$x=\\operatorname{arctg} a+\\pi n$'],
['$\\sin x=0$', '$x=\\pi n$'],
['$\\cos x=0$', '$x=\\tfrac{\\pi}{2}+\\pi n$'],
]},
{ title: 'ЦТ · Стереометрия — формулы', color: '#00BBF9', cards: [
['$V$ призмы', '$S_{\\text{осн}}\\cdot h$'],
['$V$ пирамиды', '$\\tfrac{1}{3}S_{\\text{осн}}\\cdot h$'],
['$V$ цилиндра', '$\\pi R^2 h$'],
['$V$ конуса', '$\\tfrac{1}{3}\\pi R^2 h$'],
['$V$ шара', '$\\tfrac{4}{3}\\pi R^3$'],
['$S$ сферы', '$4\\pi R^2$'],
['$S_{\\text{бок}}$ цилиндра', '$2\\pi R h$'],
['$S_{\\text{бок}}$ конуса', '$\\pi R l$'],
['Сечение $\\parallel$ основанию: отношение площадей', '$k^2$, где $k$ — отношение высот от вершины'],
['Угол между прямыми (векторы)', '$\\cos\\varphi=\\dfrac{|\\vec a\\cdot\\vec b|}{|\\vec a|\\,|\\vec b|}$'],
['Скалярное произведение', '$a_xb_x+a_yb_y+a_zb_z$'],
['Длина вектора', '$\\sqrt{a_x^2+a_y^2+a_z^2}$'],
['Сфера касается плоскости', 'Радиус в точку касания $\\perp$ плоскости (далее Пифагор)'],
['Расстояние между скрещивающимися прямыми', 'Длина их общего перпендикуляра'],
]},
{ title: 'ЦТ · Логарифмы и степени — формулы', color: '#F15BB5', cards: [
['$\\log_a(xy)$', '$\\log_a x+\\log_a y$'],
['$\\log_a\\dfrac{x}{y}$', '$\\log_a x-\\log_a y$'],
['$\\log_a x^p$', '$p\\log_a x$'],
['Переход к новому основанию', '$\\log_a x=\\dfrac{\\log_b x}{\\log_b a}$'],
['$a^{\\log_a x}$', '$x$'],
['$\\log_a a$ и $\\log_a 1$', '$1$ и $0$'],
['$a^m\\cdot a^n$', '$a^{m+n}$'],
['$(a^m)^n$', '$a^{mn}$'],
['$a^{-n}$', '$\\dfrac{1}{a^n}$'],
['$a^{m/n}$', '$\\sqrt[n]{a^m}$'],
]},
{ title: 'ЦТ · Производная — формулы', color: '#00F5D4', cards: [
['$(x^n)\'$', '$n x^{n-1}$'],
['$(\\sin x)\'$', '$\\cos x$'],
['$(\\cos x)\'$', '$-\\sin x$'],
['$(e^x)\'$', '$e^x$'],
['$(\\ln x)\'$', '$\\dfrac{1}{x}$'],
['$(uv)\'$', '$u\'v+uv\'$'],
['$\\left(\\dfrac{u}{v}\\right)\'$', '$\\dfrac{u\'v-uv\'}{v^2}$'],
['Монотонность по производной', '$f\'>0$ — возрастает; $f\'<0$ — убывает'],
['Точка экстремума', '$f\'=0$ и меняет знак'],
]},
];
const findDeck = db.prepare('SELECT id FROM flashcard_decks WHERE user_id=? AND title=?');
const insDeck = db.prepare('INSERT INTO flashcard_decks (user_id,title,description,color) VALUES (?,?,?,?)');
const insCard = db.prepare('INSERT INTO flashcard_cards (deck_id,front,back,order_idx) VALUES (?,?,?,?)');
console.log(DRY ? '[DRY-RUN]' : '[APPLY]', 'владелец user_id=', owner);
for (const d of DECKS) {
const ex = findDeck.get(owner, d.title);
if (ex) { console.log(` есть колода: «${d.title}» (id ${ex.id}) — пропуск`); continue; }
if (DRY) { console.log(` + колода «${d.title}» (${d.cards.length} карт)`); continue; }
const did = insDeck.run(owner, d.title, 'Формулы для подготовки к ЦЭ/ЦТ. Интервальное повторение.', d.color).lastInsertRowid;
d.cards.forEach(([f, b], i) => insCard.run(did, f, b, i));
console.log(` + колода «${d.title}» (id ${did}, ${d.cards.length} карт)`);
}
console.log(DRY ? 'DRY-RUN: ничего не записано.' : 'Готово. Колоды формул добавлены (владелец — admin; раздать классу можно через доступ к колоде).');
+176
View File
@@ -0,0 +1,176 @@
'use strict';
/*
* Уроки остальных блоков курса «ЦЭ/ЦТ — Математика» (по PLAN.md, шаблон пилотов).
* Числа · Преобразования · Уравнения(×2) · Функции · Прогрессии/текстовые ·
* Планиметрия · Продвинутое. Форматы блоков — под рендер lesson.html
* (text/heading/callout esc-only; математика $…$/$$…$$; callout.style). Идемпотентно.
* node backend/scripts/seed_ctmath_lessons_rest.js [--dry]
*/
const db = require('../src/db/db');
const DRY = process.argv.includes('--dry');
const COURSE_TITLE = 'ЦЭ/ЦТ — Математика';
const course = db.prepare("SELECT id FROM courses WHERE subject_slug='math' AND title=?").get(COURSE_TITLE);
if (!course) { console.error('Нет курса. Сначала seed_ctmath_course.js'); process.exit(1); }
const H = (text, level = 2) => ['heading', { text, level }];
const P = (text) => ['text', { text }];
const F = (tex, label) => ['formula', label ? { label, tex } : { tex }];
const CI = (text) => ['callout', { style: 'info', text }];
const CW = (text) => ['callout', { style: 'warning', text }];
const CS = (text) => ['callout', { style: 'success', text }];
const SIM = (simId, caption) => ['sim', { simId, caption }];
const FC = (front, back) => ['flashcard', { front, back }];
const QZ = (question, options, correctIndex) => ['quiz', { question, options, correctIndex }];
const PR = () => CI('Тренажёр по теме — в модуле /exam-prep/ctmath (реальные задания ЦТ прошлых лет) и в практике по теме.');
const LESSONS = [
{ section: 'Числа и вычисления', title: 'Числа, делимость и проценты', read: 8, blocks: [
H('Числа, делимость и проценты'),
P('Действительные числа на координатной прямой нужно уметь оценивать и сравнивать. Деление с остатком записывается формулой ниже.'),
F('n = d\\cdot q + r,\\qquad 0\\le r\\lt d', 'Деление с остатком'),
P('Проценты: $p\\%$ числа $a$ равно $\\dfrac{p}{100}\\cdot a$. Увеличение на $p\\%$ — умножение на $\\left(1+\\dfrac{p}{100}\\right)$, уменьшение — на $\\left(1-\\dfrac{p}{100}\\right)$.'),
F('\\text{НОД}(a,b)\\cdot\\text{НОК}(a,b)=a\\cdot b', 'Связь НОД и НОК'),
H('Разбор А4', 3),
P('Делитель $15$, неполное частное $k$, остаток $7$. Тогда делимое $n=15k+7$.'),
CS('Ответ: $n=15k+7$.'),
H('Разбор (проценты)', 3),
P('$15\\%$ числа равны $33$. Число $=33:0{,}15=220$, а $20\\%$ от него $=44$.'),
CS('Ответ: $44$.'),
FC('Деление с остатком', '$n=dq+r,\\ 0\\le r<d$'),
FC('$\\text{НОД}\\cdot\\text{НОК}$', '$a\\cdot b$'),
FC('$p\\%$ от $a$', '$\\dfrac{p}{100}\\,a$'),
QZ('20% некоторого числа равны 40. Само число равно:', ['200', '80', '160', '20'], 0),
PR(),
]},
{ section: 'Алгебраические преобразования', title: 'Степени, корни, дроби', read: 9, blocks: [
H('Преобразования выражений: степени, корни, дроби'),
F('a^m\\cdot a^n=a^{m+n},\\quad (a^m)^n=a^{mn},\\quad a^{-n}=\\dfrac{1}{a^n},\\quad a^{m/n}=\\sqrt[n]{a^m}', 'Степени'),
F('\\sqrt[n]{ab}=\\sqrt[n]{a}\\,\\sqrt[n]{b},\\qquad \\sqrt[n]{a^n}=|a|\\ (n\\text{ — чётное})', 'Корни'),
F('(a\\pm b)^2=a^2\\pm2ab+b^2,\\qquad a^2-b^2=(a-b)(a+b)', 'Формулы сокращённого умножения'),
P('ОДЗ выражения: под корнем чётной степени — неотрицательное число; знаменатель не равен нулю; аргумент логарифма положителен.'),
CW('В задании А10 проверяют именно ОДЗ: при каком значении выражение имеет смысл.'),
H('Разбор А10', 3),
P('При $a=-4$ из $\\sqrt{a}$, $\\sqrt[3]{a}$, $\\dfrac{1}{a+4}$ смысл имеет только $\\sqrt[3]{a}$: корень нечётной степени из отрицательного определён; $\\sqrt{-4}$ — нет; $\\dfrac{1}{0}$ — деление на ноль.'),
CS('Ответ: $\\sqrt[3]{a}$.'),
FC('$a^m\\cdot a^n$', '$a^{m+n}$'),
FC('$a^{m/n}$', '$\\sqrt[n]{a^m}$'),
FC('$a^2-b^2$', '$(a-b)(a+b)$'),
QZ('Значение $a^{1/2}$ при $a=9$:', ['3', '4,5', '81', '18'], 0),
PR(),
]},
{ section: 'Уравнения и неравенства', title: 'Квадратные, рациональные, модуль', read: 11, blocks: [
H('Квадратные и рациональные уравнения и неравенства. Модуль'),
F('x_{1,2}=\\dfrac{-b\\pm\\sqrt{D}}{2a},\\ \\ D=b^2-4ac;\\qquad x_1x_2=\\dfrac{c}{a},\\ \\ x_1+x_2=-\\dfrac{b}{a}', 'Квадратное уравнение и теорема Виета'),
SIM('quadratic', 'Корни квадратного уравнения и дискриминант'),
P('Метод интервалов: разложить на множители, отметить нули, расставить знаки по промежуткам. Учитывать кратность корня (при чётной кратности знак не меняется).'),
F('|x|=a\\Rightarrow x=\\pm a\\ (a\\ge0);\\qquad |f(x)|\\lt a\\Leftrightarrow -a\\lt f(x)\\lt a', 'Модуль'),
CI('Двойное неравенство $a\\le f(x)<b$ решают как систему; целые решения отбирают на полученном промежутке.'),
H('Разбор А5', 3),
P('Произведение действительных корней уравнения $x^2-5x+6=0$ по теореме Виета равно $6$ (корни $2$ и $3$).'),
CS('Ответ: $6$.'),
H('Разбор (целые решения)', 3),
P('Сколько целых решений у неравенства $-4<2x-1\\le5$? Имеем $-1{,}5<x\\le3$, то есть $x\\in\\{-1,0,1,2,3\\}$ — пять решений.'),
CS('Ответ: $5$.'),
FC('Дискриминант', '$D=b^2-4ac$'),
FC('Виет: сумма и произведение корней', '$x_1+x_2=-\\dfrac{b}{a},\\ x_1x_2=\\dfrac{c}{a}$'),
FC('$|f(x)|<a$', '$-a<f(x)<a$'),
QZ('Сумма корней уравнения $x^2-7x+12=0$:', ['7', '12', '-7', '3'], 0),
PR(),
]},
{ section: 'Уравнения и неравенства', title: 'Показательные, логарифмические, иррациональные', read: 12, blocks: [
H('Показательные, логарифмические, иррациональные уравнения и неравенства'),
F('a^{f}=a^{g}\\Leftrightarrow f=g;\\qquad \\log_a f=\\log_a g\\Leftrightarrow f=g\\gt 0', 'Равносильные переходы'),
F('\\sqrt{f}=g\\ \\Leftrightarrow\\ \\begin{cases}g\\ge0\\\\ f=g^2\\end{cases}', 'Иррациональное уравнение'),
CI('Метод рационализации (для неравенств): знак $\\log_a f-\\log_a g$ совпадает со знаком $(a-1)(f-g)$; знак $a^{f}-a^{g}$ — со знаком $(a-1)(f-g)$. Экономит время на сложных неравенствах.'),
CW('В логарифмических всегда выписывайте ОДЗ: аргумент $>0$, основание $>0$ и $\\ne1$.'),
H('Разбор В11', 3),
P('$\\log_2^2 x-3\\log_2 x+2=0$. Замена $t=\\log_2 x$: $t^2-3t+2=0$, $t=1$ или $t=2$, откуда $x=2$ или $x=4$; их произведение $8$.'),
CS('Ответ: $8$.'),
H('Разбор В14', 3),
P('Наименьшее целое решение неравенства $3^{x}>9$: так как основание $>1$, получаем $x>2$, наименьшее целое $x=3$.'),
CS('Ответ: $3$.'),
FC('$a^{f}=a^{g}$', '$f=g$'),
FC('$\\log_a f=\\log_a g$', '$f=g>0$'),
FC('Знак $\\log_a f-\\log_a g$ (рационализация)', 'как у $(a-1)(f-g)$'),
QZ('$\\log_3 81$ равно:', ['4', '3', '27', '9'], 0),
PR(),
]},
{ section: 'Функции и производная', title: 'Функции, графики, производная', read: 11, blocks: [
H('Функции: свойства, графики, производная'),
P('Ключевые свойства: ОДЗ, чётность (если $f(-x)=f(x)$ — чётная, график симметричен относительно $Oy$; если $f(-x)=-f(x)$ — нечётная), монотонность, нули.'),
SIM('graphtransform', 'Преобразования графиков: сдвиги и растяжения'),
F('f\'\\gt 0\\Rightarrow\\text{возрастает};\\quad f\'\\lt 0\\Rightarrow\\text{убывает};\\quad f\'=0\\ \\text{со сменой знака}\\Rightarrow\\text{экстремум}', 'Производная и поведение функции'),
H('Разбор В2 (квадратичная)', 3),
P('$f(x)=x^2-6x+5$: нули $1$ и $5$ (их сумма $6$); $f(0)=5$; вершина при $x=3$, наименьшее значение $f(3)=-4$.'),
CS('Сумма нулей $=6$; наименьшее значение $=-4$.'),
H('Разбор В19 (производная)', 3),
P('$f(x)=x^3-3x^2+5$: $f\'(x)=3x^2-6x=3x(x-2)$; функция возрастает на $(-\\infty;0]$ и $[2;+\\infty)$, убывает на $[0;2]$.'),
CS('Промежутки возрастания: $(-\\infty;0]$ и $[2;+\\infty)$.'),
FC('Чётная функция', '$f(-x)=f(x)$, симметрия относительно $Oy$'),
FC('$(x^n)\'$', '$n x^{n-1}$'),
FC('Признак возрастания', '$f\'(x)>0$'),
QZ('Функция $y=x^2$ является:', ['чётной', 'нечётной', 'ни чётной, ни нечётной', 'периодической'], 0),
PR(),
]},
{ section: 'Прогрессии и текстовые задачи', title: 'Прогрессии и текстовые задачи', read: 10, blocks: [
H('Прогрессии и текстовые задачи'),
F('a_n=a_1+(n-1)d,\\qquad S_n=\\dfrac{a_1+a_n}{2}\\,n', 'Арифметическая прогрессия'),
F('b_n=b_1 q^{\\,n-1},\\qquad S_n=\\dfrac{b_1(q^{n}-1)}{q-1}\\ (q\\ne1)', 'Геометрическая прогрессия'),
P('Текстовые задачи: проценты; движение ($s=vt$); работа (производительность $=\\dfrac{1}{t}$); смеси и сплавы (масса вещества $=$ доля $\\times$ масса смеси).'),
H('Разбор В6', 3),
P('$b_3=12$, $b_5=48$ (знаменатель положителен): $q^2=\\dfrac{48}{12}=4$, $q=2$, $b_1=\\dfrac{12}{4}=3$. Сумма первых четырёх членов $3+6+12+24=45$.'),
CS('Ответ: $45$.'),
H('Разбор (сплавы)', 3),
P('Сплав массой $200$ г содержит $30\\%$ меди. Масса меди $=0{,}3\\cdot200=60$ г. На таких долях строятся уравнения смесей.'),
FC('$n$-й член арифм. прогрессии', '$a_n=a_1+(n-1)d$'),
FC('Сумма геом. прогрессии', '$S_n=\\dfrac{b_1(q^n-1)}{q-1}$'),
FC('Путь', '$s=v\\cdot t$'),
QZ('В арифметической прогрессии $a_1=2$, $d=3$. Тогда $a_4$ равно:', ['11', '14', '8', '9'], 0),
PR(),
]},
{ section: 'Планиметрия', title: 'Треугольники, четырёхугольники, окружность', read: 11, blocks: [
H('Планиметрия: треугольники, четырёхугольники, окружность'),
F('S_\\triangle=\\tfrac12 a h_a=\\tfrac12 ab\\sin C;\\qquad \\dfrac{a}{\\sin A}=2R;\\qquad c^2=a^2+b^2-2ab\\cos C', 'Треугольник'),
SIM('triangle', 'Геометрия треугольника'),
P('Прямоугольный треугольник: гипотенуза $=2R$ (радиус описанной окружности). Правильный $n$-угольник связывает сторону, радиус описанной $R$ и вписанной $r$ окружностей.'),
CI('Вписанный угол равен половине центрального, опирающегося на ту же дугу.'),
H('Разбор В5', 3),
P('В прямоугольном треугольнике радиус описанной окружности $R=13$, один катет $10$. Гипотенуза $=2R=26$, второй катет $=\\sqrt{26^2-10^2}=\\sqrt{576}=24$.'),
CS('Ответ: $24$.'),
H('Разбор В10 (правильный шестиугольник)', 3),
P('У правильного шестиугольника со стороной $a$: $R=a$, $r=\\dfrac{\\sqrt3}{2}a$, площадь $S=\\dfrac{3\\sqrt3}{2}a^2$.'),
FC('Площадь треугольника', '$\\tfrac12 ab\\sin C$'),
FC('Теорема синусов', '$\\dfrac{a}{\\sin A}=2R$'),
FC('Вписанный угол', 'половина центрального на ту же дугу'),
QZ('Гипотенуза прямоугольного треугольника, вписанного в окружность радиуса 5, равна:', ['10', '5', '2,5', '25'], 0),
PR(),
]},
{ section: 'Продвинутое и комбинированное', title: 'Параметры и комбинированные задачи', read: 10, blocks: [
H('Задачи с параметрами и комбинированные задачи'),
P('Параметр — буква, от которой зависит ответ. Два подхода: аналитический (исследовать решение по параметру) и графический (семейство графиков и их пересечения).'),
CI('Частый приём: выразить параметр $a=\\varphi(x)$ и смотреть, сколько решений даёт горизонтальная прямая $y=a$ (число пересечений с графиком $\\varphi$).'),
P('Комбинированные задачи смешивают темы (алгебра и геометрия, прогрессии и проценты). Стратегия: разбить на подзадачи, аккуратно следя за ОДЗ и единицами.'),
CI('Продвинутый уровень подробно — в плане курса (Сканави, Высоцкий «Параметры», Прасолов). Здесь — общая стратегия и ориентиры.'),
FC('Графический метод для параметра', '$a=\\varphi(x)$; число решений = число пересечений $y=a$ с графиком'),
FC('Уравнение $x^2=a$: число решений', '$a>0$ — два, $a=0$ — одно, $a<0$ — нет'),
QZ('При каком $a$ уравнение $x^2=a$ имеет ровно одно решение?', ['a=0', 'a>0', 'a<0', 'при любом'], 0),
PR(),
]},
];
console.log(DRY ? '[DRY-RUN]' : '[APPLY]', 'курс id=', course.id);
const insLesson = db.prepare('INSERT INTO lessons (course_id, title, order_index, is_published, section_id, read_time) VALUES (?,?,?,1,?,?)');
const insBlock = db.prepare('INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?,?,?,?)');
const secOrder = {};
for (const L of LESSONS) {
const sec = db.prepare('SELECT id FROM course_sections WHERE course_id=? AND title=?').get(course.id, L.section);
if (!sec) { console.log(` [skip] нет секции «${L.section}»`); continue; }
const ex = db.prepare('SELECT id FROM lessons WHERE course_id=? AND title=?').get(course.id, L.title);
if (ex) { console.log(` есть урок: «${L.title}» (id ${ex.id}) — пропуск`); continue; }
secOrder[sec.id] = (secOrder[sec.id] || 0) + 1;
if (DRY) { console.log(` + [${L.section}] «${L.title}» (${L.blocks.length} блоков)`); continue; }
const lid = insLesson.run(course.id, L.title, secOrder[sec.id], sec.id, L.read).lastInsertRowid;
L.blocks.forEach(([type, data], bi) => insBlock.run(lid, type, bi, JSON.stringify(data)));
console.log(` + [${L.section}] «${L.title}» (id ${lid}, ${L.blocks.length} блоков)`);
}
console.log(DRY ? 'DRY-RUN: ничего не записано.' : 'Готово. Уроки остальных блоков добавлены (черновик курса).');
@@ -0,0 +1,128 @@
'use strict';
/*
* Уроки блока «Стереометрия» курса «ЦЭ/ЦТ — Математика» (по PILOT_STEREOMETRY.md).
* 4 урока: расположение/сечения → многогранники → тела вращения → углы/расстояния.
* Форматы блоков — под рендер frontend/lesson.html (text/heading/callout esc-only;
* математика $…$/$$…$$; callout.style=info|warning|success|error). Идемпотентно.
* node backend/scripts/seed_ctmath_lessons_stereo.js [--dry]
*/
const db = require('../src/db/db');
const DRY = process.argv.includes('--dry');
const COURSE_TITLE = 'ЦЭ/ЦТ — Математика', SECTION_TITLE = 'Стереометрия';
const course = db.prepare("SELECT id FROM courses WHERE subject_slug='math' AND title=?").get(COURSE_TITLE);
if (!course) { console.error('Нет курса. Сначала seed_ctmath_course.js'); process.exit(1); }
const section = db.prepare('SELECT id FROM course_sections WHERE course_id=? AND title=?').get(course.id, SECTION_TITLE);
if (!section) { console.error('Нет секции «' + SECTION_TITLE + '»'); process.exit(1); }
const H = (text, level = 2) => ['heading', { text, level }];
const P = (text) => ['text', { text }];
const F = (tex, label) => ['formula', label ? { label, tex } : { tex }];
const CI = (text) => ['callout', { style: 'info', text }];
const CW = (text) => ['callout', { style: 'warning', text }];
const CS = (text) => ['callout', { style: 'success', text }];
const SIM = (caption) => ['sim', { simId: 'stereo', caption }];
const FC = (front, back) => ['flashcard', { front, back }];
const ORD = (question, items) => ['ordering', { question, items }];
const ACC = (title, content) => ['accordion', { title, content }];
// M26 — расположение, сечения (А2, В1)
const L1 = [
H('Прямые и плоскости в пространстве'),
P('Две прямые в пространстве: пересекаются, параллельны или скрещиваются. Прямая и плоскость: прямая лежит в плоскости, параллельна ей или пересекает её. Две плоскости: параллельны или пересекаются по прямой.'),
SIM('Покрутите фигуру: найдите линию пересечения двух плоскостей и пары скрещивающихся прямых'),
CI('Линия пересечения двух плоскостей проходит через их общие точки. В правильной пирамиде плоскости, проходящие через вершину и центр основания, пересекаются по прямой через вершину (например, $SO$).'),
F('a\\parallel b,\\ b\\subset\\alpha,\\ a\\not\\subset\\alpha \\Rightarrow a\\parallel\\alpha', 'Признак параллельности прямой и плоскости'),
CW('В задании В1 (выбор верных утверждений о расстояниях) проверяйте каждое утверждение отдельно: расстояние между скрещивающимися прямыми — это длина их общего перпендикуляра, а не любого отрезка.'),
H('Разбор А2', 3),
P('Пример. В правильной четырёхугольной пирамиде $SABCD$ ($O$ — центр основания) найдите прямую пересечения плоскостей $DSO$ и $SCB$.'),
P('Решение. Обе плоскости проходят через вершину $S$, значит линия их пересечения проходит через $S$; анализом общих точек получаем прямую $SO$.'),
CS('Метод: ищем общие точки двух плоскостей — через них проходит линия пересечения.'),
FC('Расстояние между скрещивающимися прямыми', 'Длина их общего перпендикуляра'),
FC('Линия пересечения двух плоскостей', 'Проходит через все их общие точки'),
CI('Тренажёр: задания А2 и В1 по теме «Стереометрия» в практике модуля /exam-prep/ctmath. Цель: не менее 80%.'),
];
// M27 — многогранники (В13, В17)
const L2 = [
H('Многогранники: объёмы, площади, подобие'),
F('V_{\\text{призмы}}=S_{\\text{осн}}\\cdot h,\\qquad V_{\\text{пирамиды}}=\\tfrac{1}{3}S_{\\text{осн}}\\cdot h', 'Объёмы'),
P('Сечение, параллельное основанию пирамиды, отсекает подобную фигуру. Если высота делится от вершины в отношении $k$, то линейные размеры сечения относятся к основанию как $k$, а площади — как $k^2$.'),
F('\\dfrac{S_{\\text{сеч}}}{S_{\\text{осн}}}=k^2,\\quad k=\\dfrac{\\text{высота до сечения}}{\\text{вся высота}}', 'Сечение, параллельное основанию'),
SIM('Сечение пирамиды плоскостью, параллельной основанию'),
CW('В задании В17 ловят на том, что как $k^2$ относятся именно площади, а не длины. Сначала найдите $k$ из отношения высот, затем возводите в квадрат.'),
H('Разбор В17', 3),
P('Пример. Плоскость, параллельная основанию треугольной пирамиды, делит высоту в отношении $5:3$ от вершины. Площадь сечения меньше площади основания на $39$. Найдите площадь сечения.'),
P('Решение. $k=\\dfrac{5}{5+3}=\\dfrac{5}{8}$, поэтому $\\dfrac{S_{\\text{сеч}}}{S_{\\text{осн}}}=\\dfrac{25}{64}$. Пусть $S_{\\text{осн}}=x$: $x-\\dfrac{25}{64}x=39\\Rightarrow\\dfrac{39}{64}x=39\\Rightarrow x=64$. Тогда $S_{\\text{сеч}}=25$.'),
CS('Ответ: $25$.'),
FC('$V$ пирамиды', '$\\tfrac{1}{3}S_{\\text{осн}}\\cdot h$'),
FC('Отношение площадей сечения и основания (сечение $\\parallel$ основанию)', '$k^2$, где $k$ — отношение высот от вершины'),
CI('Тренажёр: В13 и В17 по теме «Стереометрия». Цель: не менее 75%.'),
];
// M28 — тела вращения (А9, В13)
const L3 = [
H('Тела вращения: цилиндр, конус, шар'),
F('S_{\\text{сферы}}=4\\pi R^2,\\qquad V_{\\text{шара}}=\\tfrac{4}{3}\\pi R^3', 'Шар и сфера'),
F('S_{\\text{бок}}=2\\pi R h,\\qquad V=\\pi R^2 h', 'Цилиндр'),
F('S_{\\text{бок}}=\\pi R l,\\qquad V=\\tfrac{1}{3}\\pi R^2 h', 'Конус'),
SIM('Сечение цилиндра плоскостью, параллельной оси'),
CI('Сфера, касающаяся плоскости: радиус в точку касания перпендикулярен плоскости. Расстояние от центра до точки плоскости и радиус образуют прямоугольный треугольник — работает теорема Пифагора.'),
H('Разбор А9', 3),
P('Пример. Квадрат с диагональю $8$ лежит в плоскости $\\alpha$; сфера касается $\\alpha$ в точке пересечения диагоналей; расстояние от центра сферы до вершины квадрата равно $4\\sqrt2$. Найдите площадь сферы.'),
P('Решение. Полудиагональ $=4$. $R^2=(4\\sqrt2)^2-4^2=32-16=16$, $R=4$. Площадь $=4\\pi R^2=64\\pi$.'),
CS('Ответ: $64\\pi$.'),
H('Разбор В13', 3),
P('Пример. Цилиндр рассечён плоскостью, параллельной оси; в сечении квадрат площади $100$; расстояние от оси до плоскости $\\sqrt{39}$. Найдите $\\dfrac{S_{\\text{бок}}}{\\pi}$.'),
P('Решение. Сторона квадрата $=10$ (это и высота, и хорда). $R^2=(\\sqrt{39})^2+5^2=39+25=64$, $R=8$. $S_{\\text{бок}}=2\\pi\\cdot8\\cdot10=160\\pi$.'),
CS('Ответ: $160$.'),
FC('$S$ сферы', '$4\\pi R^2$'),
FC('$V$ шара', '$\\tfrac{4}{3}\\pi R^3$'),
FC('$S_{\\text{бок}}$ конуса', '$\\pi R l$'),
CI('Тренажёр: А9 и В13 по теме «Стереометрия». Цель: не менее 80% (А9) и 70% (В13).'),
];
// M29 — углы и расстояния, координатный метод (В20)
const L4 = [
H('Координатный метод: угол между прямыми'),
P('Универсальный приём для В20: ввести удобную систему координат (вершину фигуры в начало), выписать координаты нужных точек, составить направляющие векторы прямых и найти угол через косинус скалярного произведения. Если геометрия «не идёт» — считайте координатами.'),
F('\\cos\\varphi=\\dfrac{|\\vec a\\cdot\\vec b|}{|\\vec a|\\,|\\vec b|}', 'Угол между прямыми через векторы'),
F('\\vec a\\cdot\\vec b=a_xb_x+a_yb_y+a_zb_z,\\qquad |\\vec a|=\\sqrt{a_x^2+a_y^2+a_z^2}', 'Скалярное произведение и длина'),
SIM('Угол между скрещивающимися прямыми'),
ORD('Расставьте шаги решения В20 координатным методом', [
'Ввести систему координат',
'Выписать координаты точек (учесть отношения деления рёбер)',
'Составить направляющие векторы прямых',
'Найти cos φ через скалярное произведение и длины',
]),
CW('В числителе — модуль скалярного произведения (угол между прямыми не превосходит $90^\\circ$). Частые ошибки В20 — потеря модуля и неверные координаты точек деления рёбер.'),
ACC('Альтернативы (раскрыть)', 'Угол между прямой и плоскостью считают через нормаль плоскости; есть также теорема о трёх синусах. Но координатный метод универсален и почти всегда быстрее в задачах ЦТ.'),
H('Разбор В20', 3),
P('Пример. В кубе $ABCDA_1B_1C_1D_1$ с ребром $1$ найдите $8\\cos^2\\varphi$, где $\\varphi$ — угол между прямыми $AB_1$ и $BC_1$.'),
P('Решение. Координаты: $A(0;0;0)$, $B(1;0;0)$, $B_1(1;0;1)$, $C_1(1;1;1)$. Векторы $\\vec{AB_1}=(1;0;1)$, $\\vec{BC_1}=(0;1;1)$. $\\cos\\varphi=\\dfrac{|1|}{\\sqrt2\\cdot\\sqrt2}=\\dfrac{1}{2}$, поэтому $8\\cos^2\\varphi=8\\cdot\\dfrac14=2$.'),
CS('Ответ: $2$.'),
FC('Угол между прямыми (векторы)', '$\\cos\\varphi=\\dfrac{|\\vec a\\cdot\\vec b|}{|\\vec a||\\vec b|}$'),
FC('Скалярное произведение', '$a_xb_x+a_yb_y+a_zb_z$'),
FC('Длина вектора', '$\\sqrt{a_x^2+a_y^2+a_z^2}$'),
CI('Тренажёр: В20 по теме «Стереометрия» (координатный метод). Цель: не менее 60% — это самые «дорогие» баллы.'),
];
const LESSONS = [
{ title: 'Расположение прямых и плоскостей. Сечения', read: 9, blocks: L1 },
{ title: 'Многогранники: объёмы, площади, подобие', read: 11, blocks: L2 },
{ title: 'Тела вращения: цилиндр, конус, шар', read: 11, blocks: L3 },
{ title: 'Углы и расстояния: координатный метод', read: 12, blocks: L4 },
];
console.log(DRY ? '[DRY-RUN]' : '[APPLY]', `курс id=${course.id}, секция «${SECTION_TITLE}» id=${section.id}`);
const insLesson = db.prepare('INSERT INTO lessons (course_id, title, order_index, is_published, section_id, read_time) VALUES (?,?,?,1,?,?)');
const insBlock = db.prepare('INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?,?,?,?)');
LESSONS.forEach((L, i) => {
const ex = db.prepare('SELECT id FROM lessons WHERE course_id=? AND title=?').get(course.id, L.title);
if (ex) { console.log(` есть урок: «${L.title}» (id ${ex.id}) — пропуск`); return; }
if (DRY) { console.log(` + урок «${L.title}» (${L.blocks.length} блоков)`); return; }
const lid = insLesson.run(course.id, L.title, 10 + i + 1, section.id, L.read).lastInsertRowid;
L.blocks.forEach(([type, data], bi) => insBlock.run(lid, type, bi, JSON.stringify(data)));
console.log(` + урок «${L.title}» (id ${lid}, ${L.blocks.length} блоков)`);
});
console.log(DRY ? 'DRY-RUN: ничего не записано.' : 'Готово. Уроки стереометрии добавлены (черновик курса).');
+136
View File
@@ -0,0 +1,136 @@
'use strict';
/*
* Уроки блока «Тригонометрия» курса «ЦЭ/ЦТ — Математика» (по PILOT_TRIGONOMETRY.md).
* Создаёт 3 урока (круг → тождества → уравнения) в секции «Тригонометрия» курса.
* Форматы блоков — РОВНО под рендер frontend/lesson.html (text/heading/callout
* экранируются → только текст; математика через $...$ / $$...$$; callout.style
* = info|warning|success|error). data хранится JSON-строкой (API её парсит).
* ИДЕМПОТЕНТЕН: урок с тем же title в курсе не создаётся повторно.
* Запуск: node backend/scripts/seed_ctmath_lessons_trig.js [--dry]
*/
const db = require('../src/db/db');
const DRY = process.argv.includes('--dry');
const COURSE_TITLE = 'ЦЭ/ЦТ — Математика';
const SECTION_TITLE = 'Тригонометрия';
const course = db.prepare("SELECT id FROM courses WHERE subject_slug='math' AND title=?").get(COURSE_TITLE);
if (!course) { console.error('Нет курса «' + COURSE_TITLE + '». Сначала: node backend/scripts/seed_ctmath_course.js'); process.exit(1); }
const section = db.prepare('SELECT id FROM course_sections WHERE course_id=? AND title=?').get(course.id, SECTION_TITLE);
if (!section) { console.error('Нет секции «' + SECTION_TITLE + '» в курсе ' + course.id); process.exit(1); }
// helpers для краткости описания блоков
const H = (text, level = 2) => ['heading', { text, level }];
const P = (text) => ['text', { text }];
const F = (tex, label) => ['formula', label ? { label, tex } : { tex }];
const CI = (text) => ['callout', { style: 'info', text }];
const CW = (text) => ['callout', { style: 'warning', text }];
const CS = (text) => ['callout', { style: 'success', text }];
const SIM= (caption) => ['sim', { simId: 'trigcircle', caption }];
const FC = (front, back) => ['flashcard', { front, back }];
const QZ = (question, options, correctIndex) => ['quiz', { question, options, correctIndex }];
const ORD= (question, items) => ['ordering', { question, items }];
const MAT= (question, pairs) => ['matching', { question, pairs }];
const ACC= (title, content) => ['accordion', { title, content }];
const TBL= (header, rows) => ['table', { header, rows }];
// ── Урок 1: Тригонометрический круг и значения (А3, базовый) ──
const L1 = [
H('Тригонометрический круг: смысл синуса и косинуса'),
P('Возьмём окружность радиуса 1 с центром в начале координат. При повороте на угол $\\alpha$ точка на этой окружности получает координаты $(\\cos\\alpha;\\ \\sin\\alpha)$. Это определение, из которого выводится вся тригонометрия: заучивать таблицы наизусть не нужно — нужно уметь «читать» круг.'),
F('\\cos\\alpha = x,\\quad \\sin\\alpha = y,\\quad \\operatorname{tg}\\alpha=\\dfrac{y}{x},\\quad \\operatorname{ctg}\\alpha=\\dfrac{x}{y}', 'Определения через единичную окружность'),
SIM('Покрутите угол и следите за координатами точки — это и есть $\\cos\\alpha$ и $\\sin\\alpha$'),
CI('Знаки по четвертям: I (+,+), II (,+), III (,), IV (+,−). Косинус — это абсцисса, синус — ордината.'),
H('Значения для основных углов', 3),
TBL(
['$\\alpha$', '$0$', '$\\tfrac{\\pi}{6}$', '$\\tfrac{\\pi}{4}$', '$\\tfrac{\\pi}{3}$', '$\\tfrac{\\pi}{2}$'],
[
['$\\sin\\alpha$', '$0$', '$\\tfrac{1}{2}$', '$\\tfrac{\\sqrt2}{2}$', '$\\tfrac{\\sqrt3}{2}$', '$1$'],
['$\\cos\\alpha$', '$1$', '$\\tfrac{\\sqrt3}{2}$', '$\\tfrac{\\sqrt2}{2}$', '$\\tfrac{1}{2}$', '$0$'],
['$\\operatorname{tg}\\alpha$', '$0$', '$\\tfrac{\\sqrt3}{3}$', '$1$', '$\\sqrt3$', '—'],
]
),
F('\\sin x = 0 \\iff x=\\pi k;\\qquad \\cos x = 0 \\iff x=\\tfrac{\\pi}{2}+\\pi k', 'Когда функция равна нулю'),
CW('Типичная ошибка — путать, где ноль у синуса (при $0,\\ \\pi,\\ 2\\pi,\\dots$) и у косинуса (при $\\tfrac{\\pi}{2},\\ \\tfrac{3\\pi}{2},\\dots$). На круге это видно сразу: синус — высота, косинус — горизонталь.'),
FC('$\\sin x = 0$ при каких $x$?', '$x = \\pi k,\\ k\\in\\mathbb{Z}$'),
FC('$\\cos x = 0$ при каких $x$?', '$x = \\tfrac{\\pi}{2}+\\pi k$'),
H('Разбор задания А3', 3),
P('Типичное А3: среди нескольких значений аргумента указать то, при котором функция равна нулю.'),
P('Пример. Среди $-\\tfrac{\\pi}{6};\\ \\tfrac{\\pi}{4};\\ \\tfrac{\\pi}{3};\\ -\\tfrac{3\\pi}{2};\\ -6\\pi$ укажите то, при котором $\\sin x = 0$.'),
P('Решение. $\\sin x=0$ только когда $x$ кратно $\\pi$. Из списка кратно $\\pi$ лишь $-6\\pi$.'),
CS('Ответ: $-6\\pi$.'),
QZ('При каком значении аргумента cos x = 1?', ['π/2', 'π', '0', '3π/2'], 2),
CI('Тренажёр по теме «Тригонометрия» (реальные задания А3 прошлых лет) — в практике курса. Цель освоения: не менее 90% на заданиях А3.'),
];
// ── Урок 2: Тождества и формулы (А8, В4, средний) ──
const L2 = [
H('Тождества: как не учить 30 формул'),
F('\\sin^2\\alpha+\\cos^2\\alpha=1', 'Основное тригонометрическое тождество'),
P('Это теорема Пифагора для точки $(\\cos\\alpha;\\ \\sin\\alpha)$ на единичной окружности. Разделив его на $\\cos^2\\alpha$ и на $\\sin^2\\alpha$, получаем связи с тангенсом и котангенсом — их выводят на месте, а не заучивают.'),
F('1+\\operatorname{tg}^2\\alpha=\\dfrac{1}{\\cos^2\\alpha},\\qquad 1+\\operatorname{ctg}^2\\alpha=\\dfrac{1}{\\sin^2\\alpha}'),
ACC('Формулы сложения и двойного угла (раскрыть)', 'Сложение: $\\sin(\\alpha\\pm\\beta)=\\sin\\alpha\\cos\\beta\\pm\\cos\\alpha\\sin\\beta$; $\\cos(\\alpha\\pm\\beta)=\\cos\\alpha\\cos\\beta\\mp\\sin\\alpha\\sin\\beta$. Двойной угол: $\\sin 2\\alpha=2\\sin\\alpha\\cos\\alpha$; $\\cos 2\\alpha=\\cos^2\\alpha-\\sin^2\\alpha$. Все они следуют из формул сложения.'),
CI('Обратные функции и их области значений (на них ловят в А8): $\\arcsin x\\in[-\\tfrac{\\pi}{2};\\tfrac{\\pi}{2}]$, $\\arccos x\\in[0;\\pi]$, $\\operatorname{arctg} x\\in(-\\tfrac{\\pi}{2};\\tfrac{\\pi}{2})$.'),
MAT('Сопоставьте выражение и тождественно равное ему', [
{ left: '$\\sin 2\\alpha$', right: '$2\\sin\\alpha\\cos\\alpha$' },
{ left: '$\\cos 2\\alpha$', right: '$\\cos^2\\alpha-\\sin^2\\alpha$' },
{ left: '$1-\\cos 2\\alpha$', right: '$2\\sin^2\\alpha$' },
]),
H('Разбор А8 (обратные функции и модуль)', 3),
P('Пример. Найдите значение $\\dfrac{38}{\\pi}\\cdot\\arcsin(-1)-|-7|$.'),
P('Решение. $\\arcsin(-1)=-\\tfrac{\\pi}{2}$, поэтому $\\dfrac{38}{\\pi}\\cdot\\left(-\\tfrac{\\pi}{2}\\right)=-19$; далее $-19-7=-26$.'),
CS('Ответ: $-26$.'),
H('Разбор В4 (тождество)', 3),
P('Пример. Найдите $\\operatorname{ctg}^2\\alpha$, если $\\sin\\alpha=\\tfrac{1}{5}$.'),
P('Решение. $\\cos^2\\alpha=1-\\tfrac{1}{25}=\\tfrac{24}{25}$, поэтому $\\operatorname{ctg}^2\\alpha=\\dfrac{\\cos^2\\alpha}{\\sin^2\\alpha}=\\dfrac{24/25}{1/25}=24$.'),
CS('Ответ: $24$.'),
FC('$1+\\operatorname{tg}^2\\alpha$', '$\\dfrac{1}{\\cos^2\\alpha}$'),
FC('$\\cos 2\\alpha$', '$\\cos^2\\alpha-\\sin^2\\alpha=1-2\\sin^2\\alpha=2\\cos^2\\alpha-1$'),
FC('Область значений $\\arccos x$', '$[0;\\ \\pi]$'),
CI('Тренажёр: реальные задания А8 и В4 в практике курса. Цель освоения: не менее 85%.'),
];
// ── Урок 3: Уравнения и отбор корней (В15, продвинутый) ──
const L3 = [
H('Тригонометрические уравнения и отбор корней'),
F('\\sin x=a\\Rightarrow x=(-1)^n\\arcsin a+\\pi n;\\quad \\cos x=a\\Rightarrow x=\\pm\\arccos a+2\\pi n;\\quad \\operatorname{tg} x=a\\Rightarrow x=\\operatorname{arctg} a+\\pi n', 'Формулы корней простейших уравнений'),
P('Стратегия В15: сначала свести уравнение к произведению или простейшему виду формулами преобразования; затем выписать общие формулы корней; затем отобрать корни, попадающие в заданный промежуток; и наконец выполнить требуемое (например, найти сумму корней).'),
ORD('Расставьте шаги решения В15 по порядку', [
'Преобразовать уравнение к произведению или простейшему виду',
'Выписать общие формулы корней',
'Подставить целые n и отобрать корни на заданном промежутке',
'Сложить (или иначе обработать) отобранные корни',
]),
SIM('Отбор корней: отметьте промежуток и проверьте, какие корни в него попадают'),
CW('Самая частая потеря баллов в В15 — неполный отбор корней и потеря ОДЗ (для $\\operatorname{tg}$ и $\\operatorname{ctg}$). Проверяйте оба семейства корней.'),
H('Разбор простого примера', 3),
P('Найдите (в градусах) сумму корней уравнения $\\cos 2x=0$ на промежутке $(0^\\circ;\\ 180^\\circ)$.'),
P('Решение. $\\cos 2x=0\\Rightarrow 2x=90^\\circ+180^\\circ k\\Rightarrow x=45^\\circ+90^\\circ k$. На промежутке лежат $45^\\circ$ и $135^\\circ$. Их сумма равна $180$.'),
CS('Ответ: $180$.'),
ACC('Более сложный пример (В15 из ЦЭ-2024) — раскрыть', 'Найдите сумму различных корней уравнения $2\\sin 3x\\cos 3x-\\sin 6x\\sin 10x=0$ на промежутке $(-150^\\circ;-55^\\circ)$. Идея: $2\\sin 3x\\cos 3x=\\sin 6x$, выносим общий множитель: $\\sin 6x\\,(1-\\sin 10x)=0$. Дальше решаем $\\sin 6x=0$ или $\\sin 10x=1$ и отбираем корни на промежутке.'),
FC('$\\sin x=a$ (корни)', '$x=(-1)^n\\arcsin a+\\pi n$'),
FC('$\\cos x=a$ (корни)', '$x=\\pm\\arccos a+2\\pi n$'),
FC('$\\operatorname{tg} x=a$ (корни)', '$x=\\operatorname{arctg} a+\\pi n$'),
CI('Тренажёр: тема «Тригонометрические уравнения» (В15) в практике курса. Цель освоения: не менее 70%, отбор корней без потерь.'),
];
const LESSONS = [
{ title: 'Тригонометрический круг и значения', read: 9, blocks: L1 },
{ title: 'Тождества и формулы', read: 10, blocks: L2 },
{ title: 'Уравнения и отбор корней', read: 11, blocks: L3 },
];
console.log(DRY ? '[DRY-RUN]' : '[APPLY]', `курс id=${course.id}, секция «${SECTION_TITLE}» id=${section.id}`);
const insLesson = db.prepare('INSERT INTO lessons (course_id, title, order_index, is_published, section_id, read_time) VALUES (?,?,?,1,?,?)');
const insBlock = db.prepare('INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?,?,?,?)');
LESSONS.forEach((L, i) => {
const ex = db.prepare('SELECT id FROM lessons WHERE course_id=? AND title=?').get(course.id, L.title);
if (ex) { console.log(` есть урок: «${L.title}» (id ${ex.id}) — пропуск`); return; }
if (DRY) { console.log(` + урок «${L.title}» (${L.blocks.length} блоков)`); return; }
const lid = insLesson.run(course.id, L.title, i + 1, section.id, L.read).lastInsertRowid;
L.blocks.forEach(([type, data], bi) => insBlock.run(lid, type, bi, JSON.stringify(data)));
console.log(` + урок «${L.title}» (id ${lid}, ${L.blocks.length} блоков)`);
});
console.log(DRY ? 'DRY-RUN: ничего не записано.' : 'Готово. Уроки тригонометрии добавлены в курс (черновик; ученикам видны после публикации курса).');
+384
View File
@@ -0,0 +1,384 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
seed_ctmath_rt2425_e1v1.js
Эталонный «чистый» вариант-пробник для трека exam-prep `ctmath`.
Источник: РТ–2024/2025, Этап I, Вариант 1 (РИКЗ, «Тематическое
консультирование по математике»). 30 заданий: А1–А10 (часть A) +
В1–В20 (часть B). Перенабрано вручную в KaTeX по PDF
(F:\!Рабочие\ЦТ\Математика\Математика\РТ\2024-2025\МАТ РТ-1 24_25 В1.pdf).
Чем отличается от уже залитых 723 задач: те сгруппированы по `variant`=ГОД
(2024 → 114 задач в одной «пачке») и НЕ образуют чистого 30-задачного
варианта, поэтому таймер-пробник на них собирается криво. Здесь
variant=101 — ровно 30 заданий (task_idx 1..30) → корректный пробник
на 180 мин (mock source='variant').
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx). Повторный
запуск обновляет строки, не плодит дубли.
Запуск:
node backend/scripts/seed_ctmath_rt2425_e1v1.js # DRY-RUN (по умолчанию)
node backend/scripts/seed_ctmath_rt2425_e1v1.js --apply # запись в БД
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную (авто-режим
Claude Code блокирует продакшн-записи). Скрипт безопасен: без --apply
ничего не пишет, только печатает сводку и самопроверку.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
const VARIANT = 101; // чистый 30-задачный вариант (не год)
const PROV = 'РТ–2024/2025, Этап I, Вариант 1';
const R = String.raw;
// figure helper — белый фон у самого PNG, поэтому смотрится на любой теме
const FIG = (name, alt) =>
`<img src="/img/ct/math/rt2425_e1v1/${name}" alt="${alt}" ` +
`style="max-width:300px;width:100%;height:auto;display:block;margin:10px auto;` +
`background:#fff;border-radius:8px;padding:6px;">`;
/* opts: метки кириллица а–д (как в существующих 723 строках ctmath;
checkAnswerServer имеет ветку /^[а-д]$/). РТ-варианты 1..5 → а..д. */
const L = ['а', 'б', 'в', 'г', 'д'];
const mc = (...html) => html.map((h, i) => [L[i], h]);
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
const TASKS = [
// ── Часть A: А1–А10 ──────────────────────────────────────────────────────
{ idx: 1, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 1,
text: R`Результатом округления числа $1{,}3678$ до тысячных является число:`,
opts: mc('$0{,}368$', '$1{,}367$', '$1{,}368$', '$1{,}370$', '$1{,}363$'),
answer: 'в',
sol: R`Округляем до третьего знака после запятой: $1{,}3678\approx 1{,}368$.`,
ref: 'Герасимов «Математика, 6 кл.», гл. 1, § 2' },
{ idx: 2, type: 'mc', topic: 'planimetry', subtopic: 'plan-circle', diff: 1,
text: R`Среди отрезков $FP$, $OA$, $NK$, $MA$, $TE$ укажите отрезок, который является хордой окружности, изображённой на рисунке. (Точка $O$ — центр окружности.)`,
opts: mc('$FP$', '$OA$', '$NK$', '$MA$', '$TE$'),
answer: 'г',
sol: R`Хорда — это отрезок, соединяющий две точки окружности. Обе точки $M$ и $A$ лежат на окружности, поэтому хордой является отрезок $MA$.`,
ref: 'Казаков «Геометрия, 7 кл.», гл. 1, § 4',
fig: FIG('a2.png', 'Окружность с центром O и точками E, K, P, F, N, A, T, M') },
{ idx: 3, type: 'mc', topic: 'functions', subtopic: 'fn-graphs', diff: 2,
text: R`Укажите номер функции, график которой параллелен графику функции $y=4x+1$.`,
opts: mc('$y=4x-1$', '$y=-4x+1$', '$y=-4x-1$', '$y=x+4$', '$y=-x-4$'),
answer: 'а',
sol: R`Графики линейных функций параллельны, если их угловые коэффициенты равны, а свободные члены различны. У $y=4x+1$ коэффициент $k=4$; из предложенных только $y=4x-1$ имеет $k=4$ и $b=-1\ne 1$.`,
ref: 'Арефьева «Алгебра, 7 кл.», гл. 3, § 20' },
{ idx: 4, type: 'mc', topic: 'expressions', subtopic: 'expr-polynomials', diff: 2,
text: R`Найдите значения данных выражений при $x=-1{,}1$. Укажите номер того выражения, значение которого является наибольшим.`,
opts: mc('$2x$', '$1-x$', '$|x|$', '$x+1$', '$x^2$'),
answer: 'б',
sol: R`Подставим $x=-1{,}1$: $\ 1)\ 2x=-2{,}2;\quad 2)\ 1-x=2{,}1;\quad 3)\ |x|=1{,}1;\quad 4)\ x+1=-0{,}1;\quad 5)\ x^2=1{,}21.$ Наибольшее значение $2{,}1$у выражения $1-x$.`,
ref: 'Арефьева «Алгебра, 7 кл.», гл. 2, § 4' },
{ idx: 5, type: 'mc', topic: 'equations', subtopic: 'eq-linear', diff: 1,
text: R`Определите, при каком из значений $x$, равных $6;\ 0;\ 1{,}8;\ 4{,}2;\ -1$, верно двойное неравенство $3\le x+1<7$.`,
opts: mc('$6$', '$0$', '$1{,}8$', '$4{,}2$', '$-1$'),
answer: 'г',
sol: R`Неравенство $3\le x+1<7$ равносильно $2\le x<6$. Из данных чисел этому промежутку принадлежит только $x=4{,}2$.`,
ref: 'Арефьева «Алгебра, 7 кл.», гл. 3, § 18' },
{ idx: 6, type: 'open', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Укажите номера верных равенств.<br>1) $2^{5/7}:2^{4/7}=2^{1/7}$;<br>2) $2^{1/3}=\dfrac{1}{8}$;<br>3) $2^{1/3}\cdot 2^{2}=2^{2/3}$;<br>4) $\left(2^{1/3}\right)^{2}=2^{1/9}$;<br>5) $2^{1/3}\cdot 5^{1/3}=10^{1/3}$.<br><i>Ответ запишите цифрами в порядке возрастания, без пробелов.</i>`,
answer: '15', ansShow: '1, 5',
sol: R`$1)\ 2^{5/7}:2^{4/7}=2^{5/7-4/7}=2^{1/7}$ — верно. $\ 2)$ неверно. $\ 3)\ 2^{1/3}\cdot 2^{2}=2^{1/3+2}=2^{7/3}\ne 2^{2/3}$ — неверно. $\ 4)\ \left(2^{1/3}\right)^{2}=2^{2/3}\ne 2^{1/9}$ — неверно. $\ 5)\ 2^{1/3}\cdot 5^{1/3}=(2\cdot5)^{1/3}=10^{1/3}$ — верно.`,
ref: 'Арефьева «Алгебра, 11 кл.», гл. 1, § 1' },
{ idx: 7, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 2,
text: R`В состав чайного сбора входят мята и липа в отношении $2:3$ соответственно. Сколько граммов липы входит в $975$ г такого сбора?`,
opts: mc('$390$ г', '$325$ г', '$875$ г', '$545$ г', '$585$ г'),
answer: 'д',
sol: R`Пусть на одну часть приходится $k$ г: мята — $2k$, липа — $3k$. Тогда $2k+3k=975$, $5k=975$, $k=195$. Липа: $3k=585$ г.`,
ref: 'Герасимов «Математика, 6 кл.», гл. 2, § 5' },
{ idx: 8, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Результат упрощения выражения $\sqrt{(x-3)^{2}}$ при $-1{,}6<x<-1$ имеет вид:`,
opts: mc('$x-6$', '$-x+3$', '$x+3$', '$-x-3$', '$x-3$'),
answer: 'б',
sol: R`По свойству $\sqrt{a^{2}}=|a|$ имеем $\sqrt{(x-3)^{2}}=|x-3|$. При $-1{,}6<x<-1$ выражение $x-3<0$, поэтому $|x-3|=-(x-3)=-x+3$.`,
ref: 'Арефьева «Алгебра, 8 кл.», гл. 1, § 3' },
{ idx: 9, type: 'mc', topic: 'stereometry', subtopic: 'ster-basics', diff: 2,
text: R`Прямая $a$ перпендикулярна плоскости $\alpha$ и пересекает её в точке $A$. Точка $B$ находится на расстоянии $2$ от прямой $a$ и на расстоянии $\sqrt{21}$ от плоскости $\alpha$. Найдите расстояние от точки $B$ до точки $A$.`,
opts: mc('$5$', '$\sqrt{17}$', '$2\sqrt{21}$', '$\sqrt{23}$', '$6$'),
answer: 'а',
sol: R`Опустим перпендикуляры: $BK=2$ — на прямую $a$, $BM=\sqrt{21}$ — на плоскость $\alpha$; тогда $AM=BK=2$. В прямоугольном треугольнике $BMA$ по теореме Пифагора $BA^{2}=BM^{2}+AM^{2}=(\sqrt{21})^{2}+2^{2}=25$, значит $BA=5$.`,
ref: 'Латотин «Геометрия, 10 кл.», разд. 3, § 8' },
{ idx: 10, type: 'open', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Укажите номера выражений, которые имеют смысл.<br>1) $-\sqrt[4]{\sqrt{51}-8}$;<br>2) $\sqrt[3]{-8}$;<br>3) $\sqrt[4]{8^{-1}}$;<br>4) $\sqrt[4]{-8}$;<br>5) $-\sqrt[5]{8^{-1}}$.<br><i>Ответ запишите цифрами в порядке возрастания, без пробелов.</i>`,
answer: '235', ansShow: '2, 3, 5',
sol: R`$1)$ не имеет смысла: $\sqrt{51}-8<0$, а корень чётной степени из отрицательного числа не существует. $\ 2)\ \sqrt[3]{-8}$ — корень нечётной степени, смысл есть. $\ 3)\ \sqrt[4]{8^{-1}}$$8^{-1}=\tfrac18>0$, смысл есть. $\ 4)\ \sqrt[4]{-8}$ — смысла нет. $\ 5)\ -\sqrt[5]{8^{-1}}$ — смысл есть.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 2, § 13' },
// ── Часть B: В1–В20 ──────────────────────────────────────────────────────
{ idx: 11, type: 'open', topic: 'stereometry', subtopic: 'ster-basics', diff: 3,
text: R`Дана правильная четырёхугольная пирамида $SABCD$. Точки $M$ и $N$ — середины боковых рёбер $SA$ и $SC$ соответственно. Выберите верные утверждения.<br>1) прямая $MN$ пересекает прямую $SD$;<br>2) прямая $MN$ пересекает плоскость $SBD$;<br>3) прямая $MN$ лежит в плоскости $SDC$;<br>4) прямая $MN$ параллельна прямой $AB$;<br>5) прямая $MN$ параллельна плоскости $ADC$;<br>6) прямые $MN$ и $CD$ являются скрещивающимися.<br><i>Ответ запишите цифрами в порядке возрастания, без пробелов.</i>`,
answer: '256', ansShow: '2, 5, 6',
sol: R`$1)$ неверно: $MN$ и $SD$ скрещиваются. $\ 2)$ верно: $M$ и $N$ по разные стороны от плоскости $SBD$. $\ 3)$ неверно: $MN$ пересекает $SDC$ в точке $N$. $\ 4)$ неверно: $MN$ и $AB$ скрещиваются. $\ 5)$ верно: $MN$ — средняя линия треугольника $SAC$, значит $MN\parallel AC$, $AC\subset ADC$. $\ 6)$ верно: $MN$ и $CD$ скрещиваются.`,
ref: 'Латотин «Геометрия, 10 кл.», разд. 13' },
{ idx: 12, type: 'long', topic: 'planimetry', subtopic: 'plan-circle', diff: 3,
text: R`Для начала каждого из предложений А–В подберите его окончание 1–7 так, чтобы получилось верное утверждение.<br><b>Начало:</b><br>А) Уравнение окружности с центром в начале координат и радиусом $\sqrt{11}$ имеет вид …<br>Б) Уравнение окружности с центром в начале координат, проходящей через точку $M(-2;5)$, имеет вид …<br>В) Уравнение прямой, проходящей через точки $M(-2;5)$ и $A(2;-5)$, имеет вид …<br><b>Окончание:</b><br>1) $5y-2x=0$;&emsp;2) $x^{2}-y^{2}=29$;&emsp;3) $x^{2}+y^{2}=29$;&emsp;4) $x+y=11$;<br>5) $2y+5x=0$;&emsp;6) $x^{2}+y^{2}=11$;&emsp;7) $x^{2}-y^{2}=11$.<br><i>Ответ запишите сочетанием букв и цифр, например: А1Б1В4.</i>`,
answer: 'А6Б3В5', ansShow: 'А6Б3В5',
sol: R`А) Центр $(0;0)$, радиус $\sqrt{11}$: $x^{2}+y^{2}=11$ — окончание 6. Б) Центр $(0;0)$, проходит через $M(-2;5)$: $R^{2}=(-2)^{2}+5^{2}=29$, то есть $x^{2}+y^{2}=29$ — окончание 3. В) Прямая через $M(-2;5)$ и $A(2;-5)$: подходит $2y+5x=0$ (обе точки ему удовлетворяют) — окончание 5.`,
ref: 'Арефьева «Алгебра, 9 кл.», гл. 3, § 12' },
{ idx: 13, type: 'open', topic: 'numbers', subtopic: 'num-divisibility', diff: 1,
text: R`Фломастеры, которых всего было $445$ штук, упаковывали в коробки по $16$ штук в каждую. Сколько получилось полных коробок, если $13$ фломастеров остались неупакованными?`,
answer: '27',
sol: R`Пусть $x$ — число полных коробок. По смыслу деления с остатком $445=16x+13$, откуда $16x=432$, $x=27$.`,
ref: 'Герасимов «Математика, 5 кл.», ч. 1, гл. 1, § 11' },
{ idx: 14, type: 'open', topic: 'functions', subtopic: 'fn-graphs', diff: 2,
text: R`Найдите значение выражения $4p$, где $p$ — произведение координат вершины параболы, заданной уравнением $y=-2x^{2}-6x+3$.`,
answer: '-45',
sol: R`Абсцисса вершины $x_0=-\dfrac{b}{2a}=-\dfrac{-6}{2\cdot(-2)}=-\dfrac32$. Ордината $y_0=-2\left(-\dfrac32\right)^{2}-6\left(-\dfrac32\right)+3=-\dfrac92+9+3=\dfrac{15}{2}$. Тогда $p=x_0y_0=-\dfrac32\cdot\dfrac{15}{2}=-\dfrac{45}{4}$ и $4p=-45$.`,
ref: 'Арефьева «Алгебра, 8 кл.», гл. 3, § 13' },
{ idx: 15, type: 'open', topic: 'planimetry', subtopic: 'plan-triangles', diff: 2,
text: R`В треугольнике $ABC$ точки $M$ и $N$ — середины сторон $AB$ и $AC$ соответственно, $\angle ABC=95^\circ$, $\angle ANM=36^\circ$. Найдите градусную меру угла $BAC$.`,
answer: '49',
sol: R`$MN$ — средняя линия треугольника $ABC$, поэтому $MN\parallel BC$, и $\angle ACB=\angle ANM=36^\circ$ (соответственные углы). По сумме углов треугольника $\angle BAC=180^\circ-95^\circ-36^\circ=49^\circ$.`,
ref: 'Казаков «Геометрия, 7 кл.», гл. 3, § 17' },
{ idx: 16, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 3,
text: R`Найдите значение выражения $6\sqrt3\,\sin 600^\circ-\sqrt2\,\cos 225^\circ$.`,
answer: '-8',
sol: R`$\sin600^\circ=\sin240^\circ=\sin(270^\circ-30^\circ)=-\cos30^\circ=-\dfrac{\sqrt3}{2}$; $\cos225^\circ=\cos(180^\circ+45^\circ)=-\cos45^\circ=-\dfrac{\sqrt2}{2}$. Тогда $6\sqrt3\cdot\left(-\dfrac{\sqrt3}{2}\right)-\sqrt2\cdot\left(-\dfrac{\sqrt2}{2}\right)=-9+1=-8$.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 1, § 2; § 9' },
{ idx: 17, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 3,
text: R`Найдите произведение наибольшего целого решения на количество всех целых решений неравенства $\left(x+\log_{0{,}5}64\right)^{2}(x-3)(x+13)\le 0$.`,
answer: '108',
sol: R`Так как $\log_{0{,}5}64=-6$, неравенство принимает вид $(x-6)^{2}(x-3)(x+13)\le 0$. Методом интервалов решение: $[-13;3]\cup\{6\}$. Наибольшее целое решение $6$; всего целых решений $18$ (17 на отрезке $[-13;3]$ и $x=6$). Произведение: $6\cdot18=108$.`,
ref: 'Арефьева «Алгебра, 9 кл.», гл. 3, § 13' },
{ idx: 18, type: 'open', topic: 'equations', subtopic: 'eq-linear', diff: 2,
text: R`Найдите сумму всех целых решений системы неравенств $\begin{cases}(x-2)^{2}+23>(x+3)^{2}-2,\\[2pt] 1{,}6x\ge 0{,}9x-6{,}3.\end{cases}$`,
answer: '-44',
sol: R`Первое неравенство: $x^{2}-4x+4+23>x^{2}+6x+9-2$, то есть $-10x>-20$, $x<2$. Второе: $0{,}7x\ge-6{,}3$, $x\ge-9$. Решение системы — полуинтервал $[-9;2)$. Сумма всех целых из него равна $-44$.`,
ref: 'Арефьева «Алгебра, 8 кл.», гл. 1, § 6' },
{ idx: 19, type: 'open', topic: 'functions', subtopic: 'fn-properties', diff: 3,
text: R`Функция $y=f(x)$ нечётна и определена на отрезке $[-8;8]$. Её график для $x\le 0$ изображён на рисунке. Найдите значение выражения $3n$, где $n$ — количество всех целых значений аргумента, при которых функция принимает неположительные значения.`,
answer: '30',
sol: R`График нечётной функции симметричен относительно начала координат. Функция неположительна ($f(x)\le0$) на промежутках $[-8;-6]$ и $(0;6)$, а также в точках $x=-6,\ 0,\ 6$. Целых значений с $f(x)<0$ — семь, плюс три нуля, итого $n=10$, значит $3n=30$.`,
ref: 'Арефьева «Алгебра, 9 кл.», гл. 2, § 8',
fig: FIG('b9.png', 'График нечётной функции для x ≤ 0 на отрезке [-8;0]') },
{ idx: 20, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 2,
text: R`Найдите площадь ромба $ABCD$, если его периметр равен $72$, а величина угла $BAD$ равна $30^\circ$.`,
answer: '162',
sol: R`Сторона ромба $a=\dfrac{72}{4}=18$. Площадь $S=a^{2}\sin\alpha=18^{2}\cdot\sin30^\circ=324\cdot\dfrac12=162$.`,
ref: 'Казаков «Геометрия, 8 кл.», гл. 2, § 15' },
{ idx: 21, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 3,
text: R`Найдите значение выражения $25m$, где $m$ — сумма корней уравнения $\left(\dfrac37\right)^{5x^{2}-5x+2}-\left(\dfrac73\right)^{1-3x}=0$.`,
answer: '40',
sol: R`Так как $\left(\dfrac73\right)^{1-3x}=\left(\dfrac37\right)^{3x-1}$, получаем $5x^{2}-5x+2=3x-1$, то есть $5x^{2}-8x+3=0$. Дискриминант положителен, корни существуют. По теореме Виета сумма корней $m=\dfrac{8}{5}=1{,}6$, поэтому $25m=40$.`,
ref: 'Арефьева «Алгебра, 11 кл.», гл. 2, § 5' },
{ idx: 22, type: 'open', topic: 'equations', subtopic: 'eq-linear', diff: 2,
text: R`Поле разбили на два участка $A$ и $B$ одинаковой площади, как показано на рисунке (размеры указаны в метрах). Найдите (в метрах) периметр участка $B$.`,
answer: '390',
sol: R`Обозначим горизонтальный размер участка $B$ через $x$ м. Из равенства площадей: $140\cdot40+70\cdot(170-x)=70x$, откуда $14x=1750$, $x=125$. Значит, участок $B$ — прямоугольник $70\times125$, его периметр $2(70+125)=390$ м.`,
ref: 'Арефьева «Алгебра, 7 кл.», гл. 3, § 16',
fig: FIG('b12.png', 'L-образный участок: A и B, размеры 170, 70, 210, 40 м') },
{ idx: 23, type: 'open', topic: 'stereometry', subtopic: 'ster-rotation', diff: 3,
text: R`Осевым сечением цилиндра является квадрат, длина диагонали которого равна $4\sqrt6$. Найдите значение выражения $\dfrac{\sqrt3\,V}{\pi}$, где $V$ — объём цилиндра.`,
answer: '144',
sol: R`Сторона квадрата $\dfrac{4\sqrt6}{\sqrt2}=4\sqrt3$, значит высота цилиндра и диаметр основания равны $4\sqrt3$, радиус $R=2\sqrt3$. Объём $V=\pi R^{2}h=\pi(2\sqrt3)^{2}\cdot4\sqrt3=48\pi\sqrt3$. Тогда $\dfrac{\sqrt3\,V}{\pi}=48\cdot3=144$.`,
ref: 'Латотин «Геометрия, 11 кл.», разд. 1, § 2' },
{ idx: 24, type: 'open', topic: 'equations', subtopic: 'eq-irrational', diff: 3,
text: R`Найдите произведение корней (корень, если он единственный) уравнения $\sqrt{x+7}-\sqrt{x^{2}-6x-91}=0$.`,
answer: '-98',
sol: R`Так как $x^{2}-6x-91=(x+7)(x-13)$, уравнение приводится к $\sqrt{x+7}=\sqrt{(x+7)(x-13)}$. После возведения в квадрат $(x+7)(x-14)=0$. Проверка показывает, что оба числа $-7$ и $14$ — корни. Их произведение $-7\cdot14=-98$.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 2, § 17' },
{ idx: 25, type: 'open', topic: 'trigonometry', subtopic: 'trig-equations', diff: 4,
text: R`Найдите (в градусах) сумму наименьшего положительного и наибольшего отрицательного корней уравнения $\sin 2x\cos 17x-\cos 2x\sin 17x=\sin\dfrac{3\pi}{2}$.`,
answer: '-12',
sol: R`Левая часть по формуле синуса разности равна $\sin(2x-17x)=\sin(-15x)$, а $\sin\dfrac{3\pi}{2}=-1$. Значит $\sin(-15x)=-1$, то есть $\sin15x=1$, $15x=90^\circ+360^\circ n$, $x=6^\circ+24^\circ n$. Наименьший положительный корень $6^\circ$, наибольший отрицательный $-18^\circ$; их сумма $-12^\circ$.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 1, § 8; § 10' },
{ idx: 26, type: 'open', topic: 'equations', subtopic: 'eq-quadratic', diff: 3,
text: R`Найдите сумму всех целых решений совокупности неравенств $\left[\begin{array}{l}x^{2}-x-6\le0,\\ x^{2}-4x-5>0\end{array}\right.$ на промежутке $[-10;7]$.`,
answer: '-36',
sol: R`$1)\ x^{2}-x-6\le0$: решение $[-2;3]$. $\ 2)\ x^{2}-4x-5>0$: решение $(-\infty;-1)\cup(5;+\infty)$. Объединение решений совокупности: $(-\infty;3]\cup(5;+\infty)$. Пересечение с $[-10;7]$ даёт $[-10;3]\cup(5;7]$. Сумма целых: $-49+13=-36$.`,
ref: 'Арефьева «Алгебра, 8 кл.», гл. 3, § 16' },
{ idx: 27, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 4,
text: R`В правильной четырёхугольной пирамиде $QABCD$ длина бокового ребра равна $17$, длина диагонали основания $ABCD$ равна $16$. Через середины рёбер $AB$ и $AD$ и точку $Q$ проведена секущая плоскость. Найдите значение выражения $S^{2}$, где $S$ — площадь сечения пирамиды этой плоскостью.`,
answer: '3856',
sol: R`Пусть $K$, $M$ — середины рёбер $AB$, $AD$; сечение — равнобедренный треугольник $KQM$. $KM$ — средняя линия треугольника $ABD$, $KM=\dfrac12 BD=8$. Высота пирамиды $QO=\sqrt{QA^{2}-OA^{2}}=\sqrt{17^{2}-8^{2}}=15$. Высота $QN$ треугольника $KQM$: $QN=\sqrt{QO^{2}+ON^{2}}=\sqrt{15^{2}+4^{2}}=\sqrt{241}$. Площадь $S=\dfrac12\cdot KM\cdot QN=4\sqrt{241}$, откуда $S^{2}=16\cdot241=3856$.`,
ref: 'Латотин «Геометрия, 10 кл.», разд. 1, § 3' },
{ idx: 28, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 4,
text: R`При делении некоторого натурального двузначного числа на произведение его цифр неполное частное равно $3$, а остаток равен $10$. Если цифры этого числа поменять местами, то полученное число будет меньше данного на $36$. Найдите исходное число.`,
answer: '73',
sol: R`Пусть $x$ — цифра десятков, $y$ — цифра единиц; число равно $10x+y$. Условия дают систему $\begin{cases}10x+y=3xy+10,\\ 10x+y=(10y+x)+36.\end{cases}$ Из второго уравнения $x-y=4$. Подставив $x=y+4$, получаем $3y^{2}+y-30=0$, откуда $y=3$, $x=7$. Искомое число — $73$.`,
ref: 'Арефьева «Алгебра, 9 кл.», гл. 3, § 11' },
{ idx: 29, type: 'open', topic: 'functions', subtopic: 'fn-derivative', diff: 4,
text: R`Составьте уравнение касательной к графику функции $f(x)=32x^{3}-24x-5$ в точке с абсциссой $x_0=\dfrac14$. В ответ запишите произведение координат точки пересечения этой касательной с прямой $y=-16x-10$.`,
answer: '-84',
sol: R`$f'(x)=96x^{2}-24$, $f'\!\left(\dfrac14\right)=-18=k$; $f\!\left(\dfrac14\right)=-10{,}5$. Касательная $y=-18x-6$. Пересечение с $y=-16x-10$: $-18x-6=-16x-10$, $x=2$, $y=-42$. Произведение координат $2\cdot(-42)=-84$.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 3, § 20' },
{ idx: 30, type: 'open', topic: 'stereometry', subtopic: 'ster-angles-distances', diff: 5,
text: R`Из точки $E$ — середины стороны $BC$ равностороннего треугольника $ABC$ — проведён перпендикуляр $EP$ к плоскости треугольника, причём $EP=\dfrac12 BC$. На отрезке $PC$ взята точка $M$ так, что $PM:MC=2:3$. Найдите значение выражения $176\sin^{2}\alpha$, где $\alpha$ — угол между прямой $AM$ и плоскостью $ABC$.`,
answer: '18',
sol: R`Пусть сторона равна $a$, тогда $EP=\dfrac a2$. Проекция $M$ на плоскость — точка $K$ на $EC$, $\angle MAK=\alpha$. Из подобия $\triangle MKC\sim\triangle PEC$: $MK=\dfrac{3a}{10}$, $CK=\dfrac{3a}{10}$. По теореме косинусов в $\triangle AKC$: $AK^{2}=a^{2}+\left(\dfrac{3a}{10}\right)^{2}-2a\cdot\dfrac{3a}{10}\cos60^\circ=\dfrac{79a^{2}}{100}$. Тогда $AM^{2}=MK^{2}+AK^{2}=\dfrac{88a^{2}}{100}$, $\sin\alpha=\dfrac{MK}{AM}=\dfrac{3}{2\sqrt{22}}$, и $176\sin^{2}\alpha=176\cdot\dfrac{9}{88}=18$.`,
ref: 'Латотин «Геометрия, 10 кл.», разд. 3, § 9' },
];
/* ── Сборка solution_html ────────────────────────────────────────────────── */
function ansShowOf(t) {
if (t.ansShow != null) return t.ansShow;
if (t.type === 'mc') return `${t.answer})`;
return `$${t.answer}$`; // числовой/комбинация цифр — в KaTeX
}
function buildSolution(t) {
const ans = ansShowOf(t);
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
return html;
}
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
const EPS = 1e-6;
function srvToNumber(s) {
if (s == null) return NaN;
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
const n = Number(t); return Number.isFinite(n) ? n : NaN;
}
function checkAnswerServer(userInput, canonical) {
if (userInput == null || canonical == null) return false;
const c = String(canonical).trim();
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
if (/^[^;]+;[^;]+$/.test(c)) return false; // (пар нет в этом варианте)
const cn = srvToNumber(c), un = srvToNumber(userInput);
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
return Math.abs(cn - un) < EPS;
}
/* ── Валидация набора ──────────────────────────────────────────────────────── */
const problems = [];
if (TASKS.length !== 30) problems.push(`Ожидалось 30 заданий, получено ${TASKS.length}`);
const seen = new Set();
for (const t of TASKS) {
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
if (t.idx < 1 || t.idx > 30) problems.push(`task_idx вне 1..30: ${t.idx}`);
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
if (t.type === 'mc') {
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
}
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
// self-check автопроверки (long не автопроверяется)
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
// запрет Unicode-минуса в answer (нужен ASCII '-')
if (//.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
}
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
if (require.main !== module) return;
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
// Защита: трек должен существовать
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
console.log(`\n=== seed_ctmath_rt2425_e1v1 (${PROV}) variant=${VARIANT} ===`);
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
console.log('Типы:', JSON.stringify(byType), '| фигур:', TASKS.filter(t => t.fig).length, '\n');
console.log('idx | type | subtopic | d | answer | fig');
console.log('----+------+-----------------------+---+-----------+----');
for (const t of TASKS) {
console.log(
`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer).padEnd(9)} | ${t.fig ? '✓' : ''}`
);
}
if (problems.length) {
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
problems.forEach(p => console.error(' - ' + p));
console.error('\nЗапись отменена из-за ошибок валидации.');
db.close();
process.exit(1);
}
console.log('\n✓ Валидация и self-check ответов пройдены (30/30).');
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
if (!APPLY) {
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_rt2425_e1v1.js --apply\n');
db.close();
process.exit(0);
}
const upsert = db.prepare(`
INSERT INTO exam_tasks
(exam_key, variant, task_idx, task_type, text_html, figure_html,
opts_json, answer, solution_html, topic, subtopic, difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
task_type = excluded.task_type,
text_html = excluded.text_html,
figure_html = excluded.figure_html,
opts_json = excluded.opts_json,
answer = excluded.answer,
solution_html = excluded.solution_html,
topic = excluded.topic,
subtopic = excluded.subtopic,
difficulty = excluded.difficulty
`);
let n = 0;
db.exec('BEGIN');
try {
for (const t of TASKS) {
upsert.run(
EXAM, VARIANT, t.idx, t.type,
t.text,
t.fig || null,
t.type === 'mc' ? JSON.stringify(t.opts) : null,
t.answer,
buildSolution(t),
t.topic, t.subtopic, t.diff
);
n++;
}
// variants_count = число «чистых» вариантов-пробников [101;1999]; год-пачки (годы≥2011, 0) скрыты роутом
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
db.exec('COMMIT');
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «Вариант ${VARIANT}».\n`);
} catch (e) {
db.exec('ROLLBACK');
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
process.exitCode = 1;
}
db.close();
+298
View File
@@ -0,0 +1,298 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
seed_ctmath_rt2425_e2v1.js — РТ–2024/2025, Этап II, Вариант 1 → variant=102
Чистый 30-задачный пробник (А1–А10 + В1–В20). Этап II — другой набор тем, чем
Этап I (позже по программе: обратные тригфункции, логарифмы, производная,
стереометрия). Перенабрано вручную в KaTeX по PDF
(…\РТ\2024-2025\МАТ РТ-2 24_25 В1.pdf); чертежи вырезаны из PDF.
Правило тиража: 1 вариант на Этап (В1/В2 одного этапа — дубли, берём один).
Запуск: node backend/scripts/seed_ctmath_rt2425_e2v1.js [--apply]
Контракт формата/проверок — см. seed_ctmath_rt2425_e1v1.js.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
const VARIANT = 102;
const PROV = 'РТ–2024/2025, Этап II, Вариант 1';
const FIGDIR = 'rt2425_e2v1';
const R = String.raw;
const FIG = (name, alt) =>
`<img src="/img/ct/math/${FIGDIR}/${name}" alt="${alt}" ` +
`style="max-width:300px;width:100%;height:auto;display:block;margin:10px auto;` +
`background:#fff;border-radius:8px;padding:6px;">`;
const L = ['а', 'б', 'в', 'г', 'д'];
const mc = (...html) => html.map((h, i) => [L[i], h]);
const TASKS = [
// ── Часть A ──────────────────────────────────────────────────────────────
{ idx: 1, type: 'mc', topic: 'numbers', subtopic: 'num-divisibility', diff: 1,
text: R`Юра и Ян собирали яблоки. Юра собрал яблок в $4$ раза больше, чем Ян. Какую часть всех собранных яблок собрал Ян?`,
opts: mc('$\dfrac45$', '$\dfrac15$', '$\dfrac13$', '$\dfrac14$', '$\dfrac34$'),
answer: 'б',
sol: R`Ян собрал в $4$ раза меньше Юры, поэтому всё количество яблок делится на $4+1=5$ равных частей, и Ян собрал одну из них, то есть $\dfrac15$.`,
ref: 'Герасимов «Математика, 5 кл.», ч. 2, гл. 3, § 1' },
{ idx: 2, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 2,
text: R`Используя данные рисунка, определите, чему должна быть равна градусная мера угла $1$, чтобы прямые $a$ и $b$ были параллельны.`,
opts: mc('$68^\circ$', '$48^\circ$', '$46^\circ$', '$36^\circ$', '$44^\circ$'),
answer: 'д',
sol: R`Угол $2$, смежный с углом $136^\circ$, равен $44^\circ$. Прямые $a$ и $b$ параллельны, если соответственные углы $1$ и $2$ при секущей $c$ равны, поэтому $\angle 1=44^\circ$.`,
ref: 'Казаков «Геометрия, 7 кл.», гл. 3, § 15',
fig: FIG('a2.png', 'Прямые a и b, секущая c; угол 1 и угол 136°') },
{ idx: 3, type: 'mc', topic: 'functions', subtopic: 'fn-graphs', diff: 1,
text: R`Укажите номер рисунка, на котором изображён график функции $y=|x|$.`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'в',
sol: R`График функции $y=|x|$ — это «уголок» с вершиной в начале координат (ветви $y=x$ при $x\ge0$ и $y=-x$ при $x<0$). Ему соответствует рисунок $3$.`,
ref: 'Арефьева «Алгебра, 8 кл.», гл. 4, § 19',
fig: FIG('a3.png', 'Пять графиков-кандидатов 1–5; график 3 — «уголок» y=|x|') },
{ idx: 4, type: 'mc', topic: 'expressions', subtopic: 'expr-polynomials', diff: 1,
text: R`Среди значений переменной $x$, равных $16;\ -1;\ 1;\ -4;\ -15$, укажите то, при котором значение выражения $0{,}36-x^2$ равно $-15{,}64$.`,
opts: mc('$16$', '$-1$', '$1$', '$-4$', '$-15$'),
answer: 'г',
sol: R`Проверяем: при $x=-4$ имеем $0{,}36-(-4)^2=0{,}36-16=-15{,}64$. Остальные значения дают другой результат.`,
ref: 'Арефьева «Алгебра, 7 кл.», гл. 2, § 4' },
{ idx: 5, type: 'mc', topic: 'equations', subtopic: 'eq-linear', diff: 2,
text: R`Укажите номер, под которым приведено множество всех решений системы неравенств $\begin{cases}x\le 6,\\ x<-4.\end{cases}$`,
opts: mc('$(-\infty;-4)$', '$(-\infty;6]$', '$(-4;6]$', '$(-\infty;6)$', '$(-\infty;-4)\cup(-4;6]$'),
answer: 'а',
sol: R`Решение первого неравенства — луч $(-\infty;6]$, второго — открытый луч $(-\infty;-4)$. Пересечением является $(-\infty;-4)$.`,
ref: 'Арефьева «Алгебра, 8 кл.», гл. 1, § 6' },
{ idx: 6, type: 'open', topic: 'numbers', subtopic: 'num-real', diff: 2,
text: R`Среди выражений $\log_{\sqrt2}4$; $\ -5^2$; $\ \cos\dfrac{5\pi}{6}$; $\ 7^{-1}$; $\ \sqrt[5]{(-2)^5}$ укажите те, значение которых является отрицательным числом.<br><i>Ответ запишите номерами в порядке возрастания, без пробелов.</i>`,
answer: '235', ansShow: '2, 3, 5',
sol: R`$1)\ \log_{\sqrt2}4=4>0$. $\ 2)\ -5^2=-25<0$. $\ 3)\ \cos\dfrac{5\pi}{6}=-\dfrac{\sqrt3}{2}<0$. $\ 4)\ 7^{-1}=\dfrac17>0$. $\ 5)\ \sqrt[5]{(-2)^5}=-2<0$. Отрицательны выражения 2, 3, 5.`,
ref: 'Арефьева «Алгебра, 11 кл.», гл. 1, § 3' },
{ idx: 7, type: 'mc', topic: 'expressions', subtopic: 'expr-polynomials', diff: 2,
text: R`Результат разложения многочлена $(a-b)+2c(b-a)$ на множители имеет вид:`,
opts: mc('$(a-b)(1+2c)$', '$(a-b)(2c-1)$', '$(a-b)(1-2c)$', '$-2c(a-b)$', '$2c(a-b)$'),
answer: 'в',
sol: R`$(a-b)+2c(b-a)=(a-b)-2c(a-b)=(a-b)(1-2c)$.`,
ref: 'Арефьева «Алгебра, 7 кл.», гл. 2, § 14' },
{ idx: 8, type: 'mc', topic: 'equations', subtopic: 'eq-linear', diff: 2,
text: R`Укажите номер неравенства, которое равносильно неравенству $x>5$.`,
opts: mc('$x^2>5x$', '$\dfrac{1}{x-5}<0$', '$(x-5)^2>0$', '$-2x<-10$', '$(0{,}5)^{x-5}>0$'),
answer: 'г',
sol: R`Решение $x>5$ — луч $(5;+\infty)$. Неравенство $-2x<-10$ равносильно $x>5$ — то же множество решений. (Остальные дают другие множества.)`,
ref: 'Арефьева «Алгебра, 9 кл.», гл. 3, § 13' },
{ idx: 9, type: 'mc', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 2,
text: R`У правильной четырёхугольной призмы площадь основания равна $28$ см$^2$. Какой должна быть высота (в сантиметрах) этой призмы, чтобы её объём был равен $98$ см$^3$?`,
opts: mc('$2$', '$4$', '$3{,}2$', '$4{,}5$', '$3{,}5$'),
answer: 'д',
sol: R`Объём призмы $V=S_{\text{осн}}\cdot h$. Тогда $98=28h$, откуда $h=3{,}5$ см.`,
ref: 'Латотин «Геометрия, 11 кл.», разд. 1, § 1' },
{ idx: 10, type: 'open', topic: 'functions', subtopic: 'fn-properties', diff: 3,
text: R`На рисунке изображён график функции $y=f(x)$, определённой на промежутке $[-6;6]$. Укажите номера верных утверждений.<br>1) множеством значений функции является отрезок $[-3;4]$;<br>2) функция является нечётной;<br>3) график функции $y=f(x-1)$ проходит через точку $(0;2)$;<br>4) функция убывает на промежутках $[-1;0]$ и $[1;6]$;<br>5) $f(-5)+f(2)<0$.<br><i>Ответ запишите номерами в порядке возрастания, без пробелов.</i>`,
answer: '14', ansShow: '1, 4',
sol: R`$1)$ верно: $E(f)=[-3;4]$. $\ 2)$ неверно: график симметричен относительно оси ординат, функция чётная. $\ 3)$ неверно: точка $(0;2)$ не принадлежит графику $y=f(x-1)$. $\ 4)$ верно: на $[-1;0]$ и $[1;6]$ значения убывают. $\ 5)$ неверно: $-2<f(-5)<-1$ и $2<f(2)<3$, поэтому $f(-5)+f(2)>0$.`,
ref: 'Арефьева «Алгебра, 9 кл.», гл. 2, § 69',
fig: FIG('a10.png', 'График чётной функции y=f(x) на [-6;6], с пиками y=4 и краями y=-3') },
// ── Часть B ──────────────────────────────────────────────────────────────
{ idx: 11, type: 'long', topic: 'trigonometry', subtopic: 'trig-identities', diff: 3,
text: R`Для начала каждого из предложений А–В подберите его окончание 1–6 так, чтобы получилось верное утверждение.<br><b>Начало:</b><br>А) Значение выражения $\arcsin 0-|-5|$ равно …<br>Б) Значение выражения $\dfrac1\pi\arccos\left(-\dfrac{\sqrt3}{2}\right)-\dfrac13$ равно …<br>В) Значение выражения $4\sqrt6\,\sin\left(2\arccos\dfrac{\sqrt2}{2}-\dfrac\pi4\right)$ равно …<br><b>Окончание:</b><br>1) $6\sqrt2$;&emsp;2) $-5$;&emsp;3) $\dfrac13$;&emsp;4) $-4$;&emsp;5) $4\sqrt3$;&emsp;6) $\dfrac12$.<br><i>Ответ запишите сочетанием букв и цифр, например: А1Б1В4.</i>`,
answer: 'А2Б6В5', ansShow: 'А2Б6В5',
sol: R`А) $\arcsin0-|-5|=0-5=-5$ — окончание 2. Б) $\dfrac1\pi\cdot\dfrac{5\pi}{6}-\dfrac13=\dfrac56-\dfrac13=\dfrac12$ — окончание 6. В) $4\sqrt6\,\sin\left(2\cdot\dfrac\pi4-\dfrac\pi4\right)=4\sqrt6\sin\dfrac\pi4=4\sqrt6\cdot\dfrac{\sqrt2}{2}=4\sqrt3$ — окончание 5.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 1, § 7' },
{ idx: 12, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 3,
text: R`$ABCDA_1B_1C_1D_1$ — куб. Длина пространственной ломаной $ABB_1C_1C$ равна $16\sqrt3$. Выберите верные утверждения.<br>1) длина диагонали грани $ABCD$ равна $4\sqrt3$;<br>2) площадь полной поверхности куба равна $192$;<br>3) длина диагонали куба равна $4\sqrt6$;<br>4) площадь треугольника $AC_1C$ равна $24\sqrt2$;<br>5) длина ребра куба равна $4\sqrt3$;<br>6) объём куба равен $192\sqrt3$.<br><i>Ответ запишите номерами в порядке возрастания, без пробелов.</i>`,
answer: '456', ansShow: '4, 5, 6',
sol: R`Ломаная $ABB_1C_1C$ состоит из четырёх рёбер: $16\sqrt3:4=4\sqrt3$ — ребро. $\ 1)$ диагональ грани $=4\sqrt3\cdot\sqrt2=4\sqrt6$ — неверно. $\ 2)\ S=6a^2=6\cdot48=288$ — неверно. $\ 3)$ диагональ куба $=a\sqrt3=4\sqrt3\cdot\sqrt3=12$ — неверно. $\ 4)\ S_{AC_1C}=\tfrac12\cdot4\sqrt6\cdot4\sqrt3=24\sqrt2$ — верно. $\ 5)$ ребро $=4\sqrt3$ — верно. $\ 6)\ V=a^3=(4\sqrt3)^3=192\sqrt3$ — верно.`,
ref: 'Латотин «Геометрия, 11 кл.», разд. 1, § 1' },
{ idx: 13, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 3,
text: R`Найдите значение выражения $15\sqrt{10}\,\operatorname{tg}\alpha$, если $\operatorname{ctg}\alpha=-\dfrac{\sqrt{10}}{8}$.`,
answer: '-120',
sol: R`Из тождества $\operatorname{tg}\alpha\cdot\operatorname{ctg}\alpha=1$: $\operatorname{tg}\alpha=\dfrac1{\operatorname{ctg}\alpha}=-\dfrac{8}{\sqrt{10}}=-\dfrac{4\sqrt{10}}{5}$. Тогда $15\sqrt{10}\cdot\left(-\dfrac{4\sqrt{10}}{5}\right)=15\cdot\left(-\dfrac{4\cdot10}{5}\right)=-120$.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 1, § 4' },
{ idx: 14, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 2,
text: R`Диагонали ромба равны $4$ и $10$. Найдите значение выражения $\sqrt{29}\cdot P$, где $P$ — периметр ромба.`,
answer: '116',
sol: R`Диагонали ромба перпендикулярны и делятся точкой пересечения пополам. Сторона $a=\sqrt{2^2+5^2}=\sqrt{29}$. Периметр $P=4\sqrt{29}$, тогда $\sqrt{29}\cdot P=\sqrt{29}\cdot4\sqrt{29}=4\cdot29=116$.`,
ref: 'Казаков «Геометрия, 8 кл.», гл. 1, § 5' },
{ idx: 15, type: 'open', topic: 'numbers', subtopic: 'num-divisibility', diff: 3,
text: R`Пусть $A$ — наименьшее натуральное число, большее $50$, при делении которого на $9$ и на $12$ получается остаток $1$. Найдите остаток при делении числа $A$ на $13$. В ответ запишите сумму числа $A$ и полученного остатка.`,
answer: '81',
sol: R`$A-1$ делится и на $9$, и на $12$, то есть кратно $\text{НОК}(9;12)=36$. Наименьшее такое $A-1>49$ равно $72$, значит $A=73$. Остаток от деления $73$ на $13$ равен $8$. Сумма $73+8=81$.`,
ref: 'Герасимов «Математика, 5 кл.», ч. 1, гл. 1, § 1113' },
{ idx: 16, type: 'open', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 3,
text: R`Найдите, при каком значении переменной $x$ значения выражений $x-18$; $\ x-3$; $\ x+17$ будут последовательными членами геометрической прогрессии.`,
answer: '63',
sol: R`По характеристическому свойству геометрической прогрессии $(x-3)^2=(x-18)(x+17)$. Раскрывая: $x^2-6x+9=x^2-x-306$, $-5x=-315$, $x=63$.`,
ref: 'Арефьева «Алгебра, 9 кл.», гл. 4, § 17' },
{ idx: 17, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 4,
text: R`Пусть $(x_1;y_1)$ и $(x_2;y_2)$ — решения системы уравнений $\begin{cases}x^2+3y=27,\\ x-y=-9.\end{cases}$ Найдите значение выражения $x_1x_2-y_1y_2$.`,
answer: '-54',
sol: R`Из второго уравнения $y=x+9$. Тогда $x^2+3(x+9)=27$, $x^2+3x=0$, $x=0$ или $x=-3$. Решения: $(-3;6)$ и $(0;9)$. Значение $x_1x_2-y_1y_2=(-3)\cdot0-6\cdot9=-54$ (не зависит от выбора нумерации пар).`,
ref: 'Арефьева «Алгебра, 9 кл.», гл. 3, § 11' },
{ idx: 18, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 3,
text: R`Аппликация состоит из двух подобных треугольников $\mathrm{I}$ и $\mathrm{II}$. Площадь треугольника $\mathrm{I}$ равна $75$ см$^2$, а длины сторон треугольника $\mathrm{II}$ на $20\%$ больше длин соответствующих сторон треугольника $\mathrm{I}$. Найдите (в см$^2$) площадь всей аппликации.`,
answer: '183',
sol: R`Коэффициент подобия (II к I) равен $1{,}2=\dfrac65$. Отношение площадей подобных треугольников равно квадрату коэффициента: $S_{\mathrm{II}}=75\cdot\left(\dfrac65\right)^2=75\cdot\dfrac{36}{25}=108$ см$^2$. Площадь всей аппликации $75+108=183$ см$^2$.`,
ref: 'Казаков «Геометрия, 8 кл.», гл. 3, § 23' },
{ idx: 19, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 3,
text: R`Найдите значение выражения $2\log_{25}\left(\dfrac{a}{125}\right)-\log_5\dfrac{25}{b}$, если $\log_{25}(ab)=19$.`,
answer: '33',
sol: R`$2\log_{25}\left(\dfrac{a}{125}\right)=\log_5\dfrac{a}{125}$. Тогда выражение равно $\log_5\dfrac{a}{125}-\log_5\dfrac{25}{b}=\log_5\dfrac{ab}{5^5}=\log_5(ab)-5=2\log_{25}(ab)-5=2\cdot19-5=33$.`,
ref: 'Арефьева «Алгебра, 11 кл.», гл. 3, § 7' },
{ idx: 20, type: 'open', topic: 'planimetry', subtopic: 'plan-triangles', diff: 4,
text: R`В равнобедренном треугольнике $KMN$ проведена высота $MH$ к основанию $KN$. Точка $P$ — середина боковой стороны $MN$. Известно, что длина высоты $MH$ равна длине отрезка $HP$ и $KN=6\sqrt6$. Найдите значение выражения $S^2$, где $S$ — площадь треугольника $KMN$.`,
answer: '972',
sol: R`Высота $MH$ равнобедренного треугольника является и медианой, поэтому $HN=\tfrac12 KN=3\sqrt6$. В прямоугольном треугольнике $MHN$ отрезок $HP$ — медиана к гипотенузе $MN$, значит $HP=\tfrac12 MN$; по условию $MH=HP=\tfrac12 MN$, то есть катет $MH$ равен половине гипотенузы, и $\angle MNH=30^\circ$. Тогда $MH=HN\operatorname{tg}30^\circ=3\sqrt6\cdot\dfrac{\sqrt3}{3}=3\sqrt2$. Площадь $S=\tfrac12\cdot KN\cdot MH=\tfrac12\cdot6\sqrt6\cdot3\sqrt2=18\sqrt3$, откуда $S^2=972$.`,
ref: 'Казаков «Геометрия, 8 кл.», гл. 2, § 1516' },
{ idx: 21, type: 'open', topic: 'functions', subtopic: 'fn-properties', diff: 3,
text: R`Найдите количество всех целых чисел из множества значений функции $y=\left(\dfrac13\right)^{-x}$ на отрезке $[3;4]$.`,
answer: '55',
sol: R`$y=\left(\dfrac13\right)^{-x}=3^x$ — возрастающая функция. При $3\le x\le4$ имеем $3^3\le 3^x\le 3^4$, то есть $E=[27;81]$. Целых чисел на отрезке $[27;81]$$81-27+1=55$.`,
ref: 'Арефьева «Алгебра, 11 кл.», гл. 2, § 4' },
{ idx: 22, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 3,
text: R`Первый турист ехал от базы со скоростью $40$ км/ч и успел на станцию за $3$ мин до отправления поезда. Второй турист, выехавший одновременно с первым от той же базы со скоростью $35$ км/ч, опоздал на этот же поезд на $3$ мин. На каком расстоянии (в километрах) от базы находится станция?`,
answer: '28',
sol: R`Пусть расстояние равно $x$ км. Разница во времени между туристами составляет $6$ мин $=\dfrac1{10}$ ч: $\dfrac{x}{35}-\dfrac{x}{40}=\dfrac1{10}$, $\dfrac{x}{280}=\dfrac1{10}$, $x=28$ км.`,
ref: 'Арефьева «Алгебра, 7 кл.», гл. 3, § 16; гл. 4, § 25' },
{ idx: 23, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 4,
text: R`Найдите произведение наименьшего целого решения на количество всех целых решений неравенства $\log_{0{,}4}\left(\dfrac{x^2}{4}-3\right)\ge 0$.`,
answer: '-8',
sol: R`$0=\log_{0{,}4}1$, и так как $0<0{,}4<1$, неравенство равносильно системе $\dfrac{x^2}{4}-3\le1$ и $\dfrac{x^2}{4}-3>0$, то есть $x^2\le16$ и $x^2>12$. Решение: $[-4;-2\sqrt3)\cup(2\sqrt3;4]$. Целых решений два ($-4$ и $4$), наименьшее $-4$. Произведение $-4\cdot2=-8$.`,
ref: 'Арефьева «Алгебра, 11 кл.», гл. 3, § 10' },
{ idx: 24, type: 'open', topic: 'trigonometry', subtopic: 'trig-equations', diff: 4,
text: R`Найдите (в градусах) наименьший положительный корень уравнения $4\sin\dfrac{x}{7}\cos\dfrac{x}{7}=\sqrt3$.`,
answer: '210',
sol: R`По формуле синуса двойного аргумента $2\sin\dfrac{2x}{7}=\sqrt3$, $\sin\dfrac{2x}{7}=\dfrac{\sqrt3}{2}$. Тогда $\dfrac{2x}{7}=(-1)^k60^\circ+180^\circ k$, $x=(-1)^k210^\circ+630^\circ k$. Наименьший положительный корень — $210^\circ$ (при $k=0$).`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 1, § 8; § 11' },
{ idx: 25, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 4,
text: R`В правильной треугольной пирамиде ребро основания равно $2\sqrt2$, а угол между боковым ребром и плоскостью основания равен $30^\circ$. Найдите значение выражения $9\sqrt6\cdot V$, где $V$ — объём этой пирамиды.`,
answer: '24',
sol: R`Высота $SO$, $\angle SAO=30^\circ$. $AO=\tfrac23 AM$, где медиана $AM=\sqrt6$, значит $AO=\dfrac{2\sqrt6}{3}$. Тогда $SO=AO\operatorname{tg}30^\circ=\dfrac{2\sqrt6}{3}\cdot\dfrac{\sqrt3}{3}=\dfrac{2\sqrt2}{3}$. Объём $V=\dfrac13\cdot\dfrac{(2\sqrt2)^2\sqrt3}{4}\cdot\dfrac{2\sqrt2}{3}=\dfrac{4\sqrt6}{9}$. Тогда $9\sqrt6\cdot V=9\sqrt6\cdot\dfrac{4\sqrt6}{9}=4\cdot6=24$.`,
ref: 'Латотин «Геометрия, 11 кл.», разд. 2, § 3' },
{ idx: 26, type: 'open', topic: 'equations', subtopic: 'eq-irrational', diff: 4,
text: R`Найдите произведение корней (корень, если он единственный) уравнения $\sqrt[4]{x^2+6x-27}\cdot\sqrt[3]{x^2-6x-27}=0$.`,
answer: '-243',
sol: R`Произведение равно нулю, когда один из множителей равен нулю, а другой имеет смысл. $x^2+6x-27=0\Rightarrow x=-9,\ 3$ (оба удовлетворяют ОДЗ). $x^2-6x-27=0\Rightarrow x=-3,\ 9$, но при $x=-3$ подкоренное выражение $\sqrt[4]{\;}$ отрицательно — не подходит, остаётся $x=9$. Корни уравнения: $-9,\ 3,\ 9$; произведение $-9\cdot3\cdot9=-243$.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 2, § 17' },
{ idx: 27, type: 'open', topic: 'stereometry', subtopic: 'ster-angles-distances', diff: 5,
text: R`$ABCA_1B_1C_1$ — прямая треугольная призма, все рёбра которой равны. Точки $K$ и $M$ — середины рёбер $A_1C_1$ и $B_1C_1$ соответственно. Точка $N$ лежит на ребре $AB$ так, что $AN:NB=1:5$. Найдите значение выражения $\dfrac{1}{\cos^2\varphi}$, где $\varphi$ — угол между прямыми $A_1N$ и $KM$.`,
answer: '37',
sol: R`Пусть ребро равно $a$, $AN=\dfrac a6$. Так как $KM\parallel AB$ (средняя линия), угол между $A_1N$ и $KM$ равен углу $\angle NA_1B_1$. В прямоугольном треугольнике $A_1AN$: $A_1N=\sqrt{a^2+\left(\dfrac a6\right)^2}=\dfrac{a\sqrt{37}}{6}$, $\sin\angle AA_1N=\dfrac{a/6}{A_1N}=\dfrac{\sqrt{37}}{37}$. Тогда $\cos\varphi=\sin\angle AA_1N=\dfrac{\sqrt{37}}{37}$, и $\dfrac{1}{\cos^2\varphi}=37$.`,
ref: 'Латотин «Геометрия, 10 кл.», разд. 2, § 4' },
{ idx: 28, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
text: R`Найдите произведение наибольшего целого отрицательного и наименьшего целого положительного решений неравенства $5\cdot25^{\frac{5-x}{23}}-26\cdot25^{\frac{5-x}{46}}+5\ge 0$.`,
answer: '-504',
sol: R`Замена $t=25^{\frac{5-x}{46}}$ даёт $5t^2-26t+5\ge0$, откуда $t\le\dfrac15$ или $t\ge5$. Тогда $\dfrac{5-x}{23}\le-1$ или $\dfrac{5-x}{23}\ge1$, то есть $x\ge28$ или $x\le-18$. Решение: $(-\infty;-18]\cup[28;+\infty)$. Наибольшее целое отрицательное — $-18$, наименьшее целое положительное — $28$; произведение $-18\cdot28=-504$.`,
ref: 'Арефьева «Алгебра, 11 кл.», гл. 2, § 6' },
{ idx: 29, type: 'open', topic: 'functions', subtopic: 'fn-derivative', diff: 4,
text: R`Найдите точку максимума и максимум функции $f(x)=x^3-75x-24\sin\dfrac{7\pi}{6}$. В ответ запишите их сумму.`,
answer: '257',
sol: R`$24\sin\dfrac{7\pi}{6}=24\cdot\left(-\dfrac12\right)=-12$, поэтому $f(x)=x^3-75x+12$. $f'(x)=3x^2-75=0$ при $x=\pm5$. Точка максимума $x_{\max}=-5$, $f(-5)=-125+375+12=262$. Сумма $-5+262=257$.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 3, § 20' },
{ idx: 30, type: 'open', topic: 'stereometry', subtopic: 'ster-rotation', diff: 5,
text: R`Плоскость, параллельная основанию конуса, делит его высоту в отношении $2:5$, считая от вершины. Площадь сечения конуса меньше площади основания на $270\pi$. Образующая конуса составляет с плоскостью основания угол $\operatorname{arctg}\dfrac57$. Найдите значение выражения $\dfrac{\sqrt6\,V}{\pi}$, где $V$ — объём конуса.`,
answer: '2940',
sol: R`Сечением является круг; по свойству площади относятся как квадраты расстояний от вершины: $\dfrac{S_{\text{осн}}-270\pi}{S_{\text{осн}}}=\left(\dfrac27\right)^2=\dfrac{4}{49}$, откуда $45 S_{\text{осн}}=49\cdot270\pi$, $S_{\text{осн}}=294\pi$. Тогда $R^2=294$, $R=7\sqrt6$. Высота $SO=R\operatorname{tg}\left(\operatorname{arctg}\dfrac57\right)=7\sqrt6\cdot\dfrac57=5\sqrt6$. Объём $V=\dfrac13 S_{\text{осн}}\cdot SO=\dfrac13\cdot294\pi\cdot5\sqrt6=490\pi\sqrt6$. Тогда $\dfrac{\sqrt6\,V}{\pi}=490\cdot6=2940$.`,
ref: 'Латотин «Геометрия, 11 кл.», разд. 2, § 4' },
];
/* ── машинерия (как в e1v1) ────────────────────────────────────────────────── */
function ansShowOf(t) { if (t.ansShow != null) return t.ansShow; if (t.type === 'mc') return `${t.answer})`; return `$${t.answer}$`; }
function buildSolution(t) {
let html = `${t.sol}<div class="sol-ans">Ответ: ${ansShowOf(t)}</div>`;
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
return html;
}
const EPS = 1e-6;
function srvToNumber(s) {
if (s == null) return NaN;
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
const n = Number(t); return Number.isFinite(n) ? n : NaN;
}
function checkAnswerServer(u, c0) {
if (u == null || c0 == null) return false;
const c = String(c0).trim();
if (/^[а-д]$/.test(c)) return String(u).trim().toLowerCase() === c.toLowerCase();
if (/^[^;]+;[^;]+$/.test(c)) return false;
const cn = srvToNumber(c), un = srvToNumber(u);
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
return Math.abs(cn - un) < EPS;
}
const problems = [];
if (TASKS.length !== 30) problems.push(`Ожидалось 30, получено ${TASKS.length}`);
const seen = new Set();
for (const t of TASKS) {
if (seen.has(t.idx)) problems.push(`Дубль idx=${t.idx}`); seen.add(t.idx);
if (t.idx < 1 || t.idx > 30) problems.push(`idx вне 1..30: ${t.idx}`);
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
if (t.type === 'mc') {
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc!=5 опций`);
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
}
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer)) problems.push(`#${t.idx}: self-check "${t.answer}"`);
if (//.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
}
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
if (require.main !== module) return;
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
if (!db.prepare(`SELECT exam_key FROM exam_tracks WHERE exam_key=?`).get(EXAM)) { console.error(`✗ Трек '${EXAM}' не найден.`); process.exit(1); }
console.log(`\n=== seed_ctmath_rt2425_e2v1 (${PROV}) variant=${VARIANT} ===`);
console.log(`Режим: ${APPLY ? 'APPLY' : 'DRY-RUN'}\n`);
console.log('Типы:', JSON.stringify(TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {})), '| фигур:', TASKS.filter(t => t.fig).length, '\n');
console.log('idx | type | subtopic | d | answer | fig');
console.log('----+------+-----------------------+---+-----------+----');
for (const t of TASKS) console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer).padEnd(9)} | ${t.fig ? '✓' : ''}`);
if (problems.length) { console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`); problems.forEach(p => console.error(' - ' + p)); db.close(); process.exit(1); }
console.log('\n✓ Валидация и self-check ответов пройдены (30/30).');
if (!APPLY) { console.log('\nDRY-RUN: ничего не записано. Для записи добавьте --apply\n'); db.close(); process.exit(0); }
const upsert = db.prepare(`
INSERT INTO exam_tasks (exam_key, variant, task_idx, task_type, text_html, figure_html, opts_json, answer, solution_html, topic, subtopic, difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
task_type=excluded.task_type, text_html=excluded.text_html, figure_html=excluded.figure_html,
opts_json=excluded.opts_json, answer=excluded.answer, solution_html=excluded.solution_html,
topic=excluded.topic, subtopic=excluded.subtopic, difficulty=excluded.difficulty`);
let n = 0; db.exec('BEGIN');
try {
for (const t of TASKS) { upsert.run(EXAM, VARIANT, t.idx, t.type, t.text, t.fig || null, t.type === 'mc' ? JSON.stringify(t.opts) : null, t.answer, buildSolution(t), t.topic, t.subtopic, t.diff); n++; }
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
db.exec('COMMIT');
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}). variants_count=${distinct}.`);
console.log(`\nПробник: /exam-prep/ctmath → «Варианты» → «Вариант ${VARIANT}».\n`);
} catch (e) { db.exec('ROLLBACK'); console.error('\n✗ Ошибка записи, откат:', e.message); process.exitCode = 1; }
db.close();
+294
View File
@@ -0,0 +1,294 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
seed_ctmath_rt2425_e3v1.js — РТ–2024/2025, Этап III, Вариант 1 → variant=103
Чистый 30-задачный пробник (А1–А10 + В1–В20). Этап III — завершающий, полный
охват программы (стереометрия тел вращения, сфера, производная, сечения).
Перенабрано вручную в KaTeX по PDF (…\РТ\2024-2025\МАТ РТ-3 24_25 В1.pdf).
Правило тиража: 1 вариант на Этап. Только А2 содержит данные на чертеже.
Запуск: node backend/scripts/seed_ctmath_rt2425_e3v1.js [--apply]
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
const VARIANT = 103;
const PROV = 'РТ–2024/2025, Этап III, Вариант 1';
const FIGDIR = 'rt2425_e3v1';
const R = String.raw;
const FIG = (name, alt) =>
`<img src="/img/ct/math/${FIGDIR}/${name}" alt="${alt}" ` +
`style="max-width:300px;width:100%;height:auto;display:block;margin:10px auto;` +
`background:#fff;border-radius:8px;padding:6px;">`;
const L = ['а', 'б', 'в', 'г', 'д'];
const mc = (...html) => html.map((h, i) => [L[i], h]);
const TASKS = [
// ── Часть A ──────────────────────────────────────────────────────────────
{ idx: 1, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 1,
text: R`Среди чисел $-0{,}5;\ 2^{-1};\ -0{,}2;\ -\sqrt2;\ 2$ укажите число, противоположное числу $\dfrac12$.`,
opts: mc('$-0{,}5$', '$2^{-1}$', '$-0{,}2$', '$-\sqrt2$', '$2$'),
answer: 'а',
sol: R`Противоположные числа имеют равные модули, но разные знаки. Числу $\dfrac12$ противоположно число $-\dfrac12=-0{,}5$.`,
ref: 'Герасимов «Математика, 6 кл.», гл. 4, § 2' },
{ idx: 2, type: 'mc', topic: 'planimetry', subtopic: 'plan-circle', diff: 2,
text: R`На рисунке изображены три окружности с центрами $O$, $A$, $B$, радиусы которых равны $R$, $\dfrac R4$, $\dfrac R3$ соответственно. Найдите длину отрезка $AB$, если $R=12$.`,
opts: mc('$13$', '$18$', '$15$', '$17$', '$19$'),
answer: 'г',
sol: R`Отрезок $AB$ лежит на диаметре большой окружности (радиус $R=12$, диаметр $24$). Меньшие окружности касаются большой изнутри, их радиусы $\dfrac R4=3$ и $\dfrac R3=4$. Тогда $AB=24-3-4=17$.`,
ref: 'Казаков «Геометрия, 7 кл.», гл. 1, § 4',
fig: FIG('a2.png', 'Большая окружность с центром O и две внутренние окружности A и B на диаметре') },
{ idx: 3, type: 'mc', topic: 'functions', subtopic: 'fn-properties', diff: 2,
text: R`Укажите номер множества чисел, которое может являться областью определения нечётной функции.`,
opts: mc('$[-7;7]$', '$(-6;0)\cup(0;6]$', '$[-5;10]$', '$[-9;2)\cup(2;9]$', '$(-11;0)\cup(0;11)$'),
answer: 'д',
sol: R`Область определения нечётной функции симметрична относительно нуля. Из предложенных множеств этим свойством обладает $(-11;0)\cup(0;11)$.`,
ref: 'Арефьева «Алгебра, 9 кл.», гл. 2, § 8' },
{ idx: 4, type: 'mc', topic: 'equations', subtopic: 'eq-exponential', diff: 2,
text: R`Укажите номер показательного уравнения, корнем которого является число $-2$.`,
opts: mc('$(0{,}3)^{x-6}=(0{,}3)^{6x+4}$', '$2^{2x}=64$', '$(0{,}5)^{x^2+4}=1$', '$16x+35=3$', '$7^x=11$'),
answer: 'а',
sol: R`Подставим $x=-2$: $(0{,}3)^{-2-6}=(0{,}3)^{6\cdot(-2)+4}$, то есть $(0{,}3)^{-8}=(0{,}3)^{-8}$ — верно. Остальные показательные уравнения числу $-2$ не удовлетворяют (а уравнение 4 не является показательным).`,
ref: 'Арефьева «Алгебра, 11 кл.», гл. 2, § 5' },
{ idx: 5, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Найдите значение выражения $\sqrt[7]{(-49)^7}-|5{,}25-6|$.`,
opts: mc('$-48{,}25$', '$-49{,}75$', '$-49{,}25$', '$-48{,}75$', '$-50$'),
answer: 'б',
sol: R`$\sqrt[7]{(-49)^7}=-49$ (корень нечётной степени), $|5{,}25-6|=0{,}75$. Тогда $-49-0{,}75=-49{,}75$.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 2, § 14' },
{ idx: 6, type: 'open', topic: 'expressions', subtopic: 'expr-polynomials', diff: 2,
text: R`Укажите номера пар, состоящих из подобных одночленов.<br>1) $2ab^2$ и $-2a^2b$;<br>2) $\dfrac13 m$ и $-m^3$;<br>3) $5xy$ и $-0{,}2xy$;<br>4) $-16$ и $-16n$;<br>5) $-1{,}2c^8$ и $-8c^8$.<br><i>Ответ запишите номерами в порядке возрастания, без пробелов.</i>`,
answer: '35', ansShow: '3, 5',
sol: R`Подобные одночлены отличаются только числовым коэффициентом (одинаковая буквенная часть). $\ 3)\ 5xy$ и $-0{,}2xy$ — подобны. $\ 5)\ -1{,}2c^8$ и $-8c^8$ — подобны. Остальные пары различаются буквенной частью.`,
ref: 'Арефьева «Алгебра, 7 кл.», гл. 2, § 67' },
{ idx: 7, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 2,
text: R`Юра, редактируя изображение шириной $27$ см и высотой $36$ см, уменьшил ширину на $6$ см так, что отношение ширины к высоте полученного изображения не изменилось. Найдите высоту полученного изображения (в см).`,
opts: mc('$31$', '$27$', '$28$', '$30$', '$26$'),
answer: 'в',
sol: R`Новая ширина $27-6=21$ см. Отношение сохранилось: $\dfrac{27}{36}=\dfrac{21}{x}$, откуда $x=\dfrac{36\cdot21}{27}=28$ см.`,
ref: 'Герасимов «Математика, 6 кл.», гл. 2, § 3' },
{ idx: 8, type: 'mc', topic: 'trigonometry', subtopic: 'trig-identities', diff: 2,
text: R`Найдите значение выражения $\operatorname{arcctg}(-\sqrt3)+\dfrac\pi2$.`,
opts: mc('$\dfrac\pi3$', '$\dfrac{7\pi}{6}$', '$\dfrac\pi6$', '$\dfrac{4\pi}{3}$', '$\dfrac{3\pi}{2}$'),
answer: 'г',
sol: R`$\operatorname{arcctg}(-\sqrt3)=\dfrac{5\pi}{6}$ (так как $\dfrac{5\pi}{6}\in(0;\pi)$ и $\operatorname{ctg}\dfrac{5\pi}{6}=-\sqrt3$). Тогда $\dfrac{5\pi}{6}+\dfrac\pi2=\dfrac{5\pi}{6}+\dfrac{3\pi}{6}=\dfrac{8\pi}{6}=\dfrac{4\pi}{3}$.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 1, § 7' },
{ idx: 9, type: 'mc', topic: 'stereometry', subtopic: 'ster-angles-distances', diff: 3,
text: R`Из точки $A$, отстоящей на $\sqrt3$ от плоскости $\alpha$, проведена наклонная $AB$. Проекция наклонной $AB$ на плоскость $\alpha$ равна $\sqrt{13}$. Найдите косинус угла между наклонной $AB$ и плоскостью $\alpha$.`,
opts: mc('$\dfrac{\sqrt3}{4}$', '$\dfrac{\sqrt{13}}{4}$', '$\dfrac{\sqrt{39}}{13}$', '$\dfrac14$', '$\dfrac{\sqrt3}{2}$'),
answer: 'б',
sol: R`Пусть $O$ — основание перпендикуляра: $AO=\sqrt3$, проекция $OB=\sqrt{13}$. По теореме Пифагора $AB=\sqrt{(\sqrt3)^2+(\sqrt{13})^2}=\sqrt{16}=4$. Искомый угол — $\angle ABO$, $\cos\angle ABO=\dfrac{OB}{AB}=\dfrac{\sqrt{13}}{4}$.`,
ref: 'Латотин «Геометрия, 10 кл.», разд. 3, § 9' },
{ idx: 10, type: 'open', topic: 'functions', subtopic: 'fn-properties', diff: 3,
text: R`Укажите номера верных утверждений.<br>1) функция $f(x)=(\sqrt3-1)^x$ является возрастающей на области определения;<br>2) график функции $f(x)=3^x$ пересекает прямую $y=1$;<br>3) значение функции $f(x)=\log_{0{,}5}x$ меньше нуля при $x=\dfrac23$;<br>4) функция $f(x)=\log_{2{,}02}x$ является возрастающей на области определения;<br>5) $f(3{,}5)>f(4{,}2)$, если $f(x)=\left(\dfrac13\right)^x$.<br><i>Ответ запишите номерами в порядке возрастания, без пробелов.</i>`,
answer: '245', ansShow: '2, 4, 5',
sol: R`$1)$ неверно: $0<\sqrt3-1<1$, функция убывает. $\ 2)$ верно: график $y=3^x$ пересекает $y=1$ в точке $(0;1)$. $\ 3)$ неверно: $\log_{0{,}5}\dfrac23>0$. $\ 4)$ верно: $2{,}02>1$, функция возрастает. $\ 5)$ верно: при основании $\dfrac13$ функция убывает, и из $3{,}5<4{,}2$ следует $f(3{,}5)>f(4{,}2)$.`,
ref: 'Арефьева «Алгебра, 11 кл.», гл. 2, § 4; гл. 3, § 8' },
// ── Часть B ──────────────────────────────────────────────────────────────
{ idx: 11, type: 'long', topic: 'stereometry', subtopic: 'ster-rotation', diff: 3,
text: R`Конус получен вращением равнобедренного прямоугольного треугольника вокруг прямой, содержащей его катет, равный $\sqrt{21}$. Для начала каждого из предложений А–В подберите его окончание 1–6 так, чтобы получилось верное утверждение.<br><b>Начало:</b><br>А) Диаметр основания конуса равен …<br>Б) Площадь осевого сечения конуса равна …<br>В) Объём конуса, если в качестве числа $\pi$ взято число Архимеда $\dfrac{22}{7}$, равен …<br><b>Окончание:</b><br>1) $42$;&emsp;2) $22\sqrt{21}$;&emsp;3) $66\sqrt{21}$;&emsp;4) $21$;&emsp;5) $2\sqrt{21}$;&emsp;6) $\sqrt{21}$.<br><i>Ответ запишите сочетанием букв и цифр, например: А1Б1В4.</i>`,
answer: 'А5Б4В2', ansShow: 'А5Б4В2',
sol: R`Радиус и высота конуса равны катету $\sqrt{21}$. А) Диаметр $=2\sqrt{21}$ — окончание 5. Б) Осевое сечение — равнобедренный треугольник с основанием $2\sqrt{21}$ и высотой $\sqrt{21}$: $S=\tfrac12\cdot2\sqrt{21}\cdot\sqrt{21}=21$ — окончание 4. В) $V=\tfrac13\cdot\dfrac{22}{7}\cdot(\sqrt{21})^2\cdot\sqrt{21}=\tfrac13\cdot\dfrac{22}{7}\cdot21\sqrt{21}=22\sqrt{21}$ — окончание 2.`,
ref: 'Латотин «Геометрия, 11 кл.», разд. 2, § 4' },
{ idx: 12, type: 'open', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 3,
text: R`Выберите верные утверждения.<br>1) значение выражения $(-1)^{-5}\cdot(-2)^2$ равно $-4$;<br>2) значение выражения $8^{1/3}\cdot12^0$ равно $-2$;<br>3) значение выражения $5^{-1/7}:25^{-4/7}$ равно $0{,}2$;<br>4) значение выражения $4-64^{1/3}$ равно $8$;<br>5) значение выражения $16^{-1/4}$ равно $0{,}5$;<br>6) значение выражения $2\cdot49^{0{,}5}+\left(2^{-1{,}5}\right)^{-2}$ равно $22$.<br><i>Ответ запишите номерами в порядке возрастания, без пробелов.</i>`,
answer: '156', ansShow: '1, 5, 6',
sol: R`$1)\ (-1)^{-5}\cdot(-2)^2=-1\cdot4=-4$ — верно. $\ 2)\ 8^{1/3}\cdot1=2\ne-2$ — неверно. $\ 3)\ 5^{-1/7}:5^{-8/7}=5^{1}=5\ne0{,}2$ — неверно. $\ 4)\ 4-4=0\ne8$ — неверно. $\ 5)\ 16^{-1/4}=2^{-1}=0{,}5$ — верно. $\ 6)\ 2\cdot7+2^3=14+8=22$ — верно.`,
ref: 'Арефьева «Алгебра, 11 кл.», гл. 1, § 1' },
{ idx: 13, type: 'open', topic: 'numbers', subtopic: 'num-divisibility', diff: 2,
text: R`Первый диспетчер такси принял за день $155$ заявок. Найдите наибольшее число заявок, принятых вторым диспетчером, если число заявок, принятых двумя диспетчерами вместе, не превосходит $300$ и кратно $9$.`,
answer: '142',
sol: R`Наибольшее не превосходящее $300$ число, кратное $9$, равно $297$. Тогда наибольшее число заявок второго диспетчера $297-155=142$.`,
ref: 'Герасимов «Математика, 5 кл.», ч. 1, гл. 1, § 13' },
{ idx: 14, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 2,
text: R`В параллелограмме $ABCD$ угол $BAD$ равен $45^\circ$, $BH$ — высота, проведённая к стороне $AD$, $AH=4$, $DH=8$. Найдите площадь параллелограмма $ABCD$.`,
answer: '48',
sol: R`Так как $\angle BAD=45^\circ$, прямоугольный треугольник $BHA$ равнобедренный, поэтому $BH=AH=4$. Сторона $AD=AH+HD=12$. Площадь $S=AD\cdot BH=12\cdot4=48$.`,
ref: 'Казаков «Геометрия, 8 кл.», гл. 2, § 14' },
{ idx: 15, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 4,
text: R`Найдите значение выражения $x_0-4$, где $x_0$ — корень уравнения $\log_{81}(7-x)-1\dfrac14=0$.`,
answer: '-240',
sol: R`$\log_{81}(7-x)=\dfrac54$, $\dfrac14\log_3(7-x)=\dfrac54$, $\log_3(7-x)=5$, $7-x=3^5=243$, $x=-236$. Тогда $x_0-4=-236-4=-240$.`,
ref: 'Арефьева «Алгебра, 11 кл.», гл. 3, § 9' },
{ idx: 16, type: 'open', topic: 'functions', subtopic: 'fn-graphs', diff: 3,
text: R`Найдите количество всех целых значений аргумента, при которых функция $f(x)=\dfrac1{12}(x-8)^2-3$ принимает отрицательные значения.`,
answer: '11',
sol: R`Нули функции: $\dfrac1{12}(x-8)^2-3=0$, $(x-8)^2=36$, $x_1=2$, $x_2=14$. Ветви параболы направлены вверх, поэтому функция отрицательна на $(2;14)$. Целых значений на этом промежутке — $11$.`,
ref: 'Арефьева «Алгебра, 8 кл.», гл. 3, § 14' },
{ idx: 17, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 3,
text: R`Найдите значение выражения $64\cos 2\alpha$, если $\sin\alpha=\dfrac18$.`,
answer: '62',
sol: R`$\cos2\alpha=1-2\sin^2\alpha=1-2\cdot\dfrac1{64}=\dfrac{62}{64}$. Тогда $64\cos2\alpha=64\cdot\dfrac{62}{64}=62$.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 1, § 11' },
{ idx: 18, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 4,
text: R`Два дачных участка прямоугольной формы имеют одинаковую длину. Площадь первого участка равна $434$ м$^2$, площадь второго участка равна $558$ м$^2$. Найдите (в метрах) периметр второго участка, если известно, что сумма ширин двух участков составляет $320$ дм.`,
answer: '98',
sol: R`$320$ дм $=32$ м. Пусть ширина второго участка $x$ м, первого $(32-x)$ м, общая длина $y$ м. Тогда $\begin{cases}(32-x)y=434,\\ xy=558.\end{cases}$ Подставив $xy=558$: $32y-558=434$, $32y=992$, $y=31$, $x=18$. Второй участок $31\times18$, периметр $2(31+18)=98$ м.`,
ref: 'Арефьева «Алгебра, 9 кл.», гл. 3, § 11' },
{ idx: 19, type: 'open', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 4,
text: R`В арифметической прогрессии $(a_n)$ четвёртый, пятый и шестой члены имеют вид $a_4=-2x$; $\ a_5=15-3x$; $\ a_6=55-5x$. Найдите сумму тридцати первых членов этой прогрессии.`,
answer: '-4950',
sol: R`По свойству $a_5=\dfrac{a_4+a_6}{2}$: $15-3x=\dfrac{-2x+55-5x}{2}$, $30-6x=-7x+55$, $x=25$. Тогда $a_4=-50$, $a_5=-60$, $a_6=-70$, разность $d=-10$, $a_1=a_4-3d=-20$. $S_{30}=\dfrac{2a_1+d(30-1)}{2}\cdot30=\dfrac{-40-290}{2}\cdot30=-4950$.`,
ref: 'Арефьева «Алгебра, 9 кл.», гл. 4, § 1516' },
{ idx: 20, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 4,
text: R`Радиус окружности, вписанной в равнобедренную трапецию, равен $3\sqrt2$. Тупой угол равнобедренной трапеции равен $120^\circ$. Найдите значение выражения $P^2$, где $P$ — периметр равнобедренной трапеции.`,
answer: '1536',
sol: R`Высота трапеции равна диаметру вписанной окружности: $BK=6\sqrt2$. В прямоугольном треугольнике с острым углом $30^\circ$ боковая сторона $AB=\dfrac{BK}{\sin60^\circ}=\dfrac{6\sqrt2}{\sqrt3/?}$… Для описанной окружностью трапеции $AB+CD=BC+AD$, $AB=CD=4\sqrt6$, поэтому сумма оснований $BC+AD=8\sqrt6$. Периметр $P=2\cdot8\sqrt6=16\sqrt6$, тогда $P^2=256\cdot6=1536$.`,
ref: 'Казаков «Геометрия, 9 кл.», гл. 2, § 10' },
{ idx: 21, type: 'open', topic: 'equations', subtopic: 'eq-linear', diff: 3,
text: R`Найдите сумму всех целых решений совокупности неравенств $\left[\begin{array}{l}\dfrac{x-2}{7}<\dfrac{x+2}{2}-\dfrac1{14},\\[4pt] (x-3)^2+5<(x+2)^2-20\end{array}\right.$ на промежутке $[-7;7]$.`,
answer: '22',
sol: R`Первое неравенство: $2x-4<7x+14-1$, $-5x<17$, $x>-3{,}4$. Второе: $x^2-6x+9+5<x^2+4x+4-20$, $-10x<-30$, $x>3$. Объединение совокупности — луч $(-3{,}4;+\infty)$. Пересечение с $[-7;7]$ даёт $(-3{,}4;7]$. Сумма целых от $-3$ до $7$ равна $22$.`,
ref: 'Арефьева «Алгебра, 8 кл.», гл. 1, § 6' },
{ idx: 22, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 3,
text: R`Имеется $28$ кг сплава меди с цинком, содержащего $34{,}5\%$ меди. Сколько меди (в граммах) необходимо добавить к этому сплаву, чтобы получить сплав, содержащий $60\%$ меди?`,
answer: '17850',
sol: R`Масса меди в исходном сплаве $28\cdot0{,}345=9{,}66$ кг. Пусть добавили $x$ кг меди: $(9{,}66+x)=(28+x)\cdot0{,}6$, $9{,}66+x=16{,}8+0{,}6x$, $0{,}4x=7{,}14$, $x=17{,}85$ кг $=17850$ г.`,
ref: 'Арефьева «Алгебра, 7 кл.», гл. 3, § 16' },
{ idx: 23, type: 'open', topic: 'equations', subtopic: 'eq-irrational', diff: 4,
text: R`Найдите произведение корней (корень, если он единственный) уравнения $\sqrt{2x^2+11x-14}=-x-2$.`,
answer: '-9',
sol: R`Возведём в квадрат: $2x^2+11x-14=x^2+4x+4$, $x^2+7x-18=0$, корни $-9$ и $2$. Проверка: при $x=-9$ $\sqrt{49}=7=-(-9)-2$ — верно; при $x=2$ $\sqrt{16}=4\ne-4$ — посторонний. Единственный корень $-9$; произведение равно $-9$.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 2, § 17' },
{ idx: 24, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 5,
text: R`$ABCDA_1B_1C_1D_1$ — куб, у которого длина ребра равна $6\sqrt3$. Точки $M$ и $N$ — середины рёбер $AB$ и $AD$. Через точки $M$, $N$ и $C_1$ проведена секущая плоскость. Найдите значение выражения $n\cdot a^2$, где $n$ — количество вершин многоугольника сечения, $a$ — длина отрезка, по которому секущая плоскость пересекает грань $AA_1D_1D$.`,
answer: '195',
sol: R`Сечение — пятиугольник $C_1LNMK$, поэтому $n=5$. Сторона $NL$ (по грани $AA_1D_1D$): из построения и подобия $DL=2\sqrt3$, $DN=3\sqrt3$, тогда $NL=\sqrt{DL^2+DN^2}=\sqrt{(2\sqrt3)^2+(3\sqrt3)^2}=\sqrt{39}$, то есть $a=\sqrt{39}$. Значение $n\cdot a^2=5\cdot39=195$.`,
ref: 'Латотин «Геометрия, 10 кл.», разд. 1, § 3' },
{ idx: 25, type: 'open', topic: 'trigonometry', subtopic: 'trig-equations', diff: 5,
text: R`Найдите (в градусах) сумму различных корней уравнения $\sin 3x\cos 3x\cos 6x=-\dfrac{\sqrt3}{8}$ на промежутке $[-60^\circ;0^\circ]$.`,
answer: '-90',
sol: R`$\tfrac12\sin6x\cos6x=-\dfrac{\sqrt3}{8}$, $\tfrac14\sin12x=-\dfrac{\sqrt3}{8}$, $\sin12x=-\dfrac{\sqrt3}{2}$. Тогда $12x=(-1)^{k+1}60^\circ+180^\circ k$, $x=(-1)^{k+1}5^\circ+15^\circ k$. На $[-60^\circ;0^\circ]$ корни: $-5^\circ,\ -10^\circ,\ -35^\circ,\ -40^\circ$. Их сумма $-90^\circ$.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 1, § 8; § 11' },
{ idx: 26, type: 'open', topic: 'stereometry', subtopic: 'ster-rotation', diff: 5,
text: R`Сфера касается всех сторон равнобедренного треугольника $ABC$, у которого длина основания $AC$ равна $10$ и длина боковой стороны $AB$ равна $11$. Расстояние от центра сферы до плоскости треугольника $ABC$ равно $\dfrac{5\sqrt{42}}{4}$. Найдите значение выражения $\dfrac{S}{\pi}$, где $S$ — площадь сферы.`,
answer: '300',
sol: R`Точки касания равноудалены от проекции $O_1$ центра сферы, значит $O_1$ — центр вписанной в $ABC$ окружности. Площадь по Герону: $p=16$, $S_{ABC}=\sqrt{16\cdot5\cdot5\cdot6}=20\sqrt6$, радиус вписанной $r=\dfrac{S}{p}=\dfrac{20\sqrt6}{16}=\dfrac{5\sqrt6}{4}$. Радиус сферы $OK=\sqrt{OO_1^2+r^2}=\sqrt{\dfrac{25\cdot42}{16}+\dfrac{25\cdot6}{16}}=\sqrt{75}=5\sqrt3$. Площадь сферы $S=4\pi R^2=4\pi\cdot75=300\pi$, поэтому $\dfrac{S}{\pi}=300$.`,
ref: 'Латотин «Геометрия, 11 кл.», разд. 3, § 5' },
{ idx: 27, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 4,
text: R`Найдите сумму всех целых решений неравенства $\log_{\lg 8}(8-x)-\log_{\lg 8}(x-4)\ge 0$.`,
answer: '13',
sol: R`Так как $0<\lg8<1$, функция $\log_{\lg8}t$ убывает, поэтому неравенство $\log_{\lg8}(8-x)\ge\log_{\lg8}(x-4)$ равносильно системе $8-x\le x-4$ и $8-x>0$, то есть $x\ge6$ и $x<8$. Решение $[6;8)$, целые $6$ и $7$, сумма $13$.`,
ref: 'Арефьева «Алгебра, 11 кл.», гл. 3, § 10' },
{ idx: 28, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
text: R`Найдите произведение наименьшего целого решения на количество всех целых решений неравенства $\left(\sqrt2-1\right)^{\frac{(x+9)^2(3-x)}{x-6}}\le 1$.`,
answer: '-36',
sol: R`Так как $\sqrt2-1\in(0;1)$, неравенство равносильно $\dfrac{(x+9)^2(3-x)}{x-6}\ge0$, или $\dfrac{(x+9)^2(x-3)}{x-6}\le0$. Методом интервалов (нули $-9,3$; разрыв $6$): решение $\{-9\}\cup[3;6)$. Целых решений $4$ ($-9,3,4,5$), наименьшее $-9$. Произведение $-9\cdot4=-36$.`,
ref: 'Арефьева «Алгебра, 11 кл.», гл. 2, § 6' },
{ idx: 29, type: 'open', topic: 'functions', subtopic: 'fn-derivative', diff: 5,
text: R`Дана функция $f(x)=\dfrac{2x^2-x}{x+5}$. Найдите значение выражения $a\cdot n$, где $a$ — наименьшее целое число из промежутков убывания данной функции, $n$ — количество всех целых чисел из промежутков убывания данной функции.`,
answer: '-100',
sol: R`$f'(x)=\dfrac{2x^2+20x-5}{(x+5)^2}$. Убывание: $f'(x)<0$ при $\dfrac{-10-\sqrt{110}}{2}<x<-5$ и $-5<x<\dfrac{-10+\sqrt{110}}{2}$ (то есть примерно на $(-10{,}2;-5)$ и $(-5;0{,}2)$). Наименьшее целое из этих промежутков $a=-10$, количество целых $n=10$. Значение $a\cdot n=-100$.`,
ref: 'Арефьева «Алгебра, 10 кл.», гл. 3, § 20' },
{ idx: 30, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 5,
text: R`В основании пирамиды лежит прямоугольный треугольник, у которого гипотенуза равна $8$ и один из острых углов равен $60^\circ$. Каждая боковая грань пирамиды наклонена к плоскости основания под углом, равным $\operatorname{arcctg}\dfrac{\sqrt5}{2}$. Найдите значение выражения $\left(3\sqrt5+\sqrt{15}\right)\cdot V$, где $V$ — объём данной пирамиды.`,
answer: '64',
sol: R`Высота пирамиды опущена в центр вписанной в основание окружности (двугранные углы при основании равны). В прямоугольном треугольнике $BC=4$, $AB=4\sqrt3$, $S_{ABC}=\tfrac12\cdot4\sqrt3\cdot4=8\sqrt3$, $r=\dfrac{AB+BC-AC}{2}=2\sqrt3-2$. Высота $SO=\dfrac{r}{\operatorname{ctg}\angle SMO}=\dfrac{2\sqrt3-2}{\sqrt5/2}=\dfrac{4\sqrt{15}-4\sqrt5}{5}$. Объём $V=\tfrac13\cdot8\sqrt3\cdot SO=\dfrac{32(3\sqrt5-\sqrt{15})}{15}$. Тогда $\left(3\sqrt5+\sqrt{15}\right)\cdot V=64$.`,
ref: 'Латотин «Геометрия, 11 кл.», разд. 2, § 3' },
];
/* ── машинерия ─────────────────────────────────────────────────────────────── */
function ansShowOf(t) { if (t.ansShow != null) return t.ansShow; if (t.type === 'mc') return `${t.answer})`; return `$${t.answer}$`; }
function buildSolution(t) {
let html = `${t.sol}<div class="sol-ans">Ответ: ${ansShowOf(t)}</div>`;
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
return html;
}
const EPS = 1e-6;
function srvToNumber(s) {
if (s == null) return NaN;
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
const n = Number(t); return Number.isFinite(n) ? n : NaN;
}
function checkAnswerServer(u, c0) {
if (u == null || c0 == null) return false;
const c = String(c0).trim();
if (/^[а-д]$/.test(c)) return String(u).trim().toLowerCase() === c.toLowerCase();
if (/^[^;]+;[^;]+$/.test(c)) return false;
const cn = srvToNumber(c), un = srvToNumber(u);
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
return Math.abs(cn - un) < EPS;
}
const problems = [];
if (TASKS.length !== 30) problems.push(`Ожидалось 30, получено ${TASKS.length}`);
const seen = new Set();
for (const t of TASKS) {
if (seen.has(t.idx)) problems.push(`Дубль idx=${t.idx}`); seen.add(t.idx);
if (t.idx < 1 || t.idx > 30) problems.push(`idx вне 1..30: ${t.idx}`);
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
if (t.type === 'mc') {
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc!=5 опций`);
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
}
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer)) problems.push(`#${t.idx}: self-check "${t.answer}"`);
if (//.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
}
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
if (require.main !== module) return;
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
if (!db.prepare(`SELECT exam_key FROM exam_tracks WHERE exam_key=?`).get(EXAM)) { console.error(`✗ Трек '${EXAM}' не найден.`); process.exit(1); }
console.log(`\n=== seed_ctmath_rt2425_e3v1 (${PROV}) variant=${VARIANT} ===`);
console.log(`Режим: ${APPLY ? 'APPLY' : 'DRY-RUN'}\n`);
console.log('Типы:', JSON.stringify(TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {})), '| фигур:', TASKS.filter(t => t.fig).length, '\n');
console.log('idx | type | subtopic | d | answer | fig');
console.log('----+------+-----------------------+---+-----------+----');
for (const t of TASKS) console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer).padEnd(9)} | ${t.fig ? '✓' : ''}`);
if (problems.length) { console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`); problems.forEach(p => console.error(' - ' + p)); db.close(); process.exit(1); }
console.log('\n✓ Валидация и self-check ответов пройдены (30/30).');
if (!APPLY) { console.log('\nDRY-RUN: ничего не записано. Для записи добавьте --apply\n'); db.close(); process.exit(0); }
const upsert = db.prepare(`
INSERT INTO exam_tasks (exam_key, variant, task_idx, task_type, text_html, figure_html, opts_json, answer, solution_html, topic, subtopic, difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
task_type=excluded.task_type, text_html=excluded.text_html, figure_html=excluded.figure_html,
opts_json=excluded.opts_json, answer=excluded.answer, solution_html=excluded.solution_html,
topic=excluded.topic, subtopic=excluded.subtopic, difficulty=excluded.difficulty`);
let n = 0; db.exec('BEGIN');
try {
for (const t of TASKS) { upsert.run(EXAM, VARIANT, t.idx, t.type, t.text, t.fig || null, t.type === 'mc' ? JSON.stringify(t.opts) : null, t.answer, buildSolution(t), t.topic, t.subtopic, t.diff); n++; }
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
db.exec('COMMIT');
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}). variants_count=${distinct}.`);
console.log(`\nПробник: /exam-prep/ctmath → «Варианты» → «Вариант ${VARIANT}».\n`);
} catch (e) { db.exec('ROLLBACK'); console.error('\n✗ Ошибка записи, откат:', e.message); process.exitCode = 1; }
db.close();
+1 -1
View File
@@ -525,7 +525,7 @@ function getFeatures(_req, res) {
function updateFeatures(req, res) {
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom',
'gamification', 'assistant'];
'gamification', 'assistant', 'sim_builder', 'quantik'];
const updates = req.body;
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
+65 -4
View File
@@ -28,6 +28,7 @@ const MAX_POINTS = 1000; // точек в polyline/path/points
const OBJECT_TYPES = new Set([
'point', 'segment', 'vector', 'circle', 'rect',
'polyline', 'path', 'label', 'plot', 'readout',
'zone', // Квантик Ф3: зона-препятствие/цель/сбор (граф-уровни)
]);
const STATUSES = new Set(['draft', 'published']);
@@ -189,6 +190,14 @@ function validateSpec(spec) {
out.drag.param = sanitizeText(o.drag.param, 60);
if (o.drag.paramY !== undefined) out.drag.paramY = sanitizeText(o.drag.paramY, 60);
}
// zone{} — track = id отслеживаемой точки (Квантик Ф3): санитизируем как id.
if (type === 'zone' && o.track !== undefined) out.track = sanitizeText(o.track, 60);
// runner{} на plot (Квантик Ф3): duration — число/выражение (длина).
if (o.runner && typeof o.runner === 'object' && !Array.isArray(o.runner)) {
if (o.runner.duration !== undefined) checkExpr(o.runner.duration, `objects[${i}].runner.duration`, errs);
}
return out;
});
@@ -235,6 +244,51 @@ function validateSpec(spec) {
clean.physics = cph;
}
// goal{} — слой цели/победы (Квантик, Фаза 0). Выражения НЕ исполняем (длина),
// текст — sanitizeText (escape + обрезка), не более 3 звёзд, hold — число.
if (spec.goal && typeof spec.goal === 'object' && !Array.isArray(spec.goal)) {
const g = spec.goal;
const cg = {};
if (g.when !== undefined) { checkExpr(g.when, 'goal.when', errs); cg.when = g.when; }
if (g.fail !== undefined) { checkExpr(g.fail, 'goal.fail', errs); cg.fail = g.fail; }
if (g.title !== undefined) cg.title = sanitizeText(g.title, 120);
if (g.hint !== undefined) cg.hint = sanitizeText(g.hint, 300);
if (g.hold !== undefined) {
if (typeof g.hold !== 'number') errs.push('goal.hold должно быть числом');
else cg.hold = g.hold;
}
if (g.stars !== undefined) {
if (!Array.isArray(g.stars)) {
errs.push('goal.stars должно быть массивом');
} else if (g.stars.length > 3) {
return { ok: false, error: 'goal.stars > 3' };
} else {
cg.stars = g.stars.map((s, i) => {
if (!s || typeof s !== 'object') { errs.push(`goal.stars[${i}]: не объект`); return {}; }
const os = {};
if (s.when !== undefined) { checkExpr(s.when, `goal.stars[${i}].when`, errs); os.when = s.when; }
if (s.label !== undefined) os.label = sanitizeText(s.label, 120);
return os;
});
}
}
clean.goal = cg;
}
// game{} — мета-слой игрового уровня (Фаза 1/5). Санитизируем ПОИМЁННО (как goal):
// строки → sanitizeText (escape), числа → проверка типа, неизвестные ключи отбрасываем.
// Иначе произвольная строка в game.* стала бы хранимым XSS у любого, кому раздали уровень.
if (spec.game && typeof spec.game === 'object' && !Array.isArray(spec.game)) {
const gm = spec.game;
const cgm = {};
if (gm.chapter !== undefined) cgm.chapter = sanitizeText(gm.chapter, 60);
if (gm.subject !== undefined) cgm.subject = sanitizeText(gm.subject, 60);
if (typeof gm.order === 'number') cgm.order = gm.order;
if (typeof gm.par_ms === 'number') cgm.par_ms = gm.par_ms;
if (typeof gm.unlockStars === 'number') cgm.unlockStars = gm.unlockStars;
clean.game = cgm;
}
if (errs.length) return { ok: false, error: errs.slice(0, 8).join('; ') };
return { ok: true, clean };
}
@@ -408,17 +462,24 @@ function share(req, res) {
}
const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель';
const simTitle = row.title || 'симуляция';
const link = '/lab?sim=custom:' + row.id;
const isGame = row.cat === 'game';
const simTitle = row.title || (isGame ? 'игровой уровень' : 'симуляция');
// Игровой уровень открывается в «Квантике» (/quantik?level=custom:<id>),
// обычная симуляция — в лаборатории (/lab?sim=custom:<id>). Фаза 5/6.
const link = (isGame ? '/quantik?level=custom:' : '/lab?sim=custom:') + row.id;
const notifType = isGame ? 'game_level_shared' : 'sim_shared';
const notifMsg = isGame
? `Новый игровой уровень от ${teacherName}: «${simTitle}»`
: `Новая симуляция от ${teacherName}: «${simTitle}»`;
const recipients = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(classId).map(r => r.user_id);
let sent = 0;
for (const uid of recipients) {
if (!uid || uid === req.user.id) continue;
pushNotif(uid, 'sim_shared', `Новая симуляция от ${teacherName}: «${simTitle}»`, link);
pushNotif(uid, notifType, notifMsg, link);
sent++;
}
res.json({ ok: true, sent, status: 'published' });
res.json({ ok: true, sent, status: 'published', link });
}
/* POST /api/custom-sims/:id/clone — копия спеки текущему пользователю как draft.
+84
View File
@@ -0,0 +1,84 @@
'use strict';
/* Game progress ("Квантик — Законы Мира", Фаза 1).
*
* Прогресс игрока по уровням. Уровень = спека SimForge с блоком goal;
* идентифицируется строковым level_id. На победу клиент шлёт результат
* (time_ms, stars); сервер делает upsert, сохраняя ЛУЧШИЙ результат
* (минимальное время, максимум звёзд) и инкрементируя attempts.
*
* Стиль следует customSimController / studentMaterialsController:
* node:sqlite db.prepare, auth-only (роутер ставит authMiddleware),
* валидация входа без исполнения, статусы 400.
*/
const db = require('../db/db');
const MAX_LEVEL_ID = 120; // длина level_id (TEXT)
const MAX_TIME_MS = 24 * 60 * 60 * 1000; // санитарный потолок: сутки в мс
/* Целое неотрицательное число (отвергаем NaN/Infinity/дробь/отрицательное). */
function isNonNegInt(v) {
return typeof v === 'number' && Number.isInteger(v) && v >= 0;
}
/* GET /api/game/progress — прогресс текущего пользователя по всем уровням. */
function listProgress(req, res) {
const uid = req.user.id;
const rows = db.prepare(`
SELECT level_id, best_time_ms, best_stars, attempts, completed_at
FROM game_progress
WHERE user_id = ?
ORDER BY completed_at DESC, id DESC
`).all(uid);
res.json({ progress: rows });
}
/* POST /api/game/progress body: { level_id, time_ms, stars }
* Upsert: сохраняем ЛУЧШИЙ результат (min time_ms, max stars); attempts++.
* Валидация: level_id строка ≤120; time_ms/stars — неотрицательные целые;
* stars 0..3. БЕЗ исполнения чего-либо. */
function submitProgress(req, res) {
const uid = req.user.id;
const b = req.body || {};
const levelId = typeof b.level_id === 'string' ? b.level_id.trim() : '';
if (!levelId) return res.status(400).json({ error: 'level_id обязателен' });
if (levelId.length > MAX_LEVEL_ID) {
return res.status(400).json({ error: `level_id длиннее ${MAX_LEVEL_ID} символов` });
}
const timeMs = b.time_ms;
const stars = b.stars;
if (!isNonNegInt(timeMs)) return res.status(400).json({ error: 'time_ms должно быть неотрицательным целым' });
if (timeMs > MAX_TIME_MS) return res.status(400).json({ error: 'time_ms вне допустимого диапазона' });
if (!isNonNegInt(stars)) return res.status(400).json({ error: 'stars должно быть неотрицательным целым' });
if (stars > 3) return res.status(400).json({ error: 'stars вне диапазона 0..3' });
const existing = db.prepare(
'SELECT id, best_time_ms, best_stars FROM game_progress WHERE user_id = ? AND level_id = ?'
).get(uid, levelId);
if (!existing) {
db.prepare(`
INSERT INTO game_progress (user_id, level_id, best_time_ms, best_stars, attempts)
VALUES (?, ?, ?, ?, 1)
`).run(uid, levelId, timeMs, stars);
} else {
// Лучшее время = минимум (null трактуем как «нет результата»); лучшие звёзды = максимум.
const bestTime = (existing.best_time_ms == null)
? timeMs
: Math.min(existing.best_time_ms, timeMs);
const bestStars = Math.max(existing.best_stars || 0, stars);
db.prepare(`
UPDATE game_progress
SET best_time_ms = ?, best_stars = ?, attempts = attempts + 1
WHERE id = ?
`).run(bestTime, bestStars, existing.id);
}
const row = db.prepare(
'SELECT level_id, best_time_ms, best_stars, attempts, completed_at FROM game_progress WHERE user_id = ? AND level_id = ?'
).get(uid, levelId);
res.json({ ok: true, progress: row });
}
module.exports = { listProgress, submitProgress };
@@ -0,0 +1,25 @@
-- ═══════════════════════════════════════════════════════════════
-- 076: Game progress (Квантик — Законы Мира, Фаза 1).
--
-- Прогресс игрока по уровням игры «Квантик». Уровень идентифицируется
-- строковым level_id (напр. 'phys-artillery-1'); сами уровни — это спеки
-- SimForge (встроенные данные сейчас, custom_sims cat='game' в Ф5).
--
-- Upsert хранит ЛУЧШИЙ результат: best_time_ms (минимальное время прохождения),
-- best_stars (максимум собранных звёзд 0..3). attempts растёт на каждый submit.
-- UNIQUE(user_id, level_id) — одна строка прогресса на пару игрок-уровень.
-- user_id ON DELETE CASCADE — прогресс удаляется вместе с игроком.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS game_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
level_id TEXT NOT NULL, -- идентификатор уровня (спека)
best_time_ms INTEGER, -- лучшее (минимальное) время, мс
best_stars INTEGER NOT NULL DEFAULT 0, -- лучшее число звёзд 0..3
attempts INTEGER NOT NULL DEFAULT 0, -- число попыток (++ на submit)
completed_at TEXT DEFAULT (datetime('now')), -- время первого прохождения
UNIQUE (user_id, level_id)
);
CREATE INDEX IF NOT EXISTS idx_game_progress_user ON game_progress (user_id);
@@ -0,0 +1,90 @@
-- ═══════════════════════════════════════════════════════════════
-- 077: ЦЭ/ЦТ по математике — трек 'ctmath' + дерево тем exam_topics
--
-- План: plans/ct-math/ (PLAN.md, TOPICS_SEED.md).
-- Формат экзамена: часть А (А1–А10, выбор из 5) + часть В (В1–В20,
-- открытый ответ) = 30 заданий, ~180 мин, до 100 тестовых баллов.
--
-- Двухуровневая иерархия (как 024_exam_topics_seed): раздел (parent=NULL)
-- → подтема. textbook_slug ссылается на существующие учебники платформы
-- (algebra-7..11, geometry-8..11, math-5/6); textbook_paragraph пока NULL
-- (точные § проставляются при маппинге контента).
--
-- variants_count=0 — варианты ещё не оцифрованы (см. DIGITIZATION_SPEC.md).
-- scoring_json — иллюстративный placeholder; заменить официальной
-- таблицей РИКЗ (первичный→тестовый) при наличии вариантов.
-- ═══════════════════════════════════════════════════════════════
-- ── Трек ─────────────────────────────────────────────────────────
INSERT INTO exam_tracks (
exam_key, title, subject_slug, grade, duration_min,
tasks_per_variant, variants_count, scoring_json, intro_html, enabled, sort_order
) VALUES (
'ctmath',
'ЦЭ/ЦТ — Математика',
'math',
11,
180,
30,
0,
'[{"correct":30,"score":100},{"correct":28,"score":92},{"correct":26,"score":85},{"correct":24,"score":78},{"correct":22,"score":71},{"correct":20,"score":64},{"correct":18,"score":57},{"correct":16,"score":50},{"correct":14,"score":43},{"correct":12,"score":36},{"correct":10,"score":30},{"correct":8,"score":24},{"correct":6,"score":17},{"correct":4,"score":11},{"correct":2,"score":5},{"correct":0,"score":0}]',
'<p><b>Подготовка к ЦЭ/ЦТ по математике.</b> Формат: часть А — 10 заданий с выбором ответа (А1–А10), часть В — 20 заданий с открытым ответом (В1–В20), всего 30 заданий, ~180 минут, без калькулятора.</p><p>Курс устроен по темам с входной диагностикой и тремя уровнями сложности (База / Ядро / Продвинутый): теория с выводом формул, разбор эталонных задач, тренажёр по темам, карточки формул с интервальным повторением и пробные экзамены с таймером на реальных вариантах РТ/ЦТ прошлых лет.</p>',
1,
20
);
-- ── Разделы (parent_slug = NULL) ─────────────────────────────────
INSERT INTO exam_topics (slug, exam_key, parent_slug, title, description, sort_order, textbook_slug) VALUES
('numbers', 'ctmath', NULL, 'Числа и вычисления', 'Действительные числа, делимость, проценты, преобразование числовых выражений', 10, 'math-6'),
('expressions', 'ctmath', NULL, 'Алгебраические преобразования','Многочлены, степени и корни, рациональные дроби, ОДЗ', 20, 'algebra-7'),
('equations', 'ctmath', NULL, 'Уравнения и неравенства', 'Линейные, квадратные, рациональные, модуль, иррациональные, показательные, логарифмические; метод рационализации', 30, 'algebra-9'),
('functions', 'ctmath', NULL, 'Функции и производная', 'Свойства функций, графики, исследование с производной', 40, 'algebra-9-ch2'),
('trigonometry', 'ctmath', NULL, 'Тригонометрия', 'Круг, тождества, уравнения и отбор корней', 50, 'algebra-10-ch1'),
('word-sequences', 'ctmath', NULL, 'Прогрессии и текстовые задачи','Арифметическая/геометрическая прогрессии; проценты, движение, работа, смеси', 60, 'algebra-9-ch4'),
('planimetry', 'ctmath', NULL, 'Планиметрия', 'Треугольники, четырёхугольники, окружность; координатный метод', 70, 'geometry-8'),
('stereometry', 'ctmath', NULL, 'Стереометрия', 'Расположение, многогранники, тела вращения, углы и расстояния', 80, 'geometry-10'),
('advanced', 'ctmath', NULL, 'Продвинутое и комбинированное','Параметры, комбинированные задачи, функциональные методы', 90, NULL);
-- ── Подтемы (parent_slug = раздел) ───────────────────────────────
INSERT INTO exam_topics (slug, exam_key, parent_slug, title, sort_order, textbook_slug) VALUES
-- Числа и вычисления
('num-real', 'ctmath', 'numbers', 'Действительные числа, координатная прямая', 11, 'math-6'),
('num-divisibility', 'ctmath', 'numbers', 'Делимость, дроби, НОД/НОК', 12, 'math-5-ch1'),
('num-expressions', 'ctmath', 'numbers', 'Преобразование числовых выражений', 13, 'algebra-7-ch2'),
-- Алгебраические преобразования
('expr-polynomials', 'ctmath', 'expressions', 'Многочлены, ФСУ, разложение на множители', 21, 'algebra-7-ch2'),
('expr-powers-roots', 'ctmath', 'expressions', 'Степени и корни, ОДЗ выражений', 22, 'algebra-10-ch2'),
('expr-fractions', 'ctmath', 'expressions', 'Рациональные (алгебраические) дроби', 23, 'algebra-9-ch1'),
-- Уравнения и неравенства
('eq-linear', 'ctmath', 'equations', 'Линейные уравнения/неравенства, системы', 31, 'algebra-7-ch3'),
('eq-quadratic', 'ctmath', 'equations', 'Квадратные уравнения/неравенства, Виет', 32, 'algebra-8'),
('eq-rational', 'ctmath', 'equations', 'Рациональные уравнения/неравенства, метод интервалов', 33, 'algebra-9-ch3'),
('eq-modulus', 'ctmath', 'equations', 'Уравнения и неравенства с модулем', 34, 'algebra-9'),
('eq-irrational', 'ctmath', 'equations', 'Иррациональные уравнения/неравенства', 35, 'algebra-10-ch2'),
('eq-exponential', 'ctmath', 'equations', 'Показательные уравнения/неравенства', 36, 'algebra-11-ch2'),
('eq-logarithmic', 'ctmath', 'equations', 'Логарифмические уравнения/неравенства', 37, 'algebra-11-ch3'),
('eq-rationalization','ctmath', 'equations', 'Метод рационализации (замена множителей)', 38, 'algebra-11'),
-- Функции и производная
('fn-properties', 'ctmath', 'functions', 'Свойства функций: ОДЗ, чётность, монотонность', 41, 'algebra-9-ch2'),
('fn-graphs', 'ctmath', 'functions', 'Графики и их преобразования, чтение графиков', 42, 'algebra-9-ch2'),
('fn-derivative', 'ctmath', 'functions', 'Производная: монотонность, экстремумы, исследование', 43, 'algebra-10-ch3'),
-- Тригонометрия
('trig-circle', 'ctmath', 'trigonometry', 'Тригонометрический круг, значения, простейшие уравнения', 51, 'algebra-10-ch1'),
('trig-identities', 'ctmath', 'trigonometry', 'Тождества и формулы (вывод), обратные функции', 52, 'algebra-10-ch1'),
('trig-equations', 'ctmath', 'trigonometry', 'Тригонометрические уравнения, отбор корней', 53, 'algebra-10-ch1'),
-- Прогрессии и текстовые задачи
('seq-progressions', 'ctmath', 'word-sequences', 'Арифметическая и геометрическая прогрессии', 61, 'algebra-9-ch4'),
('word-problems', 'ctmath', 'word-sequences', 'Текстовые: проценты, движение, работа, смеси', 62, 'math-6-ch2'),
-- Планиметрия
('plan-triangles', 'ctmath', 'planimetry', 'Треугольники, площади, теоремы синусов/косинусов, окружности', 71, 'geometry-8'),
('plan-quadrilaterals','ctmath','planimetry', 'Четырёхугольники и правильные многоугольники', 72, 'geometry-8-ch1'),
('plan-circle', 'ctmath', 'planimetry', 'Окружность: углы, касательные; координатный метод', 73, 'geometry-8-ch4'),
-- Стереометрия
('ster-basics', 'ctmath', 'stereometry', 'Расположение прямых/плоскостей, сечения', 81, 'geometry-10'),
('ster-polyhedra', 'ctmath', 'stereometry', 'Многогранники: объёмы, площади, сечения, подобие', 82, 'geometry-10'),
('ster-rotation', 'ctmath', 'stereometry', 'Тела вращения: цилиндр, конус, шар/сфера', 83, 'geometry-11'),
('ster-angles-distances','ctmath','stereometry', 'Углы и расстояния; координатно-векторный метод', 84, 'geometry-11'),
-- Продвинутое
('adv-parameters', 'ctmath', 'advanced', 'Задачи с параметрами', 91, NULL),
('adv-combined', 'ctmath', 'advanced', 'Комбинированные задачи, нестандартные приёмы', 92, NULL),
('adv-functional', 'ctmath', 'advanced', 'Функциональные методы, целые числа (бонус)', 93, NULL);
+13 -7
View File
@@ -6,29 +6,35 @@
const express = require('express');
const router = express.Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { requireFeature } = require('../middleware/features');
const c = require('../controllers/customSimController');
router.use(authMiddleware);
// «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
// Чтение/проигрывание уже сохранённых симуляций остаётся доступным; гейтим только
// авторинг — создание/правку/удаление/раздачу/клон/связи.
const gate = requireFeature('sim_builder');
router.get('/', c.list);
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
router.get('/:id', c.get);
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
router.get('/:id/related', c.related);
router.post('/', requireRole('teacher', 'admin'), c.create);
router.post('/', gate, requireRole('teacher', 'admin'), c.create);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.put('/:id', requireRole('teacher', 'admin'), c.update);
router.put('/:id', gate, requireRole('teacher', 'admin'), c.update);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.delete('/:id', requireRole('teacher', 'admin'), c.remove);
router.delete('/:id', gate, requireRole('teacher', 'admin'), c.remove);
// Фаза 6 — раздача классу / клон / курикулумные связи. Мутации — inline
// requireRole(teacher,admin) + per-row ownership в хендлере.
router.post('/:id/share', requireRole('teacher', 'admin'), c.share);
router.post('/:id/clone', requireRole('teacher', 'admin'), c.clone);
router.post('/:id/share', gate, requireRole('teacher', 'admin'), c.share);
router.post('/:id/clone', gate, requireRole('teacher', 'admin'), c.clone);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.post('/:id/links', requireRole('teacher', 'admin'), c.addLink);
router.post('/:id/links', gate, requireRole('teacher', 'admin'), c.addLink);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.delete('/:id/links/:linkId', requireRole('teacher', 'admin'), c.removeLink);
router.delete('/:id/links/:linkId', gate, requireRole('teacher', 'admin'), c.removeLink);
module.exports = router;
+76 -6
View File
@@ -1,7 +1,7 @@
'use strict';
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware } = require('../middleware/auth');
const { authMiddleware, requireRole } = require('../middleware/auth');
const access = require('../services/contentAccess');
router.use(authMiddleware);
@@ -15,6 +15,33 @@ router.param('examKey', (req, res, next, examKey) => {
next();
});
/* ── Mock/variant picker: какие variant считаются «пробниками» ──────
ctmath: год-пачки (variant=год 20112024 и 0) — это тематический ПУЛ для
тренажёра по темам, а НЕ чистые 30-задачные варианты (у части до 114 задач).
Чистые варианты-пробники нумеруются 3-значно (101, 102, …), а год-пачки —
4-значными годами (≥2011) и 0, поэтому фильтр — ДИАПАЗОН [101;1999], а не
просто порог (год 2024 > 101 и иначе бы прошёл!). В пикере пробников,
mock/start и просмотре вариантов показываем только чистые. Тренажёр по темам
отбирает по subtopic и этот фильтр НЕ использует — пул задач не теряется.
Для остальных треков (math9: варианты 1..80) диапазона нет — показываются все. */
const MOCK_VARIANT_RANGE = { ctmath: [101, 1999] };
const isMockVariant = (examKey, v) => {
const r = MOCK_VARIANT_RANGE[examKey];
return r ? (v >= r[0] && v <= r[1]) : (v >= 1);
};
/* Человекочитаемая подпись варианта (номер в БД остаётся техническим, напр. 101).
Для ctmath варианты-пробники именуются по источнику; при добавлении новых
вариантов (104+) — дописывать сюда. Иначе fallback «Вариант N». */
const VARIANT_LABEL = {
ctmath: {
101: 'РТ-2024/25 · этап I',
102: 'РТ-2024/25 · этап II',
103: 'РТ-2024/25 · этап III',
},
};
const examVariantLabel = (examKey, v) => VARIANT_LABEL[examKey]?.[v] || `Вариант ${v}`;
/* ── Statements (prepared once) ────────────────────────────────── */
const SQL = {
listTracks: db.prepare(`
@@ -416,6 +443,28 @@ router.get('/tracks', (req, res) => {
res.json({ tracks });
});
/* ── Админ: управление экзамен-модулями (вкл/выкл) ──
Отдельные пути (без :examKey, чтобы не задеть гейт content_access). */
router.get('/admin/tracks', requireRole('admin'), (_req, res) => {
const tracks = db.prepare(`
SELECT exam_key, title, subject_slug, grade, enabled, variants_count, sort_order,
(SELECT COUNT(*) FROM exam_tasks t WHERE t.exam_key = exam_tracks.exam_key) AS task_count
FROM exam_tracks
ORDER BY sort_order, exam_key
`).all();
res.json({ tracks });
});
router.patch('/admin/track', requireRole('admin'), (req, res) => {
const key = String(req.body && req.body.exam_key || '').trim();
if (!key) return res.status(400).json({ error: 'exam_key required' });
if (!db.prepare('SELECT 1 FROM exam_tracks WHERE exam_key = ?').get(key))
return res.status(404).json({ error: 'Unknown exam track' });
const enabled = req.body && req.body.enabled ? 1 : 0;
db.prepare('UPDATE exam_tracks SET enabled = ? WHERE exam_key = ?').run(enabled, key);
res.json({ ok: true, exam_key: key, enabled });
});
/* ── GET /api/exam-prep/:examKey/info ──
Track metadata + global counts + this user's aggregate progress. */
// @public-by-design: router-level authMiddleware (line 6) covers this route
@@ -456,10 +505,10 @@ router.get('/:examKey/info', (req, res) => {
router.get('/:examKey/variants', (req, res) => {
const { examKey } = req.params;
if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
const rows = SQL.listVariants.all(req.user.id, examKey);
const rows = SQL.listVariants.all(req.user.id, examKey).filter(r => isMockVariant(examKey, r.variant));
const variants = rows.map(r => ({
n: r.variant,
label: `Вариант ${r.variant}`,
label: examVariantLabel(examKey, r.variant),
total: r.total,
solved: r.solved,
viewed_sol: r.viewed_sol,
@@ -476,6 +525,7 @@ router.get('/:examKey/variants/:n/tasks', (req, res) => {
const { examKey } = req.params;
const n = parseInt(req.params.n, 10);
if (!Number.isFinite(n) || n < 1) return res.status(400).json({ error: 'Bad variant number' });
if (!isMockVariant(examKey, n)) return res.status(404).json({ error: 'Variant not found or empty' });
const rows = SQL.getVariantTasks.all(examKey, n);
if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' });
@@ -630,13 +680,15 @@ function pickRandomByDifficulty(examKey, count, excludeSlugs) {
? `AND (subtopic IS NULL OR subtopic NOT IN (${exParams.map(() => '?').join(',')}))`
: '';
const COLS = `id, task_idx, variant, task_type, text_html, figure_html, opts_json,
answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph`;
const out = [];
const seen = new Set();
for (let d = 1; d <= 5; d++) {
const limit = dist[d - 1];
if (limit === 0) continue;
const sql = `
SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json,
answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph
SELECT ${COLS}
FROM exam_tasks
WHERE exam_key = ? AND task_type IN ('mc','open')
AND difficulty = ?
@@ -646,8 +698,25 @@ function pickRandomByDifficulty(examKey, count, excludeSlugs) {
const args = exParams
? [examKey, d, ...exParams, limit]
: [examKey, d, limit];
for (const r of db.prepare(sql).all(...args)) { if (!seen.has(r.id)) { seen.add(r.id); out.push(r); } }
}
// Backfill to `count` from any difficulty — covers tracks whose tasks don't
// span all 5 difficulty levels (otherwise empty levels would shrink the batch).
if (out.length < count) {
const ids = [...seen];
const notIn = ids.length ? `AND id NOT IN (${ids.map(() => '?').join(',')})` : '';
const sql = `
SELECT ${COLS}
FROM exam_tasks
WHERE exam_key = ? AND task_type IN ('mc','open')
${exClause}
${notIn}
ORDER BY RANDOM()
LIMIT ?`;
const args = [examKey, ...(exParams || []), ...ids, count - out.length];
out.push(...db.prepare(sql).all(...args));
}
out.sort((a, b) => (a.difficulty || 0) - (b.difficulty || 0));
return out;
}
@@ -1139,7 +1208,7 @@ router.post('/:examKey/mock/start', (req, res) => {
if (source === 'variant') {
variant = Number(req.body?.variant);
if (!Number.isInteger(variant) || variant < 1) {
if (!Number.isInteger(variant) || !isMockVariant(examKey, variant)) {
return res.status(400).json({ error: 'Variant number required' });
}
const rows = SQL.getTasksByVariant.all(examKey, variant);
@@ -1210,6 +1279,7 @@ router.get('/mock/:id', (req, res) => {
id: sess.id,
exam_key: sess.exam_key,
variant: sess.variant,
variant_label: sess.variant != null ? examVariantLabel(sess.exam_key, sess.variant) : null,
source: sess.source,
status: sess.status,
started_at: sess.started_at,
+16
View File
@@ -0,0 +1,16 @@
'use strict';
/* /api/game — прогресс игрока в игре «Квантик — Законы Мира» (Фаза 1).
* Все роуты — auth-only (играют и ученики). router.use(authMiddleware)
* → lint:routes baseline 0. Прогресс всегда принадлежит req.user — нет
* межпользовательских роутов, проверка владения не требуется. */
const express = require('express');
const router = express.Router();
const { authMiddleware } = require('../middleware/auth');
const c = require('../controllers/gameController');
router.use(authMiddleware);
router.get('/progress', c.listProgress);
router.post('/progress', c.submitProgress);
module.exports = router;
+1
View File
@@ -197,6 +197,7 @@ app.use('/api/teacher-students', teacherStudentsRoutes);
app.use('/api/lab', labRoutes);
app.use('/api/materials', require('./routes/materials'));
app.use('/api/custom-sims', require('./routes/customSims'));
app.use('/api/game', require('./routes/game'));
app.use('/api/dashboard', require('./routes/dashboard'));
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
+28
View File
@@ -203,6 +203,34 @@ describe('/api/custom-sims', () => {
assert.ok(txt.includes('&lt;img'), 'escaped form present');
});
it('accepts graph-level spec with zone + runner (Квантик Ф3)', async () => {
const spec = {
specVersion: 1,
meta: { title: 'Граф-уровень' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8 },
params: [{ name: 'a', min: -1, max: 2, step: 0.05, value: 0.5 }],
objects: [
{ id: 'curve', type: 'plot', expr: 'a*x', var: 'x', range: [0, 10], runner: { duration: 5 } },
{ id: 'ball', type: 'point', x: 'curve.runX', y: 'curve.runY', r: 7 },
{ type: 'zone', id: 'pit', kind: 'forbidden', shape: 'rect', x: 5, y: 0, w: 4, h: 2, track: 'ball', label: 'яма' },
{ type: 'zone', id: 'gate', kind: 'target', shape: 'circle', x: 10, y: 5, r: 1, track: 'ball' },
],
goal: { when: 'gate.hit', fail: 'pit.hit', stars: [{ when: 'gate.hit' }] },
};
const res = await inject('POST', '/api/custom-sims', { spec }, teacherToken);
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
const get = await inject('GET', `/api/custom-sims/${res.body.id}`, null, teacherToken);
const objs = get.body.sim.spec.objects;
assert.ok(objs.find(o => o.type === 'zone' && o.id === 'pit'), 'zone object preserved');
assert.ok(objs.find(o => o.type === 'plot' && o.runner), 'runner block preserved');
});
it('rejects unknown object type even with zone allowed (400)', async () => {
const bad = { ...VALID_SPEC, objects: [{ type: 'zoney_fake', x: 0, y: 0 }] };
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('owner can DELETE own sim (then 404)', async () => {
const del = await inject('DELETE', `/api/custom-sims/${simId}`, null, teacherToken);
assert.equal(del.status, 200, `got ${del.status}`);
+108
View File
@@ -0,0 +1,108 @@
'use strict';
/**
* Integration tests: /api/game — прогресс игрока «Квантик» (Фаза 1).
* Covers: submit создаёт строку; лучший результат перезаписывает, худший — нет;
* attempts++; auth-only (401 без токена); валидация входа (400).
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, inject, getToken, cleanup } = require('./setup');
// Mount /api/game on the shared test app (setup.js не монтирует новые роуты).
app.use('/api/game', require('../src/routes/game'));
after(() => cleanup());
const LVL = 'phys-artillery-1';
describe('/api/game progress', () => {
let token;
before(async () => {
token = (await getToken('student')).token;
});
it('GET /progress requires auth (401)', async () => {
const res = await inject('GET', '/api/game/progress', null, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
it('POST /progress requires auth (401)', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 1000, stars: 1 }, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
it('submit creates a progress row', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 5000, stars: 1 }, token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.ok, true);
assert.equal(res.body.progress.level_id, LVL);
assert.equal(res.body.progress.best_time_ms, 5000);
assert.equal(res.body.progress.best_stars, 1);
assert.equal(res.body.progress.attempts, 1);
});
it('GET /progress lists the row', async () => {
const res = await inject('GET', '/api/game/progress', null, token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.ok(Array.isArray(res.body.progress), 'progress is array');
const row = res.body.progress.find(r => r.level_id === LVL);
assert.ok(row, 'level row present');
assert.equal(row.best_time_ms, 5000);
});
it('better result (less time, more stars) overwrites best; attempts++', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 3200, stars: 2 }, token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.progress.best_time_ms, 3200, 'time improved');
assert.equal(res.body.progress.best_stars, 2, 'stars improved');
assert.equal(res.body.progress.attempts, 2, 'attempts incremented');
});
it('worse result does NOT overwrite best, but still counts an attempt', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 9999, stars: 0 }, token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.progress.best_time_ms, 3200, 'best time kept');
assert.equal(res.body.progress.best_stars, 2, 'best stars kept');
assert.equal(res.body.progress.attempts, 3, 'attempts still incremented');
});
it('progress is per-user (другой игрок начинает с нуля)', async () => {
const other = (await getToken('student')).token;
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 7000, stars: 1 }, other);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.progress.attempts, 1, 'fresh user has attempts=1');
assert.equal(res.body.progress.best_time_ms, 7000);
});
it('validation: missing level_id → 400', async () => {
const res = await inject('POST', '/api/game/progress', { time_ms: 1000, stars: 1 }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('validation: negative time_ms → 400', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: -5, stars: 1 }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('validation: non-integer time_ms → 400', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 12.5, stars: 1 }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('validation: stars out of range (>3) → 400', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 1000, stars: 4 }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('validation: negative stars → 400', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 1000, stars: -1 }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('validation: level_id too long → 400', async () => {
const res = await inject('POST', '/api/game/progress',
{ level_id: 'x'.repeat(200), time_ms: 1000, stars: 1 }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
});
+136
View File
@@ -0,0 +1,136 @@
'use strict';
/**
* Integration tests: Квантик Фаза 5 — авторинг/раздача игровых уровней.
* Уровень = custom_sims с cat='game' + блок goal/game в спеке. Покрываем:
* - создание игрового уровня (goal+game принимаются validateSpec'ом);
* - доступ: чужой DRAFT игровой уровень → 403 (deep-link/embed не утечёт),
* свой draft / чужой published → виден;
* - раздача классу игрового уровня шлёт ДОЛГОВЕЧНОЕ уведомление со ссылкой
* /quantik?level=custom:<id> (тип game_level_shared), авто-публикация.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, db, inject, getToken, cleanup } = require('./setup');
// Mount /api/custom-sims on the shared test app (setup.js его не монтирует).
app.use('/api/custom-sims', require('../src/routes/customSims'));
after(() => cleanup());
/* Минимальная валидная спека ИГРОВОГО уровня: goal + game-метаданные. */
const GAME_SPEC = {
specVersion: 1,
meta: { title: 'Мой уровень' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8 },
params: [{ name: 'theta', label: 'Угол', min: 10, max: 80, step: 1, value: 45 }],
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
objects: [
{ id: 'ball', type: 'point', x: 0, y: 0, r: 7, body: { mass: 1, vx: 'cos(theta*pi/180)*10', vy: 'sin(theta*pi/180)*10' } },
{ type: 'circle', id: 'gate', x: 8, y: 1, r: 0.8, color: '#A78BFA' },
],
goal: {
title: 'Попади в портал',
hint: 'Подбери угол',
when: 'hypot(ball.x - 8, ball.y - 1) < 0.8',
fail: 'ball.y < -1 || t > 8',
stars: [{ when: 't*1000 <= 1500', label: 'Быстро' }],
},
game: { chapter: 'custom', order: 3, par_ms: 1500 },
};
function seedClass(teacherId, studentIds) {
const code = 'C' + Math.random().toString(36).slice(2, 10).toUpperCase();
const r = db.prepare(
'INSERT INTO classes (name, teacher_id, invite_code) VALUES (?, ?, ?)'
).run('Класс ' + code, teacherId, code);
const classId = Number(r.lastInsertRowid);
const ins = db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)');
for (const uid of studentIds) ins.run(classId, uid);
return classId;
}
async function createGameLevel(token, overrides) {
return inject('POST', '/api/custom-sims',
Object.assign({ title: 'Мой уровень', cat: 'game', spec: GAME_SPEC }, overrides || {}), token);
}
describe('Квантик Ф5 — авторинг игровых уровней', () => {
let teacher, otherTeacher, student, studentB, admin;
before(async () => {
teacher = await getToken('teacher');
otherTeacher = await getToken('teacher');
student = await getToken('student');
studentB = await getToken('student');
admin = await getToken('admin');
});
it('teacher creates a GAME level (cat=game, goal+game preserved)', async () => {
const res = await createGameLevel(teacher.token);
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
const get = await inject('GET', `/api/custom-sims/${res.body.id}`, null, teacher.token);
const s = get.body.sim;
assert.equal(s.cat, 'game', 'cat=game accepted');
assert.ok(s.spec.goal && s.spec.goal.when, 'goal.when preserved');
assert.equal(s.spec.goal.stars.length, 1, 'star preserved');
assert.ok(s.spec.game && s.spec.game.chapter === 'custom', 'game.chapter preserved');
assert.equal(s.spec.game.order, 3, 'game.order preserved');
assert.equal(s.spec.game.par_ms, 1500, 'game.par_ms preserved');
});
it("another user's DRAFT game level → 403 (deep-link / ensureSpec cannot leak)", async () => {
const c = await createGameLevel(teacher.token); // draft
const get = await inject('GET', `/api/custom-sims/${c.body.id}`, null, otherTeacher.token);
assert.equal(get.status, 403, `got ${get.status}`);
// student also cannot open another user's draft level
const getS = await inject('GET', `/api/custom-sims/${c.body.id}`, null, student.token);
assert.equal(getS.status, 403, `student got ${getS.status}`);
});
it('published game level is visible to any user (deep-link works)', async () => {
const c = await createGameLevel(teacher.token, { status: 'published' });
assert.equal(c.status, 201);
const get = await inject('GET', `/api/custom-sims/${c.body.id}`, null, student.token);
assert.equal(get.status, 200, 'student can read published game level');
assert.ok(get.body.sim.spec.goal, 'goal present');
// и присутствует в общем списке для другого учителя
const list = await inject('GET', '/api/custom-sims', null, otherTeacher.token);
assert.ok(list.body.sims.find(s => s.id === c.body.id && s.cat === 'game'), 'published game in list');
});
it('owner sees own draft game level (for editing)', async () => {
const c = await createGameLevel(teacher.token);
const get = await inject('GET', `/api/custom-sims/${c.body.id}`, null, teacher.token);
assert.equal(get.status, 200, 'owner reads own draft');
});
it('share game level → game_level_shared notification with /quantik link + auto-publish', async () => {
const classId = seedClass(teacher.userId, [student.userId, studentB.userId]);
const c = await createGameLevel(teacher.token); // draft
const simId = c.body.id;
const res = await inject('POST', `/api/custom-sims/${simId}/share`, { classId }, teacher.token);
assert.equal(res.status, 200, `got ${res.status}: ${JSON.stringify(res.body)}`);
assert.equal(res.body.sent, 2, 'two students notified');
assert.equal(res.body.status, 'published', 'auto-published');
assert.equal(res.body.link, '/quantik?level=custom:' + simId, 'reports game link');
const after = db.prepare('SELECT status FROM custom_sims WHERE id = ?').get(simId);
assert.equal(after.status, 'published', 'sim auto-published in DB');
const notif = db.prepare(
"SELECT type, link FROM notifications WHERE user_id = ? AND type = 'game_level_shared' ORDER BY id DESC"
).get(student.userId);
assert.ok(notif, 'student has game_level_shared notification');
assert.equal(notif.link, '/quantik?level=custom:' + simId, 'notification links to /quantik');
});
it('rejects game level with too many stars (>3) (400)', async () => {
const bad = {
...GAME_SPEC,
goal: { ...GAME_SPEC.goal, stars: [{ when: 'a' }, { when: 'b' }, { when: 'c' }, { when: 'd' }] },
};
const res = await createGameLevel(teacher.token, { spec: bad });
assert.equal(res.status, 400, `got ${res.status}`);
});
});
+14
View File
@@ -1067,6 +1067,9 @@
<button class="admin-nav-item" data-tab="sims" onclick="switchTab(this)" id="btn-tab-sims" style="display:none">
<i data-lucide="atom" style="width:15px;height:15px"></i> Симуляции
</button>
<button class="admin-nav-item" data-tab="exams" onclick="switchTab(this)" id="btn-tab-exams" style="display:none">
<i data-lucide="clipboard-check" style="width:15px;height:15px"></i> Экзамен-модули
</button>
<button class="admin-nav-item" data-tab="games" onclick="switchTab(this)" id="btn-tab-games" style="display:none">
<i data-lucide="gamepad-2" style="width:15px;height:15px"></i> Игры
</button>
@@ -1616,6 +1619,16 @@
<div id="topics-list"></div>
</div>
<!-- ── Экзамен-модули (вкл/выкл) ── -->
<div class="tab-pane" id="tab-exams">
<div class="section-title">Экзамен-модули</div>
<p style="color:var(--muted);font-size:13px;margin:4px 0 16px;max-width:760px">
Включение/выключение модулей подготовки к экзамену (<code>/exam-prep</code>). Выключенный модуль
скрыт у учеников и не показывается в каталоге прав доступа. Доступ ученикам открывается отдельно
в разделе «Доступ · контент» → «Экзамены».</p>
<div class="perm-grid" id="exams-grid"></div>
</div>
<!-- ── Доступ к учебникам / экзаменам ── -->
<div class="tab-pane" id="tab-access">
<div class="section-title">Доступ к учебникам и экзаменам</div>
@@ -2136,6 +2149,7 @@
<script src="/js/admin/sections/overview.js"></script>
<script src="/js/admin/sections/sublog.js"></script>
<script src="/js/admin/sections/sims.js"></script>
<script src="/js/admin/sections/exams.js"></script>
<script src="/js/admin/sections/games.js"></script>
<script src="/js/admin/sections/assistant.js"></script>
<script src="/js/admin/sections/imggen.js"></script>
+34
View File
@@ -388,6 +388,40 @@
border-color: var(--violet) !important; color: var(--violet) !important; background: rgba(155,93,229,.12) !important;
}
/* ── stereo panel: collapsible accordion (UX) ── */
.stereo-panel .st-acc-toolbar { display: flex; gap: 6px; margin: 0 0 8px; }
.stereo-panel .st-acc-toolbar button {
flex: 1; padding: 5px 6px; border-radius: 8px; border: 1px solid var(--border);
background: transparent; color: var(--text-3);
font-family: 'Manrope', sans-serif; font-size: .62rem; font-weight: 700;
cursor: pointer; transition: all .12s;
}
.stereo-panel .st-acc-toolbar button:hover {
color: var(--violet); border-color: rgba(155,93,229,.4); background: rgba(155,93,229,.06);
}
.stereo-panel .st-acc-hdr {
cursor: pointer; justify-content: space-between; user-select: none;
margin: 3px 0; padding: 8px 8px; border-radius: 9px;
background: rgba(255,255,255,.025); transition: background .12s, color .12s;
}
.stereo-panel .st-acc-hdr::after { display: none; } /* drop the divider line */
.stereo-panel .st-acc-hdr:hover { background: rgba(155,93,229,.09); color: var(--violet); }
.stereo-panel .st-acc-hdr.open { color: var(--text-2); background: rgba(155,93,229,.06); }
.stereo-panel .st-acc-chev { display: flex; align-items: center; opacity: .6; transition: transform .18s; }
.stereo-panel .st-acc-chev svg { width: 13px; height: 13px; stroke: currentColor; stroke-width: 2.5; fill: none; }
.stereo-panel .st-acc-hdr.open .st-acc-chev { transform: rotate(180deg); }
.stereo-panel .st-acc-body { margin: 0 0 8px; padding: 0 1px; }
.stereo-panel .st-sublabel { opacity: .8; margin: 8px 0 6px; }
/* highlight-polygon colour palette */
.stereo-panel .st-poly-palette { display: flex; gap: 6px; margin: 4px 0 2px; flex-wrap: wrap; }
.stereo-panel .st-sw {
width: 20px; height: 20px; border-radius: 50%; cursor: pointer; padding: 0;
border: 2px solid rgba(255,255,255,.25); transition: transform .1s, border-color .12s, box-shadow .12s;
}
.stereo-panel .st-sw:hover { transform: scale(1.12); }
.stereo-panel .st-sw.active { border-color: #fff; box-shadow: 0 0 0 2px rgba(255,255,255,.25); }
.gp-preset-group { margin-bottom: 8px; }
.gp-preset-label {
font-size: 0.68rem; font-weight: 700; text-transform: uppercase;
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+2 -1
View File
@@ -15,7 +15,7 @@
AdminCtx.isAdmin = isAdmin;
/* Admin-only tabs: show to everyone for discoverability, but lock for non-admins */
const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games','btn-tab-assistant','btn-tab-imggen'];
const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-exams','btn-tab-games','btn-tab-assistant','btn-tab-imggen'];
const lockSvg = '<svg class="adm-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
ADMIN_ONLY_TABS.forEach(id => {
const el = document.getElementById(id);
@@ -64,6 +64,7 @@
gam: 'gam',
tpl: 'tpl',
sims: 'sims',
exams: 'exams',
games: 'games',
assistant: 'assistant',
imggen: 'imggen',
+72
View File
@@ -0,0 +1,72 @@
'use strict';
/* admin → exams (exam-prep modules) section.
* Список ВСЕХ экзамен-треков (вкл. выключенные) + тумблер enabled.
* Источник: GET /api/exam-prep/admin/tracks; переключение: PATCH /api/exam-prep/admin/track.
* Влияет на видимость модуля в /exam-prep и в каталоге прав доступа (Экзамены). */
(function () {
'use strict';
let inited = false;
let _tracks = [];
const SUBJ = { math: 'Математика', physics: 'Физика', phys: 'Физика', chemistry: 'Химия',
chem: 'Химия', biology: 'Биология', bio: 'Биология' };
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
async function load() {
try {
const data = await LS.api('/api/exam-prep/admin/tracks');
_tracks = Array.isArray(data.tracks) ? data.tracks : [];
_render();
} catch (e) { LS.toast('Ошибка загрузки экзамен-модулей: ' + e.message, 'error'); }
}
function _render() {
const grid = document.getElementById('exams-grid');
if (!grid) return;
if (!_tracks.length) { grid.innerHTML = '<p style="color:var(--muted);font-size:13px">Нет экзамен-модулей.</p>'; return; }
grid.innerHTML = _tracks.map(t => {
const subj = SUBJ[t.subject_slug] || t.subject_slug || '';
const meta = [subj, t.grade ? (t.grade + ' кл.') : '', (t.task_count || 0) + ' заданий']
.filter(Boolean).join(' · ');
return `<div class="perm-card${t.enabled ? ' enabled' : ''}" id="examcard-${esc(t.exam_key)}" style="flex-wrap:wrap">
<div class="perm-info">
<div class="perm-label">${esc(t.title)}</div>
<div class="perm-desc" style="font-size:11px;margin-top:2px;opacity:.7">${esc(t.exam_key)}${meta ? ' · ' + esc(meta) : ''}</div>
</div>
<div style="display:flex;align-items:center;gap:10px">
<a href="/exam-prep/${esc(t.exam_key)}" target="_blank" title="Открыть модуль"
style="font-size:.72rem;color:var(--text-2);text-decoration:none;border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;padding:4px 8px">Открыть</a>
<label class="perm-toggle" title="${t.enabled ? 'Выключить модуль' : 'Включить модуль'}">
<input type="checkbox" ${t.enabled ? 'checked' : ''} onchange="examToggle('${esc(t.exam_key)}', this.checked)" />
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>
</div>`;
}).join('');
if (window.lucide) lucide.createIcons();
}
async function examToggle(examKey, enabled) {
try {
await LS.api('/api/exam-prep/admin/track', {
method: 'PATCH', body: JSON.stringify({ exam_key: examKey, enabled }),
});
const t = _tracks.find(x => x.exam_key === examKey);
if (t) t.enabled = enabled ? 1 : 0;
const card = document.getElementById('examcard-' + examKey);
if (card) card.classList.toggle('enabled', !!enabled);
LS.toast(enabled ? `«${examKey}» включён` : `«${examKey}» выключен`, enabled ? 'success' : 'warning');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
window.examToggle = examToggle;
window.AdminSections = window.AdminSections || {};
window.AdminSections.exams = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();
+3
View File
@@ -17,6 +17,8 @@
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
{ key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' },
{ key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' },
{ key: 'quantik', label: 'Квантик: Законы Мира', desc: '2D физика-головоломка: уровни на движке симуляций, прогресс, скины', icon: 'rocket' },
];
const FS_FEATURES = [
@@ -27,6 +29,7 @@
{ key: 'red_book', label: 'Красная книга', desc: 'Интерактивная Красная книга РБ: виды, биомы, квесты', icon: 'leaf' },
{ key: 'collection', label: 'Коллекция', desc: 'Коллекция карточек и игровой прогресс ученика', icon: 'layers' },
{ key: 'lab', label: 'Лаборатория', desc: 'Виртуальные симуляции и интерактивные опыты', icon: 'flask-conical' },
{ key: 'quantik', label: 'Квантик: Законы Мира', desc: '2D физика-головоломка на движке симуляций', icon: 'rocket' },
{ key: 'knowledge_map',label: 'Карта знаний', desc: 'Визуальная карта тем и связей между понятиями', icon: 'map' },
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для повторения терминов и понятий', icon: 'square-stack' },
{ key: 'imggen', label: 'Генерация картинок (ИИ)', desc: 'ИИ-генерация изображений в ассистенте, флэшкартах, уроках, питомце', icon: 'image' },
+12 -7
View File
@@ -44,11 +44,16 @@
/* ════════════════════════════════════════════════════════════
PHASE 1: SETUP
════════════════════════════════════════════════════════════ */
function renderSetup() {
async function renderSetup() {
const title = EP.info?.track?.title || 'Пробный экзамен';
const dur = EP.info?.track?.duration_min || 180;
const tpv = EP.info?.track?.tasks_per_variant || 10;
const vc = EP.info?.track?.variants_count || 80;
// Реальный список вариантов-пробников (бэкенд уже отфильтровал год-пачки):
// номера вариантов могут быть не подряд (ctmath: 101, 102, …), поэтому
// показываем выпадающий список реальных вариантов, а не диапазон 1..N.
let vlist = [];
try { vlist = (await EP.api.listVariants(examKey)).variants || []; } catch {}
const vOpts = vlist.map(v => `<option value="${v.n}">${v.label}</option>`).join('');
main.innerHTML = `
<div class="ep-card mk-setup">
@@ -65,10 +70,10 @@
<span>По варианту</span>
</div>
<div class="mk-source-body">
<label>Номер варианта:
<input type="number" min="1" max="${vc}" value="1" id="mk-variant-input" class="mk-input" />
<label>Вариант:
<select id="mk-variant-input" class="mk-input" style="width:auto;min-width:14rem;max-width:100%">${vOpts || '<option value="">—</option>'}</select>
</label>
<div class="mk-source-hint">Один из ${vc} реальных вариантов целиком.</div>
<div class="mk-source-hint">Один из ${vlist.length} готовых вариантов целиком.</div>
</div>
</div>
@@ -116,7 +121,7 @@
if (!Number.isInteger(v) || v < 1) {
btn.disabled = false; btn.innerHTML = '<i data-lucide="play"></i> Начать пробник';
if (window.lucide) lucide.createIcons();
return alert('Введите номер варианта');
return alert('Выберите вариант');
}
body.variant = v;
} else {
@@ -144,7 +149,7 @@
const totalMs = session.duration_planned_min * 60 * 1000;
const sourceLabel = session.source === 'variant'
? `Вариант ${session.variant}`
? (session.variant_label || `Вариант ${session.variant}`)
: `Случайные ${tasks.length} задач`;
main.innerHTML = `
File diff suppressed because it is too large Load Diff
+388
View File
@@ -0,0 +1,388 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · Карта-созвездие (Фаза 2).
Рисует мир как звёздную карту: каждая глава (chapter) — отдельное созвездие,
уровни — узлы-«звёзды», соединённые линиями по порядку. Узел показывает статус
(заблокирован / доступен / пройден + число звёзд). По клику на доступный узел —
колбэк onPlay(level).
Зависит от:
window.QuantikLevels — реестр уровней (Ф1/Ф2)
window.QuantikProgress — чистая логика прогресса/разблокировки/XP (Ф2)
window.PetSprite — нарратор-Квантик (SVG)
window.QuantikMap.create({ host, headerHost, onPlay, getSkin, onSkin }) -> {
render(progressMap), // перерисовать карту + шапку под новый прогресс
destroy()
}
⛔ Без эмодзи — звёзды/замки/иконки только inline SVG. Без eval/Function.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
var doc = global.document;
var NS = 'http://www.w3.org/2000/svg';
function el(tag, cls, html) {
var n = doc.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
function svgEl(tag, attrs) {
var n = doc.createElementNS(NS, tag);
if (attrs) for (var k in attrs) if (attrs.hasOwnProperty(k)) n.setAttribute(k, attrs[k]);
return n;
}
/* ── inline SVG иконки (без эмодзи) ── */
function starPath() { return 'M12 2 15.1 8.6 22 9.3 17 14.1 18.2 21 12 17.6 5.8 21 7 14.1 2 9.3 8.9 8.6 Z'; }
function starSvg(filled, size) {
var s = size || 16;
var fill = filled ? '#FBBF24' : 'none';
var stroke = filled ? '#FBBF24' : 'rgba(148,163,184,0.55)';
// Цвета — через inline style, а НЕ presentation-атрибуты: правило .ic в ls.css
// (fill:none; stroke:currentColor) перебивает атрибуты fill/stroke, из-за чего
// заработанные звёзды узлов не закрашивались. Inline style приоритетнее класса.
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s +
'" style="fill:' + fill + ';stroke:' + stroke + '" stroke-width="1.5" stroke-linejoin="round"><path d="' + starPath() + '"/></svg>';
}
function lockSvg(size) {
var s = size || 18;
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="none" ' +
'stroke="rgba(226,232,240,0.85)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
'<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>';
}
function playSvg(size) {
var s = size || 18;
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="currentColor" ' +
'stroke="none"><path d="M8 5.5 19 12 8 18.5 Z"/></svg>';
}
function checkSvg(size) {
var s = size || 18;
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="none" ' +
'stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M4 12.5 10 18.5 20 6"/></svg>';
}
/* ── Раскладка узлов созвездия ──────────────────────────────────────────
Для каждой главы раскладываем её уровни по «созвездию»: лёгкая зигзаг-дуга
внутри своего вертикального пояса. Координаты в % ширины ленты главы. */
function layoutNodes(levels) {
var n = levels.length;
var pts = [];
for (var i = 0; i < n; i++) {
// x идёт слева-направо, y — мягкий зигзаг (созвездие, не прямая)
var x = n === 1 ? 50 : (12 + (76 * i / (n - 1)));
var y = 50 + (i % 2 === 0 ? -16 : 16) + (i % 3 === 0 ? 6 : -4);
pts.push({ x: x, y: y });
}
return pts;
}
/* ── Звёздное небо (статичные точки на canvas-фоне через SVG) ──────────── */
function buildStarfield(seedCount) {
var g = svgEl('g', { class: 'qm-stars' });
var rnd = mulberry32(0x51ec7 + seedCount);
for (var i = 0; i < seedCount; i++) {
var cx = rnd() * 100, cy = rnd() * 100;
var r = 0.08 + rnd() * 0.22;
var op = 0.25 + rnd() * 0.55;
var c = svgEl('circle', { cx: cx, cy: cy, r: r, fill: '#E2E8F0', opacity: op.toFixed(2) });
c.style.setProperty('--tw', (1.6 + rnd() * 3).toFixed(2) + 's');
c.style.setProperty('--td', (rnd() * 3).toFixed(2) + 's');
c.classList.add('qm-tw');
g.appendChild(c);
}
return g;
}
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
var t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
/* ════════════════════════ Создание карты ════════════════════════ */
function create(opts) {
opts = opts || {};
var host = opts.host;
var headerHost = opts.headerHost;
var onPlay = typeof opts.onPlay === 'function' ? opts.onPlay : function () {};
var getSkin = typeof opts.getSkin === 'function' ? opts.getSkin : function () { return 'cyan'; };
var onSkin = typeof opts.onSkin === 'function' ? opts.onSkin : function () {};
if (!host) return null;
var Levels = global.QuantikLevels;
var Prog = global.QuantikProgress;
if (!Levels || !Prog) return null;
var revealTimer = null;
function clearReveal() { if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; } }
/* ── Шапка: нарратор + XP-бар + всего звёзд + скины ── */
function renderHeader(progressMap) {
if (!headerHost) return;
headerHost.innerHTML = '';
var levels = Levels.list();
var xp = Prog.computeXp(levels, progressMap);
var pl = Prog.playerLevel(xp);
var tStars = Prog.totalStars(levels, progressMap);
var maxStars = levels.reduce(function (s, L) { return s + (L.spec && L.spec.goal && L.spec.goal.stars ? L.spec.goal.stars.length : 0); }, 0);
var wrap = el('div', 'qm-header-inner');
// Нарратор-Квантик (mood по уровню игрока)
var mood = pl.level >= 5 ? 'ecstatic' : (pl.level >= 2 ? 'happy' : 'neutral');
var narr = el('div', 'qm-narrator');
if (global.PetSprite) {
var petLvl = Math.min(8, Math.max(1, pl.level));
narr.innerHTML = '<div class="qm-pet">' + global.PetSprite.render(petLvl, mood, [], getSkin(), 0, 'none') + '</div>';
}
var bubble = el('div', 'qm-bubble');
bubble.appendChild(el('div', 'qm-bubble-t', narrLine(pl, tStars, maxStars)));
narr.appendChild(bubble);
wrap.appendChild(narr);
// XP / уровень игрока
var stats = el('div', 'qm-stats');
var lvlBox = el('div', 'qm-level');
lvlBox.innerHTML = '<span class="qm-level-num">' + pl.level + '</span><span class="qm-level-lbl">уровень Квантика</span>';
stats.appendChild(lvlBox);
var xpBox = el('div', 'qm-xpbox');
var xpHead = el('div', 'qm-xp-head');
xpHead.innerHTML = '<span>' + xp + ' XP</span><span class="qm-xp-next">' +
(pl.xpForNext > 0 ? ('до ур. ' + (pl.level + 1) + ': ' + Math.max(0, pl.xpForNext - pl.xpInto) + ' XP') : 'максимум') + '</span>';
xpBox.appendChild(xpHead);
var bar = el('div', 'qm-xp-bar');
var fill = el('div', 'qm-xp-fill');
fill.style.width = '0%';
bar.appendChild(fill);
xpBox.appendChild(bar);
stats.appendChild(xpBox);
// всего звёзд
var starBox = el('div', 'qm-starcount');
starBox.innerHTML = starSvg(true, 18) + '<span>' + tStars + ' / ' + maxStars + '</span>';
stats.appendChild(starBox);
wrap.appendChild(stats);
// Скины
wrap.appendChild(buildSkinPicker(xp, tStars));
headerHost.appendChild(wrap);
// анимация XP-бара (после вставки в DOM)
requestAnimationFrame(function () {
fill.style.width = (pl.progress01 * 100).toFixed(1) + '%';
});
}
function narrLine(pl, tStars, maxStars) {
if (tStars === 0) return 'Привет! Я — Квантик. Помоги мне починить законы мира — выбери уровень и подкрути формулы.';
if (tStars >= maxStars) return 'Все звёзды собраны! Ты настоящий мастер законов мира.';
if (pl.level >= 5) return 'Невероятно! Уровень ' + pl.level + '. Осталось всего ' + (maxStars - tStars) + ' звёзд.';
if (pl.level >= 3) return 'Отлично идём — уровень ' + pl.level + '. Звёзды открывают новые созвездия.';
return 'Уже ' + tStars + ' звёзд! Собирай больше, чтобы открыть новые уровни.';
}
/* Палитра скинов: первые открыты, остальные — за XP/звёзды. */
var SKIN_GATES = [
{ key: 'cyan', name: 'Циан', need: 0 },
{ key: 'purple', name: 'Аметист', need: 0 },
{ key: 'green', name: 'Изумруд', needStars: 2 },
{ key: 'pink', name: 'Магента', needStars: 4 },
{ key: 'gold', name: 'Золото', needStars: 7 },
{ key: 'blue', name: 'Сапфир', needXp: 600 },
{ key: 'orange', name: 'Янтарь', needXp: 1000 },
{ key: 'indigo', name: 'Индиго', needStars: 11 }
];
function skinUnlocked(g, xp, stars) {
if (g.needStars && stars < g.needStars) return false;
if (g.needXp && xp < g.needXp) return false;
if (g.need && stars < g.need) return false;
return true;
}
function buildSkinPicker(xp, stars) {
var box = el('div', 'qm-skins');
box.appendChild(el('div', 'qm-skins-lbl', 'Скин'));
var row = el('div', 'qm-skins-row');
var cur = getSkin();
var pal = (global.PetSprite && global.PetSprite.PALETTES) || {};
SKIN_GATES.forEach(function (g) {
var unlocked = skinUnlocked(g, xp, stars);
var sw = el('button', 'qm-skin' + (cur === g.key ? ' active' : '') + (unlocked ? '' : ' locked'));
sw.type = 'button';
sw.style.setProperty('--sk', pal[g.key] || '#06D6E0');
sw.title = unlocked ? g.name : (g.name + ' — ' + skinReq(g));
sw.setAttribute('aria-label', g.name + (unlocked ? '' : ' (заблокирован)'));
if (!unlocked) sw.innerHTML = '<span class="qm-skin-lock">' + lockSvg(12) + '</span>';
if (unlocked) {
sw.addEventListener('click', function () { onSkin(g.key); });
} else {
sw.disabled = true;
}
row.appendChild(sw);
});
box.appendChild(row);
return box;
}
function skinReq(g) {
if (g.needStars) return 'нужно ' + g.needStars + ' звёзд';
if (g.needXp) return 'нужно ' + g.needXp + ' XP';
return 'заблокирован';
}
/* ── Тело карты: созвездия по главам ── */
function renderMap(progressMap) {
clearReveal();
host.innerHTML = '';
var groups = Prog.groupByChapter(Levels.list());
var allLevels = Levels.list();
var revealOrder = []; // узлы для поэтапного появления
groups.forEach(function (grp, gi) {
var meta = Levels.chapter(grp.chapter);
var section = el('section', 'qm-constellation');
section.style.setProperty('--accent', meta.accent || '#22D3EE');
// заголовок главы
var head = el('div', 'qm-con-head');
head.innerHTML = '<span class="qm-con-title">' + escapeHtml(meta.title) + '</span>' +
'<span class="qm-con-sub">' + escapeHtml(meta.subtitle || '') + '</span>';
// прогресс главы
var cStars = 0, cMax = 0;
grp.levels.forEach(function (L) {
cStars += Prog.starsFor(L.id, progressMap);
cMax += (L.spec && L.spec.goal && L.spec.goal.stars) ? L.spec.goal.stars.length : 0;
});
var cbadge = el('span', 'qm-con-stars', starSvg(true, 14) + ' ' + cStars + '/' + cMax);
head.appendChild(cbadge);
section.appendChild(head);
// поле созвездия
var field = el('div', 'qm-field');
var pts = layoutNodes(grp.levels);
// SVG-слой: звёздное небо + линии-связи
var svg = svgEl('svg', { class: 'qm-svg', viewBox: '0 0 100 100', preserveAspectRatio: 'none' });
svg.appendChild(buildStarfield(46 + gi * 7));
// линии между последовательными узлами
for (var li = 0; li < pts.length - 1; li++) {
var a = pts[li], b = pts[li + 1];
var nextUnlocked = Prog.isUnlocked(grp.levels[li + 1], progressMap, allLevels);
var line = svgEl('line', {
x1: a.x, y1: a.y, x2: b.x, y2: b.y,
class: 'qm-link' + (nextUnlocked ? ' on' : '')
});
svg.appendChild(line);
}
field.appendChild(svg);
// узлы-уровни
grp.levels.forEach(function (L, idx) {
var status = Prog.nodeStatus(L, progressMap, allLevels);
var node = buildNode(L, status, progressMap, allLevels, pts[idx]);
field.appendChild(node);
revealOrder.push(node);
});
section.appendChild(field);
host.appendChild(section);
});
// поэтапное появление узлов
staggerReveal(revealOrder);
}
function buildNode(level, status, progressMap, allLevels, pt) {
var stars = Prog.starsFor(level.id, progressMap);
var total = (level.spec && level.spec.goal && level.spec.goal.stars) ? level.spec.goal.stars.length : 0;
var node = el('button', 'qm-node qm-' + status);
node.type = 'button';
node.style.left = pt.x + '%';
node.style.top = pt.y + '%';
node.setAttribute('data-level', level.id);
// ядро узла
var core = el('span', 'qm-node-core');
var icon = status === 'locked' ? lockSvg(20)
: (status === 'completed' ? '<span class="qm-node-order">' + level.order + '</span>' : playSvg(18));
core.innerHTML = icon;
node.appendChild(core);
// подпись
var label = el('span', 'qm-node-label', escapeHtml(level.title));
node.appendChild(label);
// звёзды узла (для пройденных) или порог (для заблокированных)
if (status === 'completed' && total > 0) {
var sb = el('span', 'qm-node-stars');
var html = '';
for (var i = 0; i < total; i++) html += starSvg(i < stars, 13);
sb.innerHTML = html;
node.appendChild(sb);
} else if (status === 'locked') {
var need = Prog.starsToUnlock(level, progressMap, allLevels);
var hint = el('span', 'qm-node-need', starSvg(true, 11) + ' ещё ' + need);
node.appendChild(hint);
}
if (status === 'locked') {
node.disabled = true;
node.setAttribute('aria-disabled', 'true');
node.title = 'Заблокировано — собери больше звёзд в предыдущих уровнях';
} else {
node.title = level.title + (status === 'completed' ? ' — пройдено' : ' — играть');
node.addEventListener('click', function () { onPlay(level); });
}
node.setAttribute('aria-label', level.title + ' (' +
(status === 'locked' ? 'заблокировано' : status === 'completed' ? ('пройдено, ' + stars + ' из ' + total + ' звёзд') : 'доступно') + ')');
return node;
}
function staggerReveal(nodes) {
nodes.forEach(function (n) { n.classList.add('qm-pre'); });
var i = 0;
function step() {
if (i >= nodes.length) { revealTimer = null; return; }
nodes[i].classList.remove('qm-pre');
nodes[i].classList.add('qm-in');
i++;
revealTimer = setTimeout(step, 70);
}
revealTimer = setTimeout(step, 120);
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function render(progressMap) {
progressMap = progressMap || {};
renderHeader(progressMap);
renderMap(progressMap);
}
function destroy() {
clearReveal();
if (host) host.innerHTML = '';
if (headerHost) headerHost.innerHTML = '';
}
return { render: render, destroy: destroy };
}
global.QuantikMap = { create: create };
})(typeof window !== 'undefined' ? window : this);
+194
View File
@@ -0,0 +1,194 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · ЧИСТАЯ логика прогресса (Фаза 2).
Никакого DOM/сети/движка — только функции над данными. Это делает их
тривиально тестируемыми (headless vm) и переносимыми на сервер позже.
ВХОД везде:
levels — массив записей уровней (форма QuantikLevels): { id, chapter,
order, par_ms?, unlockStars?, ... }.
progressMap — объект { [level_id]: { best_stars, best_time_ms, attempts } },
агрегируется из LS.gameProgressList() (см. fromProgressList).
⛔ Без eval/Function. Без побочных эффектов.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
/* Превратить ответ /api/game/progress ([{level_id, best_stars, ...}]) в карту. */
function fromProgressList(list) {
var map = {};
if (!Array.isArray(list)) return map;
for (var i = 0; i < list.length; i++) {
var row = list[i];
if (row && row.level_id != null) map[row.level_id] = row;
}
return map;
}
/* Лучшее число звёзд по уровню (0, если не пройден). */
function starsFor(levelId, progressMap) {
var p = progressMap && progressMap[levelId];
var s = p ? p.best_stars : 0;
return (typeof s === 'number' && s > 0) ? s : 0;
}
/* Пройден ли уровень (есть хотя бы одна звезда == достигнута цель). */
function isCompleted(levelId, progressMap) {
return starsFor(levelId, progressMap) > 0;
}
/* Сумма лучших звёзд по всем уровням. */
function totalStars(levels, progressMap) {
var sum = 0;
for (var i = 0; i < levels.length; i++) sum += starsFor(levels[i].id, progressMap);
return sum;
}
/* ── Разблокировка ────────────────────────────────────────────────────────
Уровень открыт, если СУММА звёзд во ВСЕХ уровнях с меньшим ГЛОБАЛЬНЫМ order
(по всем главам, не только текущей) ≥ level.unlockStars. Уровень с
unlockStars==0 (или без него) открыт всегда. Так первый уровень главы
гейтится суммой звёзд всех предыдущих глав через свой порог unlockStars.
Чистая функция: вход — уровень + карта прогресса + ВЕСЬ список (для подсчёта
«предыдущих» по order). Возвращает bool. */
function isUnlocked(level, progressMap, levels) {
if (!level) return false;
var need = (typeof level.unlockStars === 'number') ? level.unlockStars : 0;
if (need <= 0) return true; // нет порога — всегда доступен
// звёзды, набранные во всех уровнях с меньшим глобальным order
var have = 0;
for (var i = 0; i < levels.length; i++) {
var L = levels[i];
if (L.id === level.id) continue;
if ((L.order || 0) < (level.order || 0)) {
have += starsFor(L.id, progressMap);
}
}
return have >= need;
}
/* Статус узла для карты: 'completed' | 'available' | 'locked'. */
function nodeStatus(level, progressMap, levels) {
if (isCompleted(level.id, progressMap)) return 'completed';
if (isUnlocked(level, progressMap, levels)) return 'available';
return 'locked';
}
/* ── XP ────────────────────────────────────────────────────────────────────
XP = сумма (звёзды × STAR_XP) + бонус за каждый пройденный уровень
(COMPLETE_XP) + бонус за «par» (3-я звезда == уложился в норматив времени,
она и так считается звездой; дополнительный PAR_BONUS за первое прохождение
уровня в принципе). Детерминированная функция от карты прогресса. */
var STAR_XP = 100; // за каждую звезду
var COMPLETE_XP = 40; // за факт прохождения уровня (≥1 звезда)
function computeXp(levels, progressMap) {
var xp = 0;
for (var i = 0; i < levels.length; i++) {
var id = levels[i].id;
var s = starsFor(id, progressMap);
if (s > 0) {
xp += s * STAR_XP + COMPLETE_XP;
}
}
return xp;
}
/* ── Уровень игрока ──────────────────────────────────────────────────────
Порог уровня растёт линейно-нарастающе: уровень N требует кумулятивно
XP_PER_LEVEL_BASE·N·(N+1)/2 … упрощаем до квадратичной обратной формулы.
playerLevel(xp) -> { level, xpInto, xpForNext, progress01, totalForLevel }.
level начинается с 1. */
var XP_STEP = 240; // базовый шаг XP (level n требует n*XP_STEP суммарно для перехода)
// Кумулятивный XP, нужный чтобы ДОСТИЧЬ уровня L (L>=1). level 1 = 0 XP.
function xpForLevel(L) {
if (L <= 1) return 0;
// сумма k=1..L-1 of k*XP_STEP = XP_STEP * (L-1)*L/2
return XP_STEP * (L - 1) * L / 2;
}
function playerLevel(xp) {
if (!(xp > 0)) xp = 0;
var L = 1;
// найти максимальный L, чей порог <= xp
while (xpForLevel(L + 1) <= xp) L++;
var base = xpForLevel(L);
var next = xpForLevel(L + 1);
var span = next - base;
var into = xp - base;
return {
level: L,
xp: xp,
xpInto: into,
xpForNext: span,
totalForNext: next,
progress01: span > 0 ? Math.min(1, into / span) : 1
};
}
/* ── Группировка по главам ────────────────────────────────────────────────
Возвращает массив { chapter, levels:[...] } в порядке появления глав;
уровни внутри главы сортируются по order. */
function groupByChapter(levels) {
var order = [];
var byKey = {};
for (var i = 0; i < levels.length; i++) {
var L = levels[i];
var key = L.chapter || 'misc';
if (!byKey[key]) { byKey[key] = { chapter: key, levels: [] }; order.push(key); }
byKey[key].levels.push(L);
}
return order.map(function (k) {
var g = byKey[k];
g.levels = g.levels.slice().sort(function (a, b) { return (a.order || 0) - (b.order || 0); });
return g;
});
}
/* Следующий разблокированный непройденный уровень после данного (по глоб. order),
или null. Используется кнопкой «Дальше». */
function nextPlayable(currentId, levels, progressMap) {
var sorted = levels.slice().sort(function (a, b) { return (a.order || 0) - (b.order || 0); });
var idx = -1;
for (var i = 0; i < sorted.length; i++) if (sorted[i].id === currentId) { idx = i; break; }
// сначала ищем следующий по порядку доступный (предпочтительно непройденный)
for (var j = idx + 1; j < sorted.length; j++) {
if (isUnlocked(sorted[j], progressMap, levels)) return sorted[j];
}
return null;
}
/* Сколько ещё звёзд нужно, чтобы открыть уровень (для подсказки на замке). */
function starsToUnlock(level, progressMap, levels) {
var need = (typeof level.unlockStars === 'number') ? level.unlockStars : 0;
if (need <= 0) return 0;
var have = 0;
for (var i = 0; i < levels.length; i++) {
var L = levels[i];
if (L.id === level.id) continue;
if ((L.order || 0) < (level.order || 0)) have += starsFor(L.id, progressMap);
}
return Math.max(0, need - have);
}
global.QuantikProgress = {
fromProgressList: fromProgressList,
starsFor: starsFor,
isCompleted: isCompleted,
totalStars: totalStars,
isUnlocked: isUnlocked,
nodeStatus: nodeStatus,
computeXp: computeXp,
playerLevel: playerLevel,
xpForLevel: xpForLevel,
groupByChapter: groupByChapter,
nextPlayable: nextPlayable,
starsToUnlock: starsToUnlock,
// константы (для отображения/тестов)
STAR_XP: STAR_XP, COMPLETE_XP: COMPLETE_XP, XP_STEP: XP_STEP
};
})(typeof window !== 'undefined' ? window : this);
+518
View File
@@ -0,0 +1,518 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · Квантовые способности + энергия + SR-комната (Фаза 4).
Всё АДДИТИВНО и через БЕЗОПАСНУЮ модель (без eval/Function, без правок движка):
- ЭНЕРГИЯ — клиентский ресурс в localStorage (ключ 'quantik-energy'). Чистая
логика чтения/траты/начисления (window.QuantikEnergy) — тестируется headless.
- СПОСОБНОСТИ на сцене уровня (window.QuantikAbilities.mountBar):
«Туннель» — тратит заряд → inst.setParam('tunnel', 1) (стена-барьер
уровня становится проницаемой; fail:'wall.hit && tunnel<1').
«Прицел» — ставит/снимает паузу: целься по предсказанной траектории
(пунктир-plot уже на сцене) до запуска.
- SR-КОМНАТА (window.QuantikAbilities.openRestRoom) — мини-сессия повторения
флешкарт прямо в игре: список колод → due-карты → лицо/оборот → оценка
(шкала как в flashcards.html: Снова/Трудно/Знаю/Легко) → каждый «Знаю/Легко»
начисляет энергию. Реюз серверного SR (LS.fcListDecks/fcStudySession/fcReview),
НЕ iframe страницы флешкарт. Пусто (нет колод / нет due) — дружелюбное окно
со ссылкой на /flashcards.
⛔ Без эмодзи (только inline SVG .ic). Без eval/Function.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
var doc = global.document;
/* ── Энергия: чистая логика над localStorage ─────────────────────────────
Заряд — целое ≥0. Один «заряд туннеля» = TUNNEL_COST. За правильный ответ
в SR-комнате — REWARD_GOOD (Знаю) / REWARD_EASY (Легко). */
var ENERGY_KEY = 'quantik-energy';
var ENERGY_MAX = 99; // потолок (защита от переполнения хранилища)
var TUNNEL_COST = 3; // зарядов на одно туннелирование
var REWARD_GOOD = 1; // энергия за «Знаю»
var REWARD_EASY = 2; // энергия за «Легко»
function _clampEnergy(n) {
n = Math.floor(Number(n));
if (!isFinite(n) || n < 0) n = 0;
if (n > ENERGY_MAX) n = ENERGY_MAX;
return n;
}
function getEnergy() {
try {
var v = global.localStorage && global.localStorage.getItem(ENERGY_KEY);
return _clampEnergy(v == null ? 0 : v);
} catch (_e) { return 0; }
}
function setEnergy(n) {
var v = _clampEnergy(n);
try { if (global.localStorage) global.localStorage.setItem(ENERGY_KEY, String(v)); } catch (_e) {}
_notify(v);
return v;
}
function grantEnergy(n) { return setEnergy(getEnergy() + _clampEnergy(n)); }
function canSpend(n) { return getEnergy() >= _clampEnergy(n); }
/* Потратить n зарядов. Возвращает true при успехе (хватило), иначе false (без списания). */
function spendEnergy(n) {
n = _clampEnergy(n);
var cur = getEnergy();
if (cur < n) return false;
setEnergy(cur - n);
return true;
}
/* Награда за оценку флешкарты (quality по шкале SR): Знаю(4)→GOOD, Легко(5)→EASY,
остальные (Снова/Трудно) — 0. Чистая функция (для тестов). */
function rewardForQuality(q) {
if (q === 5) return REWARD_EASY;
if (q === 4) return REWARD_GOOD;
return 0;
}
/* подписчики на изменение энергии (HUD обновляется без перезагрузки) */
var _subs = [];
function onEnergyChange(cb) { if (typeof cb === 'function') _subs.push(cb); }
function _notify(v) { for (var i = 0; i < _subs.length; i++) { try { _subs[i](v); } catch (_e) {} } }
global.QuantikEnergy = {
getEnergy: getEnergy, setEnergy: setEnergy, grantEnergy: grantEnergy,
spendEnergy: spendEnergy, canSpend: canSpend, rewardForQuality: rewardForQuality,
onEnergyChange: onEnergyChange,
ENERGY_KEY: ENERGY_KEY, TUNNEL_COST: TUNNEL_COST,
REWARD_GOOD: REWARD_GOOD, REWARD_EASY: REWARD_EASY, ENERGY_MAX: ENERGY_MAX
};
/* ════════════════════════════════════════════════════════════════════════
DOM-хелперы (только если есть document — модуль грузится и в headless vm,
где document может быть стабом без полноценного DOM).
════════════════════════════════════════════════════════════════════════ */
function el(tag, cls, html) {
var n = doc.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
function escapeText(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/* ── inline SVG иконки (без эмодзи) ── */
function boltIcon() {
return '<svg class="ic" viewBox="0 0 24 24" fill="currentColor" stroke="none">' +
'<path d="M13 2 4 14h6l-1 8 9-12h-6z"/></svg>';
}
function tunnelIcon() {
return '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V11a8 8 0 0 1 16 0v9"/>' +
'<path d="M9 20v-6a3 3 0 0 1 6 0v6"/></svg>';
}
function aimIcon() {
return '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8"/>' +
'<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/>' +
'<line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>' +
'<circle cx="12" cy="12" r="1.6" fill="currentColor"/></svg>';
}
function cardsIcon() {
return '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="13" height="15" rx="2"/>' +
'<path d="M8 5V4a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-1"/></svg>';
}
/* ── tunnel-флаг уровня: спека ссылается на param 'tunnel' в fail? ──────────
Если ни goal.fail, ни stars, ни goal.when не упоминают 'tunnel', способность
«Туннель» бессмысленна для уровня → кнопка скрыта. */
function levelHasTunnel(level) {
var g = level && level.spec && level.spec.goal;
if (!g) return false;
var blob = String(g.fail || '') + ' ' + String(g.when || '');
if (Array.isArray(g.stars)) for (var i = 0; i < g.stars.length; i++) blob += ' ' + String(g.stars[i] && g.stars[i].when || '');
return /\btunnel\b/.test(blob);
}
/* ── aim-флаг уровня: на сцене есть объект-предсказание (id 'aim' или plot
с lineStyle 'dashed')? Тогда способность «Прицел» осмысленна. */
function levelHasAim(level) {
var objs = level && level.spec && level.spec.objects;
if (!Array.isArray(objs)) return false;
for (var i = 0; i < objs.length; i++) {
var o = objs[i];
if (o && o.type === 'plot' && (o.id === 'aim' || o.lineStyle === 'dashed')) return true;
}
return false;
}
/* ════════════════════════════════════════════════════════════════════════
Панель способностей + HUD энергии на сцене уровня.
mountBar({ host, inst, level, onOpenRest }) -> { el, destroy, refresh }
host — контейнер сцены (qg-stage). Кнопки появляются только если уместны
для уровня. tunnel сбрасывается в 0 при каждом mount (новый уровень/попытка).
════════════════════════════════════════════════════════════════════════ */
function mountBar(opts) {
opts = opts || {};
var host = opts.host, inst = opts.inst, level = opts.level;
if (!host || !inst) return null;
var onOpenRest = typeof opts.onOpenRest === 'function' ? opts.onOpenRest : function () {};
var hasTunnel = levelHasTunnel(level);
var hasAim = levelHasAim(level);
var tunnelUsed = false; // потрачен ли заряд в этой попытке
// tunnel стартует выключенным каждую попытку (стена сплошная)
try { inst.setParam('tunnel', 0); } catch (_e) {}
var bar = el('div', 'qa-bar');
// ── HUD энергии ──
var meter = el('div', 'qa-energy', boltIcon() + '<span class="qa-energy-n">' + getEnergy() + '</span>');
meter.title = 'Квантовая энергия — копится в комнате повторения';
bar.appendChild(meter);
// ── Кнопка «Комната повторения» (всегда — заработать энергию) ──
var btnRest = el('button', 'qa-btn qa-rest', cardsIcon() + '<span>Повторение</span>');
btnRest.type = 'button';
btnRest.title = 'Повтори флешкарты — заработай квантовую энергию';
btnRest.addEventListener('click', function () { onOpenRest(); });
bar.appendChild(btnRest);
// ── Способность «Туннель» ──
var btnTunnel = null;
if (hasTunnel) {
btnTunnel = el('button', 'qa-btn qa-ability qa-tunnel',
tunnelIcon() + '<span>Туннель</span><span class="qa-cost">' + boltIcon() + TUNNEL_COST + '</span>');
btnTunnel.type = 'button';
btnTunnel.addEventListener('click', function () {
if (tunnelUsed) return;
if (!canSpend(TUNNEL_COST)) { _flashHint(host, 'Не хватает энергии — повтори флешкарты'); refresh(); return; }
if (!spendEnergy(TUNNEL_COST)) { refresh(); return; }
tunnelUsed = true;
try { inst.setParam('tunnel', 1); } catch (_e) {}
_flashHint(host, 'Туннелирование активно — барьер проницаем');
refresh();
});
bar.appendChild(btnTunnel);
}
// ── Способность «Прицел» (пауза-тоггл) ──
var btnAim = null;
if (hasAim) {
btnAim = el('button', 'qa-btn qa-ability qa-aim', aimIcon() + '<span>Прицел</span>');
btnAim.type = 'button';
btnAim.title = 'Поставить паузу и прицелиться по предсказанной траектории';
btnAim.addEventListener('click', function () {
try {
if (inst.isRunning()) { inst.pause(); }
else { inst.play(); }
} catch (_e) {}
refresh();
});
bar.appendChild(btnAim);
}
function refresh() {
var e = getEnergy();
var n = meter.querySelector('.qa-energy-n');
if (n) n.textContent = String(e);
if (btnTunnel) {
var dis = tunnelUsed || !canSpend(TUNNEL_COST);
btnTunnel.disabled = dis;
btnTunnel.classList.toggle('qa-on', tunnelUsed);
btnTunnel.title = tunnelUsed ? 'Туннель уже активен в этой попытке'
: (canSpend(TUNNEL_COST) ? 'Пройти сквозь барьер (−' + TUNNEL_COST + ' энергии)'
: 'Нужно ' + TUNNEL_COST + ' энергии — повтори флешкарты');
}
if (btnAim) {
var running = false; try { running = inst.isRunning(); } catch (_e) {}
btnAim.classList.toggle('qa-on', !running);
var lbl = btnAim.querySelector('span');
if (lbl) lbl.textContent = running ? 'Прицел' : 'Цельтесь';
}
}
// следить за изменением энергии (после SR-комнаты)
var unsub = function () {};
var sub = function (v) { refresh(); };
onEnergyChange(sub);
unsub = function () {
var i = _subs.indexOf(sub);
if (i >= 0) _subs.splice(i, 1);
};
host.appendChild(bar);
refresh();
return {
el: bar,
refresh: refresh,
// сбросить tunnel-состояние (новая попытка того же уровня)
resetAbilities: function () {
tunnelUsed = false;
try { inst.setParam('tunnel', 0); } catch (_e) {}
refresh();
},
destroy: function () {
unsub();
if (bar.parentNode) bar.parentNode.removeChild(bar);
}
};
}
/* всплывающая подсказка над сценой (без эмодзи) */
function _flashHint(host, text) {
var t = el('div', 'qa-toast', escapeText(text));
host.appendChild(t);
requestAnimationFrame(function () { t.classList.add('show'); });
setTimeout(function () {
t.classList.remove('show');
setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 320);
}, 1900);
}
/* ════════════════════════════════════════════════════════════════════════
SR-комната — модальная мини-сессия повторения флешкарт.
openRestRoom({ host, onClose }) — асинхронно тянет колоды и due-карты.
════════════════════════════════════════════════════════════════════════ */
function openRestRoom(opts) {
opts = opts || {};
var host = opts.host || doc.body;
var onClose = typeof opts.onClose === 'function' ? opts.onClose : function () {};
var LS = global.LS;
var overlay = el('div', 'qa-overlay');
var modal = el('div', 'qa-modal');
overlay.appendChild(modal);
host.appendChild(overlay);
var earned = 0; // энергия, начисленная за сессию
function close() {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
onClose(earned);
}
overlay.addEventListener('click', function (ev) { if (ev.target === overlay) close(); });
function header(title) {
var h = el('div', 'qa-modal-head');
h.appendChild(el('div', 'qa-modal-title', cardsIcon() + '<span>' + escapeText(title) + '</span>'));
var meter = el('div', 'qa-modal-energy', boltIcon() + '<span class="qa-modal-energy-n">' + getEnergy() + '</span>');
h.appendChild(meter);
var x = el('button', 'qa-modal-x', '&times;');
x.type = 'button'; x.setAttribute('aria-label', 'Закрыть');
x.addEventListener('click', close);
h.appendChild(x);
return h;
}
function setEnergyChip() {
var n = modal.querySelector('.qa-modal-energy-n');
if (n) n.textContent = String(getEnergy());
}
function renderMessage(title, msg, withFlashcardsLink) {
modal.innerHTML = '';
modal.appendChild(header('Комната повторения'));
var body = el('div', 'qa-modal-body');
body.appendChild(el('div', 'qa-empty-title', escapeText(title)));
body.appendChild(el('div', 'qa-empty-msg', escapeText(msg)));
var actions = el('div', 'qa-modal-actions');
if (withFlashcardsLink) {
var open = el('a', 'btn-primary qa-modal-btn', 'Открыть флешкарты');
open.href = '/flashcards';
actions.appendChild(open);
}
var done = el('button', 'btn-ghost qa-modal-btn', 'Закрыть');
done.type = 'button';
done.addEventListener('click', close);
actions.appendChild(done);
body.appendChild(actions);
modal.appendChild(body);
}
function renderLoading() {
modal.innerHTML = '';
modal.appendChild(header('Комната повторения'));
var body = el('div', 'qa-modal-body');
body.appendChild(el('div', 'qa-loading', 'Загрузка колод…'));
modal.appendChild(body);
}
if (!LS || !LS.fcListDecks || !LS.fcStudySession || !LS.fcReview) {
renderMessage('Повторение недоступно', 'Не удалось подключиться к флешкартам. Попробуй позже.', false);
return { el: overlay, close: close };
}
renderLoading();
LS.fcListDecks().then(function (r) {
var decks = (r && r.decks) || [];
if (!decks.length) {
renderMessage('Нет колод', 'Создай колоду флешкарт, чтобы повторять и зарабатывать энергию.', true);
return;
}
// авто-выбор колоды с наибольшим числом due-карт
var withDue = decks.filter(function (d) { return (d.due_count || 0) > 0; });
if (!withDue.length) {
renderMessage('Всё повторено', 'Сейчас нет карточек к повторению. Возвращайся позже — энергия копится повторением.', true);
return;
}
withDue.sort(function (a, b) { return (b.due_count || 0) - (a.due_count || 0); });
// если несколько колод с due — дать выбрать; иначе сразу учить
if (withDue.length === 1) startStudy(withDue[0]);
else renderDeckPicker(withDue);
}).catch(function () {
renderMessage('Ошибка', 'Не удалось загрузить колоды. Проверь соединение.', false);
});
function renderDeckPicker(decks) {
modal.innerHTML = '';
modal.appendChild(header('Выбери колоду'));
var body = el('div', 'qa-modal-body');
var list = el('div', 'qa-deck-list');
decks.forEach(function (d) {
var b = el('button', 'qa-deck', '');
b.type = 'button';
b.style.setProperty('--dk', d.color || '#9B5DE5');
b.innerHTML = '<span class="qa-deck-dot"></span>' +
'<span class="qa-deck-title">' + escapeText(d.title || 'Колода') + '</span>' +
'<span class="qa-deck-due">' + (d.due_count || 0) + ' к повтору</span>';
b.addEventListener('click', function () { startStudy(d); });
list.appendChild(b);
});
body.appendChild(list);
modal.appendChild(body);
}
/* ── Сессия изучения одной колоды ── */
function startStudy(deck) {
renderLoading();
LS.fcStudySession(deck.id).then(function (r) {
var cards = (r && r.cards) || [];
if (!cards.length) {
renderMessage('Всё повторено', 'В этой колоде нет карточек к повторению. Возвращайся позже.', false);
return;
}
runSession(deck, cards.slice());
}).catch(function () {
renderMessage('Ошибка', 'Не удалось загрузить карточки колоды.', false);
});
}
var RQ_GAP = 3; // через сколько карт вернуть недоученную (как FC_RQ_GAP в flashcards.html)
function runSession(deck, queue) {
var idx = 0, done = 0, flipped = false;
var seenCount = 0;
function finish() {
renderMessage('Готово!', 'Повторено ' + seenCount + ' карточек. Заработано энергии: ' + earned + '.', false);
}
function show() {
if (idx >= queue.length) { finish(); return; }
flipped = false;
var card = queue[idx];
modal.innerHTML = '';
modal.appendChild(header(escapeText(deck.title || 'Повторение')));
var body = el('div', 'qa-modal-body qa-study');
// прогресс
var total = done + (queue.length - idx);
var prog = el('div', 'qa-prog');
var fill = el('div', 'qa-prog-fill');
fill.style.width = (total ? (done / total * 100) : 0) + '%';
prog.appendChild(fill);
body.appendChild(prog);
body.appendChild(el('div', 'qa-prog-count', Math.min(done + 1, total) + ' / ' + total));
// карточка
var cardEl = el('div', 'qa-card');
var front = el('div', 'qa-card-side qa-card-front');
front.innerHTML = _cardHtml(card.front, card.front_image);
cardEl.appendChild(front);
var back = el('div', 'qa-card-side qa-card-back');
back.innerHTML = _cardHtml(card.back, card.back_image);
back.style.display = 'none';
cardEl.appendChild(back);
body.appendChild(cardEl);
// кнопка «показать ответ» / оценки
var flipBtn = el('button', 'btn-primary qa-flip', 'Показать ответ');
flipBtn.type = 'button';
body.appendChild(flipBtn);
var grades = el('div', 'qa-grades');
grades.style.display = 'none';
// шкала как в flashcards.html: Снова(0) Трудно(3) Знаю(4) Легко(5)
[
{ q: 0, l: 'Снова', cls: 'qa-g-again' },
{ q: 3, l: 'Трудно', cls: 'qa-g-hard' },
{ q: 4, l: 'Знаю', cls: 'qa-g-good' },
{ q: 5, l: 'Легко', cls: 'qa-g-easy' }
].forEach(function (g) {
var gb = el('button', 'qa-grade ' + g.cls, g.l);
gb.type = 'button';
gb.addEventListener('click', function () { answer(card, g.q); });
grades.appendChild(gb);
});
body.appendChild(grades);
flipBtn.addEventListener('click', function () {
if (flipped) return;
flipped = true;
back.style.display = '';
flipBtn.style.display = 'none';
grades.style.display = '';
});
modal.appendChild(body);
}
function answer(card, quality) {
// награда сразу (оптимистично) — энергия за «Знаю/Легко»
var rw = rewardForQuality(quality);
if (rw > 0) { grantEnergy(rw); earned += rw; setEnergyChip(); }
seenCount++;
// отправляем отзыв; re-queue недоученных в пределах сессии
var requeue = (quality < 3); // фолбэк-эвристика, уточняется ответом
LS.fcReview(card.id, quality).then(function (resp) {
requeue = resp ? !resp.graduated : (quality < 3);
advance(card, requeue);
}).catch(function () {
advance(card, requeue);
});
}
function advance(card, requeue) {
queue.splice(idx, 1);
if (requeue) {
var pos = Math.min(idx + RQ_GAP, queue.length);
queue.splice(pos, 0, card);
} else {
done++;
}
if (idx >= queue.length) finish();
else show();
}
show();
}
return { el: overlay, close: close };
}
/* безопасный рендер стороны карточки (текст escape, картинка — только свой /uploads) */
function _cardHtml(text, image) {
var html = '';
if (image && /^\/uploads\/flashcards\/[A-Za-z0-9._-]+$/.test(image)) {
html += '<img class="qa-card-img" src="' + image + '" alt=""/>';
}
if (text) html += '<div class="qa-card-text">' + escapeText(text) + '</div>';
return html || '<div class="qa-card-text qa-card-empty">—</div>';
}
global.QuantikAbilities = {
mountBar: mountBar,
openRestRoom: openRestRoom,
levelHasTunnel: levelHasTunnel,
levelHasAim: levelHasAim
};
})(typeof window !== 'undefined' ? window : this);
+316
View File
@@ -0,0 +1,316 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · логика игрового уровня (Фаза 2).
Монтирует уровень-спеку через SimEngine.mount (тот же движок, что lab.html
и sim-builder.html). «Игровой режим» включается САМ наличием блока goal в
спеке (Фаза 0). На победу (inst.onGoal) шлём результат на сервер и показываем
экран успеха с нарратором-Квантиком; реакция нарратора зависит от числа звёзд.
Фаза 2:
- Скин Квантика (colorKey из палитр PetSprite, localStorage 'quantik-skin')
тинтует glow-точку героя в уровне и нарратора.
- Экран успеха активирует «Дальше» (переход к следующему уровню) через колбэк.
- Интро-карточка с нарратором перед стартом уровня.
window.QuantikGame.start({ host, level, skin?, onNext?, onMap?, hasNext?, resolveNext? }) -> инстанс.
⛔ Без eval/Function. Уровни — данные из window.QuantikLevels.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
var doc = global.document;
var SKIN_KEY = 'quantik-skin';
var DEFAULT_SKIN = 'cyan';
function el(tag, cls, html) {
var n = doc.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
/* ── Скин ──────────────────────────────────────────────────────────────── */
function getSkin() {
try {
var v = global.localStorage && global.localStorage.getItem(SKIN_KEY);
if (v && global.PetSprite && global.PetSprite.PALETTES && global.PetSprite.PALETTES[v]) return v;
} catch (_e) {}
return DEFAULT_SKIN;
}
function setSkin(key) {
try { if (global.localStorage) global.localStorage.setItem(SKIN_KEY, key); } catch (_e) {}
}
function skinColor(key) {
var pal = (global.PetSprite && global.PetSprite.PALETTES) || {};
return pal[key || getSkin()] || '#06D6E0';
}
/* Тинтуем героя уровня (объект с id 'ball') цветом скина — БЕЗ исполнения,
просто переписываем цветовые поля спеки-копии перед монтированием.
Фаза 4: вторую копию суперпозиции (id 'ball2') тоже тинтуем, но осветлённым
«фантомным» оттенком (полупрозрачность задаётся самой спекой). */
function tintHeroSpec(spec, skinKey) {
var color = skinColor(skinKey);
var phantom = lighten(color, 0.42);
// глубокая копия (спека — данные, без функций) чтобы не мутировать реестр
var copy = JSON.parse(JSON.stringify(spec));
if (Array.isArray(copy.objects)) {
for (var i = 0; i < copy.objects.length; i++) {
var o = copy.objects[i];
if (!o) continue;
if (o.id === 'ball') {
o.color = color;
if (o.glow) o.glowColor = color;
if (o.trail) o.trailColor = color;
} else if (o.id === 'ball2') {
o.color = phantom;
if (o.glow) o.glowColor = phantom;
if (o.trail) o.trailColor = phantom;
}
}
}
return copy;
}
/* Осветлить hex-цвет к белому на долю t (0..1). Для «фантома» суперпозиции.
Принимает #RGB/#RRGGBB; прочее возвращает как есть. */
function lighten(hex, t) {
if (typeof hex !== 'string') return hex;
var m = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim());
if (!m) return hex;
var h = m[1];
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
var r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
r = Math.round(r + (255 - r) * t); g = Math.round(g + (255 - g) * t); b = Math.round(b + (255 - b) * t);
function hx(n) { var s = n.toString(16); return s.length === 1 ? '0' + s : s; }
return '#' + hx(r) + hx(g) + hx(b);
}
/* ── Inline SVG звезды ── */
function starSvg(filled) {
var fill = filled ? '#FBBF24' : 'none';
var stroke = filled ? '#FBBF24' : '#64748B';
// Цвета через inline style: .ic в ls.css (fill:none; stroke:currentColor) иначе
// перебивает атрибуты fill/stroke и заработанные звёзды не закрашиваются.
return '<svg class="ic qg-star-svg" viewBox="0 0 24 24" width="34" height="34" style="fill:' + fill +
';stroke:' + stroke + '" stroke-width="1.6" stroke-linejoin="round">' +
'<polygon points="12 2 15.1 8.6 22 9.3 17 14.1 18.2 21 12 17.6 5.8 21 7 14.1 2 9.3 8.9 8.6"/></svg>';
}
function fmtTime(ms) {
if (!ms && ms !== 0) return '—';
return (ms / 1000).toFixed(2) + ' с';
}
function petSvg(mood, skinKey) {
if (!global.PetSprite) return '';
return global.PetSprite.render(4, mood, [], skinKey || getSkin(), 0, 'none');
}
/* ── Интро-карточка уровня (нарратор «почини закон…») ───────────────────── */
function buildIntro(level, skinKey) {
var overlay = el('div', 'qg-overlay qg-intro');
var card = el('div', 'qg-card qg-card-intro');
var pet = el('div', 'qg-intro-pet', petSvg('happy', skinKey));
card.appendChild(pet);
card.appendChild(el('div', 'qg-card-kicker', 'Почини закон'));
card.appendChild(el('div', 'qg-card-title', escapeText(level.title)));
var goalT = (level.spec && level.spec.goal && level.spec.goal.title) || '';
if (goalT) card.appendChild(el('div', 'qg-intro-goal', escapeText(goalT)));
if (level.hint) card.appendChild(el('div', 'qg-intro-hint', escapeText(level.hint)));
var actions = el('div', 'qg-actions');
var btnGo = el('button', 'btn-primary qg-btn', 'Начать');
btnGo.type = 'button';
var btnBack = el('button', 'btn-ghost qg-btn', 'К карте');
btnBack.type = 'button';
actions.appendChild(btnGo);
actions.appendChild(btnBack);
card.appendChild(actions);
overlay.appendChild(card);
return { overlay: overlay, btnGo: btnGo, btnBack: btnBack };
}
/* ── Экран успеха ───────────────────────────────────────────────────────── */
function buildSuccessOverlay(state, ctx) {
ctx = ctx || {};
var got = (state && state.stars && state.stars.got) || 0;
var total = (state && state.stars && state.stars.total) || 0;
var overlay = el('div', 'qg-overlay');
var card = el('div', 'qg-card');
// нарратор: все звёзды (>=2) -> ecstatic, иначе happy
var mood = (total > 0 && got >= total && total >= 2) ? 'ecstatic' : (got >= 1 ? 'happy' : 'neutral');
if (global.PetSprite) {
var pet = el('div', 'qg-success-pet', petSvg(mood, ctx.skin));
card.appendChild(pet);
}
card.appendChild(el('div', 'qg-card-title', 'Уровень пройден!'));
var starsBox = el('div', 'qg-stars');
var slots = Math.max(total, got, 1);
for (var i = 0; i < slots; i++) {
var w = el('span', 'qg-star' + (i < got ? ' qg-star-on' : ''));
w.style.setProperty('--si', i);
w.innerHTML = starSvg(i < got);
starsBox.appendChild(w);
}
card.appendChild(starsBox);
var stats = el('div', 'qg-stats');
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Время</span><span class="qg-stat-val">' + fmtTime(state && state.timeMs) + '</span>'));
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Звёзды</span><span class="qg-stat-val">' + got + ' / ' + (total || slots) + '</span>'));
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Попытки</span><span class="qg-stat-val">' + ((state && state.attempts) || 0) + '</span>'));
card.appendChild(stats);
var actions = el('div', 'qg-actions');
var btnAgain = el('button', 'btn-ghost qg-btn', 'Ещё раз');
btnAgain.type = 'button';
var btnNext = el('button', 'btn-primary qg-btn', ctx.hasNext ? 'Дальше' : 'К карте');
btnNext.type = 'button';
actions.appendChild(btnAgain);
actions.appendChild(btnNext);
card.appendChild(actions);
overlay.appendChild(card);
return { overlay: overlay, btnAgain: btnAgain, btnNext: btnNext };
}
function escapeText(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/* ── Открыть SR-комнату (повторение флешкарт → энергия) ────────────────────
Делегирует в QuantikAbilities.openRestRoom; после закрытия обновляет HUD
панели способностей (энергия могла измениться). */
function openRest(host, abilities) {
if (!global.QuantikAbilities || !global.QuantikAbilities.openRestRoom) return;
global.QuantikAbilities.openRestRoom({
host: host,
onClose: function () { if (abilities) try { abilities.refresh(); } catch (_e) {} }
});
}
/* ── Старт уровня ───────────────────────────────────────────────────────
opts: { host, level, skin?, onNext?(level), onMap?(), hasNext?, resolveNext? }
resolveNext?() -> Promise<{ hasNext, next }>: пересчитать следующий уровень
ПОСЛЕ перезагрузки прогресса (победа разблокирует след. уровень). Если не
задан / упал — откатываемся к pre-win opts.hasNext (ровно прежнее поведение). */
function start(opts) {
opts = opts || {};
var host = opts.host;
var level = opts.level;
if (!host || !level || !level.spec) return null;
if (!global.SimEngine || !global.SimExpr) return null;
var skin = opts.skin || getSkin();
var spec = tintHeroSpec(level.spec, skin);
var inst = global.SimEngine.mount(host, spec);
// ── Панель квантовых способностей + HUD энергии (Фаза 4) ──
// Аддитивно: монтируется только если доступен модуль; кнопки сами решают,
// уместны ли они для уровня (tunnel/aim-флаги). SR-комната открывается отсюда.
var abilities = null;
if (global.QuantikAbilities && global.QuantikAbilities.mountBar) {
abilities = global.QuantikAbilities.mountBar({
host: host,
inst: inst,
level: level,
onOpenRest: function () { openRest(host, abilities); }
});
// Убираем панель при destroy инстанса (оборачиваем существующий destroy).
if (abilities) {
var _origDestroy = inst.destroy.bind(inst);
inst.destroy = function () {
try { abilities.destroy(); } catch (_e) {}
return _origDestroy();
};
}
}
var overlayRef = null;
function clearOverlay() {
if (overlayRef && overlayRef.overlay && overlayRef.overlay.parentNode) {
overlayRef.overlay.parentNode.removeChild(overlayRef.overlay);
}
overlayRef = null;
}
// submitDone — promise сабмита прогресса (или null, если сабмита нет).
// Экран успеха показываем СРАЗУ (без ожидания сети) с pre-win hasNext, затем
// ОБНОВЛЯЕМ кнопку «Дальше/К карте», когда пересчёт после победы (resolveNext)
// увидит свежеразблокированный уровень. Это чинит «мёртвую Дальше» на первом
// прохождении (0 звёзд → доступен только L1 → pre-win nextPlayable == null).
function showSuccess(state, submitDone) {
clearOverlay();
// Текущее решение кнопки. Замыкания ниже читают его «живьём» (мутируем var),
// поэтому если игрок успеет нажать раньше пересчёта — отработает фолбэк,
// а после пересчёта та же кнопка уже ведёт «Дальше».
var canNext = typeof opts.onNext === 'function' && !!opts.hasNext;
overlayRef = buildSuccessOverlay(state, { skin: skin, hasNext: canNext });
overlayRef.btnAgain.addEventListener('click', function () {
clearOverlay();
try { inst.reset(); } catch (_e) {}
if (abilities) try { abilities.resetAbilities(); } catch (_e) {}
});
overlayRef.btnNext.addEventListener('click', function () {
clearOverlay();
if (canNext) opts.onNext(level);
else if (typeof opts.onMap === 'function') opts.onMap();
});
host.appendChild(overlayRef.overlay);
if (typeof opts.resolveNext !== 'function') return;
var btn = overlayRef.btnNext;
// Пересчёт идёт ПОСЛЕ сабмита: победа сначала сохраняется на сервере, и только
// затем перезагрузка прогресса увидит разблокированный уровень.
Promise.resolve(submitDone)
.catch(function () {}) // сабмит best-effort: даже при ошибке пробуем пересчёт
.then(function () { return opts.resolveNext(); })
.then(function (r) {
// overlayRef мог смениться/закрыться, пока шла сеть — обновляем только «свою» кнопку.
if (!r || !overlayRef || overlayRef.btnNext !== btn) return;
var next = typeof opts.onNext === 'function' && !!r.hasNext;
if (next === canNext) return; // ничего не изменилось
canNext = next;
btn.textContent = next ? 'Дальше' : 'К карте';
})
.catch(function () {}); // пересчёт упал → остаёмся на pre-win решении
}
inst.onGoal(function (res) {
if (!res || !res.won) return;
var got = (res.stars && res.stars.got) || 0;
var payload = { time_ms: res.timeMs, stars: got };
var submitDone = null;
try {
if (global.LS && global.LS.gameProgressSubmit) {
submitDone = global.LS.gameProgressSubmit(level.id, payload);
if (submitDone && typeof submitDone.catch === 'function') submitDone.catch(function () {});
}
} catch (_e) {}
showSuccess(res, submitDone);
});
return inst;
}
global.QuantikGame = {
start: start,
buildSuccessOverlay: buildSuccessOverlay,
buildIntro: buildIntro,
getSkin: getSkin,
setSkin: setSkin,
skinColor: skinColor,
SKIN_KEY: SKIN_KEY
};
})(typeof window !== 'undefined' ? window : this);
+655 -20
View File
@@ -44,7 +44,9 @@
range:[a,b], // отрезок построения (деф. xmin..xmax)
samples?:200, // число точек (деф. 200, клампится)
trace?:false, // true -> точка (varValue=t) пишется в след по времени
color?, width? },
color?, width?,
// ── Квантик Ф3: «бегунок по кривой» (граф-уровни) ──
runner?:{ duration?:8, hold?:false } }, // см. блок «БЕГУНОК ПО КРИВОЙ» ниже
{ type:'vector', origin:[ox,oy], dx, dy, // стрелка из origin на (dx,dy)
color?, width? }, // (x1/y1/x2/y2 тоже поддерживаются)
{ type:'readout', // живой числовой бейдж
@@ -79,13 +81,61 @@
{ a:'ballId'|[x,y], b:'ballId'|[x,y], // концы: id тела ИЛИ якорь-точка
k:40, length:2, damping?:0.5 }
]
},
// ── ЦЕЛЬ / ИГРА (Квантик, Фаза 0) ── декларативный слой победы.
// Аддитивно: спека БЕЗ goal ведёт себя как раньше (нет HUD, нет вычислений побед).
goal: {
when: '<bool expr>', // SimExpr: победа, когда станет истинным (≠0)
title?: 'Цель уровня', // краткая формулировка цели для HUD (escape на сервере)
hint?: 'текст подсказки', // показывается в HUD (escape на сервере)
hold?: 0, // сек: сколько when должно держаться непрерывно (деф. 0)
fail?: '<bool expr>', // опц.: мягкий проигрыш (вышел за поле/задел шип)
stars?: [ // 0..3 доп.условий-«звёзд» (бонусы, «залипают» до reset)
{ when:'<bool expr>', label?:'...' }
]
}
// game?: {...} — зарезервированный блок мета-слоя (Фаза 1/5); сервер его пропускает.
// ── ГРАФ-УРОВНИ (Квантик, Фаза 3) ── «бегунок по кривой» + зоны-препятствия.
// Аддитивно: спека без runner/zone ведёт себя как раньше.
//
// БЕГУНОК ПО КРИВОЙ: на объекте plot поле runner:{ duration?, hold? } делает
// из ПЕРВОЙ кривой plot «дорожку»: за время t от 0 до duration (деф. 8 с)
// свободная переменная (x) линейно проходит range[a..b], а герой едет по
// точке (x, f(x)) ТОЙ ЖЕ скомпилированной функции, что рисует кривую — видимая
// кривая и путь героя идентичны (нет рассинхрона). Движок кладёт в env поля
// <plotId>.runX — текущий x бегунка (a + (b-a)·clamp(t/duration,0,1));
// <plotId>.runY — f(runX) первой кривой (тот же exprFn, что у кривой);
// <plotId>.runDone — 1, когда бегунок дошёл до конца (t>=duration), иначе 0.
// Герой = ОБЫЧНЫЙ point с x:'curve.runX', y:'curve.runY', glow+trail (визуал P2).
// Так нет само-ссылки (точка не ссылается на собственный x в одном проходе env):
// f компилируется один раз и питает И кривую, И бегунок. hold:true оставляет
// бегунок на последней точке после конца (иначе t зацикливается по time.loop).
// ⛔ Никакого eval: f — это SimExpr-выражение кривой (компилируется как обычно).
//
// ЗОНЫ-ПРЕПЯТСТВИЯ: объект type:'zone' — прямоугольная/круговая область в мире.
// { type:'zone', id:'pit', shape:'rect'|'circle',
// kind:'forbidden'|'target'|'collect', // цвет/семантика (деф. forbidden)
// // rect: x,y (центр), w, h ; circle: x,y (центр), r — числа ИЛИ выражения
// track?:'ball', // чью позицию проверять (деф. 'ball')
// color?, fill?, label? }
// Движок кладёт в env булево поле <zoneId>.hit = 1, если точка track сейчас
// ВНУТРИ зоны, иначе 0. goal.when/fail/stars[].when ссылаются на него
// (напр. fail:'pit.hit', goal:'gate.hit', stars:[{when:'coin.hit'}]).
// ⛔ В синтаксис выражений предикаты НЕ добавляются (безопасность контракта) —
// только именованные булевы env-поля, как `t`/`tries` (Фаза 0).
}
Выражения видят: t, все params по имени, w/h (мир-размер вьюпорта), а также
<objId>.x / <objId>.y для объектов, у которых заданы числовые/выраж. x,y.
Для физических тел (body) в env кладутся <objId>.x/.y/.vx/.vy ИЗ СОСТОЯНИЯ
интегратора (а не из выражения) — это снимает проблему forward-ref однопроходного
env для тел: их позиция/скорость не пересчитываются формулой каждый кадр.
Выражения цели (goal.when/fail/stars[].when) видят ВЕСЬ env кадра ПЛЮС `tries`
(число пользовательских reset с начала). Граф-уровни (Ф3) добавляют ИМЕНОВАННЫЕ
булевы/числовые env-поля: <plotId>.runX/.runY/.runDone (бегунок) и <zoneId>.hit
(попадание в зону). Это данные env, а не функции синтаксиса — контракт выражений
остаётся закрытым (никаких inzone()/предикатов). Новых небезопасных идентификаторов нет.
── ИНТЕРАКЦИИ (Фаза 1) ──────────────────────────────────────────────────
Объект с полем drag:{param, axis, min?, max?, paramY?} становится ручкой:
@@ -103,6 +153,10 @@
inst.isRunning() -> bool
inst.destroy()
inst.el -> корневой DOM-узел (для скрытия/показа адаптером)
// ── цель/игра (Фаза 0) ──
inst.onGoal(cb) -> подписка: cb(getResult()) при первой победе
inst.getResult() -> { won, failed, timeMs, attempts, stars:{got,total} }
inst.resetResult() -> сбросить состояние результата (как новый уровень)
════════════════════════════════════════════════════════════════════════ */
(function (global) {
@@ -137,6 +191,26 @@
return v < 0 ? 0 : (v > 1 ? 1 : v);
}
/* нормализовать стиль маркера узлов кривой plot: 'dot'|'ring'|'none' (деф. none). */
function _markerStyle(v) {
return (v === 'dot' || v === 'ring') ? v : 'none';
}
/* полупрозрачная версия цвета для заливки под кривой. #RGB/#RRGGBB -> rgba(...,a);
прочие форматы (rgb()/named) оставляем как есть (canvas сам применит globalAlpha). */
function _fillAlpha(color, a) {
if (typeof color !== 'string') return color;
var m = color.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
if (!m) return color;
var h = m[1], r, g, b;
if (h.length === 3) {
r = parseInt(h[0] + h[0], 16); g = parseInt(h[1] + h[1], 16); b = parseInt(h[2] + h[2], 16);
} else {
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
}
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
/* Компилятор свойства: число/строка -> { ev(env) } (всегда число). */
function bind(value, dflt) {
if (value === undefined || value === null) {
@@ -342,6 +416,12 @@
this._phys = null; // состояние интегратора { bodies, springs, walls, opts, dt, acc }
this._bodyById = {}; // objId -> body (для drag/env/пружин)
this._dragBody = null; // активный захват физ-тела { body, lastW, lastT, vx, vy }
// ── цель/игра (Фаза 0 «Квантик») ──
this._goal = null; // скомпилированный блок цели { whenFn, failFn, hold, stars:[{fn,label}], title, hint } | null
this._goalState = null; // { won, failed, timeMs, attempts, starsGot:[], firstWinT } | null (только при наличии goal)
this._goalHoldT = 0; // сколько секунд (мирового t) условие when держится непрерывно
this._goalCbs = []; // подписчики onGoal
this._hud = null; // DOM-узлы HUD-оверлея (только при наличии goal)
this._build();
}
@@ -482,6 +562,11 @@
// подготовить объекты (компиляция привязок один раз)
this._prepareObjects();
// подготовить цель/игру (компиляция when/fail/stars один раз) + HUD-оверлей.
// Аддитивно: при отсутствии goal в спеке _goal остаётся null и HUD не создаётся.
this._prepareGoal();
if (this._goal) this._buildHud(stage);
// resize
if (global.ResizeObserver) {
this._ro = new ResizeObserver(function () { self._fit(); self._renderFrame(); });
@@ -635,13 +720,71 @@
bp('x', 0); bp('y', 0);
} else if (type === 'plot') {
prep.varName = (typeof o['var'] === 'string' && o['var']) ? o['var'] : 'x';
prep.exprFn = bind(o.expr != null ? o.expr : '0', 0);
var rng = Array.isArray(o.range) ? o.range : null;
prep.rangeA = bind(rng ? rng[0] : null, null);
prep.rangeB = bind(rng ? rng[1] : null, null);
prep.hasRange = !!rng;
prep.samples = Math.max(2, Math.min(2000, num(o.samples, 200) | 0));
prep.trace = !!o.trace;
// ── P3: несколько кривых на одном plot ──
// Источник кривых (приоритет): curves[] -> exprs[] -> одиночный expr (легаси).
// Каждой кривой свой нормализованный стиль; цвет — явный или из палитры по индексу.
var curveDefs = [];
if (Array.isArray(o.curves) && o.curves.length) {
curveDefs = o.curves.map(function (cv) {
return (cv && typeof cv === 'object') ? cv : { expr: cv };
});
} else if (Array.isArray(o.exprs) && o.exprs.length) {
curveDefs = o.exprs.map(function (ex) { return { expr: ex }; });
} else {
curveDefs = [{ expr: o.expr != null ? o.expr : '0' }];
}
var plotMarker = _markerStyle(o.marker);
// plot-уровневые дефолты заливки/маркера наследуются кривыми (если у кривой не задано)
prep.curves = curveDefs.map(function (cv, ci) {
cv = cv || {};
var cFill = (cv.fill !== undefined) ? cv.fill : o.fill;
return {
exprFn: bind(cv.expr != null ? cv.expr : '0', 0),
color: cv.color || o.color || DEFAULT_PALETTE[ci % DEFAULT_PALETTE.length],
label: (cv.label != null) ? String(cv.label) : '',
width: num(cv.width, prep.width),
lineStyle: (cv.lineStyle === 'dashed' || cv.lineStyle === 'dotted') ? cv.lineStyle
: prep.lineStyle,
opacity: (cv.opacity === undefined || cv.opacity === null) ? prep.opacity : _opacity(cv.opacity),
// заливка под кривой: true -> полупрозрачный цвет кривой; строка -> явный цвет
fill: (cFill === true || (typeof cFill === 'string' && cFill)) ? cFill : false,
// маркеры узлов: none|dot|ring (наследует plot-уровень)
marker: (cv.marker !== undefined) ? _markerStyle(cv.marker) : plotMarker,
glow: prep.glow,
glowColor: prep.glowColor,
glowBlur: prep.glowBlur
};
});
// легаси: одиночное выражение для trace-режима (накопление по t)
prep.exprFn = prep.curves[0].exprFn;
// легенда: показывать, если есть хотя бы одна подпись (можно явно legend:false)
var anyLabel = prep.curves.some(function (c) { return !!c.label; });
prep.legend = (o.legend === false) ? false : anyLabel;
// ── Квантик Ф3: «бегунок по кривой» ──
// runner делает из ПЕРВОЙ кривой дорожку: x проходит range[a..b] за duration
// секунд (мирового t), y = f(x) той же кривой. Кладём в env <id>.runX/.runY/.runDone.
if (o.runner && typeof o.runner === 'object') {
prep.runner = {
duration: (typeof o.runner.duration === 'number' && o.runner.duration > 0) ? o.runner.duration : 8,
hold: o.runner.hold !== false // деф. true: остаётся на конце (не зацикливается)
};
}
} else if (type === 'zone') {
// ── Квантик Ф3: зона-препятствие/цель/сбор (прямоугольник или круг) ──
prep.shape = (o.shape === 'circle') ? 'circle' : 'rect';
prep.kind = (o.kind === 'target' || o.kind === 'collect') ? o.kind : 'forbidden';
prep.track = (typeof o.track === 'string' && o.track) ? o.track : 'ball';
prep.label = o.label != null ? String(o.label) : '';
bp('x', 0); bp('y', 0);
if (prep.shape === 'circle') { B.r = bind(o.r, 1); }
else { bp('w', 1); bp('h', 1); }
// зона НЕ участвует в obj.x/obj.y центрах (это область, не точка) — hasCenter не ставим
} else if (type === 'readout') {
// компилируем выражение один раз: храним и fn (быстро), и ast (для evalSafe — мягкая ошибка)
var rc = global.SimExpr ? global.SimExpr.compileValue(o.expr != null ? o.expr : '0')
@@ -683,13 +826,216 @@
}
// привязки для центра объекта (для obj.x/obj.y в env): point/circle/rect/label
if (B.x && B.y) { prep.hasCenter = true; }
// (zone — область, не точка: его x/y не кладём в env как центр объекта)
if (B.x && B.y && type !== 'zone') { prep.hasCenter = true; }
out.push(prep);
}
this._objs = out;
};
/* ════════════════════ Цель / игра (Фаза 0 «Квантик») ════════════════════
Декларативный слой победы: булевы SimExpr-выражения, компилируемые ОДИН РАЗ
(как все выражения движка). В rAF после построения env — оценка. Безопасно:
никакого eval, выражения исполняет SimExpr (кривое выражение -> 0, не бросает). */
/* Скомпилировать блок goal (when/fail/каждое stars[].when) один раз при mount.
Спека без goal -> _goal остаётся null (полная аддитивность). */
SimEngineInstance.prototype._prepareGoal = function () {
var g = this.spec.goal;
if (!g || typeof g !== 'object' || Array.isArray(g)) { this._goal = null; this._goalState = null; return; }
var compile = (global.SimExpr && global.SimExpr.compile)
? global.SimExpr.compile
: function () { return { fn: function () { return 0; }, ast: null, error: null }; };
var whenC = compile(g.when != null ? g.when : '0');
var failC = (g.fail != null) ? compile(g.fail) : null;
var rawStars = Array.isArray(g.stars) ? g.stars.slice(0, 3) : []; // не более 3 звёзд
var stars = rawStars.map(function (s) {
s = (s && typeof s === 'object') ? s : { when: s };
var c = compile(s.when != null ? s.when : '0');
return { fn: c.fn, label: (s.label != null) ? String(s.label) : '' };
});
this._goal = {
whenFn: whenC.fn,
failFn: failC ? failC.fn : null,
hold: (typeof g.hold === 'number' && isFinite(g.hold) && g.hold > 0) ? g.hold : 0,
stars: stars,
title: (g.title != null) ? String(g.title) : '',
hint: (g.hint != null) ? String(g.hint) : ''
};
// первичное состояние результата (attempts=0; первый mount/авто-reset попыткой не считается)
this._goalState = {
won: false, failed: false, timeMs: 0,
attempts: 0, starsGot: stars.map(function () { return false; }), firstWinT: null
};
this._goalHoldT = 0;
};
/* Оценить цель за кадр (после построения env и шага физики). Накапливает звёзды,
проверяет fail (мягкий проигрыш), then when с учётом hold (удержание). При победе
фиксирует timeMs (мировое t, детерминизм), ставит won, ставит на паузу, дёргает onGoal. */
SimEngineInstance.prototype._evalGoal = function (env, dt) {
var g = this._goal, st = this._goalState;
if (!g || !st) return;
// tries — число пользовательских reset; добавляем ТОЛЬКО его (безопасность контракта).
env.tries = st.attempts;
// звёзды «залипают»: однажды истинное условие остаётся засчитанным до reset.
for (var i = 0; i < g.stars.length; i++) {
if (!st.starsGot[i] && _truthy(g.stars[i].fn(env))) st.starsGot[i] = true;
}
if (st.won || st.failed) return; // итог зафиксирован — больше не пересчитываем
// мягкий проигрыш: fail имеет приоритет над when (НЕ победа)
if (g.failFn && _truthy(g.failFn(env))) {
st.failed = true;
this._goalHoldT = 0;
this.pause();
this._renderHud();
return;
}
// победа: when (с учётом hold — условие должно держаться hold секунд)
if (_truthy(g.whenFn(env))) {
this._goalHoldT += (typeof dt === 'number' && dt > 0) ? dt : 0;
if (this._goalHoldT >= g.hold) {
st.won = true;
st.firstWinT = this._t;
// время победы: мировое t от старта уровня (детерминизм, headless-тест)
st.timeMs = Math.max(1, Math.round(this._t * 1000));
this.pause();
this._fireGoal();
this._renderHud();
}
} else {
this._goalHoldT = 0; // условие пропало до удержания — сброс таймера
}
};
/* Вызвать onGoal-подписчиков один раз (после первой победы). */
SimEngineInstance.prototype._fireGoal = function () {
var res = this.getResult();
var cbs = this._goalCbs.slice();
for (var i = 0; i < cbs.length; i++) {
try { cbs[i](res); } catch (e) { /* подписчик не должен ронять цикл */ }
}
};
/* ════════════════════ HUD цели (DOM-оверлей) ════════════════════
Появляется ТОЛЬКО при наличии goal. Контейнер — pointer-events:none (не крадёт
pan/drag сцены), интерактивные кнопки — pointer-events:auto. Стиль — тёмная
плашка как у readout-бейджей. Без эмодзи: звёзды/иконки — inline SVG. */
SimEngineInstance.prototype._buildHud = function (stage) {
var self = this;
var hud = {};
// ── верхняя плашка: цель + звёзды (по центру сверху) ──
var top = document.createElement('div');
top.style.cssText = 'position:absolute;left:50%;top:10px;transform:translateX(-50%);z-index:6;' +
'pointer-events:none;display:flex;flex-direction:column;gap:5px;align-items:center;max-width:80%';
var objLine = document.createElement('div');
objLine.style.cssText = 'display:flex;align-items:center;gap:8px;' + _readoutBadgeCss('#fff') +
';font-size:.82rem;font-weight:600;pointer-events:none';
var titleSpan = document.createElement('span');
var starsWrap = document.createElement('span');
starsWrap.style.cssText = 'display:inline-flex;gap:3px;align-items:center';
objLine.appendChild(titleSpan);
objLine.appendChild(starsWrap);
top.appendChild(objLine);
hud.titleSpan = titleSpan;
hud.starsWrap = starsWrap;
var hintEl = document.createElement('div');
hintEl.style.cssText = _readoutBadgeCss('rgba(255,255,255,0.72)') +
';font-size:.74rem;pointer-events:none;max-width:100%;white-space:normal;text-align:center';
top.appendChild(hintEl);
hud.hintEl = hintEl;
stage.appendChild(top);
hud.top = top;
// ── центральный баннер «Победа» / «Ещё раз» (скрыт по умолчанию) ──
var banner = document.createElement('div');
banner.style.cssText = 'position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:7;' +
'display:none;flex-direction:column;align-items:center;gap:10px;pointer-events:none;' +
'background:rgba(13,13,26,0.92);border:1px solid rgba(255,255,255,0.16);border-radius:16px;' +
'padding:18px 24px;box-shadow:0 12px 40px rgba(0,0,0,0.5);text-align:center';
var bannerTitle = document.createElement('div');
bannerTitle.style.cssText = 'font-size:1.1rem;font-weight:800;letter-spacing:.3px';
var bannerStars = document.createElement('div');
bannerStars.style.cssText = 'display:flex;gap:4px;align-items:center';
var btnRetry = this._btn(this._resetIcon(), 'Ещё раз');
btnRetry.style.pointerEvents = 'auto';
btnRetry.style.minWidth = '120px';
btnRetry.innerHTML = this._resetIcon() + '<span style="margin-left:7px;font-weight:700">Ещё раз</span>';
this._onHudRetry = function () { self.reset(); };
btnRetry.addEventListener('click', this._onHudRetry);
banner.appendChild(bannerTitle);
banner.appendChild(bannerStars);
banner.appendChild(btnRetry);
stage.appendChild(banner);
hud.banner = banner;
hud.bannerTitle = bannerTitle;
hud.bannerStars = bannerStars;
hud.btnRetry = btnRetry;
this._hud = hud;
this._renderHud();
};
/* SVG-звезда: заполненная (got) или контурная (ещё не получена). Без эмодзи. */
SimEngineInstance.prototype._starIcon = function (got, size) {
var s = size || 15;
var fill = got ? '#FBBF24' : 'none';
var stroke = got ? '#FBBF24' : 'rgba(255,255,255,0.42)';
return '<svg viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="' + fill +
'" stroke="' + stroke + '" stroke-width="1.6" stroke-linejoin="round">' +
'<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
};
/* Перерисовать HUD по текущему состоянию цели (вызывается каждый кадр + при reset). */
SimEngineInstance.prototype._renderHud = function () {
var hud = this._hud, g = this._goal, st = this._goalState;
if (!hud || !g || !st) return;
// строка цели
hud.titleSpan.textContent = g.title || 'Цель';
// индикаторы звёзд (только если есть звёзды)
var starsHtml = '';
for (var i = 0; i < g.stars.length; i++) starsHtml += this._starIcon(st.starsGot[i], 15);
hud.starsWrap.innerHTML = starsHtml;
// подсказка
if (g.hint) { hud.hintEl.style.display = ''; hud.hintEl.textContent = g.hint; }
else hud.hintEl.style.display = 'none';
// баннер итога
if (st.won || st.failed) {
hud.banner.style.display = 'flex';
if (st.won) {
var got = 0;
for (var k = 0; k < st.starsGot.length; k++) if (st.starsGot[k]) got++;
hud.bannerTitle.textContent = 'Победа!';
hud.bannerTitle.style.color = '#34D399';
var bs = '';
for (var j = 0; j < g.stars.length; j++) bs += this._starIcon(st.starsGot[j], 22);
hud.bannerStars.innerHTML = bs;
hud.bannerStars.style.display = g.stars.length ? 'flex' : 'none';
} else {
hud.bannerTitle.textContent = 'Не вышло';
hud.bannerTitle.style.color = '#FB7185';
hud.bannerStars.innerHTML = '';
hud.bannerStars.style.display = 'none';
}
} else {
hud.banner.style.display = 'none';
}
};
/* ── физика: есть ли в спеке тела/включён ли интегратор ── */
SimEngineInstance.prototype._physEnabled = function () {
var ph = this.spec.physics;
@@ -865,7 +1211,32 @@
}
}
// 2) центры формульных объектов (одношагово; тела пропускаем — их x/y уже в env).
// 2) бегунок по кривой (Ф3): <plotId>.runX/.runY/.runDone — ДО формульных центров,
// чтобы герой-точка (x:'curve.runX') увидела актуальную позицию в том же кадре.
// runX линейно проходит range за runner.duration сек (по мировому t); runY = f(runX)
// ТОЙ ЖЕ скомпилированной функции, что рисует кривую (нет рассинхрона, нет само-ссылки).
for (var ri = 0; ri < this._objs.length; ri++) {
var pr = this._objs[ri];
if (pr.type !== 'plot' || !pr.runner) continue;
var aR = pr.rangeA.ev(env), bR = pr.rangeB.ev(env);
if (!pr.hasRange || !isFinite(aR) || !isFinite(bR)) { aR = vp.xmin; bR = vp.xmax; }
var frac = pr.runner.duration > 0 ? (env.t / pr.runner.duration) : 1;
var done = frac >= 1;
if (frac < 0) frac = 0; if (frac > 1) frac = 1;
var rx = aR + (bR - aR) * frac;
// y = f(runX): подставляем runX во временную копию свободной переменной
var hadV = Object.prototype.hasOwnProperty.call(env, pr.varName);
var prevV = env[pr.varName];
env[pr.varName] = rx;
var ry = pr.exprFn.ev(env);
if (hadV) env[pr.varName] = prevV; else delete env[pr.varName];
if (typeof ry !== 'number' || !isFinite(ry)) ry = 0;
env[pr.id + '.runX'] = rx;
env[pr.id + '.runY'] = ry;
env[pr.id + '.runDone'] = done ? 1 : 0;
}
// 3) центры формульных объектов (одношагово; тела пропускаем — их x/y уже в env).
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (o.hasCenter && !o.body) {
@@ -875,9 +1246,32 @@
env[o.id + '.y'] = y;
}
}
// 4) зоны (Ф3): <zoneId>.hit = 1/0 по позиции отслеживаемой точки (track).
// Считаем ПОСЛЕДНИМ — нужна актуальная позиция героя (из тела/формулы выше).
for (var zi = 0; zi < this._objs.length; zi++) {
var z = this._objs[zi];
if (z.type !== 'zone') continue;
env[z.id + '.hit'] = this._zoneHit(z, env) ? 1 : 0;
}
return env;
};
/* Внутри ли зоны z отслеживаемая точка (env[track.x], env[track.y])? Геометрия в
мир-координатах. Точка отсутствует (нет такого track) -> не внутри (0). */
SimEngineInstance.prototype._zoneHit = function (z, env) {
var tx = env[z.track + '.x'], ty = env[z.track + '.y'];
if (typeof tx !== 'number' || typeof ty !== 'number' || !isFinite(tx) || !isFinite(ty)) return false;
var cx = z.b.x.ev(env), cy = z.b.y.ev(env);
if (z.shape === 'circle') {
var r = Math.abs(z.b.r.ev(env));
var dx = tx - cx, dy = ty - cy;
return (dx * dx + dy * dy) <= r * r;
}
var hw = Math.abs(z.b.w.ev(env)) / 2, hh = Math.abs(z.b.h.ev(env)) / 2;
return tx >= cx - hw && tx <= cx + hw && ty >= cy - hh && ty <= cy + hh;
};
/* ── трансформация мир→экран (ось Y вверх) с сохранением пропорций ──
Эффективный transform (_scale/_offX/_offY) = базовый fit (_baseScale/...) с
наложенным пользовательским зумом/паном. _fit пересчитывает DPR/размер и базу;
@@ -1236,6 +1630,17 @@
for (var j = 0; j < this._objs.length; j++) {
this._drawObject(ctx, this._objs[j], env);
}
// HUD цели (звёзды могут засчитываться и на паузе/предпросмотре по текущему env)
if (this._goal && this._goalState) {
env.tries = this._goalState.attempts; // тот же доп. идентификатор, что в _evalGoal
for (var gi = 0; gi < this._goal.stars.length; gi++) {
if (!this._goalState.starsGot[gi] && _truthy(this._goal.stars[gi].fn(env))) {
this._goalState.starsGot[gi] = true;
}
}
this._renderHud();
}
};
/* пружины как зигзаг между концами (наглядно для маятника/осциллятора) */
@@ -1468,14 +1873,61 @@
this._drawReadout(o, env);
break;
}
case 'zone': {
this._drawZone(ctx, o, env);
break;
}
}
};
/* ── plot: график выражения f(var) на отрезке range (мир-координаты) ── */
/* ── zone: область-препятствие/цель/сбор (Ф3) ──
Цвет по kind (forbidden=danger, target=goal, collect=bonus) ИЛИ явный o.color.
⛔ Цвета только в canvas-стоки (fillStyle/strokeStyle) — XSS-безопасно. */
var ZONE_STYLE = {
forbidden: { stroke: '#F87171', fill: 'rgba(248,113,113,0.16)', dash: true },
target: { stroke: '#34D399', fill: 'rgba(52,211,153,0.16)', dash: false },
collect: { stroke: '#FBBF24', fill: 'rgba(251,191,36,0.16)', dash: true }
};
SimEngineInstance.prototype._drawZone = function (ctx, o, env) {
var st = ZONE_STYLE[o.kind] || ZONE_STYLE.forbidden;
var stroke = o.color || st.stroke;
var fill = o.fillColor || st.fill;
var cx = o.b.x.ev(env), cy = o.b.y.ev(env);
ctx.save();
ctx.globalAlpha = o.opacity;
ctx.lineJoin = 'round';
ctx.lineWidth = 2;
ctx.strokeStyle = stroke;
ctx.fillStyle = fill;
if (st.dash) ctx.setLineDash([7, 5]); else ctx.setLineDash([]);
if (o.shape === 'circle') {
var r = Math.abs(o.b.r.ev(env)) * this._scale;
var c0 = this._toPx(cx, cy);
ctx.beginPath(); ctx.arc(c0[0], c0[1], r, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
} else {
var rw = Math.abs(o.b.w.ev(env)), rh = Math.abs(o.b.h.ev(env));
var tl = this._toPx(cx - rw / 2, cy + rh / 2); // верх-лево (Y вверх)
var pw = rw * this._scale, ph = rh * this._scale;
ctx.fillRect(tl[0], tl[1], pw, ph);
ctx.strokeRect(tl[0], tl[1], pw, ph);
}
ctx.restore();
// подпись зоны (на оверлее, через _drawLabel — KaTeX/текст; цвет = stroke зоны)
if (o.label) {
var lp = this._toPx(cx, cy);
this._drawLabel({ text: o.label, color: stroke, size: o.size || 12, latex: false }, lp[0], lp[1]);
}
};
/* ── plot: график f(var) на отрезке range (мир-координаты) ──
P3: несколько кривых (o.curves[]), заливка под кривой (к оси y=0), маркеры узлов,
легенда (на canvas). Trace-режим (накопление по t) рисуется отдельно через _drawTrail. */
SimEngineInstance.prototype._drawPlot = function (ctx, o, env) {
// trace без явного range — только накапливаемый след (статической кривой нет)
if (o.trace && !o.hasRange) return;
var vp = this._vp();
var W = this._cw, H = this._ch;
var a = o.rangeA.ev(env), b = o.rangeB.ev(env);
if (!o.hasRange || !isFinite(a) || !isFinite(b)) { a = vp.xmin; b = vp.xmax; }
if (a === b) return;
@@ -1485,25 +1937,141 @@
var prev = env[o.varName];
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
ctx.save();
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
ctx.beginPath();
var started = false;
for (var i = 0; i < n; i++) {
var xv = a + step * i;
env[o.varName] = xv;
var yv = o.exprFn.ev(env);
if (typeof yv !== 'number' || !isFinite(yv)) { started = false; continue; }
var px = this._toPx(xv, yv);
if (!started) { ctx.moveTo(px[0], px[1]); started = true; }
else ctx.lineTo(px[0], px[1]);
var curves = o.curves || [];
var legendItems = [];
// y=0 в экранных px (для клипа заливки к оси X) + видимая область для клипа
var zeroPy = this._toPx(0, 0)[1];
for (var ci = 0; ci < curves.length; ci++) {
var cv = curves[ci];
// сэмплинг: экранные точки [px,py,worldY], разрывы как null (не-finite y -> пропуск сегмента)
var pts = [];
for (var i = 0; i < n; i++) {
var xv = a + step * i;
env[o.varName] = xv;
var yv = cv.exprFn.ev(env);
if (typeof yv !== 'number' || !isFinite(yv)) { pts.push(null); continue; }
var p = this._toPx(xv, yv);
pts.push([p[0], p[1], yv]);
}
ctx.save();
// заливка под кривой (между кривой и осью y=0), клиппится к видимой высоте
if (cv.fill) {
var fillCol = (cv.fill === true) ? _fillAlpha(cv.color, 0.18) : cv.fill;
ctx.save();
ctx.globalAlpha = cv.opacity;
ctx.fillStyle = fillCol;
ctx.shadowBlur = 0;
this._fillUnderCurve(ctx, pts, _clamp(zeroPy, 0, H));
ctx.restore();
}
// линия кривой (через _applyStroke: dash/opacity/glow/width)
this._applyStroke(ctx, cv);
ctx.strokeStyle = cv.color;
ctx.beginPath();
var started = false;
for (var k = 0; k < pts.length; k++) {
if (!pts[k]) { started = false; continue; }
if (!started) { ctx.moveTo(pts[k][0], pts[k][1]); started = true; }
else ctx.lineTo(pts[k][0], pts[k][1]);
}
ctx.stroke();
// маркеры узлов (с прореживанием: не чаще ~28px по экрану)
if (cv.marker && cv.marker !== 'none') {
this._drawCurveMarkers(ctx, pts, cv);
}
ctx.restore();
if (o.legend && cv.label) legendItems.push({ color: cv.color, label: cv.label });
}
ctx.stroke();
ctx.restore();
// восстановить env
if (hadPrev) env[o.varName] = prev; else delete env[o.varName];
// легенда (поверх кривых, в углу области plot, на canvas)
if (legendItems.length) this._drawLegend(ctx, W, H, legendItems);
};
/* Заливка области под полилинией к базовой линии y=baseY (экранные px). Каждый
непрерывный сегмент (между разрывами null) заливается отдельным замкнутым контуром:
curve-up -> вдоль кривой -> curve-down к baseY -> закрыть. baseY клиппится к canvas. */
SimEngineInstance.prototype._fillUnderCurve = function (ctx, pts, baseY) {
var i = 0, n = pts.length;
while (i < n) {
// найти начало непрерывного сегмента
while (i < n && !pts[i]) i++;
var startI = i;
while (i < n && pts[i]) i++;
var endI = i; // [startI, endI)
if (endI - startI < 2) continue; // сегмент из <2 точек — заливать нечего
ctx.beginPath();
ctx.moveTo(pts[startI][0], baseY);
for (var k = startI; k < endI; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.lineTo(pts[endI - 1][0], baseY);
ctx.closePath();
ctx.fill();
}
};
/* Маркеры узлов кривой (dot|ring) с прореживанием по экранному расстоянию (~28px),
чтобы не рисовать сотни точек. Цвет — цвет кривой; opacity наследуется от ctx. */
SimEngineInstance.prototype._drawCurveMarkers = function (ctx, pts, cv) {
var MIN_PX = 28; // минимальный шаг между маркерами по экрану
var r = Math.max(2.5, (cv.width || 2) + 1.5);
var marker = { color: cv.color, fillColor: cv.color, opacity: cv.opacity, glow: false,
pointStyle: (cv.marker === 'ring') ? 'hollow' : 'filled', width: cv.width || 2 };
var lastX = -1e9, lastY = -1e9;
for (var k = 0; k < pts.length; k++) {
var p = pts[k];
if (!p) continue;
var dx = p[0] - lastX, dy = p[1] - lastY;
if (dx * dx + dy * dy < MIN_PX * MIN_PX) continue;
this._drawPoint(ctx, marker, p[0], p[1], r);
lastX = p[0]; lastY = p[1];
}
};
/* Компактная легенда в углу области plot (на canvas, без DOM): цветная метка + текст.
Позиция: верх-право, со смещением вниз, чтобы не наезжать на ось Y/подписи. */
SimEngineInstance.prototype._drawLegend = function (ctx, W, H, items) {
if (!items.length) return;
ctx.save();
ctx.font = '12px Manrope,system-ui,sans-serif';
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
var pad = 8, rowH = 18, swatch = 11, gap = 7;
// ширина по самой длинной подписи
var maxTxt = 0;
for (var i = 0; i < items.length; i++) {
var w = ctx.measureText(items[i].label).width;
if (w > maxTxt) maxTxt = w;
}
var boxW = pad * 2 + swatch + gap + Math.ceil(maxTxt);
var boxH = pad * 2 + items.length * rowH - (rowH - 14);
// верх-право; отступ от края, не наезжает на бар кнопок (right/bottom) и оси
var bx = W - boxW - 12, by = 12;
if (bx < 6) bx = 6;
// фон-плашка (полупрозрачная тёмная, как readout-бейджи)
ctx.globalAlpha = 1;
ctx.fillStyle = 'rgba(13,13,26,0.78)';
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill(); ctx.stroke(); }
else { ctx.fillRect(bx, by, boxW, boxH); ctx.strokeRect(bx, by, boxW, boxH); }
for (var j = 0; j < items.length; j++) {
var cy = by + pad + 7 + j * rowH;
// цветная метка (линия-свотч)
ctx.strokeStyle = items[j].color;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(bx + pad, cy); ctx.lineTo(bx + pad + swatch, cy);
ctx.stroke();
// текст метки (светлый, без пользовательского цвета в DOM)
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillText(items[j].label, bx + pad + swatch + gap, cy);
}
ctx.restore();
};
/* ── readout: живое значение выражения как бейдж на оверлее ── */
@@ -1767,6 +2335,8 @@
}
// продвинуть физику фиксированными подшагами (если есть)
if (self._phys) self._stepPhysics(dt);
// оценить цель после шага (env строится из актуального состояния); победа -> pause
if (self._goal) self._evalGoal(self._buildEnv(), dt);
self._renderFrame();
self._raf = global.requestAnimationFrame(frame);
}
@@ -1796,9 +2366,31 @@
this._trails = {};
this._dragBody = null;
this._preparePhysics(); // пересобрать тела/пружины с нач. условиями из params
// сбросить состояние цели: attempts++ только на ПОЛЬЗОВАТЕЛЬСКОМ reset
// (первый авто-reset при mount попыткой не считается).
if (this._goalState) {
var userReset = this._goalInited === true;
this._goalInited = true;
this._resetGoalState(userReset);
} else {
this._goalInited = true;
}
this._renderFrame();
};
/* Сбросить состояние результата к началу уровня. bumpAttempt=true -> attempts++. */
SimEngineInstance.prototype._resetGoalState = function (bumpAttempt) {
if (!this._goal) return;
var prevAttempts = this._goalState ? this._goalState.attempts : 0;
this._goalState = {
won: false, failed: false, timeMs: 0,
attempts: prevAttempts + (bumpAttempt ? 1 : 0),
starsGot: this._goal.stars.map(function () { return false; }),
firstWinT: null
};
this._goalHoldT = 0;
};
SimEngineInstance.prototype.setParam = function (name, value) {
var v = parseFloat(value);
if (!isFinite(v)) return;
@@ -1811,6 +2403,33 @@
SimEngineInstance.prototype.getParam = function (name) { return this.params[name]; };
SimEngineInstance.prototype.isRunning = function () { return this._running; };
/* ════════════════════ Цель / игра: публичное API ════════════════════ */
/* Подписаться на победу: cb(getResult()) вызывается один раз при первой победе. */
SimEngineInstance.prototype.onGoal = function (cb) {
if (typeof cb === 'function') this._goalCbs.push(cb);
return this;
};
/* Текущий результат уровня. Для спеки без goal -> null. */
SimEngineInstance.prototype.getResult = function () {
var st = this._goalState;
if (!st) return null;
var total = this._goal ? this._goal.stars.length : 0;
var got = 0;
for (var i = 0; i < st.starsGot.length; i++) if (st.starsGot[i]) got++;
return {
won: st.won, failed: st.failed, timeMs: st.timeMs,
attempts: st.attempts, stars: { got: got, total: total }
};
};
/* Сбросить результат (как новый уровень) — НЕ считается попыткой. */
SimEngineInstance.prototype.resetResult = function () {
if (!this._goal) return;
var keep = this._goalState ? this._goalState.attempts : 0;
this._resetGoalState(false);
if (this._goalState) this._goalState.attempts = keep;
this._renderHud();
};
SimEngineInstance.prototype.destroy = function () {
this.pause();
this._destroyed = true;
@@ -1840,6 +2459,19 @@
this._dragBody = null;
this._phys = null;
this._bodyById = {};
// снять HUD-слушатели/узлы (нет утечек — баланс add/removeEventListener)
if (this._hud) {
if (this._hud.btnRetry && this._onHudRetry) {
this._hud.btnRetry.removeEventListener('click', this._onHudRetry);
}
if (this._hud.top && this._hud.top.parentNode) this._hud.top.parentNode.removeChild(this._hud.top);
if (this._hud.banner && this._hud.banner.parentNode) this._hud.banner.parentNode.removeChild(this._hud.banner);
this._hud = null;
}
this._onHudRetry = null;
this._goal = null;
this._goalState = null;
this._goalCbs = [];
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
this.el = null; this.canvas = null; this.ctx = null;
};
@@ -1882,6 +2514,9 @@
}
function _clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function _nowMs() { return (global.performance && global.performance.now) ? global.performance.now() : Date.now(); }
/* истинность булева SimExpr-результата: SimExpr.fn возвращает число (NaN/∞ -> 0),
истина = любое конечное ненулевое значение. */
function _truthy(v) { return typeof v === 'number' && isFinite(v) && v !== 0; }
/* ════════════════════ public ════════════════════ */
function mount(host, spec) {
+1270 -11
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -5,6 +5,10 @@
* dashboard.html используют window.PetSprite.render(...) без дублей.
*/
(function () {
// Счётчик для УНИКАЛЬНЫХ id градиентов/клипов спрайта. Иначе два питомца с
// одинаковыми level/mood/colorKey дают совпадающие id, и url(#id) заливки тела
// резолвится в чужой (возможно display:none) градиент → тело без заливки.
let _petUidSeq = 0;
const PET_PALETTES = {
purple:'#9B5DE5', cyan:'#06D6E0', gold:'#F9C74F',
red:'#F94144', green:'#38D95A', blue:'#4A90D9',
@@ -24,7 +28,7 @@
const col = PET_PALETTES[colorKey] || '#9B5DE5';
const dark = shadeColor(col, -45);
const light = shadeColor(col, 52);
const uid = `pg${level}${mood[0]}${colorKey[0]}`;
const uid = `pg${(++_petUidSeq).toString(36)}`;
const bodyPath = 'M55,22 C70,22 86,37 87,56 C89,75 78,94 55,97 C32,94 21,75 23,56 C24,37 40,22 55,22 Z';
const eyeY = 52, eyeX1 = 40, eyeX2 = 70;
+1058 -79
View File
File diff suppressed because it is too large Load Diff
+93
View File
@@ -3569,6 +3569,10 @@
<!-- ── Отображение ── -->
<div class="gp-section-title" style="margin-top:8px;margin-bottom:4px">Отображение</div>
<div onclick="stereoToggleSt('figure',this.querySelector('.st-toggle'))" class="st-toggle-row" title="Показать/скрыть само тело (грани, рёбра, вершины, подписи) — построения и сетка остаются">
<span class="st-toggle-label"><svg viewBox="0 0 24 24"><rect x="3" y="7" width="12" height="12"/><path d="M3 7l4-4h12v12l-4 4M15 3v12"/></svg>Фигура</span>
<div class="st-toggle on" id="stg-figure"></div>
</div>
<div onclick="stereoToggleSt('edges',this.querySelector('.st-toggle'))" class="st-toggle-row">
<span class="st-toggle-label"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18"/></svg>Рёбра</span>
<div class="st-toggle on" id="stg-edges"></div>
@@ -3670,8 +3674,97 @@
<button class="st-action-btn" onclick="stereoMeasureUndo()">Удалить изм.</button>
<button class="st-action-btn" onclick="stereoMeasureClear()">Очист. изм.</button>
</div>
<div onclick="stereoToggleConnLen(this.querySelector('.st-toggle'))" class="st-toggle-row" title="Показывать длины соединённых отрезков">
<span class="st-toggle-label"><svg viewBox="0 0 24 24"><line x1="4" y1="12" x2="20" y2="12" stroke-dasharray="4,2"/><line x1="4" y1="9" x2="4" y2="15"/><line x1="20" y1="9" x2="20" y2="15"/></svg>Длины отрезков</span>
<div class="st-toggle on" id="stg-connlen"></div>
</div>
<div id="points-info" style="font-size:0.65rem;color:rgba(255,255,255,0.4);margin-top:2px"></div>
<!-- ── Выделение цветом (многоугольник по точкам) ── -->
<div class="gp-section-title" style="margin-top:8px;margin-bottom:6px">Выделение цветом</div>
<div class="st-tool-grid">
<button class="st-tool-btn st-tool-btn-wide" id="stereo-poly-btn" onclick="stereoPolyMode(this)" title="Кликайте точки/вершины по контуру → область заливается выбранным цветом">
<svg viewBox="0 0 24 24"><polygon points="4,20 9,5 19,9 16,20" fill="currentColor" fill-opacity="0.25"/><circle cx="4" cy="20" r="1.8" fill="currentColor"/><circle cx="9" cy="5" r="1.8" fill="currentColor"/><circle cx="19" cy="9" r="1.8" fill="currentColor"/><circle cx="16" cy="20" r="1.8" fill="currentColor"/></svg>Многоугольник по точкам
</button>
</div>
<div class="st-poly-palette">
<button class="st-sw active" style="background:#F59E0B" onclick="stereoPolyColor('F59E0B',this)" title="Янтарный"></button>
<button class="st-sw" style="background:#06D6E0" onclick="stereoPolyColor('06D6E0',this)" title="Бирюзовый"></button>
<button class="st-sw" style="background:#EF476F" onclick="stereoPolyColor('EF476F',this)" title="Розовый"></button>
<button class="st-sw" style="background:#7BF5A4" onclick="stereoPolyColor('7BF5A4',this)" title="Зелёный"></button>
<button class="st-sw" style="background:#C4B5FD" onclick="stereoPolyColor('C4B5FD',this)" title="Фиолетовый"></button>
<button class="st-sw" style="background:#60A5FA" onclick="stereoPolyColor('60A5FA',this)" title="Синий"></button>
</div>
<div class="st-action-grid" style="margin-top:4px">
<button class="st-action-btn" onclick="stereoPolyClose()">Замкнуть</button>
<button class="st-action-btn" onclick="stereoPolyUndo()">Отменить точку</button>
</div>
<div class="st-action-grid" style="margin-top:3px">
<button class="st-action-btn st-tool-btn-wide" onclick="stereoPolyClear()" style="grid-column:span 2">Очистить выделения</button>
</div>
<div id="poly-hint" style="font-size:0.63rem;color:rgba(255,255,255,0.38);margin-top:3px;line-height:1.4"></div>
<!-- ── Построения (прямые / плоскости) ── -->
<div class="gp-section-title" style="margin-top:8px;margin-bottom:6px">Построения</div>
<div class="st-tool-grid">
<button class="st-tool-btn" id="stereo-line-btn" onclick="stereoLineMode(this)" title="Прямая через 2 точки — кликните две вершины или точки">
<svg viewBox="0 0 24 24"><line x1="3" y1="21" x2="21" y2="3"/><circle cx="6" cy="18" r="2" fill="currentColor"/><circle cx="18" cy="6" r="2" fill="currentColor"/></svg>Прямая
</button>
<button class="st-tool-btn" id="stereo-plane-btn" onclick="stereoPlaneMode(this)" title="Плоскость через 3 точки — кликните три вершины или точки">
<svg viewBox="0 0 24 24"><polygon points="3,16 13,20 21,8 11,4" fill="none"/><circle cx="3" cy="16" r="1.8" fill="currentColor"/><circle cx="21" cy="8" r="1.8" fill="currentColor"/><circle cx="11" cy="4" r="1.8" fill="currentColor"/></svg>Плоскость
</button>
<button class="st-tool-btn st-tool-btn-wide" id="stereo-intersect-btn" onclick="stereoIntersectMode(this)" title="Пересечение: выберите 2 объекта в списке (прямая∩плоскость → точка, плоскость∩плоскость → прямая)">
<svg viewBox="0 0 24 24"><line x1="3" y1="7" x2="21" y2="17"/><line x1="3" y1="17" x2="21" y2="7"/><circle cx="12" cy="12" r="2.4" fill="currentColor"/></svg>Пересечение
</button>
</div>
<div class="st-tool-grid" style="margin-top:3px">
<button class="st-tool-btn" id="stereo-rel-lpar-btn" onclick="stereoRelMode('lpar',this)" title="Прямая, параллельная выбранной прямой, через точку">
<svg viewBox="0 0 24 24"><line x1="4" y1="8" x2="20" y2="6"/><line x1="4" y1="18" x2="20" y2="16"/></svg>∥ прямая
</button>
<button class="st-tool-btn" id="stereo-rel-lperp-btn" onclick="stereoRelMode('lperp',this)" title="Прямая, перпендикулярная выбранной плоскости, через точку">
<svg viewBox="0 0 24 24"><ellipse cx="12" cy="17" rx="9" ry="3" fill="none"/><line x1="12" y1="3" x2="12" y2="17"/><path d="M12 14 L15 14 L15 17" fill="none"/></svg>⟂ прямая
</button>
<button class="st-tool-btn" id="stereo-rel-ppar-btn" onclick="stereoRelMode('ppar',this)" title="Плоскость, параллельная выбранной плоскости, через точку">
<svg viewBox="0 0 24 24"><polygon points="3,11 12,14 21,6 12,3" fill="none"/><polygon points="3,18 12,21 21,13 12,10" fill="none"/></svg>∥ плоск.
</button>
<button class="st-tool-btn" id="stereo-rel-pperp-btn" onclick="stereoRelMode('pperp',this)" title="Плоскость, перпендикулярная выбранной прямой, через точку">
<svg viewBox="0 0 24 24"><polygon points="3,14 12,17 21,9 12,6" fill="none"/><line x1="12" y1="2" x2="12" y2="21"/></svg>⟂ плоск.
</button>
</div>
<div class="st-tool-grid" style="margin-top:3px">
<button class="st-tool-btn" id="stereo-divide-btn" onclick="stereoDivideMode(this)" title="Точка, делящая отрезок AB в отношении m:n — задайте m,n и кликните 2 точки">
<svg viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><circle cx="3" cy="12" r="1.8" fill="currentColor"/><circle cx="21" cy="12" r="1.8" fill="currentColor"/><circle cx="11" cy="12" r="2.6" fill="currentColor"/></svg>Деление
</button>
<button class="st-tool-btn" id="stereo-dragpt-btn" onclick="stereoDragPointMode(this)" title="Перетаскивать построенные точки мышью (в плоскости экрана)">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" fill="currentColor"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3"/></svg>Тащить
</button>
</div>
<div style="display:flex;align-items:center;gap:5px;margin-top:4px;font-size:.72rem;color:rgba(255,255,255,.55)">
<span>m</span>
<input id="st-div-m" type="number" min="0" step="1" value="1" oninput="stereoDivideRatio()" style="width:40px;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:6px;padding:3px 4px;font-size:.72rem">
<span>:</span>
<input id="st-div-n" type="number" min="0" step="1" value="1" oninput="stereoDivideRatio()" style="width:40px;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:6px;padding:3px 4px;font-size:.72rem">
<span>n</span><span style="opacity:.55">(AM:MB)</span>
</div>
<div style="display:flex;align-items:center;gap:4px;margin-top:4px">
<input id="st-pt-x" type="number" step="0.5" value="0" title="x" style="width:42px;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:6px;padding:3px 4px;font-size:.72rem">
<input id="st-pt-y" type="number" step="0.5" value="0" title="y" style="width:42px;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:6px;padding:3px 4px;font-size:.72rem">
<input id="st-pt-z" type="number" step="0.5" value="0" title="z" style="width:42px;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:6px;padding:3px 4px;font-size:.72rem">
<button class="st-action-btn" onclick="stereoAddCoordPoint()" style="flex:1" title="Поставить точку по координатам">Точка (x,y,z)</button>
</div>
<div class="st-action-grid" style="margin-top:3px">
<button class="st-action-btn" onclick="stereoConstructHistUndo()" title="Отменить (Ctrl+Z)">Отменить</button>
<button class="st-action-btn" onclick="stereoConstructHistRedo()" title="Вернуть (Ctrl+Shift+Z)">Вернуть</button>
</div>
<div class="st-action-grid" style="margin-top:3px">
<button class="st-action-btn" onclick="stereoConstructUndo()">Удалить последнее</button>
<button class="st-action-btn" onclick="stereoConstructClear()">Очистить</button>
</div>
<div id="construct-hint" style="font-size:0.63rem;color:rgba(255,255,255,0.38);margin-top:3px;line-height:1.4"></div>
<div style="font-size:0.6rem;color:rgba(255,255,255,0.3);margin-top:2px;line-height:1.35">Клик по плоскости в списке — показать её сечением (заливка, площадь, периметр, натуральная величина).</div>
<div id="construct-list" style="font-size:0.7rem;margin-top:4px;line-height:1.6"></div>
<div id="construct-trueshape" style="display:none;margin-top:6px;background:rgba(6,214,224,0.05);border:1px solid rgba(6,214,224,0.18);border-radius:8px;padding:6px"></div>
<!-- ── Метки рёбер ── -->
<div class="gp-section-title" style="margin-top:8px;margin-bottom:6px">Метки рёбер</div>
<div class="st-tool-grid">
+1 -1
View File
@@ -276,7 +276,7 @@
.lesson-nav-btn-prev { justify-content: flex-start; }
.lesson-nav-btn-next { justify-content: flex-end; margin-left: auto; }
.lesson-nav-btn-label { font-size: 0.7rem; font-weight: 600; color: var(--text-3); display: block; }
.lesson-nav-btn-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; }
.lesson-nav-btn-title { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; }
/* ── complete button ── */
.lesson-complete-wrap {
+584
View File
@@ -0,0 +1,584 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Квантик — Законы Мира</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/css/ls.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
/* ════════════════════ Игровая страница «Квантик» ════════════════════ */
.qg-wrap { display: flex; flex-direction: column; height: 100vh; min-height: 0; }
/* ── Topbar ── */
.qg-top {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 11px 20px; border-bottom: 1px solid rgba(148,163,184,0.18);
background: #11132A; flex-shrink: 0; position: relative; z-index: 3;
}
.qg-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.02rem; color: #E2E8F0; white-space: nowrap; }
.qg-sub { font-size: .8rem; color: #94A3B8; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.qg-pill { font-size: .66rem; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; padding: 3px 10px; border-radius: 99px; background: rgba(34,211,238,0.16); color: #67E8F9; }
.qg-back {
display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: .82rem; font-weight: 600;
color: #CBD5E1; background: rgba(148,163,184,0.12); border: 1px solid rgba(148,163,184,0.2);
border-radius: 99px; padding: 5px 13px; cursor: pointer; transition: .16s;
}
.qg-back:hover { background: rgba(34,211,238,0.16); color: #67E8F9; border-color: rgba(34,211,238,0.35); }
.qg-back .ic { width: 15px; height: 15px; }
/* ════════════════════ Карта-созвездие ════════════════════ */
.qm-root {
flex: 1; min-height: 0; position: relative; overflow-y: auto; overflow-x: hidden;
background:
radial-gradient(1100px 700px at 78% -10%, rgba(167,139,250,0.16), transparent 60%),
radial-gradient(900px 600px at 12% 8%, rgba(34,211,238,0.13), transparent 55%),
radial-gradient(700px 800px at 50% 120%, rgba(244,114,182,0.10), transparent 60%),
linear-gradient(180deg, #0B0B1A 0%, #0D0D1F 55%, #0A0A16 100%);
}
/* атмосферное зерно поверх фона */
.qm-root::before {
content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .5;
background-image: radial-gradient(rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: 3px 3px;
}
/* ── Шапка карты (нарратор + XP + скины) ── */
.qm-header { position: relative; z-index: 2; padding: 22px 26px 8px; }
.qm-header-inner {
display: grid; grid-template-columns: minmax(240px, 1.4fr) minmax(280px, 1.1fr) auto;
gap: 18px; align-items: center; max-width: 1180px; margin: 0 auto;
}
@media (max-width: 920px) { .qm-header-inner { grid-template-columns: 1fr; } }
.qm-narrator { display: flex; align-items: center; gap: 14px; }
.qm-pet { width: 76px; height: 80px; flex-shrink: 0; filter: drop-shadow(0 8px 22px rgba(34,211,238,0.28)); }
.qm-pet svg { width: 100%; height: 100%; }
.qm-bubble {
position: relative; background: rgba(20,22,44,0.78); border: 1px solid rgba(148,163,184,0.18);
border-radius: 14px; padding: 12px 15px; backdrop-filter: blur(6px); box-shadow: 0 10px 30px rgba(0,0,0,0.35);
}
.qm-bubble::before {
content: ''; position: absolute; left: -7px; top: 50%; transform: translateY(-50%) rotate(45deg);
width: 12px; height: 12px; background: rgba(20,22,44,0.78); border-left: 1px solid rgba(148,163,184,0.18); border-bottom: 1px solid rgba(148,163,184,0.18);
}
.qm-bubble-t { color: #DCE3EE; font-size: .86rem; line-height: 1.45; }
.qm-stats { display: flex; align-items: center; gap: 18px; }
.qm-level { display: flex; flex-direction: column; align-items: center; line-height: 1; flex-shrink: 0; }
.qm-level-num {
font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 2rem;
background: linear-gradient(135deg, #67E8F9, #A78BFA); -webkit-background-clip: text; background-clip: text; color: transparent;
}
.qm-level-lbl { font-size: .62rem; text-transform: uppercase; letter-spacing: .06em; color: #8B9AAE; margin-top: 4px; max-width: 84px; text-align: center; }
.qm-xpbox { flex: 1; min-width: 160px; }
.qm-xp-head { display: flex; justify-content: space-between; font-size: .74rem; color: #CBD5E1; margin-bottom: 6px; font-weight: 600; font-variant-numeric: tabular-nums; }
.qm-xp-next { color: #8B9AAE; font-weight: 500; }
.qm-xp-bar { height: 9px; border-radius: 99px; background: rgba(148,163,184,0.18); overflow: hidden; }
.qm-xp-fill {
height: 100%; border-radius: 99px; width: 0;
background: linear-gradient(90deg, #22D3EE, #A78BFA);
box-shadow: 0 0 14px rgba(103,232,249,0.5);
transition: width .9s cubic-bezier(.22,.61,.36,1);
}
.qm-starcount {
display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1rem; color: #FBBF24; font-variant-numeric: tabular-nums;
}
/* ── Скины ── */
.qm-skins { display: flex; flex-direction: column; gap: 7px; }
.qm-skins-lbl { font-size: .62rem; text-transform: uppercase; letter-spacing: .06em; color: #8B9AAE; font-weight: 700; }
.qm-skins-row { display: flex; gap: 7px; flex-wrap: wrap; max-width: 220px; }
.qm-skin {
width: 30px; height: 30px; border-radius: 50%; cursor: pointer; padding: 0;
background: radial-gradient(circle at 34% 30%, color-mix(in srgb, var(--sk) 70%, #fff), var(--sk) 70%);
border: 2px solid rgba(255,255,255,0.18); position: relative; transition: transform .14s, box-shadow .14s, border-color .14s;
}
.qm-skin:hover:not(.locked) { transform: scale(1.12); }
.qm-skin.active { border-color: #fff; box-shadow: 0 0 0 2px var(--sk), 0 0 14px var(--sk); }
.qm-skin.locked { filter: grayscale(.7) brightness(.5); cursor: not-allowed; }
.qm-skin-lock { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.qm-skin-lock .ic { width: 12px; height: 12px; }
/* ── Тело: созвездия ── */
.qm-body { position: relative; z-index: 1; max-width: 1180px; margin: 0 auto; padding: 8px 26px 60px; }
.qm-constellation { margin-top: 18px; }
.qm-con-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px; padding-left: 4px; }
.qm-con-title {
font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.1rem; color: #E8EDF5;
position: relative; padding-left: 16px;
}
.qm-con-title::before {
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 12px var(--accent);
}
.qm-con-sub { font-size: .8rem; color: #8B9AAE; flex: 1; }
.qm-con-stars { display: inline-flex; align-items: center; gap: 4px; font-size: .78rem; color: #FBBF24; font-weight: 700; font-variant-numeric: tabular-nums; }
.qm-field { position: relative; height: 220px; margin-top: 2px; }
@media (max-width: 620px) { .qm-field { height: 280px; } }
.qm-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.qm-link { stroke: rgba(148,163,184,0.22); stroke-width: .35; stroke-dasharray: 1.4 1.6; }
.qm-link.on { stroke: var(--accent); stroke-opacity: .55; stroke-dasharray: none; stroke-width: .45; }
.qm-tw { animation: qmTwinkle var(--tw, 2.4s) ease-in-out var(--td, 0s) infinite; transform-box: fill-box; transform-origin: center; }
@keyframes qmTwinkle { 0%,100% { opacity: .25; } 50% { opacity: .9; } }
/* ── Узлы ── */
.qm-node {
position: absolute; transform: translate(-50%, -50%); z-index: 2;
display: flex; flex-direction: column; align-items: center; gap: 6px;
background: none; border: none; cursor: pointer; font: inherit; padding: 0;
transition: transform .2s;
}
.qm-node-core {
width: 56px; height: 56px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
position: relative; transition: transform .18s, box-shadow .18s;
}
.qm-node-core .ic { color: #fff; }
.qm-node-label { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: .76rem; color: #CBD5E1; white-space: nowrap; text-shadow: 0 1px 4px rgba(0,0,0,.6); }
.qm-node-stars { display: inline-flex; gap: 1px; }
.qm-node-need { display: inline-flex; align-items: center; gap: 3px; font-size: .68rem; color: #94A3B8; font-weight: 600; }
/* доступный узел */
.qm-available .qm-node-core {
background: radial-gradient(circle at 35% 30%, #34D399, #0E9F6E);
box-shadow: 0 0 0 4px rgba(52,211,153,0.18), 0 8px 24px rgba(16,185,129,0.4);
animation: qmPulse 2.4s ease-in-out infinite;
}
@keyframes qmPulse {
0%,100% { box-shadow: 0 0 0 4px rgba(52,211,153,0.18), 0 8px 24px rgba(16,185,129,0.4); }
50% { box-shadow: 0 0 0 8px rgba(52,211,153,0.10), 0 10px 32px rgba(16,185,129,0.55); }
}
.qm-available:hover { transform: translate(-50%, -50%) scale(1.07); }
.qm-available:hover .qm-node-core { transform: scale(1.05); }
/* пройденный узел */
.qm-completed .qm-node-core {
background: radial-gradient(circle at 35% 30%, #67E8F9, #2563EB);
box-shadow: 0 0 0 3px rgba(34,211,238,0.2), 0 6px 20px rgba(37,99,235,0.4);
}
.qm-completed:hover { transform: translate(-50%, -50%) scale(1.06); }
.qm-node-order { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.15rem; color: #fff; }
/* заблокированный узел */
.qm-locked { cursor: not-allowed; }
.qm-locked .qm-node-core {
background: rgba(30,33,58,0.85); border: 1.5px solid rgba(148,163,184,0.22);
box-shadow: inset 0 2px 10px rgba(0,0,0,0.4);
}
.qm-locked .qm-node-label { color: #6B7A90; }
/* фокус для клавиатуры */
.qm-node:focus-visible { outline: none; }
.qm-node:focus-visible .qm-node-core { box-shadow: 0 0 0 3px #fff, 0 0 0 6px var(--accent, #22D3EE); }
/* поэтапное появление */
.qm-node.qm-pre { opacity: 0; transform: translate(-50%, -40%) scale(.6); }
.qm-node.qm-in { animation: qmNodeIn .5s cubic-bezier(.22,1.2,.4,1) forwards; }
@keyframes qmNodeIn {
from { opacity: 0; transform: translate(-50%, -40%) scale(.6); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
/* ════════════════════ Сцена уровня ════════════════════ */
.qg-stage { flex: 1; min-height: 0; position: relative; background: #0D0D1A; overflow: hidden; }
.qg-stage .sim-spec-root { position: absolute; inset: 0; }
.qg-fallback { padding: 40px; color: #cbd5e1; font-family: 'Manrope', sans-serif; max-width: 520px; }
.qg-view { display: none; flex: 1; min-height: 0; }
.qg-view.show { display: flex; flex-direction: column; }
/* ── Оверлеи (интро / успех) ── */
.qg-overlay {
position: absolute; inset: 0; z-index: 20;
display: flex; align-items: center; justify-content: center;
background: rgba(7, 7, 18, 0.74); backdrop-filter: blur(5px);
}
.qg-card {
background: linear-gradient(180deg, #15173099, #0F1024EE), #14152C;
border: 1px solid rgba(148,163,184,0.18); border-radius: 20px;
padding: 26px 30px 24px; width: min(440px, 92vw); text-align: center;
box-shadow: 0 24px 70px rgba(0,0,0,0.55);
animation: qg-pop .26s cubic-bezier(.22,1.1,.4,1);
}
@keyframes qg-pop { from { transform: scale(.9) translateY(8px); opacity: 0; } to { transform: scale(1) translateY(0); opacity: 1; } }
.qg-card-kicker { font-size: .68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; color: #67E8F9; margin-bottom: 4px; }
.qg-card-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.32rem; color: #EAF0F8; margin-bottom: 12px; }
.qg-intro-pet, .qg-success-pet { width: 92px; height: 96px; margin: 0 auto 6px; filter: drop-shadow(0 10px 26px rgba(34,211,238,0.3)); }
.qg-intro-pet svg, .qg-success-pet svg { width: 100%; height: 100%; }
.qg-success-pet { animation: qgBob 1.6s ease-in-out infinite; }
@keyframes qgBob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
.qg-intro-goal { font-weight: 700; font-size: .98rem; color: #DCE3EE; margin-bottom: 8px; }
.qg-intro-hint { font-size: .85rem; color: #A8B4C6; line-height: 1.5; margin-bottom: 18px; max-width: 360px; margin-left: auto; margin-right: auto; }
.qg-stars { display: flex; justify-content: center; gap: 6px; margin-bottom: 18px; }
.qg-star { display: inline-flex; }
.qg-star-svg { filter: drop-shadow(0 2px 6px rgba(251,191,36,0.4)); }
.qg-star-on { animation: qgStarPop .45s cubic-bezier(.22,1.3,.4,1) backwards; animation-delay: calc(.12s * var(--si, 0) + .15s); }
@keyframes qgStarPop { 0% { transform: scale(0) rotate(-30deg); opacity: 0; } 70% { transform: scale(1.25) rotate(6deg); } 100% { transform: scale(1) rotate(0); opacity: 1; } }
/* Доступность: уважаем prefers-reduced-motion. Делаем анимации мгновенными
(а не выключаем) — иначе forwards-анимация появления узлов (.qm-in/qmNodeIn)
не применит конечное состояние и узлы останутся скрытыми. Циклы (пульс/мерцание/
покачивание) при этом фактически останавливаются (1 итерация мгновенно). */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
scroll-behavior: auto !important;
}
}
.qg-stats { display: flex; justify-content: center; gap: 22px; margin-bottom: 20px; }
.qg-stat { display: flex; flex-direction: column; gap: 3px; }
.qg-stat-lbl { font-size: .68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: #8B9AAE; }
.qg-stat-val { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.05rem; color: #EAF0F8; font-variant-numeric: tabular-nums; }
.qg-actions { display: flex; justify-content: center; gap: 10px; }
.qg-btn { min-width: 118px; }
/* ════════════════════ Квантовые способности (Фаза 4) ════════════════════ */
/* Панель способностей + HUD энергии — оверлеем поверх сцены уровня. */
.qa-bar {
position: absolute; right: 12px; bottom: 12px; z-index: 12;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end;
pointer-events: auto; max-width: calc(100% - 24px);
}
.qa-energy {
display: inline-flex; align-items: center; gap: 5px;
background: rgba(17,19,42,0.86); border: 1px solid rgba(251,191,36,0.4);
border-radius: 99px; padding: 6px 12px 6px 10px; color: #FBBF24;
font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: .9rem; font-variant-numeric: tabular-nums;
box-shadow: 0 6px 18px rgba(0,0,0,0.4);
}
.qa-energy .ic { width: 16px; height: 16px; color: #FBBF24; }
.qa-btn {
display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: .82rem; font-weight: 600;
color: #E2E8F0; background: rgba(17,19,42,0.86); border: 1px solid rgba(148,163,184,0.28);
border-radius: 99px; padding: 7px 13px; cursor: pointer; transition: .16s;
box-shadow: 0 6px 18px rgba(0,0,0,0.4);
}
.qa-btn .ic { width: 16px; height: 16px; }
.qa-btn:hover:not(:disabled) { border-color: rgba(196,181,253,0.6); color: #fff; background: rgba(30,33,66,0.94); }
.qa-btn:disabled { opacity: .42; cursor: not-allowed; }
.qa-rest { color: #67E8F9; border-color: rgba(34,211,238,0.32); }
.qa-rest:hover:not(:disabled) { border-color: rgba(34,211,238,0.6); }
.qa-tunnel { color: #F0ABFC; border-color: rgba(244,114,182,0.34); }
.qa-aim { color: #7DD3FC; border-color: rgba(56,189,248,0.34); }
.qa-cost { display: inline-flex; align-items: center; gap: 2px; font-size: .74rem; color: #FBBF24; font-weight: 800; }
.qa-cost .ic { width: 12px; height: 12px; color: #FBBF24; }
.qa-ability.qa-on {
color: #fff; border-color: rgba(196,181,253,0.85);
box-shadow: 0 0 0 2px rgba(196,181,253,0.35), 0 6px 18px rgba(167,139,250,0.4);
}
/* всплывающая подсказка способности */
.qa-toast {
position: absolute; left: 50%; bottom: 70px; transform: translateX(-50%) translateY(8px); z-index: 14;
background: rgba(13,13,26,0.94); border: 1px solid rgba(196,181,253,0.5); color: #E8EDF5;
padding: 9px 16px; border-radius: 12px; font-size: .85rem; font-weight: 600;
box-shadow: 0 12px 34px rgba(0,0,0,0.5); opacity: 0; transition: opacity .28s, transform .28s; pointer-events: none;
max-width: 84%; text-align: center;
}
.qa-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ── SR-комната (модалка повторения) ── */
.qa-overlay {
position: fixed; inset: 0; z-index: 60;
display: flex; align-items: center; justify-content: center;
background: rgba(7,7,18,0.78); backdrop-filter: blur(6px); padding: 16px;
}
.qa-modal {
background: linear-gradient(180deg, #15173099, #0F1024EE), #14152C;
border: 1px solid rgba(148,163,184,0.2); border-radius: 18px;
width: min(460px, 96vw); max-height: 92vh; overflow: hidden;
display: flex; flex-direction: column; box-shadow: 0 24px 70px rgba(0,0,0,0.6);
animation: qg-pop .24s cubic-bezier(.22,1.1,.4,1);
}
.qa-modal-head {
display: flex; align-items: center; gap: 10px; padding: 14px 16px;
border-bottom: 1px solid rgba(148,163,184,0.16); flex-shrink: 0;
}
.qa-modal-title { display: inline-flex; align-items: center; gap: 8px; font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1rem; color: #EAF0F8; flex: 1; min-width: 0; }
.qa-modal-title .ic { width: 18px; height: 18px; color: #67E8F9; flex-shrink: 0; }
.qa-modal-title span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.qa-modal-energy { display: inline-flex; align-items: center; gap: 4px; color: #FBBF24; font-weight: 800; font-variant-numeric: tabular-nums; }
.qa-modal-energy .ic { width: 15px; height: 15px; color: #FBBF24; }
.qa-modal-x { background: none; border: none; color: #94A3B8; font-size: 1.5rem; line-height: 1; cursor: pointer; padding: 0 4px; }
.qa-modal-x:hover { color: #fff; }
.qa-modal-body { padding: 18px 18px 20px; overflow-y: auto; }
.qa-loading { text-align: center; color: #94A3B8; padding: 30px 0; }
.qa-empty-title { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.1rem; color: #EAF0F8; text-align: center; margin-bottom: 8px; }
.qa-empty-msg { color: #A8B4C6; text-align: center; line-height: 1.5; margin-bottom: 18px; }
.qa-modal-actions { display: flex; justify-content: center; gap: 10px; flex-wrap: wrap; }
.qa-modal-btn { min-width: 130px; text-align: center; text-decoration: none; }
.qa-deck-list { display: flex; flex-direction: column; gap: 8px; }
.qa-deck {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
background: rgba(30,33,58,0.6); border: 1px solid rgba(148,163,184,0.18); border-radius: 12px;
padding: 11px 13px; cursor: pointer; font: inherit; transition: .15s;
}
.qa-deck:hover { border-color: var(--dk, #9B5DE5); background: rgba(40,44,78,0.7); }
.qa-deck-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--dk, #9B5DE5); flex-shrink: 0; }
.qa-deck-title { color: #E2E8F0; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.qa-deck-due { color: #67E8F9; font-size: .76rem; font-weight: 700; flex-shrink: 0; }
/* сессия повторения */
.qa-prog { height: 6px; border-radius: 99px; background: rgba(148,163,184,0.2); overflow: hidden; margin-bottom: 6px; }
.qa-prog-fill { height: 100%; background: linear-gradient(90deg, #22D3EE, #A78BFA); border-radius: 99px; transition: width .3s; }
.qa-prog-count { text-align: right; font-size: .72rem; color: #8B9AAE; margin-bottom: 12px; font-variant-numeric: tabular-nums; }
.qa-card {
min-height: 130px; background: rgba(20,22,44,0.72); border: 1px solid rgba(148,163,184,0.18);
border-radius: 14px; padding: 20px; margin-bottom: 14px; display: flex; flex-direction: column; gap: 12px;
}
.qa-card-side { display: flex; flex-direction: column; align-items: center; gap: 10px; }
.qa-card-back { border-top: 1px dashed rgba(148,163,184,0.3); padding-top: 12px; }
.qa-card-text { color: #E8EDF5; font-size: 1.02rem; line-height: 1.45; text-align: center; word-break: break-word; }
.qa-card-empty { color: #64748B; }
.qa-card-img { max-width: 100%; max-height: 160px; border-radius: 10px; }
.qa-flip { width: 100%; }
.qa-grades { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
.qa-grade { font: inherit; font-size: .82rem; font-weight: 700; color: #fff; border: none; border-radius: 10px; padding: 10px 4px; cursor: pointer; transition: filter .14s; }
.qa-grade:hover { filter: brightness(1.12); }
.qa-g-again { background: #DC2626; }
.qa-g-hard { background: #D97706; }
.qa-g-good { background: #2563EB; }
.qa-g-easy { background: #16A34A; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<main class="sb-content">
<div class="qg-wrap">
<div class="qg-top">
<span class="qg-title" id="qg-title">Квантик — Законы Мира</span>
<span class="qg-sub" id="qg-sub"></span>
<button class="qg-back" id="qg-back" type="button" style="display:none">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18 9 12 15 6"/></svg>
К карте
</button>
<span class="qg-pill" id="qg-pill">Физика</span>
</div>
<!-- Вид карты -->
<div class="qg-view show" id="qg-map-view">
<div class="qm-root">
<div class="qm-header" id="qg-map-header"></div>
<div class="qm-body" id="qg-map-body"></div>
</div>
</div>
<!-- Вид уровня -->
<div class="qg-view" id="qg-level-view">
<div class="qg-stage" id="qg-stage"></div>
</div>
</div>
</main>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
<!-- модель питомца (нарратор-Квантик) -->
<script src="/js/pet-sprite.js"></script>
<!-- движок спек-симуляций (тот же путь, что lab.html / sim-builder.html) -->
<script src="/js/labs/_sim_expr.js"></script>
<script src="/js/labs/_sim_engine.js"></script>
<!-- KaTeX для подписей сцены -->
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<!-- уровни (данные) + логика прогресса + карта + способности + игра -->
<script src="/js/game/levels.js"></script>
<script src="/js/game/progress-logic.js"></script>
<script src="/js/game/map.js"></script>
<script src="/js/game/quantik-abilities.js"></script>
<script src="/js/game/quantik-game.js"></script>
<script>
(function () {
// Доступ: любой авторизованный пользователь (играют и ученики).
if (!LS.initPage()) { return; }
var mapView = document.getElementById('qg-map-view');
var lvlView = document.getElementById('qg-level-view');
var stage = document.getElementById('qg-stage');
var backBtn = document.getElementById('qg-back');
var titleEl = document.getElementById('qg-title');
var subEl = document.getElementById('qg-sub');
var pillEl = document.getElementById('qg-pill');
// Бейдж темы по предмету уровня (аддитивно; граф-уровни — «Алгебра»).
var SUBJECT_LABEL = { physics: 'Физика', algebra: 'Алгебра', math: 'Математика' };
function setPill(level) {
if (!pillEl) return;
pillEl.textContent = SUBJECT_LABEL[level && level.subject] || 'Физика';
}
if (!window.SimEngine || !window.SimExpr || !window.QuantikLevels ||
!window.QuantikGame || !window.QuantikMap || !window.QuantikProgress) {
stage.innerHTML = '<div class="qg-fallback">Движок игры не загрузился. Обновите страницу.</div>';
lvlView.classList.add('show'); mapView.classList.remove('show');
return;
}
var progressMap = {}; // { level_id: row }
var curInst = null; // текущий инстанс движка уровня
var map = null;
function loadProgress() {
if (window.LS && window.LS.gameProgressList) {
return window.LS.gameProgressList()
.then(function (r) { progressMap = window.QuantikProgress.fromProgressList(r && r.progress); })
.catch(function () { progressMap = {}; });
}
return Promise.resolve();
}
function destroyLevel() {
if (curInst) { try { curInst.destroy(); } catch (_e) {} curInst = null; }
stage.innerHTML = '';
}
/* ── Показать карту ── */
function showMap() {
destroyLevel();
lvlView.classList.remove('show');
mapView.classList.add('show');
backBtn.style.display = 'none';
titleEl.textContent = 'Квантик — Законы Мира';
subEl.textContent = 'Карта мира — выбери уровень и почини закон';
if (pillEl) pillEl.textContent = 'Физика';
history.replaceState(null, '', '/quantik');
// перезагрузить прогресс (мог обновиться после победы) и перерисовать
loadProgress().then(function () { map.render(progressMap); });
}
/* ── Запустить уровень (после интро) ── */
function launchLevel(level) {
destroyLevel();
mapView.classList.remove('show');
lvlView.classList.add('show');
backBtn.style.display = '';
titleEl.textContent = level.title || 'Квантик';
subEl.textContent = (level.spec && level.spec.goal && level.spec.goal.title) || level.hint || '';
setPill(level);
history.replaceState(null, '', '/quantik?level=' + encodeURIComponent(level.id));
// Pre-win значение (фолбэк, если пересчёт после победы недоступен).
var nextLevel = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
curInst = window.QuantikGame.start({
host: stage,
level: level,
skin: window.QuantikGame.getSkin(),
hasNext: !!nextLevel,
// Победа разблокирует след. уровень → перезагружаем прогресс и пересчитываем
// «следующий доступный» на свежей карте, чтобы экран успеха показал «Дальше».
resolveNext: function () {
return loadProgress().then(function () {
var nx = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
return { hasNext: !!nx, next: nx };
});
},
onNext: function () {
// прогресс уже перезагружен в resolveNext → берём след. доступный из свежей карты
var nx = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
if (nx) openLevel(nx); else showMap();
},
onMap: showMap
});
if (!curInst) {
stage.innerHTML = '<div class="qg-fallback">Не удалось запустить уровень.</div>';
}
}
/* ── Открыть уровень: показать интро-карточку, потом launch ── */
function openLevel(level) {
destroyLevel();
mapView.classList.remove('show');
lvlView.classList.add('show');
backBtn.style.display = '';
titleEl.textContent = level.title || 'Квантик';
subEl.textContent = (level.spec && level.spec.goal && level.spec.goal.title) || '';
setPill(level);
var intro = window.QuantikGame.buildIntro(level, window.QuantikGame.getSkin());
intro.btnGo.addEventListener('click', function () {
if (intro.overlay.parentNode) intro.overlay.parentNode.removeChild(intro.overlay);
launchLevel(level);
});
intro.btnBack.addEventListener('click', function () {
if (intro.overlay.parentNode) intro.overlay.parentNode.removeChild(intro.overlay);
showMap();
});
stage.appendChild(intro.overlay);
}
/* ── Карта ── */
map = window.QuantikMap.create({
host: document.getElementById('qg-map-body'),
headerHost: document.getElementById('qg-map-header'),
onPlay: function (level) { openLevel(level); },
getSkin: function () { return window.QuantikGame.getSkin(); },
onSkin: function (key) {
window.QuantikGame.setSkin(key);
map.render(progressMap); // перерисовать (нарратор + активный свотч)
}
});
backBtn.addEventListener('click', showMap);
// Подмешать авторённые уровни (custom_sims cat='game') до рендера карты (Ф5).
function ensureCustomLevels() {
if (window.QuantikLevels.ensureCustom) {
return window.QuantikLevels.ensureCustom().catch(function () {});
}
return Promise.resolve();
}
// Старт: если ?level=<id> в URL и уровень доступен — открыть его, иначе карта.
// Сначала грузим прогресс И авторённые уровни (параллельно), затем deep-link.
Promise.all([loadProgress(), ensureCustomLevels()]).then(function () {
map.render(progressMap);
var params = new URLSearchParams(location.search);
var wantId = params.get('level');
if (wantId) {
// custom:<id> может быть свой draft (нет в списке) — резолвим асинхронно с
// проверкой доступа на сервере (own|published|admin → иначе 404/403 → карта).
var resolve = window.QuantikLevels.getAsync
? window.QuantikLevels.getAsync(wantId)
: Promise.resolve(window.QuantikLevels.get(wantId));
resolve.then(function (lvl) {
// Авторённый уровень (deep-link) — открываем без гейта unlockStars
// (учитель/получатель ссылки заходит прямо в него). Встроенный — как раньше.
var isCustom = /^custom:/.test(wantId);
if (lvl && (isCustom || window.QuantikProgress.isUnlocked(lvl, progressMap, window.QuantikLevels.list()))) {
openLevel(lvl);
} else {
showMapNoReload();
}
});
return;
}
showMapNoReload();
});
// показать карту без повторной загрузки прогресса (стартовый случай)
function showMapNoReload() {
lvlView.classList.remove('show');
mapView.classList.add('show');
backBtn.style.display = 'none';
titleEl.textContent = 'Квантик — Законы Мира';
subEl.textContent = 'Карта мира — выбери уровень и почини закон';
}
window.__quantik = { map: map, getInst: function () { return curInst; } };
})();
</script>
</body>
</html>
+52 -2
View File
@@ -82,10 +82,42 @@
/* ── объект ── */
.sbu-obj.sel { border-color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.16); }
.sbu-obj-hdr { display: flex; align-items: center; gap: 6px; }
.sbu-obj.is-hidden, .sbu-plot.is-hidden { opacity: .62; }
.sbu-obj.is-hidden .sbu-obj-fields, .sbu-obj.is-hidden .sbu-obj-style { opacity: .7; }
.sbu-obj-hdr { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
.sbu-obj-type { font-size: .72rem; font-weight: 800; color: var(--violet); flex-shrink: 0; }
.sbu-in-id { flex: 1; max-width: 120px; }
.sbu-in-id { flex: 1; min-width: 64px; max-width: 110px; }
.sbu-obj-hdr .sbu-icon-btn { width: 26px; height: 26px; }
.sbu-icon-btn:disabled { opacity: .32; cursor: default; pointer-events: none; }
.sbu-icon-btn.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,0.1); }
.sbu-zord { color: var(--text-3); }
.sbu-obj-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
/* ── блок «Стиль» объекта (P4) ── */
.sbu-obj-style { border-top: 1px dashed var(--border); padding-top: 7px; margin-top: 1px; display: flex; flex-direction: column; gap: 7px; }
.sbu-obj-style .sbu-sub { margin-top: 0; }
.sbu-style-row { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; align-items: end; }
.sbu-style-row > * { min-width: 0; }
.sbu-grad-row { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
/* ── color-picker контрол (нативный пикер + текст + очистка) ── */
.sbu-color-mini { min-width: 0; }
.sbu-color-wrap { display: flex; align-items: center; gap: 5px; }
.sbu-color-pick { width: 30px; height: 30px; flex-shrink: 0; padding: 0; border: 1px solid var(--border); border-radius: 8px; background: #fff; cursor: pointer; }
.sbu-color-pick::-webkit-color-swatch-wrapper { padding: 3px; }
.sbu-color-pick::-webkit-color-swatch { border: none; border-radius: 5px; }
.sbu-color-wrap .sbu-in-color { flex: 1; min-width: 0; }
.sbu-color-clr { width: 26px; height: 26px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 7px; background: #fff; color: var(--text-3); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.sbu-color-clr:hover { border-color: #ef4444; color: #ef4444; }
/* ── range (opacity) ── */
.sbu-range-mini { min-width: 0; }
.sbu-range-val { color: var(--violet); font-variant-numeric: tabular-nums; }
.sbu-range { width: 100%; accent-color: var(--violet); height: 30px; box-sizing: border-box; }
/* ── кривые графика ── */
.sbu-curves { display: flex; flex-direction: column; gap: 8px; }
.sbu-curve { border: 1px solid var(--border); border-radius: 9px; padding: 8px; background: #fafbfd; display: flex; flex-direction: column; gap: 7px; }
.sbu-curve-del { width: 24px; height: 24px; }
.sbu-of { display: flex; flex-direction: column; gap: 2px; }
.sbu-of-lbl { font-size: .66rem; color: var(--text-3); display: flex; align-items: center; justify-content: space-between; gap: 4px; }
.sbu-fx { font-size: .62rem; font-weight: 800; font-style: italic; color: var(--violet); background: rgba(155,93,229,0.1); border: none; border-radius: 5px; padding: 1px 6px; cursor: pointer; }
@@ -101,6 +133,12 @@
.sbu-phys-fields { display: flex; flex-direction: column; gap: 8px; }
.sbu-wall { display: flex; flex-direction: column; gap: 6px; }
/* ── игровой уровень (P5-Квантик): цель + звёзды ── */
.sbu-game-fields { display: flex; flex-direction: column; gap: 8px; }
.sbu-stars-list { display: flex; flex-direction: column; gap: 8px; }
.sbu-star { border: 1px solid var(--border); border-radius: 10px; padding: 9px; background: #fafbfd; display: flex; flex-direction: column; gap: 7px; }
.sbu-star-hdr { display: flex; align-items: center; gap: 5px; }
/* ── палитра ── */
.sbu-pal { display: flex; flex-direction: column; gap: 12px; max-height: 60vh; overflow-y: auto; }
.sbu-pal-title { font-size: .72rem; font-weight: 700; color: var(--text-3); margin-bottom: 5px; }
@@ -113,6 +151,11 @@
.sbu-panels { width: auto; max-height: 50vh; border-right: none; border-bottom: 1px solid var(--border); }
.sbu-preview { min-height: 320px; }
}
@media (max-width: 560px) {
.sbu-obj-fields { grid-template-columns: 1fr; }
.sbu-style-row, .sbu-grad-row { grid-template-columns: 1fr; }
.sbu-row4 { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
@@ -152,6 +195,13 @@
var ip = LS.initPage() || {};
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
// Фича-гейт: «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
if (LS.loadFeatures) {
LS.loadFeatures().then(function (feats) {
if (feats && feats.sim_builder === false) { LS.toast && LS.toast('Конструктор симуляций отключён', 'warn'); location.href = '/dashboard'; }
}).catch(function () {});
}
if (!window.SimEngine || !window.SimExpr || !window.SimBuilder) {
document.getElementById('sbu-preview').innerHTML =
'<div style="padding:40px;color:#fff">Движок симуляций не загрузился. Обновите страницу.</div>';
+131 -2
View File
@@ -416,7 +416,7 @@
<div class="tg-nav-title">Содержание</div>
<div class="tg-progress-wrap">
<div class="tg-progress-bar-outer"><div class="tg-progress-bar-inner" id="tg-prog-bar"></div></div>
<div class="tg-progress-text" id="tg-prog-text">0 из 13 глав прочитано</div>
<div class="tg-progress-text" id="tg-prog-text">0 из 21 глав прочитано</div>
</div>
</div>
<div class="tg-nav-search">
@@ -1587,7 +1587,7 @@
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Магазин наград</b> — за монеты (начисляются вместе с XP) ученик покупает предметы и награды.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Родительские аккаунты</b> (<a href="/parent">/parent</a>) — родитель привязывается к ученику и видит его прогресс и уведомления.</div></div>
</div>
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Это вся учительская часть</div>Дальше — главы для администраторов (видны только под ролью admin).</div></div>
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Почти всё</div>Дальше — мощный «Конструктор симуляций» (создание своих интерактивных сцен), а за ним — главы для администраторов (видны только под ролью admin).</div></div>
</div>
<div class="tg-chapter-nav">
@@ -1595,6 +1595,134 @@
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Флэшкарты</div></div>
</div>
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-21')" style="text-align:right">
<div class="tg-ch-nav-icon"><i data-lucide="arrow-right"></i></div>
<div><div class="tg-ch-nav-label">Следующая глава</div><div class="tg-ch-nav-title">Конструктор симуляций</div></div>
</div>
</div>
</div>
<!-- ═══ CHAPTER 21 — КОНСТРУКТОР СИМУЛЯЦИЙ ═══ -->
<div class="tg-chapter" id="ch-21">
<div class="tg-chapter-header">
<div class="tg-chapter-icon"><i data-lucide="pencil-ruler"></i></div>
<div class="tg-chapter-meta">
<div class="tg-chapter-num">Глава 21</div>
<div class="tg-chapter-title">Конструктор симуляций</div>
</div>
<a href="/sim-builder" class="tg-chapter-try" target="_blank"><i data-lucide="external-link"></i> Открыть конструктор</a>
</div>
<div class="tg-section" id="s-21-1">
<div class="tg-section-title">21.1 Что это и где</div>
<p><b>Конструктор симуляций</b> — инструмент, в котором вы сами, без программирования, собираете интерактивную 2D-сцену: параметры-ползунки, объекты (точки, отрезки, векторы, фигуры, подписи), привязанные <b>формулами</b> к параметрам и времени, настоящую физику и графики. Готовую симуляцию можно сохранить, опубликовать в лабораторию, раздать классу и открыть на доске онлайн-урока.</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Открыть: в боковом меню пункт <b>«Конструктор симуляций»</b> или адрес <a href="/sim-builder">/sim-builder</a>. Доступно учителю и администратору.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Симуляция — это <b>данные</b>, а не код. Формулы вычисляются безопасным движком (доступны только математические функции), поэтому готовыми сценами безопасно делиться.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="lightbulb"></i></div><div class="tg-box-body"><div class="tg-box-label">Что реально собрать</div>Кинематику (брошенное тело, равноускоренное движение), колебания и волны, графики функций, геометрические чертежи, а с включённой физикой — маятники, пружины и упругие столкновения.</div></div>
<div class="tg-note"><div class="tg-box-icon"><i data-lucide="shield"></i></div><div class="tg-box-body"><div class="tg-box-label">Если пункта меню нет</div>Администратор мог отключить конструктор: <b>Админка → Функции → «Конструктор симуляций»</b>. При выключенном тумблере страница недоступна, но ранее опубликованные симуляции в лаборатории продолжают работать.</div></div>
</div>
<div class="tg-section" id="s-21-2">
<div class="tg-section-title">21.2 Рабочее поле</div>
<p>В центре — <b>живое превью</b>: всё, что вы добавляете и меняете, сразу видно. Слева — панели настроек, сверху — панель инструментов (Тест, Сброс, Сохранить, Опубликовать, Шаблон, Раздать, отмена/повтор).</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Масштаб</b> — колесо мыши (приближает к курсору).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Перемещение вида (панорама)</b> — перетаскивание мышью по пустому месту сцены.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Кнопки в углу сцены</b> — «Вписать» (показать всю область) и «Сбросить вид».</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Сетка и оси</b> с числовыми делениями и точкой (0,0) рисуются автоматически; границы области задаются в настройках сцены (xmin/xmax/ymin/ymax).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Контролы запущенной симуляции (ползунки, плей/пауза) — плавающая панель в углу, не закрывает сцену; её можно свернуть.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="play"></i></div><div class="tg-box-body"><div class="tg-box-label">Кнопка «Тест»</div>Запускает анимацию (время <code>t</code> идёт), «Сброс» — возвращает в начало. Пока симуляция на паузе, перетаскивание объектов и ползунки служат для настройки сцены.</div></div>
</div>
<div class="tg-section" id="s-21-3">
<div class="tg-section-title">21.3 Параметры (ползунки)</div>
<p>Параметр — это переменная со ползунком, которой можно управлять прямо в симуляции. На параметры потом ссылаются формулы объектов.</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Откройте панель <b>«Параметры»</b> → «Добавить параметр».</div></div>
<div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">Задайте: <b>имя</b> (латиницей, например <code>v</code>, <code>theta</code>), минимум, максимум, шаг, начальное значение и (необязательно) единицу.</div></div>
<div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">В превью появится ползунок — двигайте его, чтобы видеть, как меняется сцена.</div></div>
</div>
<div class="tg-note"><div class="tg-box-icon"><i data-lucide="alert-triangle"></i></div><div class="tg-box-body"><div class="tg-box-label">Зарезервированные имена</div>Нельзя называть параметр <code>t</code> (время), <code>e</code>, <code>pi</code>, <code>w</code>, <code>h</code> — это служебные имена в формулах. Конструктор предупредит и не даст сохранить.</div></div>
</div>
<div class="tg-section" id="s-21-4">
<div class="tg-section-title">21.4 Объекты и формулы</div>
<p>Объекты — это то, что рисуется на сцене. Любое числовое поле объекта (координата, радиус, размер) можно задать <b>числом или формулой</b> от параметров и времени <code>t</code>.</p>
<div class="tg-tools-grid">
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="dot"></i></div><div><div class="tg-tool-name">Точка / Отрезок / Вектор</div><div class="tg-tool-desc">Базовая геометрия со стрелками</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="circle"></i></div><div><div class="tg-tool-name">Круг / Прямоугольник</div><div class="tg-tool-desc">Фигуры с заливкой</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="spline"></i></div><div><div class="tg-tool-name">Ломаная / Кривая</div><div class="tg-tool-desc">Набор точек</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="type"></i></div><div><div class="tg-tool-name">Подпись (LaTeX)</div><div class="tg-tool-desc">Текст и формулы KaTeX</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="line-chart"></i></div><div><div class="tg-tool-name">График</div><div class="tg-tool-desc">Кривая y=f(x), см. 21.7</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="gauge"></i></div><div><div class="tg-tool-name">Индикатор (readout)</div><div class="tg-tool-desc">Живое числовое значение</div></div></div>
</div>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Панель <b>«Объекты»</b> → выберите тип → «Добавить».</div></div>
<div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">В полях координат пишите формулы: например для броска тела <code>x = v*cos(theta)*t</code>, <code>y = v*sin(theta)*t - 5*t^2</code>.</div></div>
<div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">Кнопка <b>fx</b> у поля открывает палитру: параметры, время <code>t</code>, функции (sin, cos, sqrt, abs, exp, ln…), константы (pi, e). Клик вставляет имя в формулу.</div></div>
<div class="tg-step"><div class="tg-step-num">4</div><div class="tg-step-body">Объект можно поставить мышью: нажмите значок <b>«прицел»</b> у объекта и кликните по сцене — координаты подставятся (см. 21.5).</div></div>
<div class="tg-step"><div class="tg-step-num">5</div><div class="tg-step-body">У объекта можно задать <b>id</b> и затем ссылаться на его координаты в других формулах: <code>id.x</code>, <code>id.y</code>.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="function-square"></i></div><div class="tg-box-body"><div class="tg-box-label">Ошибки в формуле</div>Если выражение записано неверно, поле подсветится и покажет ошибку — симуляция при этом не ломается. Деление на ноль и неопределённости дают 0.</div></div>
</div>
<div class="tg-section" id="s-21-5">
<div class="tg-section-title">21.5 Стиль, порядок и прямое редактирование</div>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Стиль объекта</b>: выбор цвета (палитра), прозрачность, толщина и тип линии (сплошная / штрих / пунктир), стиль точки, свечение, градиентная заливка.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Порядок и операции</b>: кнопки «вверх/вниз» меняют порядок отрисовки (что поверх чего), есть дублирование и тумблер видимости (глаз), удаление.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Перетаскивание на сцене</b>: значок «прицел» включает ручки — тяните точку, концы отрезка/вектора, вершины ломаной прямо на превью (на паузе). Поля с формулами при этом не затираются.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Привязка к сетке</b> (тумблер в панели инструментов) — координаты при перетаскивании округляются к узлам сетки.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Отмена/повтор</b>: кнопки в панели инструментов и горячие клавиши <code>Ctrl+Z</code> / <code>Ctrl+Y</code> (или <code>Ctrl+Shift+Z</code>).</div></div>
</div>
</div>
<div class="tg-section" id="s-21-6">
<div class="tg-section-title">21.6 Физика</div>
<p>Включите тумблер <b>«Физика»</b> — и часть объектов будет двигаться по законам механики, а не по формуле. Движок сам интегрирует движение.</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Общие силы</b>: гравитация (g по X и Y), трение, упругость столкновений (0…1).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Тело</b>: у точки/круга включите «тело» и задайте массу и начальную скорость (vx, vy). Тело падает, сталкивается, отскакивает.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Пружины</b> — между двумя телами или телом и точкой-якорем (жёсткость, длина покоя, демпфирование).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Стены</b> — границы области (низ/верх/лево/право) или произвольный отрезок: от них тела отражаются.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Тело можно <b>тянуть мышью</b> на паузе; при отпускании в запущенной сцене оно полетит.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="info"></i></div><div class="tg-box-body"><div class="tg-box-label">Формулы и физика вместе</div>Физические тела и формульные объекты сосуществуют на одной сцене: например, подпись или вектор скорости можно привязать к координатам тела через <code>id.x</code>, <code>id.y</code>.</div></div>
</div>
<div class="tg-section" id="s-21-7">
<div class="tg-section-title">21.7 Графики и диаграммы</div>
<p>Объект <b>«График»</b> рисует кривую функции прямо на сцене в мировых координатах.</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Задайте выражение (например <code>sin(x)</code>), переменную (по умолчанию <code>x</code>), диапазон и число точек.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Несколько кривых</b> на одном графике — у каждой свои цвет, подпись, толщина и стиль линии.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Заливка под кривой</b>, <b>маркеры точек</b> и <b>легенда</b> (по подписям кривых) включаются переключателями.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Режим <b>«след» (trace)</b> — кривая накапливается по времени <code>t</code> (удобно строить график величины в ходе анимации).</div></div>
</div>
</div>
<div class="tg-section" id="s-21-8">
<div class="tg-section-title">21.8 Сохранение, публикация и раздача</div>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Сохранить</b> — симуляция сохраняется как черновик (видна только вам).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Опубликовать</b> — симуляция появляется в лаборатории для всех; «Снять с публикации» возвращает в черновик.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Шаблон</b> — начать не с нуля, а с заготовки (пустая, маятник, график, бросок).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Раздать классу</b> — ученики класса получают уведомление со ссылкой на симуляцию (она автоматически публикуется).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Клонировать</b> — сделать свою копию чужой опубликованной симуляции и доработать её.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="flask-conical"></i></div><div class="tg-box-body"><div class="tg-box-label">Где появляются ваши симуляции</div>В <a href="/lab">лаборатории</a> есть раздел <b>«Мои симуляции»</b> — там ваши черновики и опубликованные, с кнопками «Редактировать» и «Удалить». Прямая ссылка вида <code>/lab?sim=custom:НОМЕР</code> открывает конкретную симуляцию.</div></div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="presentation"></i></div><div class="tg-box-body"><div class="tg-box-label">На онлайн-уроке</div>Свою симуляцию можно открыть на доске урока — значения ползунков синхронизируются у учеников, а поверх можно рисовать аннотации.</div></div>
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Готово</div>Это вся учительская часть руководства. Дальше — главы для администраторов (видны только под ролью admin).</div></div>
</div>
<div class="tg-chapter-nav">
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-20')">
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Ещё модули платформы</div></div>
</div>
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-1')" style="text-align:right">
<div class="tg-ch-nav-icon"><i data-lucide="rotate-ccw"></i></div>
<div><div class="tg-ch-nav-label">Вернуться к началу</div><div class="tg-ch-nav-title">Быстрый старт</div></div>
@@ -1928,6 +2056,7 @@
{ id:'ch-18', label:'Квантик-ассистент', icon:'sparkles', sections:['s-18-1','s-18-2','s-18-3'], sLabels:['Что умеет','Спроси Квантика','Подсказки на экзамене'] },
{ id:'ch-19', label:'Флэшкарты', icon:'copy', sections:['s-19-1','s-19-2','s-19-3'], sLabels:['Колоды и карточки','Картинки и формулы','Импорт и генерация ИИ'] },
{ id:'ch-20', label:'Ещё модули платформы', icon:'grid-3x3', sections:['s-20-1','s-20-2','s-20-3','s-20-4'], sLabels:['Карта знаний и Теория','Игры: Кроссворд, Виселица','Красная книга и Коллекция','Материалы, Магазин, Родители'] },
{ id:'ch-21', label:'Конструктор симуляций', icon:'pencil-ruler', sections:['s-21-1','s-21-2','s-21-3','s-21-4','s-21-5','s-21-6','s-21-7','s-21-8'], sLabels:['Что это и где','Рабочее поле','Параметры','Объекты и формулы','Стиль и порядок','Физика','Графики','Сохранение и раздача'] },
];
const ADMIN_CHAPTERS = [
+27 -3
View File
@@ -860,8 +860,10 @@ async function hideDisabledFeatures() {
biochem: ['/biochem', '/biochem-library', '/biochem-reactions'],
live_quiz: ['/live-quiz'],
classroom: ['/classroom'],
sim_builder: ['/sim-builder', '/sim-builder.html'],
exam9: ['/exam9', '/exam9.html'],
textbooks: ['/textbooks', '/textbooks.html', '/textbook'],
quantik: ['/quantik', '/quantik.html'],
};
for (const [key, hrefs] of Object.entries(map)) {
if (feats[key] === false) {
@@ -884,13 +886,30 @@ async function hideDisabledFeatures() {
}
}
// Exam-prep track links (/exam-prep/<key>): показываем только включённые
// (exam_tracks.enabled) и доступные пользователю треки. /api/exam-prep/tracks
// уже отдаёт enabled-треки, отфильтрованные по правам доступа.
const examLinks = document.querySelectorAll('[href^="/exam-prep/"]');
if (examLinks.length) {
try {
const data = await apiFetch('/api/exam-prep/tracks');
const allowed = new Set((data.tracks || []).map(t => t.exam_key));
examLinks.forEach(el => {
const m = (el.getAttribute('href') || '').match(/^\/exam-prep\/([^/?#]+)/);
if (m && !allowed.has(m[1])) el.style.display = 'none';
});
const cur = window.location.pathname.match(/^\/exam-prep\/([^/?#]+)/);
if (cur && !allowed.has(cur[1])) window.location.href = '/dashboard.html';
} catch { /* сеть/доступ недоступны — ссылки оставляем как есть */ }
}
// Student with no class — restrict to dashboard, homework, library, theory
if (feats._no_class) {
const classOnlyHrefs = [
'/board', '/lab', '/hangman', '/crossword', '/pet',
'/collection', '/collection.html', '/knowledge-map',
'/red-book', '/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html',
'/flashcards', '/live-quiz',
'/flashcards', '/live-quiz', '/quantik',
];
classOnlyHrefs.forEach(href => {
document.querySelectorAll(`[href="${href}"]`).forEach(el => el.style.display = 'none');
@@ -901,7 +920,7 @@ async function hideDisabledFeatures() {
'/board', '/lab', '/hangman', '/crossword', '/pet',
'/collection', '/collection-rb', '/knowledge-map',
'/red-book', '/red-book-ecosystem', '/red-book-biomes', '/red-book-games',
'/flashcards', '/live-quiz',
'/flashcards', '/live-quiz', '/quantik',
];
if (classOnlyPaths.some(h => cur === h || cur === h + '.html')) {
window.location.href = '/dashboard';
@@ -1041,10 +1060,11 @@ window.LS = {
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
gameProgressList, gameProgressSubmit,
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
fcListDecks, fcCreateDeck, fcAddCard,
fcListDecks, fcCreateDeck, fcAddCard, fcStudySession, fcReview,
escapeHtml, esc,
parseDate, fmtRelTime, safeHref,
initPage,
@@ -1271,6 +1291,8 @@ async function customSimClone(id) { return req('POST', `/custom-sims/${i
async function customSimRelated(id) { return req('GET', `/custom-sims/${id}/related`); }
async function customSimAddLink(id, d) { return req('POST', `/custom-sims/${id}/links`, d); }
async function customSimDelLink(id, lid){ return req('DELETE', `/custom-sims/${id}/links/${lid}`); }
async function gameProgressList() { return req('GET', '/game/progress'); }
async function gameProgressSubmit(levelId, d) { return req('POST', '/game/progress', { level_id: levelId, time_ms: d && d.time_ms, stars: d && d.stars }); }
async function assistantContext() { return req('GET', '/assistant/context'); }
async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); }
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
@@ -1293,6 +1315,8 @@ async function adminAssistantModels(params) { const q = new URLSearchParams(para
async function fcListDecks() { return req('GET', '/flashcards/decks'); }
async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); }
async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }
async function fcStudySession(deckId){ return req('GET', `/flashcards/decks/${deckId}/study`); }
async function fcReview(cardId, quality) { return req('POST', `/flashcards/cards/${cardId}/review`, { quality }); }
async function deleteFile(id) { return req('DELETE', `/files/${id}`); }
async function getFileAccess(id) { return req('GET', `/files/${id}/access`); }
async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); }
+2
View File
@@ -83,10 +83,12 @@
${L('/flashcards', 'copy', 'Флэшкарты')}
${L('/question-bank', 'database', 'Банк вопросов', { cls: 'sb-teacher-only', hidden: !isTch })}
${L('/exam-prep/math9', 'clipboard-check', 'Подготовка к экзамену 9')}
${L('/exam-prep/ctmath', 'clipboard-check', 'Подготовка к ЦЭ/ЦТ')}
`)}
${G('practice', 'Практика и игры', `
${L('/lab', 'atom', 'Лаборатория')}
${L('/quantik', 'rocket', 'Квантик: Законы Мира')}
${L('/sim-builder', 'pencil-ruler', 'Конструктор симуляций', { cls: 'sb-teacher-only', hidden: !isTch })}
${L('/biochem', 'flask-conical', 'Биохимия')}
${L('/red-book', 'leaf', 'Красная книга')}
+67 -1
View File
@@ -64,4 +64,70 @@
---
История: создан 2026-05-30. Фаза 6 добавлена 2026-05-30.
## Раунд «Конструктор» (2026-06-17) — упор на ученика-самоучку (песочница)
Цель: превратить отличный **визуализатор** в полноценный **конструктор** для самостоятельных
построений. Приоритеты, выбранные пользователем: **Фаза A (конструкторное ядро)** и
**Фаза C (сечения+)**. A — фундамент C (сечение через прямую+точку, параллельно прямой/плоскости
опираются на объекты-прямые/плоскости).
### Фаза A — Конструкторное ядро
Прямые и плоскости как объекты первого класса + пересечения + параллели/перпендикуляры +
общий undo/redo + дерево именованных объектов.
- [x] A1 — **Объектная модель + базовые построения.** `_lines[]` (имена a,b,c…), `_planes[]`
(имена α,β,γ…), группа `_constructGroup`, сериализуемое хранение `{x,y,z}`. Инструменты
«Прямая по 2 точкам» и «Плоскость по 3 точкам» (пикинг вершин/точек). Плоскость рисует
полупрозрачный квад + пунктирную рамку + **сечение тела этой плоскостью** (через `_sliceByPlane`,
делает плоскость осмысленной сразу). Панель «Построения», список объектов с уравнением плоскости.
- [x] A2 — **Пересечения** (прямая∩плоскость → точка `_cpoints` имена M,N,K…; плоскость∩плоскость
→ прямая; прямая∩прямая → точка или «скрещиваются») — выбор 2 объектов в дереве (`setIntersectMode`/
`pickConstructObject`). **Интерактивное дерево**: видимость (глаз)/удаление (×) по объекту, выбор
для пересечения. Точки-пересечения пикабельны → по ним строятся новые прямые/плоскости.
- [x] A3 — **Параллели/перпендикуляры** через точку (`setRelMode`/`_onRelClick`): `lpar` прямая ∥
прямой; `lperp` прямая ⟂ плоскости; `ppar` плоскость ∥ плоскости; `pperp` плоскость ⟂ прямой
(= «плоскость по точке и нормали» через `_createPlaneFromPointNormal` — мост к Фазе C). Поток:
кнопка op → выбор опоры в дереве → клик точки. **Общий undo/redo** конструкторного слоя (JSON-
снапшот `_undoStack`/`_redoStack`, кап 60; хуки в create/remove/clear; Ctrl+Z / Ctrl+Shift+Z /
Ctrl+Y + кнопки «Отменить»/«Вернуть»). Видимость — не шаг истории (намеренно).
### UX панели управления (2026-06-17)
- [x] Панель `.stereo-panel` разрослась за фазы A–C до ~14 всегда-раскрытых секций (длинный скролл) →
**сворачиваемый аккордеон**: `_stereoInitPanel()` (вызывается из `_openStereo`, идемпотентно) оборачивает
контролы каждой секции в `.st-acc-body`, заголовки `.gp-section-title` → кликабельные `.st-acc-hdr`
с шевроном; состояние каждой секции в localStorage (`stereo-acc-<имя>`). Тройка фигурных секций
(Многогранники/Правильные/Тела вращения) слита в одну «Фигуры» (под-метки `.st-sublabel`). По
умолчанию открыты «Фигуры» и «Параметры». Кнопки «Развернуть всё / Свернуть всё» (`stereoAccAll`).
Сами контролы/обработчики не тронуты — только раскладка. Только `stereo.js` + `lab.css`.
### Фаза B — Умные точки
- [x] B1 — **Деление отрезка m:n** (`setDivideMode`/`setDivideRatio`): задаёшь m,n → кликаешь 2 точки A,B
→ точка делит AB как AM:MB = m:n (`t = m/(m+n)`), создаётся как `_cpoints` (M,N,K…).
- [x] B2 — **Точка по координатам** (`addPointAt(x,y,z)`): поля x/y/z + кнопка → точка-построение.
- [x] B3 — **Перетаскивание точек** (`setDragPointMode`): pointerdown по точке-построению (приоритет над
орбитой) → drag в плоскости, обращённой к камере (нормаль = направление камеры, фикс. на старте);
`_beginCPointDrag`/`_dragCPointWithRay`/`_rayPlaneHit`/`_endCPointDrag`; снапшот истории на старте
(undo откатывает весь drag). Точки-пересечения/деления непараметрические (downstream-объекты
копируют позицию при создании и за перетаскиванием НЕ следуют — параметрический граф = бэклог).
### Фаза C — Сечения+
- [x] C1 — Сечение **плоскостью-объектом** (из Фазы A): клик по плоскости в дереве (нормальный режим)
`setSectionPlane` показывает её заливкой + подписи вершин K,L,M… + площадь/периметр в readout
(`_activeSectionPolygon`, `getReadout`). Удаление/очистка/смена фигуры сбрасывают сечение.
- [x] C2 — **Покрыто Фазой A** (отдельный код не нужен): сечение через прямую+точку = плоскость по
3 точкам (2 с прямой + 1); сечение ∥ плоскости через точку = rel-операция `ppar` → затем клик
как сечение. Дополнительный UI признан избыточным.
- [x] C3 — **«Натуральная величина» сечения** (`getTrueShape`): разворот многоугольника в его
плоскость (ортонормированный базис от нормали) с сохранением истинных длин → 2D-мини-панель
(SVG, штриховка `<pattern>`, подписи вершин, длины сторон, S/P). Проверено сохранение длин и
площади для прямого и наклонного сечений.
- [ ] C4 — Честный конструктивный алгоритм следов с анимацией (из бэклога Ф6) — **отложено**
(крупная отдельная фича; текущий гибрид Ф6 + сечения-объекты Фазы A/C закрывают практику).
---
История: создан 2026-05-30. Фаза 6 добавлена 2026-05-30. Раунд «Конструктор» (Фазы A,C) — 2026-06-17.
+240
View File
@@ -0,0 +1,240 @@
# Сборка курса ЦЭ/ЦТ на СУЩЕСТВУЮЩЕМ банке `questions` (основной путь)
> ⚠️ ПИВОТ (2026-06-14): контент ЦЭ/ЦТ по математике **уже в базе** — таблица `questions`
> (`subject_id=3`), **1753 задания** за 2011–2025 (включая ЦЭ-2024 = 117, набор 2025 = 1020),
> размечены по темам (`topics`) и годам (`year`), залиты скриптами `backend/scripts/seed_math_ct*.js`.
> Поэтому курс строим **поверх этого банка** через `tests`/`assignments`/`courses`, а НЕ через
> exam-prep (`exam_tasks`). Миграция 077 (пустой exam-prep-скелет `ctmath`) оставлена как опция,
> но это НЕ основной путь и в БД не применяется.
>
> Этот документ заменяет (по части «куда складывать») разделы маппинга в [PLAN.md](PLAN.md) §6
> и спецификацию оцифровки [DIGITIZATION_SPEC.md](DIGITIZATION_SPEC.md). Карта экзамена (§1 PLAN),
> методика (§2), модульная программа (§3), уровни (§4), пилоты ([тригонометрия](PILOT_TRIGONOMETRY.md)/[стереометрия](PILOT_STEREOMETRY.md)) и инвентарь ([RESOURCES.md](RESOURCES.md)) — остаются в силе.
---
## 0. Состояние реализации
**Сделано (скрипт `backend/scripts/seed_ctmath_course.js`, идемпотентный, применён на живой БД 2026-06-14):**
- ✅ Добавлены 6 тем (`topics`, subject_id=3): Преобразование выражений (72), Модуль (73), Иррациональные уравнения (74), Показательные уравнения (75), Производная (76), Параметры (77).
- ✅ Создан **DRAFT-курс** «ЦЭ/ЦТ — Математика» (`courses.id=13`, `is_published=0`, created_by=2) — ученикам НЕ виден до публикации.
- ✅ 9 секций (`course_sections.id=27..35`) = блоки IIX.
**Сделано (скрипт `backend/scripts/seed_ctmath_diagnostic.js`, применён 2026-06-14):**
- ✅ **Диагностический `test`** «Диагностика ЦЭ/ЦТ — Математика» (`tests.id=164`, 15 вопросов, лимит 40 мин, `show_answers=1`) — собран из РЕАЛЬНЫХ размеченных вопросов ЦТ-11 (в осн. 2024): 5 `single` (базовые, по 1 на тему Теория чисел/Арифметика/Квадратные/Тригонометрия/Промежутки) + 10 `fill-blank` (средние/сложные: Словесные/Прогрессии/Функции/Геометрия/Окружность/Стереометрия/Логарифмы/Неравенства/Уравнения/Показательные). Новых вопросов не авторили. Выдать классу/ученику: assignment с `test_id=164`.
**Сделано (скрипт `backend/scripts/seed_ctmath_lessons_trig.js`, применён 2026-06-15):**
- ✅ **Блок «Тригонометрия» — 3 урока** в секции 31 курса 13 (по [PILOT_TRIGONOMETRY.md](PILOT_TRIGONOMETRY.md)):
«Тригонометрический круг и значения» (`lessons.id=41`, 18 блоков, А3 🟢), «Тождества и формулы»
(`id=42`, 19 блоков, А8/В4 🟡), «Уравнения и отбор корней» (`id=43`, 15 блоков, В15 🔴).
Блоки в формате рендера lesson.html (heading/text/formula/callout/sim `trigcircle`/flashcard/quiz/
matching/ordering/accordion/table); математика `$…$`/`$$…$$`; JSON валиден; идемпотентно.
**Дальше (не сделано):** уроки остальных 8 блоков по пилотам (тиражирование, начать со стереометрии);
assignment-практика `mode='topic'`; колоды формул (`flashcard_decks`); публикация курса; выдача
диагностики классу (assignment `test_id=164`). См. §8.
---
## 0a. Решение: ЦТ как ОТДЕЛЬНЫЙ модуль exam-prep (2026-06-15)
По решению пользователя ЦТ оформляется как **отдельный модуль** (как «Экзамен 9»): свой раздел
`/exam-prep/ctmath` с дашбордом, тренажёром по темам, пробниками на таймер, детектором слабых тем.
Это значит: применить трек+дерево тем (миграция **077**) и **перенести размеченные вопросы ЦТ-11 из
банка `questions` в `exam_tasks`** (exam_key='ctmath').
Конвертер: **`backend/scripts/seed_ctmath_exam_tasks.js`** (dry по умолчанию, запись `--apply`).
Правила сверены с exam-prep (агент-разведка): MC-метки кириллица `а,б,в,г,д`, `answer`=метка; open —
числовой/дробь/пара (иначе → `long` self-check); математика `\( \)``$`, `\[ \]``$$` (exam-prep KaTeX
знает только `$`/`$$`!); `subtopic`=slug из 077; `variant`=год; multi/multiple (radio несовместимо) — пропуск.
**Dry-run (2026-06-15):** 733 размеч. вопроса → **723 к вставке** (525 mc + 191 open + 7 long;
10 multi пропущено; 7 не-числовых → long). Делимитеры/метки/ответы корректны (проверено на выборке).
**Статус записи: ПРИМЕНЕНО (2026-06-15).** Миграцию 077 применил пользователь вручную (авто-режим
блокирует продакшн-миграции); конвертер `--apply` — тоже запускал пользователь (объём 723 был
заблокирован авто-режимом). Итог в БД: трек `ctmath` (enabled=1), дерево тем 41 (9+32), **723 задачи
в `exam_tasks`** (525 mc + 191 open + 7 long), variants_count=15. Проверка: осиротевших subtopic 0,
неконвертированных `\(` 0. Модуль доступен на **`/exam-prep/ctmath`** (учителю/админу сразу; ученику —
после `content_access`).
Постфикс (2026-06-15): варианты ответа у части mc были вшиты в текст («1) 44; 2) 22; …»), а opts —
лишь цифры-указатели. Скрипт `fix_ctmath_inline_opts.js --apply` (запускал пользователь) вытащил список
в нормальный `opts_json` и пересчитал answer — **исправлено 213 задач**; инлайн-списков в тексте
осталось **3** (нестандартный формат, не сломаны). Битых opts 0.
Известный мелкий дефект источника: 1 mc-задача `exam_tasks.id=1248` (var 2020) без верного варианта
(дубль опций) — всегда «неверно»; фикс: перевести в `long` или проставить ответ. Плюс ~3 mc с
неразобранным инлайн-списком — при желании вторая итерация парсера.
После applied — осталось: `content_access` (exam/ctmath классу) + пункт сайдбара на `/exam-prep/ctmath`.
> ⚠️ Гоча: рендер exam-prep — ТОЛЬКО `$…$`/`$$…$$` (НЕ `\(…\)`). Конвертер это учитывает.
---
## 1. Что уже есть (проверено чтением БД)
| Таблица | Роль | Факт |
|---|---|---|
| `questions` | банк заданий | 1753 матем. (subject_id=3); `topic_id`, `year`, `difficulty` 13, `type`, `explanation`, `image`, `source_type` |
| `options` | варианты ответов | `question_id`, `text`, `is_correct`, `order_index`, `match_pair` |
| `topics` | темы (ПЛОСКИЕ) | 19 тем по математике (без иерархии): id+subject_id+name+order_index |
| `tests` / `test_questions` | фикс. наборы | тест = упорядоченный список вопросов |
| `test_sessions` / `user_answers` | прохождение + баллы | score = число верных |
| `assignments` | выдача | режимы `exam/practice/topic/repeat/`**`ct`** |
| `courses`/`course_sections`/`lessons`/`lesson_blocks` | теория | общий слой контента |
| `flashcard_*` | карточки + SR | для формул |
### 1.1. Уточнение по инспекции (2026-06-14) — ВАЖНО
Из 1753 матем. заданий:
- **~733 — реальный банк ЦЭ/ЦТ-11** (20112024), **размечены по темам**. Часть A = `single`, **Часть B = `fill-blank`** (198 шт.), `short_answer` у них НЕТ. **Это база курса.**
- **1020 — набор `year=2025`, `source_type='экзамен 9'` = «Экзамен 9 класс», БЕЗ тем** (`topic_id` пустой), все `difficulty=1`, типы `single`+`short_answer`. Это контент 9-класса (из него собраны тесты «Экзамен 9 — Вариант N»). **Для курса ЦЭ/ЦТ-11 — НЕ основа** (другой экзамен/класс; не размечен).
**Типы заданий (`questions.type`)**: `single`, `multi`, `true_false`, `short_answer`, `matching`, `fill-blank`.
**Сложность**: `difficulty` 13 (CHECK), 1=базовый/2=средний/3=продвинутый. (В [PLAN.md](PLAN.md) шкала 1–5 — привести к 1–3: А-часть и лёгкая В = 1, средняя В = 2, сложная В14–В20 = 3.)
**Год**: `year` (2011…2025) — фильтр по году/варианту.
### Текущая таксономия тем (math, `subject_id=3`)
`16 Арифметика и степени` · `17 Словесные задачи` · `18 Теория чисел` · `19 Тригонометрия` · `20 Квадратные уравнения` · `21 Прогрессии` · `22 Неравенства` · `23 Геометрия` · `24 Функции` · `25 Логарифмы` · `26 Показательные неравенства` · `27 Уравнения` · `28 Статистика и диаграммы` · `61 Стереометрия` · `62 Окружность и круг` · `63 Числовые промежутки` · `64 Подобные фигуры` · `67 Парабола` · `69 Тригонометрические уравнения`
---
## 2. Ключевой механизм: режимы `assignments` (выбор вопросов)
Логика `assignmentController.startAssignment` (подтверждено по коду):
- **`mode='ct'`** — собирает ЦТ-вариант: ~половина из `type IN ('single','true_false')` (Часть A) + остаток из `type IN ('multi','short_answer')` (Часть B), добор при нехватке. ⚠️ **Гоча:** реальный банк ЦТ-11 имеет Часть B типа **`fill-blank`**, который `mode='ct'` НЕ выбирает (он ждёт `short_answer`) → для ЦТ-11 Часть B соберётся только «добором». `mode='ct'` хорошо ложится на набор «Экзамен 9» (там Часть B = `short_answer`). **Для ЦТ-11 надёжнее `mode='topic'` или ручная сборка `test` (single + fill-blank).**
- **`mode='topic'`** — `SELECT id FROM questions WHERE subject_id=3 AND topic_id=? ORDER BY RANDOM() LIMIT count`. **Тренажёр по теме модуля.**
- **`mode='exam'|'practice'|'repeat'`** — случайные `count` по предмету (или теме).
- **`test_id` задан** — берутся ИМЕННО вопросы теста в его порядке (перекрывает режим). **Так делается фиксированная диагностика.**
Выдача: `class_id` (классу) или `user_id` (ученику); `count` 1200; `deadline`; `max_attempts` 010.
---
## 3. Таксономия: довести темы под модульную карту
Текущие 19 тем покрывают большинство модулей, но грубее карты §3 [PLAN.md](PLAN.md). Два варианта (рекомендуется А):
**A. Принять текущую гранулярность + добавить недостающее (мин. усилий).**
Добавить новые `topics` (для будущей разметки и точечной практики), существующие вопросы НЕ перетегировать массово:
- `Преобразование выражений` (M3M6)
- `Модуль` (M10)
- `Иррациональные уравнения` (M11)
- `Показательные уравнения` (M12; сейчас есть только «Показательные неравенства»)
- `Производная` (M17)
- `Параметры` (M30)
**B. Полная иерархия** — перетегировать 1753 вопроса под все 32 подтемы. Дорого; не оправдано на старте.
### Маппинг модуль → тема банка (для практики `mode='topic'`)
| Модуль(и) | topic |
|---|---|
| M1M3 Числа | `18 Теория чисел`, `16 Арифметика и степени` |
| M4–M6 Преобразования | `16` (+ новый `Преобразование выражений`) |
| M7 Линейные/системы | `27 Уравнения`, `22 Неравенства`, `63 Числовые промежутки` |
| M8 Квадратные | `20 Квадратные уравнения` |
| M9 Рациональные | `27 Уравнения`, `22 Неравенства` |
| M10 Модуль | новый `Модуль` |
| M11 Иррациональные | новый `Иррациональные уравнения` |
| M12 Показательные | новый `Показательные уравнения`, `26 Показательные неравенства` |
| M13 Логарифмы | `25 Логарифмы` |
| M15–M16 Функции/графики | `24 Функции`, `67 Парабола` |
| M17 Производная | новый `Производная` |
| M18–M19 Тригонометрия | `19 Тригонометрия` |
| M20 Триг. уравнения | `69 Тригонометрические уравнения` |
| M21 Прогрессии | `21 Прогрессии` |
| M22 Текстовые | `17 Словесные задачи` |
| M23–M24 Планиметрия | `23 Геометрия`, `64 Подобные фигуры` |
| M25 Окружность | `62 Окружность и круг` |
| M26–M29 Стереометрия | `61 Стереометрия` |
| M30 Параметры | новый `Параметры` |
| (стат.) | `28 Статистика и диаграммы` |
> Реализация добавления тем — обычным `INSERT INTO topics(subject_id,name)` (как делают seed-скрипты через `getTopic`). Можно отдельной миграцией или скриптом. Перетегировать существующие вопросы под новые темы — опционально и точечно (по тексту/году).
---
## 4. Структура курса (теория) — `courses`/`sections`/`lessons`
Создаётся через API (teacher/admin), наполняется по пилотам:
1. **`courses`**: `subject_slug='math'`, `title='ЦЭ/ЦТ — Математика'`, `is_published=1`, обложка.
2. **`course_sections`**: 9 секций = блоки I–IX из [PLAN.md](PLAN.md) §3.
3. **`lessons`** + **`lesson_blocks`** по шаблону пилотов (heading → теория/формулы → sim/диаграмма → callout «ошибки» → flashcards → quiz). Типы блоков: `text/formula/callout/quiz/sim/geogebra/flashcard/image/table/accordion/ordering/matching`.
4. В уроке кнопка «тренироваться» → assignment `mode='topic'` по теме модуля (или ссылка на практику банка).
5. Доступ ученикам/классам — `content_access`/`course_access` (миграция 052) + `class_courses`.
> Связь урок↔§учебника — через ссылки в `lesson_blocks` (`text` с гиперссылкой на `/textbook/...`), т.к. в `questions`/`topics` нет поля textbook (в отличие от exam-prep). Учебники реальны (algebra-7..11, geometry-8..11) — см. [TOPICS_SEED.md](TOPICS_SEED.md).
---
## 5. Практика и пробники (на банке)
- **Тренажёр модуля** → assignment `mode='topic'`, `topic_id` = тема модуля, `count` 1025, `max_attempts=0`.
- **Пробный вариант ЦТ** → assignment `mode='ct'`, `count=30` (без `topic_id` = по всем темам). Даёт Часть A + Часть B автоматически. Для «на время» — задать через связанный `test.time_limit` (или прохождение сессии; полноценный таймер-пробник как в exam-prep здесь проще через `test` с `time_limit`).
- **Фиксированный набор** (диагностика, тематический срез) → `test` + `test_questions`, затем assignment с `test_id`.
- Сложность регулируется выбором тем + `difficulty` (1–3) при ручной сборке `test`.
---
## 6. Входная диагностика (из реальных вопросов банка, БЕЗ авторинга)
Собрать `test` «Диагностика ЦЭ/ЦТ» из ~14 существующих вопросов — по одному на ключевую тему, желательно `year=2024`:
| # | topic (bank) | критерий выбора | уровень-зонд |
|---|---|---|---|
| 1 | 18 Теория чисел | `single`, diff 1 | 🟢 |
| 2 | 20 Квадратные уравнения | `single`, diff 1 | 🟢 |
| 3 | 19 Тригонометрия | `single`, diff 1 | 🟢 |
| 4 | 16 Арифметика и степени | `single`, diff 12 | 🟢 |
| 5 | 17 Словесные задачи | `short_answer`, diff 2 | 🟡 |
| 6 | 21 Прогрессии | `short_answer`, diff 2 | 🟡 |
| 7 | 24 Функции | diff 2 | 🟡 |
| 8 | 19 Тригонометрия | `short_answer`, diff 2 (тождества) | 🟡 |
| 9 | 23 Геометрия | `short_answer`, diff 2 | 🟡 |
| 10 | 61 Стереометрия | diff 2 | 🟡 |
| 11 | 25 Логарифмы | `short_answer`, diff 3 | 🔴 |
| 12 | 69 Триг. уравнения | diff 3 | 🔴 |
| 13 | 24 Функции / Производная | diff 3 | 🔴 |
| 14 | 61 Стереометрия | diff 3 (углы/расстояния) | 🔴 |
Реализация: выбрать конкретные `question.id` по критериям (`subject_id=3 AND topic_id=? AND difficulty=? [AND year=2024]`), создать `test` + `test_questions` в нужном порядке, выдать как assignment `test_id`. По результату — назначить трек (правила §4 [PLAN.md](PLAN.md)). **Никакого нового авторинга — берём готовые проверенные вопросы.**
---
## 7. Прогресс и аналитика
- Есть: `test_sessions.score/total`, `user_answers.is_correct`, `lesson_progress.completed`.
- **Нет** (в этой подсистеме): автодетектора слабых тем и per-topic mastery (это только в exam-prep). Варианты:
- считать точность по теме join'ом `user_answers`+`questions.topic_id` (есть `assignmentQuestionStats` по вопросам — расширить до тем);
- или (доработка) добавить агрегат «точность по теме» для рекомендаций «что подтянуть».
- Формулы — `flashcard_decks` по блокам + `flashcard_deck_access` классу (SR встроен).
---
## 8. Порядок реализации (на банке `questions`)
1. **Таксономия**: добавить недостающие темы (§3, вариант A) — миграция/скрипт `INSERT INTO topics`.
2. **Курс-каркас**: `courses` + 9 `course_sections`.
3. **Диагностика**: собрать `test` из 14 реальных вопросов (§6), выдать.
4. **Уроки по приоритету** (стерео, тригонометрия — §8 PLAN): теория в `lesson_blocks` по пилотам + кнопка практики `mode='topic'`.
5. **Пробники**: assignment `mode='ct'` (вариант 30 заданий) + тематические `mode='topic'`.
6. **Карточки формул**: `flashcard_decks` по блокам.
7. (Опц.) **Аналитика по темам**: агрегат точности для рекомендаций.
8. (Опц.) Точечно дотегировать вопросы под новые темы (Производная, Иррациональные, Модуль, Показательные ур., Параметры).
---
## 9. Открытые вопросы
| Вопрос | Заметка / дефолт |
|---|---|
| Что за набор `year=2025` (1020 вопросов)? | Уточнить происхождение (свежие ЦЭ/ЦТ-2025?); вероятно главный современный банк — проверить разметку по темам/типам |
| Перетегировать ли под тонкие темы | По умолчанию — нет (вариант A); добавить только новые темы, дотегировать точечно |
| Per-topic mastery / слабые темы | Пока считать join'ом; полноценный детектор — отдельная доработка |
| Нужен ли отдельный признак «ЦТ/ЦЭ» | Уже есть `year` и `source_type`; при необходимости фильтровать по ним |
| Таймер-пробник на 180 мин | Через `test.time_limit` (есть); полноценный mock как в exam-prep — опционально |
| Судьба миграции 077 (exam-prep ctmath) | Оставлена (по решению), в БД не применяется; основной путь — этот документ |
+197
View File
@@ -0,0 +1,197 @@
# Спецификация оцифровки заданий РТ/ЦТ → `exam_tasks` + диагностика
> ⚠️ **БОЛЬШЕЙ ЧАСТЬЮ УЖЕ СДЕЛАНО / ВТОРИЧНО.** Оцифровка ЦЭ/ЦТ по математике **уже выполнена**
> 1753 задания в банке `questions` (скрипты `backend/scripts/seed_math_ct*.js`, 20112025). Поэтому
> этот документ (перенос в `exam_tasks` exam-prep) **не основной**. Сборка курса и диагностики — на
> существующем банке: **[BUILD_ON_QUESTIONS.md](BUILD_ON_QUESTIONS.md)**. Текст ниже актуален лишь
> для (а) будущей добивки годов в формате `seed_math_ct*` (правило: 1 вариант из сборника, без
> повторов — см. память `project_ct_seeded`), либо (б) если перейдём на exam-prep.
> Как переносить задания из PDF (РТ 20062025, ЦТ/ЦЭ 20042024) в банк `exam_tasks` трека `ctmath`,
> как их классифицировать (тема/сложность/тип) и как собрать входной диагностический тест.
> Опирается на реальные конвейеры платформы: `backend/scripts/import-exam-tasks.js` (импорт),
> `tag-exam-tasks.js` (классификатор темы), формат вариантов `frontend/js/exam9/variants/v*.js`.
---
## 1. Схема целевой таблицы (напоминание)
```sql
exam_tasks(
id, exam_key, variant, task_idx,
task_type TEXT CHECK (task_type IN ('mc','open','long')),
text_html, figure_html, opts_json, answer, solution_html,
topic, subtopic, difficulty,
UNIQUE(exam_key, variant, task_idx)
)
```
Для трека: `exam_key='ctmath'`, `variant` = номер варианта (сквозная нумерация по источникам, см. §6), `task_idx` = 1..30.
---
## 2. Маппинг номера задания → тип и подтема
Позиция в ЦЭ/ЦТ почти жёстко задаёт тему (карта §1.2 PLAN.md). Это **дефолт классификатора** — экономит ручную разметку; правится точечно, если конкретный вариант отклонился.
| task_idx | task_type | topic (раздел) | subtopic (по умолчанию) | difficulty |
|---|---|---|---|---|
| 1 (А1) | mc | numbers | `num-real` | 1 |
| 2 (А2) | mc | stereometry | `ster-basics` | 2 |
| 3 (А3) | mc | trigonometry | `trig-circle` | 1 |
| 4 (А4) | mc | numbers | `num-divisibility` | 1 |
| 5 (А5) | mc | equations | `eq-quadratic` | 1 |
| 6 (А6) | mc | equations | `eq-linear` | 2 |
| 7 (А7) | mc | word-sequences | `word-problems` | 2 |
| 8 (А8) | mc | trigonometry | `trig-identities` | 2 |
| 9 (А9) | mc | stereometry | `ster-rotation` | 3 |
| 10 (А10) | mc | expressions | `expr-powers-roots` | 2 |
| 11 (В1) | open | stereometry | `ster-angles-distances` | 3 |
| 12 (В2) | open | functions | `fn-properties` | 2 |
| 13 (В3) | open | word-sequences | `seq-progressions` | 2 |
| 14 (В4) | open | trigonometry | `trig-identities` | 3 |
| 15 (В5) | open | planimetry | `plan-triangles` | 3 |
| 16 (В6) | open | word-sequences | `seq-progressions` | 3 |
| 17 (В7) | open | word-sequences | `word-problems` | 3 |
| 18 (В8) | open | equations | `eq-linear` | 2 |
| 19 (В9) | open | functions | `fn-properties` | 3 |
| 20 (В10) | open | planimetry | `plan-quadrilaterals` | 3 |
| 21 (В11) | open | equations | `eq-logarithmic` | 3 |
| 22 (В12) | open | numbers | `num-divisibility` | 4 |
| 23 (В13) | open | stereometry | `ster-rotation` | 4 |
| 24 (В14) | open | equations | `eq-exponential` | 4 |
| 25 (В15) | open | trigonometry | `trig-equations` | 5 |
| 26 (В16) | open | equations | `eq-logarithmic` | 5 |
| 27 (В17) | open | stereometry | `ster-polyhedra` | 5 |
| 28 (В18) | open | equations | `eq-irrational` | 4 |
| 29 (В19) | open | functions | `fn-derivative` | 5 |
| 30 (В20) | open | stereometry | `ster-angles-distances` | 5 |
> Часть А = `mc` (всегда 5 вариантов ответа). Часть В = `open` (число/слово/комбинация цифр).
> `long` использовать только если ответ не авто-проверяем (в ЦЭ/ЦТ почти не встречается — там всё с коротким ответом).
> ⚠️ Маппинг — стартовый; при оцифровке КАЖДОГО варианта проверять реальную тему задания и при отклонении менять `subtopic`/`difficulty` вручную.
---
## 3. Форматы полей
### 3.1. `text_html`
Условие задания с формулами в KaTeX-разметке (`$...$` инлайн). HTML допустим (списки, `<br>`). Картинки/чертежи — в `figure_html`.
### 3.2. `figure_html`
Чертёж: предпочтительно inline `<svg>` (масштабируемо, тема). Допустимо `<img src="/img/ct/math/...">` (как уже практикуется в репозитории — см. `frontend/img/ct/math/`). NULL, если рисунка нет.
### 3.3. `opts_json` (только `mc`)
Массив пар `[метка, html]`, как в `exam9`:
```json
[["1","$-16$"],["2","$-12$"],["3","$12$"],["4","$26$"],["5","$-26$"]]
```
(В ЦЭ/ЦТ метки — цифры 1–5.)
### 3.4. `answer`
- `mc`: метка верного варианта, напр. `"5"`.
- `open`: строка-эталон ответа. Форматы части В:
- число: `"-26"`, `"24"`, `"153"`;
- комбинация цифр (В1-тип «выберите верные», порядок не важен): хранить нормализованно, напр. отсортированные цифры `"124"`; проверку делать как множество цифр;
- комбинация буква-цифра (В2-тип сопоставление): `"А5Б1В4"` (как в реальном бланке).
- ⚠️ Договориться о нормализации ответа на клиенте (тримминг, запятая/точка в дробях, регистр) — это логика проверки, не данные. В части В реального ЦТ ответ — целое/конечная десятичная дробь; условие часто просит «увеличьте в N раз», чтобы ответ стал целым (учитывать при вводе эталона).
### 3.5. `solution_html`
Полное решение в HTML+KaTeX. Рекомендуется завершать блоком ответа в стиле exam9:
```html
<div class="sol-ans">Ответ: $-26$</div>
```
(`import-exam-tasks.js` умеет парсить `answer` из такого блока — переиспользовать конвейер.)
### 3.6. `topic` / `subtopic` / `difficulty`
- `topic` = slug раздела, `subtopic` = slug подтемы (из [TOPICS_SEED.md](TOPICS_SEED.md)).
- Дефолт — по таблице §2; правка вручную при отклонении.
- `difficulty` 1–5: 1–2 = часть А и лёгкая В; 3 = средняя В; 45 = В12+, В14–В20. Рубрика — §4.
---
## 4. Рубрика сложности (difficulty 15)
| Балл | Признак | Где |
|---|---|---|
| 1 | одно действие/определение, устно | А1, А3, А4, А5 |
| 2 | 2–3 шага, базовая формула | А-часть, В2, В3, В8 |
| 3 | несколько шагов, выбор метода | В4–В11, А9 |
| 4 | многошаговое + ОДЗ/отбор/подобие | В12–В14, В18 |
| 5 | сложный метод (рационализация, отбор корней, координатный метод в 3D) | В15–В17, В19, В20 |
---
## 5. Конвейер оцифровки (рекомендуемый порядок шагов)
1. **Источник → структура.** Один вариант = 30 задач. Удобный промежуточный формат — JS-объект как в `frontend/js/exam9/variants/vNN.js` (`text`, `opts`, `sol` с `sol-ans`).
2. **Импорт.** Прогнать через `backend/scripts/import-exam-tasks.js` (автоопределение `mc`/`open` по наличию `opts`, парс `answer` из `sol-ans`).
3. **Классификация.** Проставить `topic`/`subtopic`/`difficulty` по §2 (можно скриптом по `task_idx`), затем выборочно проверить отклонения (по аналогии с `tag-exam-tasks.js`).
4. **Чертежи.** Для задач с рисунком — добавить `figure_html` (SVG/`<img>`); часть А2/А9/В1/В13/В17/В20 почти всегда с чертежом.
5. **Верификация.** Сверить `answer` с официальными ответами (папки `…\Ответы…`, DJVU-исходники) — критично для авто-проверки.
6. **Сборка вариантов.** Полные варианты доступны как `exam_mock_sessions` (пробники на 180 мин) автоматически — нужна только заполненность 30 задач варианта.
> OCR кириллицы+формул из PDF/DJVU ненадёжен на математике — формулы почти всегда перенабираются вручную в KaTeX. Это основной объём работы; приоритет источников — §6.
---
## 6. Приоритет источников для оцифровки
| Очередь | Источник | Почему |
|---|---|---|
| 1 | `ЦТ-ЦЭ\ЦЭ-ЦТ-2024 МАТ.pdf` | эталон текущего формата, есть ответы |
| 2 | `РТ\2022-2023 … 2024-2025` | свежие, формат совпадает, 3 этапа × 2 варианта |
| 3 | `ЦТ-ЦЭ\20172021` + DJVU-ответы | большой банк реальных заданий с ответами |
| 4 | `РТ\20162021` | расширение банка |
| 5 | `ДРТ\` | доп. варианты + разборы консультаций |
| 6 | старые `РТ/ЦТ 20042015` | архив, по мере необходимости |
Нумерация `variant`: сквозная, с префиксом-меткой источника в `solution_html`/комментарии (напр. «ЦЭ-2024», «РТ-2024 этап 2 в1»), чтобы не терять происхождение.
---
## 7. Входной диагностический тест
Цель: за ~30–40 минут определить уровень по каждому разделу → назначить трек и приоритетные модули.
### Состав (1 задание на ключевую подтему, смесь А и В, 12–15 задач)
| # | Подтема | Уровень-зонд | Источник-позиция |
|---|---|---|---|
| 1 | `num-real` | 🟢 | А1 |
| 2 | `eq-quadratic` | 🟢 | А5 |
| 3 | `trig-circle` | 🟢 | А3 |
| 4 | `expr-powers-roots` | 🟢 | А10 |
| 5 | `word-problems` | 🟡 | А7 / В7 |
| 6 | `seq-progressions` | 🟡 | В3 / В6 |
| 7 | `fn-properties` | 🟡 | В2 / В9 |
| 8 | `trig-identities` | 🟡 | В4 |
| 9 | `plan-triangles` | 🟡 | В5 |
| 10 | `ster-basics` | 🟡 | А2 / В1 |
| 11 | `eq-logarithmic` | 🔴 | В11 → В16 |
| 12 | `trig-equations` | 🔴 | В15 |
| 13 | `fn-derivative` | 🔴 | В19 |
| 14 | `ster-angles-distances` | 🔴 | В20 |
### Логика назначения трека (по результату)
- Доля верных среди 🟢-зондов < 75% **или** «проваленные» базовые разделы → стартовый трек **База**; провальные разделы проходятся с нуля.
- 🟢 уверенно, 🟡 ≥ ~50% → трек **Ядро**.
- 🟢+🟡 уверенно и хотя бы часть 🔴 решена → трек **Продвинутый**.
- Любой раздел с диагностикой < 50% → этот раздел всегда с уровня База, независимо от общего трека (правило ветвления §4.3 PLAN.md).
### Реализация на платформе
- Диагностика = `exam_mock_sessions` с `source='random'`/спец-набор `task_ids_json` из перечисленных подтем, либо practice-набор `strategy=weak`.
- Результаты пишутся в `exam_attempts` (по подтемам) → дашборд/детектор слабых тем сразу строит heatmap и список приоритетов.
- `exam_user_plan.weak_focus=1` — включить фокус на слабых темах (опционально).
---
## 8. Чек-лист «задание готово»
- [ ] `text_html` набран, формулы в KaTeX, читается;
- [ ] `figure_html` добавлен (если есть чертёж);
- [ ] `opts_json` для `mc` (5 вариантов) / отсутствует для `open`;
- [ ] `answer` сверен с официальным ключом, нормализован;
- [ ] `solution_html` с `sol-ans`;
- [ ] `topic`/`subtopic`/`difficulty` проставлены и проверены против реальной темы;
- [ ] `UNIQUE(exam_key, variant, task_idx)` не нарушен.
+132
View File
@@ -0,0 +1,132 @@
# Идеи по улучшению модуля ЦЭ/ЦТ — по всем направлениям
> Источники идей: текущее состояние модуля (`/exam-prep/ctmath` + курс + флешкарты + диагностика);
> педагогика из папки `F:\!Рабочие\ЦТ\Математика\` (roadmap-док «К прочтению», «Кедр»-отработки,
> «100 баллов», шпоры-формулы, видеоразборы); практики из интернета (разбор ошибок > подсчёт баллов,
> тайм-менеджмент/ловушки; UWorld — аналитика слабых мест + реалистичные пробники; Anki/Quizlet —
> spaced repetition + mastery, до +80% удержания; ИИ-тьютор). Возможности платформы (из памяти):
> ассистент «Квантик», геймификация (XP/ачивки/магазин/питомец), карта знаний, imggen, доска, классы.
>
> Метки готовности: ✅ уже есть · 🔧 расширить существующее · 🆕 новое. Приоритет: P1 (быстрая победа) · P2 · P3 (крупная ставка).
---
## 1. Контент и покрытие
| Идея | Польза | Готовность · Приоритет |
|---|---|---|
| **Полные варианты-пробники по годам** (собрать `exam_mock_sessions` из реальных вариантов РТ/ЦТ, а не только случайные наборы) | Реалистичная репетиция всего теста на время | 🔧 (mock есть) · P2 |
| **Улучшить `solution_html`** — у части задач сейчас заглушка «См. решение». Добавить пошаговые разборы | Разбор > ответ; учит технике | 🔧 · P1 |
| **Видео-разборы в решениях** (ссылки на Трушина / Wild Mathing / П. Маслова из roadmap-папки, по темам В15/В16/В20) | Сильные внешние объяснения сложных тем | 🆕 · P2 |
| **«Ловушки» и типичные ошибки** — поле/блок «частая ошибка» у задач и тем (из «Кедр» и опыта) | Закрывает «обидные» потери баллов | 🆕 · P2 |
| **Дотегировать 68 mc** с неразобранным инлайн-списком + фикс id=866/1248 | Чистая отрисовка вариантов | 🔧 · P1 |
| **Углубить уроки** «лёгких» секций (2-й урок, больше эталонов и тренажёра) | Полнота теории | 🔧 · P2 |
| **Справочник формул на тему** (шпаргалка-страница из папки `формулы триги ВСЕ`, `ШПОРА по СТЕОМЕ`) | Быстрый доступ к формулам в практике | 🆕 · P2 |
| **Чертежи к геом-задачам** (генерация SVG/`imggen` где фигуры нет) | Геометрия без рисунка нерешаема | 🔧 · P3 |
## 2. Методика / как учить (из roadmap-папки)
| Идея | Польза | Готовность · Приоритет |
|---|---|---|
| **Трек «Приёмы и техники»**: метод рационализации (В16/В14), отбор корней (В15), координатный метод (В20), функциональные методы | Непропорционально много баллов за приём; ядро roadmap-папки | 🆕 (часть в пилотах) · P2 |
| **«Вывод вместо зубрёжки»** — в уроках тригонометрии вывод формул из 2–3 базовых (уже заложено) распространить на логарифмы/производные | Понимание устойчивее памяти | 🔧 · P2 |
| **Спираль сложности**: один тип задания от базы к В-уровню в одном потоке | Плавный рост, виден прогресс | 🔧 · P2 |
| **Тайм-менеджмент теста**: рекомендации по распределению 180 мин (часть А быстро → В по возрастанию), тренировка «на скорость» части А | Частая потеря баллов — спешка/хаос | 🆕 · P1 |
## 3. Адаптивность и аналитика (UWorld-стиль)
| Идея | Польза | Готовность · Приоритет |
|---|---|---|
| **Детектор слабых тем** (точность <60% на 3+ попытках) | уже работает в exam-prep | ✅ |
| **Spaced repetition для ЗАДАНИЙ** (а не только флешкарт): повторно показывать ОШИБОЧНЫЕ задачи по нарастающим интервалам | Удержание +80% (исследования) | 🆕 · P2 |
| **Работа над ошибками — отдельный режим**: авто-набор из недавно проваленных задач + ссылка на урок/§ | Разбор ошибок — ключ к росту | 🔧 (данные в `exam_attempts`) · P1 |
| **Прогноз тестового балла** к цели (через `scoring_json`) + «до цели N баллов» | Мотивация, фокус | 🔧 · P2 |
| **Тепловая карта по темам/позициям** (А1–А10, В1–В20): где сильно/слабо | Виден приоритет | 🔧 (дашборд есть) · P2 |
| **Перетегировать difficulty в 15** (сейчас 1–3) | Точнее адаптивная выборка и прогрессия | 🔧 · P2 |
## 4. Режимы практики и симуляция экзамена
| Идея | Польза | Готовность · Приоритет |
|---|---|---|
| **Полный пробник на таймер** (30 заданий, 180 мин, как реальный) с итоговым тестовым баллом | Реалистичность = меньше стресса на экзамене | 🔧 (mock есть) · P1 |
| **Учёт времени на задание** + обратная связь по темпу («тратишь >X с») | Тренирует скорость | 🔧 (`time_ms` есть) · P2 |
| **«До мастерства»**: гонять тему, пока не ≥X% | Доведение до автоматизма | 🆕 · P2 |
| **Часть А «на скорость»** — блиц 10 заданий с жёстким таймером | А-часть = гарантированные баллы | 🆕 · P2 |
| **Ежедневная норма + серия** (`exam_user_plan.daily_target` уже есть) | Привычка заниматься | 🔧 · P1 |
## 5. Память (флешкарты / интервальное повторение)
| Идея | Польза | Готовность · Приоритет |
|---|---|---|
| **Колоды формул** (тригонометрия/стерео/логарифмы/производная) | ✅ созданы (49 карт) | ✅ |
| **Раздать колоды классу** (`flashcard_deck_access`) + кнопка «учить формулы» прямо из урока/практики | Доступность SR | 🔧 · P1 |
| **Карты «факт→где применить»** (не только формула, но и типовой ход) | Перенос знания в задачу | 🆕 · P2 |
| **Авто-карта из ошибки**: проваленную формулу/факт — в колоду на повторение | Замыкает цикл память↔практика | 🆕 · P3 |
## 6. Мотивация / геймификация (платформа уже умеет)
| Идея | Польза | Готовность · Приоритет |
|---|---|---|
| **XP/ачивки за ЦТ-практику** («Закрыл часть А», «10 пробников», «Неделя без пропусков», «Мастер стереометрии») | Платформа имеет XP/achievements/shop | 🔧 · P2 |
| **Серии (streak) и дневная цель** на дашборде модуля | Удержание | 🔧 · P1 |
| **Реакция питомца «Квантик»** на успехи/прогресс в ЦТ | Эмоц. вовлечение (как в игре) | 🔧 · P3 |
| **Рейтинг класса / соревнование** по решённым/точности | Соц. мотивация | 🆕 · P2 |
## 7. ИИ-ассистент (платформа имеет «Квантик-ассистент»)
| Идея | Польза | Готовность · Приоритет |
|---|---|---|
| **«Объясни это задание»** — ассистент даёт подсказку/разбор проваленной задачи | Персональный тьютор (тренд 2025) | 🔧 · P2 |
| **«Дай похожую задачу»** — генерация тренировки по слабой теме | Бесконечная практика | 🆕 · P3 |
| **Подсказки-ступени** (hint 1 → hint 2 → решение) вместо сразу ответа | Учит думать, не списывать | 🆕 · P2 |
## 8. UX / UI
| Идея | Польза | Готовность · Приоритет |
|---|---|---|
| **Ввод ответа части В**: подсказка формата (число/дробь/через `;`), нормализация запятая/точка | Меньше «ложных» ошибок ввода | 🔧 · P1 |
| **Палитра/калькулятор-блокнот** (черновик в интерфейсе) | Организует черновик (частая беда) | 🆕 · P3 |
| **Закладки «вернуться к задаче»** в пробнике | Стратегия теста (пропустить-вернуться) | 🆕 · P2 |
| **Адаптив под мобильные** (карточка задачи, формулы) | Учатся с телефона | 🔧 · P2 |
| **Прогресс-бар по темам/позициям** на входе в модуль | Сразу видно, что качать | 🔧 · P2 |
## 9. Учительские инструменты
| Идея | Польза | Готовность · Приоритет |
|---|---|---|
| **Выдать модуль классу** (`content_access` exam/ctmath) + назначить пробник/тему (`assignments`) | Базовый go-live | 🔧 · P1 |
| **Аналитика по классу**: слабые темы класса, кто отстаёт | Учитель видит, где помочь | 🆕 · P2 |
| **Конструктор пробника** (выбрать темы/сложность → собрать вариант) | Гибкие ДЗ | 🔧 · P3 |
## 10. Качество данных / техническое
| Идея | Польза | Готовность · Приоритет |
|---|---|---|
| **Авто-проверка контента**: скрипт-линтер (битые opts, нечисловые open-ответы, `<`/`>` в формулах, пустые решения) в CI | Не повторять найденные баги | 🆕 · P1 |
| **Тонкая таксономия**: дотегировать вопросы под 32 подтемы (сейчас грубое flat→subtopic) | Точнее практика по теме | 🔧 · P3 |
| **Связь exam-prep ↔ урок**: из проваленной темы — прямая ссылка на урок курса (не только §учебника) | Бесшовно «практика→теория» | 🆕 · P2 |
---
## Топ-рекомендации (если делать по очереди)
**Сразу (P1, быстрые победы):**
1. Go-live ученикам: `content_access` + публикация курса + раздача колод.
2. Режим **«работа над ошибками»** (реюз `exam_attempts`) + ссылка на урок.
3. **Полный пробник на таймер** с прогнозом балла.
4. **Дневная цель + серия** на дашборде.
5. Контент-линтер в CI + добить 866/1248/68 mc.
6. Подсказка формата ответа части В.
**Потом (P2):**
- Spaced-repetition для заданий; трек «Приёмы»; XP/ачивки за ЦТ; аналитика класса; видео-разборы; difficulty 1–5; перетегирование тем.
**Крупные ставки (P3):**
- ИИ-тьютор «объясни/дай похожую»; авто-карты из ошибок; конструктор пробников; чертежи через imggen; черновик-блокнот.
---
## Источники (веб)
- Подготовка к ЦЭ/ЦТ по математике — гайды и разбор ошибок: [centr-lazurkina.by](https://centr-lazurkina.by/podgotovka-k-cze-i-czt-po-matematike/), [formula-ct.by](https://formula-ct.by/math), [turboct.by](https://turboct.by/podgotovka-k-ct-po-matematike/programma), [РЕШУ ЦТ](https://math_ct.reshu.by/methodist)
- Фичи exam-prep / SR / аналитика: [AI study tools 2026](https://fast.io/resources/best-ai-study-tools-2026/), [Spaced repetition guide](https://makeheadway.com/blog/spaced-repetition-app/), [Test-prep platforms 2025](https://studyguides.com/articles/best-study-platforms-for-test-prep-popular-study-platforms)
- Папка материалов: roadmap-док «К прочтению» (методы, видеоканалы, приоритетные слабые темы), «Кедр»-отработки (целевые слабые темы), «100 баллов» (тематическая структура), шпоры-формулы.
+151
View File
@@ -0,0 +1,151 @@
# Пилот: блок «Стереометрия» до уровня занятий (второй эталон)
> Развёртка самого «дорогого» блока (раздел `stereometry`, модули M26–M29) в контент платформы.
> Стереометрия встречается в тесте ~6 раз — А2, А9, В1, В13, В17, В20 — и содержит сложнейшие задания (В17 подобие, В20 угол в пространстве).
> Структура совпадает с [PILOT_TRIGONOMETRY.md](PILOT_TRIGONOMETRY.md); специфика блока — sim `stereo`, **координатно-векторный метод** как универсальный «запасной» подход, и готовые «Кедр»-отработки слабых тем.
>
> ⚠️ После пивота (см. [BUILD_ON_QUESTIONS.md](BUILD_ON_QUESTIONS.md)): «тренажёр» — практика на банке
> `questions` через assignment `mode='topic'` (тема `61 Стереометрия`), а НЕ новые `exam_tasks`.
> Сложность в банке — 1–3. Уроки/карточки ниже — в силе.
---
## 0. Карта блока
| Модуль | Подтема (slug) | Позиции теста | Уровень | Sim | Учебник |
|---|---|---|---|---|---|
| M26 Расположение, сечения | `ster-basics` | А2, В1 | 🟡 | `stereo` | `geometry-10` |
| M27 Многогранники | `ster-polyhedra` | В13, В17 | 🟡🔴 | `stereo` | `geometry-10` |
| M28 Тела вращения | `ster-rotation` | А9, В13 | 🟡🔴 | `stereo` | `geometry-11` |
| M29 Углы и расстояния (коорд.-вект.) | `ster-angles-distances` | В20, В1 | 🔴 | `stereo` | `geometry-11` |
Курсовая структура: `course_section` «Стереометрия» → 4 `lessons` + колода карточек (формулы объёмов/площадей + координатный метод) + наборы `exam_tasks` по подтемам.
---
## M26. Расположение прямых и плоскостей, сечения 🟡 (А2, В1)
### Урок («Аксиоматика и взаимное расположение»)
1. `heading``{ "text": "Прямые и плоскости в пространстве: параллельность, пересечение, скрещивание" }`
2. `text``{ "html": "Три случая для двух прямых: пересекаются, параллельны, скрещиваются. Прямая и плоскость: лежит в ней, параллельна, пересекает. Две плоскости: параллельны или пересекаются по прямой." }`
3. `sim``{ "simId": "stereo", "caption": "Покрутите фигуру: найдите линию пересечения двух плоскостей и пары скрещивающихся прямых" }`
4. `callout``{ "variant": "info", "html": "Линия пересечения двух плоскостей проходит через их общие точки. В правильной пирамиде плоскости через апекс и центр основания пересекаются по прямой через апекс (например SO)." }`
5. `formula``{ "label": "Признак параллельности прямой и плоскости", "tex": "a\\parallel b,\\ b\\subset\\alpha,\\ a\\not\\subset\\alpha \\Rightarrow a\\parallel\\alpha" }`
6. `callout``{ "variant": "warn", "html": "В В1 (выбор верных утверждений о расстояниях) проверяйте КАЖДОЕ утверждение отдельно: расстояние между скрещивающимися — длина общего перпендикуляра, а не любого отрезка." }`
7. `flashcard` ×N (колода ниже).
### Разборы эталонов
- **А2** (РИКЗ-2024): правильная четырёхугольная пирамида SABCD, O — центр основания; найти прямую пересечения плоскостей DSO и SCB. Обе плоскости проходят через S → линия пересечения проходит через S; анализом получаем **SO**. Метод: общие точки двух плоскостей.
- **В1**: прямая треугольная призма, выбрать верные утверждения о расстояниях/равенстве отрезков (ответ — комбинация цифр). Метод: перевести каждое утверждение в проверяемый факт.
### Тренажёр (`exam_tasks`, subtopic=`ster-basics`)
- 🟡 difficulty 23: А2/В1-тип. Источник: «ШПОРА по СТЕОМЕ» (Кедр), Калинин-Терёшин, А2/В1 из РТ/ЦТ.
- **Критерий освоения**: ≥80% на А2+В1.
---
## M27. Многогранники: объёмы, площади, сечения, подобие 🟡🔴 (В13, В17)
### Урок («Призма, пирамида, параллелепипед»)
1. `heading``{ "text": "Объёмы и площади многогранников. Подобие в сечениях" }`
2. `formula``{ "label": "Объёмы", "tex": "V_{\\text{призмы}}=S_{\\text{осн}}\\cdot h,\\qquad V_{\\text{пирамиды}}=\\tfrac{1}{3}S_{\\text{осн}}\\cdot h" }`
3. `text``{ "html": "Сечение, параллельное основанию пирамиды, отсекает подобную фигуру. Если высота делится от вершины в отношении k, то линейные размеры сечения относятся к основанию как k, а ПЛОЩАДИ — как k²." }`
4. `formula``{ "label": "Подобие сечения ∥ основанию", "tex": "\\frac{S_{\\text{сеч}}}{S_{\\text{осн}}}=k^2,\\quad k=\\frac{\\text{высота до сечения}}{\\text{вся высота}}" }`
5. `sim``{ "simId": "stereo", "caption": "Сечение пирамиды плоскостью ∥ основанию" }`
6. `callout``{ "variant": "warn", "html": "В17 ловит на том, что относятся как k² именно ПЛОЩАДИ, а не длины. Сначала найдите k из отношения высот, потом возводите в квадрат." }`
7. `flashcard` ×N.
### Разбор эталона (В17, РИКЗ-2024)
> Плоскость ∥ основанию треуг. пирамиды делит высоту в отношении 5:3 от вершины. Площадь сечения меньше площади основания на 39. Найти площадь сечения.
> k = 5/(5+3) = 5/8 → S_сеч/S_осн = 25/64. Пусть S_осн = x → S_сеч = (25/64)x; x (25/64)x = 39 → (39/64)x = 39 → x = 64 → **S_сеч = 25**.
### Тренажёр (`exam_tasks`, subtopic=`ster-polyhedra`)
- 🟡 difficulty 3 (объёмы/площади) → 🔴 difficulty 5 (подобие В17). Источник: «Метод Кавальери», «100 баллов» стерео, В13/В17 из РТ/ЦТ.
- **Критерий освоения**: ≥75% (В17-тип уверенно).
---
## M28. Тела вращения: цилиндр, конус, шар/сфера 🟡🔴 (А9, В13)
### Урок («Цилиндр, конус, шар»)
1. `heading``{ "text": "Тела вращения: площади поверхностей и объёмы" }`
2. `formula``{ "label": "Шар и сфера", "tex": "S_{\\text{сферы}}=4\\pi R^2,\\qquad V_{\\text{шара}}=\\tfrac{4}{3}\\pi R^3" }`
3. `formula``{ "label": "Цилиндр", "tex": "S_{\\text{бок}}=2\\pi R h,\\qquad V=\\pi R^2 h" }`
4. `formula``{ "label": "Конус", "tex": "S_{\\text{бок}}=\\pi R l,\\qquad V=\\tfrac{1}{3}\\pi R^2 h" }`
5. `sim``{ "simId": "stereo", "caption": "Сечение цилиндра плоскостью, параллельной оси" }`
6. `callout``{ "variant": "info", "html": "Сфера, касающаяся плоскости: радиус в точку касания ⊥ плоскости. Расстояние от центра до точки плоскости и радиус образуют прямоугольный треугольник — теорема Пифагора." }`
7. `flashcard` ×N.
### Разборы эталонов (РИКЗ-2024)
- **А9**: квадрат с диагональю 8 в плоскости α; сфера касается α в точке пересечения диагоналей; расстояние от центра сферы до вершины квадрата 4√2. Найти площадь сферы. Полудиагональ = 4; R² = (4√2)² 4² = 32 16 = 16 → R = 4 → S = 4π·16 = **64π**.
- **В13**: цилиндр рассечён плоскостью ∥ оси, в сечении квадрат площади 100; расстояние от оси до плоскости √39. Найти S_бок/π. Сторона квадрата = 10 = высота = хорда; R² = (√39)² + 5² = 39+25 = 64 → R = 8; S_бок = 2π·8·10 = 160π → **160**.
### Тренажёр (`exam_tasks`, subtopic=`ster-rotation`)
- 🟡 difficulty 3 → 🔴 4. Источник: «Отработка по Шару» (Кедр), Калинин-Терёшин, А9/В13 из РТ/ЦТ.
- **Критерий освоения**: ≥80% А9, ≥70% В13.
---
## M29. Углы и расстояния в пространстве — координатно-векторный метод 🔴 (В20, В1)
> Ключевой модуль трека на 90–100. Универсальный приём: ввести координаты → выразить векторы → угол через скалярное произведение. «Если геометрия не идёт — считай координатами» (roadmap-документ).
### Урок («Координатный метод: угол между прямыми/плоскостями»)
1. `heading``{ "text": "Координаты в пространстве — универсальный способ найти угол и расстояние" }`
2. `text``{ "html": "Алгоритм В20: (1) ввести удобную систему координат (вершину фигуры в начало), (2) выписать координаты нужных точек, (3) составить направляющие векторы прямых, (4) угол — через косинус скалярного произведения." }`
3. `formula``{ "label": "Угол между прямыми через векторы", "tex": "\\cos\\varphi=\\frac{|\\vec a\\cdot\\vec b|}{|\\vec a|\\,|\\vec b|}" }`
4. `formula``{ "label": "Скалярное произведение и длина", "tex": "\\vec a\\cdot\\vec b=a_xb_x+a_yb_y+a_zb_z,\\quad |\\vec a|=\\sqrt{a_x^2+a_y^2+a_z^2}" }`
5. `sim``{ "simId": "stereo", "caption": "Угол между скрещивающимися прямыми" }`
6. `accordion` → альтернативы: угол между прямой и плоскостью (через нормаль), теорема о трёх синусах (раскрывается по желанию).
7. `callout``{ "variant": "warn", "html": "В числителе — МОДУЛЬ скалярного произведения (угол между прямыми ≤ 90°). Самая частая ошибка В20 — знак/потеря модуля и неверные координаты точек деления рёбер." }`
8. `ordering``{ "question": "Порядок решения В20 координатным методом", "items": ["Ввести систему координат","Выписать координаты точек (учесть отношения деления рёбер)","Составить направляющие векторы","cos φ через скалярное произведение и длины"] }`
9. `flashcard` ×N.
### Разбор эталона (В20, РИКЗ-2024)
> Прямой параллелепипед ABCDA₁B₁C₁D₁, объём 5√7/2; AB=√7, BC=√2, cos∠ABC=−√14/8; на рёбрах AA₁ и A₁B₁ точки M, N с AM:MA₁=4:1, A₁N:NB₁=1:4. Найти 8√66·cos φ, φ — угол между MN и BC₁.
> Метод: ввести координаты по основанию (с учётом cos∠ABC найти высоту из объёма), выписать M, N, B, C₁ с учётом отношений, составить MN и BC₁, найти cos φ. (Целевая задача для «Лабораторной по В20» и «Отработки В20 из РЦЭ-2025» — Кедр.)
### Тренажёр (`exam_tasks`, subtopic=`ster-angles-distances`)
- 🔴 difficulty 5. Источник (Кедр): «Лабораторная для отработки В20», «Отработка В20 из РЦЭ-2025», «Отработка скрещивающиеся», «Отработка Угол между прям. и плоск.»; «Векторы на экзаменах» (Шестаков).
- **Критерий освоения**: ≥60% В20 координатным методом (сложнейшая позиция теста).
---
## Колода карточек (`flashcard_decks` «Стереометрия — формулы»)
| front | back |
|---|---|
| V призмы | S_осн · h |
| V пирамиды | (1/3) S_осн · h |
| V цилиндра | π R² h |
| V конуса | (1/3) π R² h |
| V шара | (4/3) π R³ |
| S сферы | 4π R² |
| S_бок цилиндра | 2π R h |
| S_бок конуса | π R l |
| Сечение ∥ основанию: отношение площадей | k² (k — отношение высот от вершины) |
| Угол между прямыми (векторы) | cos φ = \|a·b\| / (\|a\|·\|b\|) |
| Скалярное произведение | aₓbₓ + a_yb_y + a_zb_z |
| Длина вектора | √(aₓ² + a_y² + a_z²) |
| Сфера касается плоскости | радиус в точку касания ⊥ плоскости (→ Пифагор) |
| Расстояние между скрещивающимися | длина общего перпендикуляра |
> Источник: «ШПОРА по СТЕОМЕ» (Кедр), «формулы» из «100 баллов» стерео.
---
## Сводный критерий освоения блока
| Уровень | Условие |
|---|---|
| 🟡 Ядро | А2/В1 ≥80%, А9 ≥80%, объёмы/площади (В13) ≥70% |
| 🔴 Продвинутый | + В17 (подобие) уверенно, В20 ≥60% координатным методом |
Детектор слабых тем вернёт `ster-*` подтему в фокус с предложением урока + sim `stereo` + § учебника (`geometry-10`/`geometry-11`).
---
## Заметки для авторинга
- `stereo` sim — единственная 3D-визуализация; ставить в каждый урок блока (повышает понимание расположения).
- В20 — отдельный мини-тренажёр из «Кедр»-материалов: это самые «дорогие» баллы, и они хорошо алгоритмизируются координатным методом.
- Чертежи задач (А2/А9/В1/В13/В17/В20) почти всегда нужны → `figure_html` (SVG/`<img>`) обязателен при оцифровке (см. [DIGITIZATION_SPEC.md](DIGITIZATION_SPEC.md) §3.2).
+152
View File
@@ -0,0 +1,152 @@
# Пилот: блок «Тригонометрия» до уровня занятий (шаблон тиражирования)
> Эталонная развёртка ОДНОГО блока (раздел `trigonometry`, модули M18–M20) в конкретный контент платформы.
> Демонстрирует полный конвейер: теория (`lesson_blocks`) → разбор → тренажёр (`exam_tasks`) → карточки (`flashcard_*`) → sim (`trigcircle`) → критерий освоения.
> Все data-shape блоков соответствуют реальному рендеру `frontend/lesson.html`. Остальные 8 блоков строятся по этому образцу.
>
> Почему тригонометрия как пилот: охватывает все три уровня (А3 🟢 → А8/В4 🟡 → В15 🔴), задействует sim `trigcircle`,
> карточки формул и философию «вывод вместо зубрёжки» — то есть прогоняет все возможности платформы.
>
> ⚠️ После пивота (см. [BUILD_ON_QUESTIONS.md](BUILD_ON_QUESTIONS.md)): уроки/`lesson_blocks`/карточки
> ниже остаются как есть; «тренажёр» — это НЕ новые `exam_tasks`, а практика на банке `questions`
> через assignment `mode='topic'` (темы `19 Тригонометрия`, `69 Тригонометрические уравнения`).
> Сложность в банке — 1–3.
---
## 0. Карта блока
| Модуль | Подтема (slug) | Позиции теста | Уровень | Sim | Учебник |
|---|---|---|---|---|---|
| M18 Круг и значения | `trig-circle` | А3 | 🟢 | `trigcircle` | `algebra-10-ch1` |
| M19 Тождества и формулы | `trig-identities` | А8, В4 | 🟡 | — | `algebra-10-ch1` |
| M20 Уравнения и отбор корней | `trig-equations` | В15 | 🔴 | `trigcircle` | `algebra-10-ch1` |
Курсовая структура: `course_section` «Тригонометрия» → 3 `lessons` (по модулю) + общая колода карточек + наборы `exam_tasks` по подтемам.
---
## M18. Тригонометрический круг и значения 🟢 (позиция А3)
### Урок (lesson «Тригонометрический круг»)
Последовательность `lesson_blocks` (type → data):
1. `heading``{ "text": "Тригонометрический круг: смысл синуса и косинуса" }`
2. `text``{ "html": "Точка на единичной окружности при повороте на угол α имеет координаты (cos α; sin α). Это определение, из которого выводится всё остальное — запоминать таблицы наизусть не нужно, нужно уметь «прочитать» круг." }`
3. `sim``{ "simId": "trigcircle", "caption": "Покрутите угол и следите за координатами точки — это и есть cos α и sin α" }`
4. `formula``{ "label": "Определения через круг", "tex": "\\cos\\alpha = x,\\quad \\sin\\alpha = y,\\quad \\tan\\alpha=\\frac{y}{x},\\quad \\cot\\alpha=\\frac{x}{y}" }`
5. `callout``{ "variant": "info", "html": "Знаки по четвертям: I (+,+), II (,+), III (,), IV (+,−). «Все Студенты Так Кричат» — sin/all/tan/cos положительны по четвертям." }`
6. `table` → таблица значений 0, π/6, π/4, π/3, π/2 для sin/cos/tan (как `{ "rows": [...] }`, формат table-блока).
7. `callout``{ "variant": "warn", "html": "Типичная ошибка: путать, где нуль у sin (при 0, π, 2π…) и у cos (при π/2, 3π/2…). На круге это видно: sin=ордината, cos=абсцисса." }`
8. `flashcard` ×N → атомы (см. колоду ниже), напр. `{ "front": "sin(π/6)", "back": "1/2" }`
9. `quiz` (само­проверка) → `{ "question": "При каком значении аргумента sin x = 0?", "options": ["π/2", "π", "π/4", "π/3"], "correctIndex": 1 }`
### Разбор эталона (позиция А3, формат реального теста)
> *Среди значений аргумента −π/6, π/4, π/3, −3π/2, −6π укажите то, при котором sin x = 0.*
> Решение: sin x = 0 ⟺ x = πk. Из списка кратно π только −6π. **Ответ: −6π.**
> (Реальное задание А3 из варианта РИКЗ-2024 — годится как эталон.)
### Тренажёр (`exam_tasks`, subtopic=`trig-circle`)
- 🟢 difficulty 12: значения sin/cos/tan по кругу, простейшие «где функция = 0/1/−1».
- Источник заданий: А3 из РТ/ЦТ всех лет + «Все_формулы_по_тригонометрии_для_ЦТ.png».
- **Критерий освоения**: ≥90% на наборе А3 (часть А — гарантированный балл).
---
## M19. Тождества и формулы (вывод!) 🟡 (позиции А8, В4)
### Урок (lesson «Тождества: как не учить 30 формул»)
1. `heading``{ "text": "Главное тождество и что из него следует" }`
2. `formula``{ "label": "Основное тригонометрическое тождество", "tex": "\\sin^2\\alpha + \\cos^2\\alpha = 1" }`
3. `text``{ "html": "Из основного тождества делением на cos²α и sin²α получаем связи с tan и cot — выводим на месте, а не заучиваем:" }`
4. `formula``{ "tex": "1+\\tan^2\\alpha=\\frac{1}{\\cos^2\\alpha},\\qquad 1+\\cot^2\\alpha=\\frac{1}{\\sin^2\\alpha}" }`
5. `accordion` → формулы сложения, двойного/половинного угла, преобразование суммы в произведение — каждая с краткой идеей вывода (раскрывается по желанию; не грузим всё сразу).
6. `callout``{ "variant": "info", "html": "Обратные функции: arcsin x ∈ [−π/2; π/2], arccos x ∈ [0; π], arctan x ∈ (−π/2; π/2). Помните области значений — на них ловят в А8." }`
7. `flashcard` ×N → ключевые формулы (колода ниже).
8. `matching``{ "pairs": [ {"left":"sin 2α","right":"2 sin α cos α"}, {"left":"cos 2α","right":"cos²α sin²α"}, {"left":"1 cos 2α","right":"2 sin²α"} ] }`
### Разборы эталонов
- **А8** (обратные функции + модуль): *Найдите значение (38/π)·arcsin(1) |7|.* arcsin(1)=−π/2 → (38/π)·(−π/2)=19; 197=26. **Ответ: 26.**
- **В4** (тождество): *Найдите ctg²α, если sin α = 1/5.* cos²α=11/25=24/25; ctg²α=cos²α/sin²α=(24/25)/(1/25)=24. **Ответ: 24.**
### Тренажёр (`exam_tasks`, subtopic=`trig-identities`)
- 🟡 difficulty 2–3: вычисление выражений по одному данному (sin→ctg² и т.п.), значения обратных функций, упрощения.
- Источник: А8/В4 из РТ/ЦТ + «формулы триги ВСЕ.pdf», `Trigonometria_2..5.pdf`, «09-11 Тригонометрия.docx».
- **Критерий освоения**: ≥85% на наборе А8+В4.
---
## M20. Тригонометрические уравнения и отбор корней 🔴 (позиция В15)
### Урок (lesson «Уравнения и отбор корней на промежутке»)
1. `heading``{ "text": "Простейшие уравнения и общие формулы корней" }`
2. `formula``{ "label": "Формулы корней", "tex": "\\sin x=a\\Rightarrow x=(-1)^n\\arcsin a+\\pi n;\\quad \\cos x=a\\Rightarrow x=\\pm\\arccos a+2\\pi n;\\quad \\tan x=a\\Rightarrow x=\\arctan a+\\pi n" }`
3. `text``{ "html": "Стратегия В15: (1) свести к произведению/простейшему виду (формулы преобразования), (2) выписать общие корни, (3) ОТОБРАТЬ корни на заданном промежутке — обычно перебором n, удобно на тригонометрическом круге." }`
4. `sim``{ "simId": "trigcircle", "caption": "Отбор корней: отметьте промежуток и проверьте, какие x = πn в него попадают" }`
5. `ordering``{ "question": "Порядок решения В15", "items": ["Преобразовать к произведению / простейшему виду","Выписать общие формулы корней","Подставить n и отобрать корни на промежутке","Сложить отобранные корни"] }`
6. `callout``{ "variant": "warn", "html": "Не теряйте ОДЗ (для tan/cot) и не забывайте оба семейства корней. Самая частая потеря баллов в В15 — неполный отбор." }`
7. `flashcard` ×N → формулы корней + преобразования произведения↔суммы.
### Разбор эталона (В15)
> *Найдите (в градусах) сумму различных корней уравнения 2·sin3x·cos3x sin6x·sin10x = 0 на (150°; 55°).*
> 2 sin3x cos3x = sin6x → sin6x sin6x·sin10x = sin6x(1 sin10x)=0 → sin6x=0 или sin10x=1. Далее отбор корней на промежутке и суммирование. (Эталон из варианта РИКЗ-2024.)
### Тренажёр (`exam_tasks`, subtopic=`trig-equations`)
- 🔴 difficulty 4–5: уравнения с преобразованием + отбор корней на промежутке.
- Источник: В15 из РТ/ЦТ + «Подборка_заданий_триг_уравнений.png», `Trigonometria_3..5.pdf`, разборы (Трушин «13 задача ЕГЭ-2017» — 4 способа, из roadmap-документа).
- **Критерий освоения**: ≥70% на наборе В15 (сложная часть В — целевой уровень).
---
## Колода карточек формул (`flashcard_decks` + `flashcard_cards`)
Одна колода на блок: `flashcard_decks.title = "Тригонометрия — формулы"`. Выдаётся классу через `flashcard_deck_access` (type='class'). Интервальное повторение — встроенный SM-2 (`flashcard_reviews`). Примеры карт (`front` / `back`):
| front | back |
|---|---|
| Определения через круг | cos α = x, sin α = y (на единичной окружности) |
| Основное тождество | sin²α + cos²α = 1 |
| 1 + tan²α | 1/cos²α |
| sin(α±β) | sin α cos β ± cos α sin β |
| cos(α±β) | cos α cos β ∓ sin α sin β |
| sin 2α | 2 sin α cos α |
| cos 2α | cos²α sin²α = 1 2sin²α = 2cos²α 1 |
| Понижение степени sin²α | (1 cos 2α)/2 |
| Область значений arcsin | [−π/2; π/2] |
| Область значений arccos | [0; π] |
| sin x = a (корни) | x = (1)ⁿ arcsin a + πn |
| cos x = a (корни) | x = ± arccos a + 2πn |
| tan x = a (корни) | x = arctan a + πn |
| Произведение в сумму: 2 sinα cosβ | sin(α+β) + sin(α−β) |
> Атомы (таблица значений π/6, π/4, π/3 и т.п.) — отдельными короткими картами для M18.
> Источник формул: «Все_формулы_по_тригонометрии_для_ЦТ.png», «формулы триги ВСЕ.pdf».
---
## Сводный критерий освоения блока
| Уровень | Условие перехода дальше |
|---|---|
| 🟢 База | А3-набор ≥90% |
| 🟡 Ядро | + А8/В4-набор ≥85% |
| 🔴 Продвинутый | + В15-набор ≥70%, отбор корней без потерь |
Детектор слабых тем платформы (точность <60% на 3+ попытках) автоматически вернёт `trig-*` подтему в фокус и предложит урок + § учебника `algebra-10-ch1`.
---
## Как тиражировать на остальные 8 блоков
1. Завести `course_section` = раздел (`numbers`, `equations`, …) из [TOPICS_SEED.md](TOPICS_SEED.md).
2. На каждый модуль — `lesson` по структуре §M18–M20: heading → теория/формулы (с выводом) → sim/диаграмма (где есть: `graph`/`graphtransform`/`geometry`/`stereo`/`trigcircle`/`quadratic`) → callout «ошибки» → flashcards → quiz.
3. Колода карточек на блок (формулы/факты) → `flashcard_deck_access` классу.
4. Наборы `exam_tasks` по подтемам (см. [DIGITIZATION_SPEC.md](DIGITIZATION_SPEC.md)) с difficulty 1–5 и привязкой к позиции теста.
5. Прописать критерий освоения (порог по мини-тесту) в описании модуля.
6. Привязать `textbook_slug` (уже в TOPICS_SEED) для добора теории при ошибках.
Приоритет тиражирования (из §8 PLAN.md): часть А → В1–В10 → стереометрия → сложная часть В.
+318
View File
@@ -0,0 +1,318 @@
# Подготовка к ЦЭ/ЦТ по математике — модульный курс для BQ-System
> Статус: ПЛАН (черновик для согласования). Дата: 2026-06-14.
> Тип: модульная программа по темам, оформленная как курс платформы LearnSpace/BQ-System.
> Универсальность: один курс с входной диагностикой и ветвлением на 3 трека (База / Ядро / Продвинутый).
> Без жёсткой привязки к датам — проходится в своём темпе, контрольные точки по освоению, а не по календарю.
>
> Источник содержания: разбор папки `F:\!Рабочие\ЦТ\Математика\` (РТ 20062025, ЦТ/ЦЭ 20042024,
> «100 баллов all», сборники Сканави/Веременюк/Сиротина/Ларченко/Федорако/Барвенов, «Кедр от Егора»,
> папка ЕГЭ для продвинутого уровня) + реальный сборник РИКЗ «ЦЭ ЦТ Математика 2024» (формат теста).
> Инвентарь источников по модулям/уровням — в [RESOURCES.md](RESOURCES.md).
> ⚠️ **ПИВОТ (2026-06-14):** задания ЦЭ/ЦТ по математике **уже в БД** — таблица `questions`
> (`subject_id=3`, **1753 задания** 2011–2025). Поэтому курс строим на этом банке через
> `tests`/`assignments` (есть готовый `mode='ct'`) и `courses`, а НЕ через exam-prep (`exam_tasks`).
> Актуальный технический маппинг — в **[BUILD_ON_QUESTIONS.md](BUILD_ON_QUESTIONS.md)** (он заменяет
> §6 и §8 ниже в части «куда складывать» и «оцифровка»). Карта теста (§1), методика (§2), модули (§3),
> уровни (§4) и шаблон модуля (§5) — в силе. Сложность в банке — **13** (а не 1–5, как в §3).
---
## 0. Как читать этот документ
Документ описывает **что учить, в каком порядке, по каким материалам и как это ляжет в платформу**
не расписание по дням. Разделы:
1. **Карта экзамена** — точная структура теста и раскладка 30 заданий по темам/сложности → задаёт приоритеты.
2. **Методические принципы** — на чём стоит курс (взято в т.ч. из roadmap-документа автора подборки).
3. **Модульная программа** — 9 блоков, ~30 тематических модулей: цель, содержание, позиции в тесте, источники, критерий освоения.
4. **Уровневые траектории** — диагностика + 3 трека, правила ветвления (универсальность).
5. **Единый шаблон модуля** — как каждый модуль превращается в уроки/тесты/карточки платформы.
6. **Маппинг на BQ-System** — конкретные таблицы/сущности (`exam_tracks`, `exam_tasks`, `exam_topics`, `courses→sections→lessons→blocks`, флешкарты, sims, `content_access`).
7. **Контроль и аналитика** — диагностика, mastery, слабые темы, пробники, прогноз балла.
8. **Порядок наполнения** — что оцифровывать/наполнять первым (по частотности и весу в балле).
9. **Открытые вопросы и решения по умолчанию**.
---
## 1. Карта экзамена (что мы готовим)
### 1.1. Формат (РИКЗ, актуальный)
| Параметр | Значение |
|---|---|
| Часть А | **А1–А10** — закрытые задания, выбор 1 из 5 |
| Часть В | **В1–В20** — открытый ответ (число / слово / комбинация цифр-букв) |
| Всего | **30 заданий** |
| Время | ~**180 минут** (уточнять по спецификации РИКЗ текущего года) |
| Балл | переводится в **100 тестовых**; часть В весит существенно больше части А |
| Калькулятор | запрещён |
> ⚠️ Точная шкала «первичный → тестовый» публикуется РИКЗ ежегодно (таблицы соответствия).
> В платформе хранится в `exam_tracks.scoring_json` — обновляется под актуальный год.
### 1.2. Раскладка заданий по темам (по реальному варианту РИКЗ-2024 + стабильным позициям прошлых лет)
Позиции в ЦЭ/ЦТ из года в год держат тему довольно стабильно. Это **главный инструмент приоритизации**:
видно, какие темы дают «дешёвые» гарантированные баллы (часть А, ранние В) и где «дорогие»/сложные баллы.
**Часть А (А1–А10) — база, цель: закрыть на 100%**
| № | Типовая тема | Раздел | Сложность |
|---|---|---|---|
| А1 | Координатная прямая, действительные числа, оценка значения | Числа | низкая |
| А2 | Стереометрия: взаимное расположение прямых/плоскостей, сечения | Стереометрия | низкая–сред. |
| А3 | Тригонометрия: значения функций, простейшие уравнения | Тригонометрия | низкая |
| А4 | Числа: деление с остатком, проценты, отношения, формула по условию | Числа | низкая |
| А5 | Квадратные уравнения, теорема Виета | Уравнения | низкая |
| А6 | Множества/числовые промежутки, объединение и пересечение | Неравенства | низкая |
| А7 | Простая текстовая задача (стоимость, проценты, остаток) | Текстовые | низкая |
| А8 | Обратные тригонометрические функции + модуль, вычисление выражения | Тригонометрия | сред. |
| А9 | Стереометрия: сфера/шар, касание плоскости, площади/объёмы | Стереометрия | сред. |
| А10 | Область определения: корни, степени с дробным показателем | Функции/выражения | низкая–сред. |
**Часть В (В1–В20) — основной вес балла; растёт по сложности к концу**
| № | Типовая тема | Раздел | Сложность |
|---|---|---|---|
| В1 | Стереометрия: расстояния/углы, выбор верных утверждений | Стереометрия | сред. |
| В2 | Свойства квадратичной функции (нули, вершина, пересечения) — сопоставление | Функции | низкая–сред. |
| В3 | Числа/прогрессии: сумма натуральных по условию (кратность, диапазон) | Прогрессии | низкая |
| В4 | Тригонометрические тождества (по sin найти ctg² и т.п.) | Тригонометрия | сред. |
| В5 | Планиметрия: прямоугольный треугольник, описанная окружность | Планиметрия | сред. |
| В6 | Прогрессии (геометрическая/арифметическая), сумма членов | Прогрессии | сред. |
| В7 | Текстовая задача: проценты/движение/работа/смеси | Текстовые | сред. |
| В8 | Двойные неравенства, целые решения | Неравенства | низкая–сред. |
| В9 | Функция: чётность/симметрия, значения | Функции | сред. |
| В10 | Планиметрия: правильные многоугольники, вписанная/описанная окружность | Планиметрия | сред. |
| В11 | Логарифмические уравнения | Логарифмы | сред. |
| В12 | Числа: дроби, деление с остатком, НОК/НОД, текст | Числа | сред.–выс. |
| В13 | Стереометрия: цилиндр/конус, сечения, площади | Стереометрия | сред.–выс. |
| В14 | Показательные неравенства | Показательные | сред.–выс. |
| В15 | Тригонометрические уравнения, отбор корней на промежутке | Тригонометрия | высокая |
| В16 | Логарифмические неравенства (часто метод рационализации) | Логарифмы | высокая |
| В17 | Стереометрия: подобие, сечение ∥ основанию, отношения площадей/объёмов | Стереометрия | высокая |
| В18 | Иррациональные уравнения | Иррациональные | высокая |
| В19 | Производная: промежутки монотонности/экстремумы, исследование функции | Производная/функции | высокая |
| В20 | Стереометрия: угол между прямыми/плоскостями в координатах/векторах | Стереометрия | очень выс. |
### 1.3. Выводы для стратегии (заложить в курс)
- **«Дешёвые» гарантированные баллы**: вся часть А + В1–В10 — это база и средний уровень. Их закрытие = проходной/средний балл. Приоритет №1 для треков «База» и «Ядро».
- **Стереометрия — сквозная и «дорогая»**: встречается ~5–6 раз (А2, А9, В1, В13, В17, В20), включая самые сложные В17/В20. Отдельный усиленный блок; именно сюда бьют «Кедр»-отработки (B20, шар, скрещивающиеся, угол прямой и плоскости).
- **Тригонометрия — частая и многоуровневая**: А3, А8, В4, В15. От простого к отбору корней. Нужен сильный модуль с выводом формул.
- **«Дорогие» сложные баллы**: В15, В16, В18, В19, В20 — для трека «Продвинутый». Метод рационализации (В16) и техника отбора корней (В15) дают непропорционально много.
- **Производная (В19)** — отдельный модуль; в школьной базе часто провисает.
---
## 2. Методические принципы курса
Сформулированы в т.ч. из roadmap-документа автора подборки (`К прочтению…docx`) и подтверждаются картой теста:
1. **Понимание > зубрёжка.** Формулы выводим, а не заучиваем (особенно тригонометрия: 2–3 факта → всё остальное). Заучивание — только для «атомов» (таблица значений, базовые тождества) и через интервальное повторение.
2. **Метод рационализации (замены множителей)** — стержневая техника для В16/В14/неравенств. Отдельный модуль; экономит баллы и время.
3. **Техника теста ≠ техника олимпиады.** Учим быстро решать закрытую часть (подстановка вариантов, прикидка, отсев), грамотно оформлять открытую часть, управлять временем (180 мин на 30 заданий).
4. **Реальные РТ/ЦТ — основной тренажёр.** Теория → типовые задачи → реальные задания этого номера из прошлых лет. В папке РТ 20062025 и ЦТ 2004–2024 — огромный банк.
5. **Диагностика и адресность.** Входной тест → персональный маршрут; постоянный детектор слабых тем (платформа умеет: точность <60% на 3+ попытках → тема в фокус).
6. **Интервальное повторение формул** через флешкарты со spaced repetition (готовый движок платформы).
7. **Спираль, а не линия.** Базовые темы повторяются на возрастающей сложности; финал — режим пробников (полные варианты на время).
---
## 3. Модульная программа (ядро)
9 блоков. Каждый модуль описан единым форматом:
**Цель · Что входит · Позиции в тесте · Уровень · Ключевые источники · Критерий освоения.**
(Полный список файлов-источников по каждому модулю и уровню — в [RESOURCES.md](RESOURCES.md).)
Обозначение уровня: 🟢 База · 🟡 Ядро · 🔴 Продвинутый (модуль может покрывать несколько уровней с разной глубиной).
### Блок I. Числа и вычисления 🟢
- **M1. Действительные числа, координатная прямая, оценка значений** — позиции А1, А4. Цель: уверенно читать числовую прямую, сравнивать/оценивать, проценты, отношения. Источники: «100 баллов» 01, Ткачук (низы), Вычисления_doc. Критерий: ≥90% на наборе А1/А4.
- **M2. Делимость, остатки, НОД/НОК, обыкновенные/десятичные дроби** — позиции А4, В3, В12. Цель: деление с остатком как формула, признаки делимости, текст на дроби/НОК. Источники: «100 баллов» 01, Сиротина (числа). Критерий: ≥85%, в т.ч. В12-тип.
- **M3. Стандартные преобразования числовых выражений** — сквозное (фундамент всего). Степени, корни, модуль числа, порядок действий. Источники: Вычисления_doc, Ткачук. Критерий: автоматизм.
### Блок II. Алгебраические преобразования 🟢🟡
- **M4. Многочлены, формулы сокращённого умножения, разложение на множители** — фундамент уравнений/неравенств. Источники: «100 баллов» 01–03, Ткачук. Критерий: безошибочное разложение, выделение полного квадрата.
- **M5. Степени и корни (степенная/иррациональная алгебра), ОДЗ выражений** — позиции А10, подготовка к В18. Источники: «100 баллов» 12 (Степенная и иррациональные), Irratsionalnye_Uravnenia.pdf. Критерий: верная ОДЗ, преобразование корней.
- **M6. Рациональные (алгебраические) дроби** — подготовка к рациональным уравнениям/неравенствам. Источники: «100 баллов» 05. Критерий: сокращение, приведение, область определения.
### Блок III. Уравнения и неравенства 🟢🟡🔴
- **M7. Линейные уравнения и неравенства, системы** — позиции А6, В8. Источники: «100 баллов» 03, «Материал по системам» (Кедр), «Операции с двойными неравенствами» (Кедр). Критерий: двойные неравенства, целые решения (В8).
- **M8. Квадратные уравнения и неравенства, теорема Виета** — позиции А5, фундамент. Источники: «100 баллов» 04. Критерий: Виет устно, метод интервалов для квадратичных.
- **M9. Рациональные уравнения и неравенства, метод интервалов** — позиции В-уровня. Источники: «100 баллов» 05, Neravenstva.pdf, «Эффективные пути решения неравенств». Критерий: метод интервалов с кратностями.
- **M10. Уравнения и неравенства с модулем** — Источники: «100 баллов» 06. Критерий: раскрытие модуля по определению и по промежуткам, геометрический смысл.
- **M11. Иррациональные уравнения и неравенства** — позиция В18. Источники: «100 баллов» 12, Irratsionalnye_Uravnenia.pdf, «Функциональные методы решения уравнений». Уровень 🟡🔴. Критерий: равносильные переходы с ОДЗ, В18-тип.
- **M12. Показательные уравнения и неравенства** — позиция В14. Источники: «100 баллов» 13–14. Критерий: В14-тип на время.
- **M13. Логарифмы: уравнения и неравенства** — позиции В11, В16. Источники: «100 баллов» 13–15, «Шпора по свойствам функций». Уровень 🟡🔴. Критерий: В11 уверенно; В16 — через ОДЗ.
- **M14. Метод рационализации (замена множителей)** 🔴 — стержень для В16/В14 и сложных неравенств. Источники: roadmap-ссылки автора + «Эффективные пути решения неравенств», Neravenstva.pdf. Критерий: решать В16 «в три строчки».
### Блок IV. Функции, графики, производная 🟡🔴
- **M15. Функции: ОДЗ, область значений, чётность/симметрия, монотонность** — позиции А10, В2, В9. Источники: «100 баллов» 16, «Шпора по свойствам функций» (Кедр), «Отработка функций» (Кедр). Критерий: В2/В9-тип.
- **M16. Преобразования графиков, чтение графиков** — поддержка В2/В9. Источники: «100 баллов» 16. Привязка sim: `graphtransform`. Критерий: строить/читать сдвиги-растяжения.
- **M17. Производная: смысл, правила, монотонность, экстремумы, исследование** — позиция В19. Источники: Пратусевич (ЕГЭ), Ткачук (анализ). Уровень 🟡🔴. Критерий: В19-тип (промежутки возрастания, наибольшее/наименьшее).
### Блок V. Тригонометрия 🟢🟡🔴
- **M18. Тригонометрический круг, значения, простейшие уравнения** — позиции А3. Источники: «Все формулы по тригонометрии для ЦТ» (Кедр, png), Trigonometrii_1. Привязка sim: `trigcircle`. Критерий: А3 устно.
- **M19. Тождества и формулы (вывод!), обратные функции** — позиции А8, В4. Источники: «формулы триги ВСЕ.pdf», «09-11 Тригонометрия», Trigonometria_2..5. Критерий: вывод формул из 2–3 базовых, В4-тип.
- **M20. Тригонометрические уравнения, отбор корней на промежутке** 🔴 — позиция В15. Источники: «Подборка заданий триг уравнений» (Кедр), Trigonometria_3..5. Критерий: В15-тип (сумма корней на интервале).
### Блок VI. Прогрессии и текстовые задачи 🟢🟡
- **M21. Арифметическая и геометрическая прогрессии** — позиции В3, В6. Источники: Progressii_I_Textovye_Zadachi.pdf, «100 баллов». Критерий: В3/В6-тип.
- **M22. Текстовые задачи: проценты, движение, работа, смеси/сплавы/растворы** — позиции А7, В7. Источники: «СОЧНАЯ подборка текстовых задач», «Текстовые задачи пути решения Инишева», «Отработка на сплавы/растворы» (Кедр), «Решение задач на концентрации». Критерий: А7 устно, В7-тип всех 4 видов.
### Блок VII. Планиметрия 🟡🔴
- **M23. Треугольники: признаки, площади, теоремы синусов/косинусов, окружности (вписанная/описанная)** — позиции В5. Источники: «100 баллов» 1718, 2325, 13_testy_Planimetria.pdf, Gordin_7-9 (для базы геометрии), Прасолов «Планиметрия» (🔴). Критерий: В5-тип.
- **M24. Четырёхугольники и правильные многоугольники** — позиции В10. Источники: «Свойства четырёхугольников» (Кедр), «100 баллов» 19–22. Критерий: В10-тип (правильный шестиугольник и т.п.).
- **M25. Окружность: углы, касательные, степень точки; координатный метод** — поддержка В5/В10. Источники: «Уравнение окружности» (Кедр), Клетеник (аналит. геометрия, 🔴). Критерий: координатный метод как запасной.
### Блок VIII. Стереометрия 🟡🔴 (усиленный — самый «дорогой» блок)
- **M26. Аксиоматика, взаимное расположение прямых и плоскостей, сечения** — позиции А2, В1. Источники: «100 баллов» 26–28, ШПОРА по СТЕОМЕ (Кедр), Калинин-Терёшин «Стереометрия». Критерий: А2/В1-тип.
- **M27. Многогранники: призма, пирамида, параллелепипед — объёмы, площади, сечения, подобие** — позиции В13(частично), В17. Источники: «Метод Кавальери», «Отработка по Шару», «100 баллов» стерео. Критерий: В17-тип (сечение ∥ основанию, отношения).
- **M28. Тела вращения: цилиндр, конус, шар/сфера** — позиции А9, В13. Источники: «Отработка по Шару» (Кедр), Калинин-Терёшин. Критерий: А9/В13-тип.
- **M29. Углы и расстояния в пространстве: угол между прямыми/прямой и плоскостью/плоскостями; координатно-векторный метод** 🔴 — позиция В20 (и В1). Источники: «Лабораторная для отработки В20», «Отработка В20 из РЦЭ-2025», «Отработка скрещивающиеся», «Отработка Угол между прям. и плоск.» (всё Кедр), «Векторы на экзаменах» (Шестаков), теорема о трёх синусах. Привязка sim: `stereo`. Критерий: В20-тип координатным методом.
### Блок IX. Продвинутое и комбинированное 🔴 (для трека на 90–100)
- **M30. Задачи с параметрами** — Источники: Высоцкий «Задачи с параметрами», Прокофьев «Задачи с параметрами». Критерий: графический и аналитический методы.
- **M31. Комбинированные задачи и нестандартные приёмы** — Источники: Сканави_2013, Ларченко_2021, Федорако Практикум, Барвенов/Бахтина «Тренинг ЦТ». Критерий: смешанные варианты без подсказки темы.
- **M32. Функциональные методы, уравнения в целых числах (по желанию)** — Источники: «Функциональные методы решения уравнений», Серпинский (целые числа). Олимпиадный бонус.
---
## 4. Уровневые траектории (универсальность)
### 4.1. Входная диагностика
Короткий адаптивный тест из реальных заданий разных номеров (по 1–2 на каждый раздел, смесь А и В).
Реализация: режим `mode='mock'`/диагностический набор `exam_tasks`. Результат → автоматический трек и список приоритетных модулей (детектор слабых тем платформы).
### 4.2. Три трека (ветвление по результату диагностики)
**🟢 Трек «База» (слабая база, цель — порог/средний балл).**
- Фокус: M1M10, M18M19, M21M24, M26 + часть А целиком и В1–В10.
- Источники: Ткачук, «60 уроков», базовая теория «100 баллов», Gordin_7-9 для геометрии.
- Глубина: восстановление школьных основ → типовые задания → А-часть на 90%+.
- Сложные В15/В16/В18/В19/В20 — обзорно («как минимум подступиться»), без обязательного мастерства.
**🟡 Трек «Ядро» (средний уровень, цель — высокий балл).**
- Фокус: все модули M1–M29, акцент на часть В и слабые темы из диагностики.
- Источники: «100 баллов» (тесты), РТ всех лет, ЦТ прошлых лет, точечные «Кедр»-отработки слабых тем.
- Глубина: уверенно вся часть А + В1–В19; В20 — координатным методом.
**🔴 Трек «Продвинутый» (сильная база, цель — 90–100).**
- Фокус: M9M14, M17, M20, M27M32 + полный разбор ошибок.
- Источники: Сканави, Высоцкий (параметры), Прасолов/Понарин/Калинин-Терёшин (геометрия), Барвенов/Бахтина, папка ЕГЭ (задачи 18/19 уровня).
- Глубина: метод рационализации, параметры, сложная стереометрия, скоростное решение полных вариантов; работа над оформлением и «глупыми» ошибками.
### 4.3. Правила ветвления
- Диагностика по разделу < 50% → раздел проходится с трека «База» независимо от общего трека.
- Раздел освоен на ≥85% → можно пропустить базовые модули и идти на повышенную сложность.
- Финальная фаза для всех треков — **режим пробников** (полные варианты РТ/ЦТ на время) + адресная доработка слабых тем.
---
## 5. Единый шаблон модуля (как модуль становится контентом платформы)
Каждый модуль M-N разворачивается в одинаковую структуру (= один `course_section` или связка `lesson`+тесты):
1. **Теория**`lesson` из `lesson_blocks`: `heading``text`/`formula` (вывод, а не список) → `callout` (типичные ошибки) → при необходимости `sim`/`geogebra` (геометрия, графики, тригокруг) → `flashcard` (формулы-атомы модуля).
2. **Разбор эталонных задач** — 3–5 решённых типовых заданий именно того номера теста (`text`+`formula` блоки или решённые `exam_tasks` с `solution_html`).
3. **Тренировка (трёхуровневая)** — наборы заданий 🟢/🟡/🔴 из `exam_tasks` (классифицированы `difficulty` 15, `topic`/`subtopic`). Практика-режим платформы: `GET /exam-prep/:key/topics/:slug/practice`.
4. **Мини-тест модуля** — короткий контрольный набор; порог mastery (см. §7).
5. **Карточки формул**`flashcard_deck` модуля, выдаётся классу/ученику (`flashcard_deck_access`), интервальное повторение.
6. **Привязка к реальным заданиям** — задания этого номера из РТ/ЦТ прошлых лет (банк `exam_tasks`), с привязкой к §учебника (`textbook_slug`/`textbook_paragraph`) для добора теории при ошибке.
7. **Критерий освоения** — конкретный порог по мини-тесту/практике (из §3 модуля).
---
## 6. Маппинг на платформу BQ-System
> ⚠️ Раздел ниже описывал маппинг на **exam-prep** (`exam_tasks`) — это оказалось НЕ тем местом:
> весь контент ЦЭ/ЦТ уже лежит в банке `questions`. **Актуальный маппинг — в
> [BUILD_ON_QUESTIONS.md](BUILD_ON_QUESTIONS.md).** Текст ниже сохранён как альтернатива (exam-prep,
> миграция 077 оставлена опцией), но основной путь — `questions`/`tests`/`assignments`/`courses`.
Платформа уже имеет почти всё необходимое (модуль exam-prep + курсы + флешкарты + sims + доступы). Наполнение = заполнение данных, не разработка движков.
### 6.1. Экзаменационный трек и банк заданий
- **`exam_tracks`**: создать трек, напр. `exam_key='ctmath'` (или `cemath`), `title='Подготовка к ЦЭ/ЦТ по математике'`, `subject_slug='math'`, `tasks_per_variant=30`, `duration_min=180`, `scoring_json` = шкала РИКЗ текущего года, `intro_html` = карта теста (§1).
- **`exam_topics`** (дерево тем): разделы (parent=NULL) = 9 блоков из §3; подтемы = модули M1–M32 (`slug`, `title`, `sort_order`, `textbook_slug`/`textbook_paragraph` как fallback). Это даёт навигацию по темам и детектор слабых тем.
- **`exam_tasks`** (главный актив): оцифровать задания из РТ/ЦТ. Каждой задаче проставить `variant`, `task_idx` (130), `task_type` (`mc` для А, `open`/`long` для В), `text_html`, `figure_html`, `opts_json` (для А), `answer`, `solution_html`, `topic`/`subtopic` (= slug модуля), `difficulty` (15), `textbook_slug`+`textbook_paragraph` (добор теории). Полные варианты → можно собирать `exam_mock_sessions` (пробники на время).
- Практика/пробники/слабые темы/дашборд — **уже реализованы** (`/api/exam-prep/...`), включаются автоматически после наполнения данными.
### 6.2. Теория как курс
- **`courses`**: `subject_slug='math'`, `title='ЦЭ/ЦТ: математика — теория и техника'`, `is_published=1`, обложка.
- **`course_sections`**: по одному на блок (I–IX).
- **`lessons`** + **`lesson_blocks`**: по шаблону §5. Типы блоков под рукой: `text`, `formula`, `callout`, `quiz`, `sim`, `geogebra`, `flashcard`, `image`, `table`, `accordion`.
- При ошибке в задании ученик уходит в `textbooks` (учебники-главы через `parent_slug`) по ссылке `textbook_slug`/`textbook_paragraph`.
### 6.3. Формулы — флешкарты со spaced repetition
- **`flashcard_decks`** по модулям (тригонометрия, логарифмы, площади/объёмы, прогрессии…), **`flashcard_cards`** = формула/факт.
- **`flashcard_reviews`** (SM-2 + learning steps) ведёт интервальное повторение; **`flashcard_deck_access`** раздаёт колоды классу/ученику.
### 6.4. Геометрия — симуляции
- **`lab_sims`** уже содержит математические: `graph`, `graphtransform`, `trigcircle`, `geometry`, `stereo`. Встраивать `{type:'sim'}` в уроки модулей M16, M18, M29.
- **`lab_sim_links`** связывает sim с темой/§учебника (`kind='topic'|'textbook'`, `ref_id`).
- (Опционально) задания на построение — `geometry_tasks`/`geometry_submissions`.
### 6.5. Выдача и доступ
- **`content_access`** (allowlist) — открыть курс/трек/учебники классу или ученику (`content_type`, `content_ref`, `scope`, `target_id`, `allow=1`).
- **`assignments`** — домашки: режимы `exam|practice|topic|repeat`, привязка `textbook_id`+`textbook_paragraphs` (чтение+тренировка), дедлайн, `max_attempts`.
- **`class_courses`** — назначить курс классу.
- **`exam_user_plan`** — личный план ученика (дата экзамена, дневная норма, фокус на слабых темах) — опционально, т.к. курс без жёстких дат.
### 6.6. Прогресс и аналитика
- `lesson_progress` (теория), `exam_attempts` (каждая попытка + верность + просмотр решения), `exam_mock_sessions` (пробники со счётом), `textbook_progress` (чтение §).
- Дашборд ученика (`/api/exam-prep/:key/dashboard`): heatmap по темам, точность, серия, прогноз балла по `scoring_json`.
---
## 7. Контроль, аналитика, пробники
- **Диагностика** (вход) → трек + приоритетные модули.
- **Mastery-порог модуля** (рекомендация): ≥80% точности на мини-тесте при ≥8 попытках для 🟡; ≥90% для модулей части А (🟢). Не освоено → модуль остаётся в фокусе.
- **Детектор слабых тем** (есть в платформе): подтема с точностью <60% на 3+ попытках → авто-фокус, доп. практика + ссылка на §учебника/урок.
- **Пробники** (`exam_mock_sessions`): полные варианты РТ/ЦТ на 180 мин; финальная фаза каждого трека. Источник вариантов — банк `exam_tasks` (по `variant`) из РТ 20162025 и ЦТ 20172024.
- **Прогноз балла**: точность по пробникам → первичный → тестовый через `scoring_json`.
- **Работа над ошибками** — обязательный шаг после каждого пробника (для 🔴 — анализ «глупых» ошибок и оформления).
---
## 8. Порядок наполнения контентом (приоритеты для построения курса)
Чтобы курс был полезен максимально быстро, наполнять в порядке «частотность × вес в балле × доступность готового материала»:
1. **Каркас платформы**: создать `exam_track`, дерево `exam_topics` (блоки I–IX → модули M1–M32), курс + секции.
2. **Часть А (А1–А10)** — оцифровать задания этих номеров из ЦТ/РТ (дешёвые гарантированные баллы, нужны всем трекам).
3. **В1–В10** — средний уровень, основной вес для «Ядра».
4. **Стереометрия** (M26M29) и **тригонометрия** (M18–M20) — частые и «дорогие»; здесь же готовые «Кедр»-отработки (B20, шар, скрещивающиеся, угол).
5. **Сложная часть В** (В14–В20: M11M14, M17, M20, M27, M29) — для «Продвинутого».
6. **Флешкарты формул** по мере наполнения теории модулей.
7. **Полные варианты-пробники** (сборка `exam_mock_sessions`) из РТ/ЦТ 20172025.
8. **Продвинутый блок IX** (параметры, комбинированные) — в последнюю очередь.
Оцифровка реальных заданий из PDF (РТ/ЦТ) — отдельная задача (OCR/ручной ввод в `exam_tasks`); в репозитории уже есть практика переноса сборников ЦТ (см. память `project_ct_seeded`).
---
## 9. Открытые вопросы и решения по умолчанию
| Вопрос | Решение по умолчанию (если не уточнят) |
|---|---|
| `exam_key` трека | `ctmath` (единый трек ЦЭ+ЦТ, формат совпадает) |
| Целевой год / шкала баллов | актуальная шкала РИКЗ; обновлять `scoring_json` ежегодно |
| Какой банк вариантов оцифровывать первым | РТ 2022–2025 + ЦТ 2024 (ближе всего к текущему формату) |
| Учебники для `textbook_slug`-привязки | переиспользовать существующие учебники платформы (алгебра/геометрия 7–11) + при пробелах создавать главы-справки |
| Язык контента | русский |
| Нужен ли отдельный «продвинутый» трек контентом сразу | нет — сначала База+Ядро (часть А + В1–В19), Продвинутый блок IX позже |
---
## 10. Что дальше
После согласования этого плана возможные следующие шаги (по запросу):
- Детализировать **один блок до уровня занятий** (теория-источник с номерами страниц, конкретные наборы задач, мини-тесты) — как пилот.
- Спроектировать **дерево `exam_topics`** в виде готового seed (slug-и, заголовки, привязки к учебникам).
- Составить **спецификацию оцифровки** заданий РТ/ЦТ в `exam_tasks` (поля, классификатор темы/сложности).
- Собрать **диагностический тест** (набор `exam_tasks` для входа).
+64
View File
@@ -0,0 +1,64 @@
# Курс «Подготовка к ЦЭ/ЦТ по математике» — план для BQ-System
Модульная программа подготовки к ЦЭ/ЦТ по математике, оформленная как курс платформы LearnSpace/BQ-System.
Универсальная (диагностика + 3 уровня), без жёсткой привязки к датам. Построена на разборе папки
`F:\!Рабочие\ЦТ\Математика\` и реального формата РИКЗ-2024.
> ⚠️ **ПИВОТ (2026-06-14)****ОТДЕЛЬНЫЙ МОДУЛЬ (2026-06-15).** Контент ЦЭ/ЦТ был уже в БД (банк
> `questions`, 1753 задания). По решению пользователя ЦТ оформлен как **отдельный модуль exam-prep**:
> реальные задания ЦТ-11 перенесены из `questions` в `exam_tasks` (exam_key=`ctmath`). Модуль живёт на
> **`/exam-prep/ctmath`**. Технический документ — **[BUILD_ON_QUESTIONS.md](BUILD_ON_QUESTIONS.md)** (§0a).
> Параллельно есть и теория-курс (courses.id=13) + диагностика — на общих подсистемах.
## Документы
| Файл | Что внутри | Статус |
|---|---|---|
| [PLAN.md](PLAN.md) | **Программа.** Карта экзамена, методика, 9 блоков / ~32 модуля, уровни, шаблон модуля. | актуально (кроме §6/§8 — см. пивот) |
| [BUILD_ON_QUESTIONS.md](BUILD_ON_QUESTIONS.md) | **Главный тех-документ.** Сборка курса на существующем банке `questions`: режимы `mode='ct'`/`'topic'`, таксономия тем, курс/уроки, диагностика, пробники, прогресс, порядок работ. | актуально |
| [PILOT_TRIGONOMETRY.md](PILOT_TRIGONOMETRY.md) | Эталон блока «Тригонометрия» до уроков/блоков/карточек — шаблон тиражирования. | актуально (тренажёр = `mode='topic'`) |
| [PILOT_STEREOMETRY.md](PILOT_STEREOMETRY.md) | Второй эталон — «Стереометрия» (координатный метод В20, sim `stereo`). | актуально |
| [RESOURCES.md](RESOURCES.md) | Инвентарь материалов папки по модулям/уровням (для добивки/гэпов). | актуально |
| [TOPICS_SEED.md](TOPICS_SEED.md) | Seed exam-prep (`exam_tracks/exam_topics`, миграция 077). | вторично (опция exam-prep) |
| [DIGITIZATION_SPEC.md](DIGITIZATION_SPEC.md) | Оцифровка РТ/ЦТ в `exam_tasks`. | вторично (оцифровка уже сделана) |
**Код:** [`backend/src/db/migrations/077_ctmath_track_topics.sql`](../../backend/src/db/migrations/077_ctmath_track_topics.sql) — миграция трека `ctmath` + дерева тем для exam-prep (валидирована in-memory). **Оставлена как опция, в БД НЕ применена.** Основной путь — банк `questions`.
## Ключевые факты
- **Формат экзамена**: часть А — А1–А10 (выбор из 5), часть В — В1–В20 (открытый ответ), всего **30 заданий**, ~180 мин, до 100 тестовых баллов; часть В весит больше.
- **Контент уже есть**: банк `questions` (`subject_id=3`) — **1753 задания** 20112025 (ЦЭ-2024 = 117, набор 2025 = 1020), размечены по темам (`topics`, 19 шт.) и годам. Залиты `backend/scripts/seed_math_ct*.js`.
- **Готовый механизм ЦТ**: `assignments` с `mode='ct'` собирает вариант (Часть A из `single/true_false` + Часть B из `multi/short_answer`); `mode='topic'` — тренажёр по теме. Сложность в банке — 1–3.
- **Самый «дорогой» блок** — стереометрия (~6 заданий, включая сложнейшие В17/В20).
## Порядок реализации (на банке `questions`, см. BUILD_ON_QUESTIONS §8)
1. Таксономия: добавить недостающие темы (Производная, Иррациональные, Модуль, Показательные ур., Параметры).
2. Каркас курса: `courses('math','ЦЭ/ЦТ — Математика')` + 9 `course_sections`.
3. Диагностика: `test` из ~14 реальных вопросов банка (по 1 на тему) → выдать.
4. Уроки по приоритету (стерео, тригонометрия) — теория по пилотам + кнопка практики `mode='topic'`.
5. Пробники: assignment `mode='ct'` (30 заданий) + тематические `mode='topic'`.
6. Карточки формул; выдача классам через `content_access`/`class_courses`.
## Статус
ПЛАН на банке `questions` (пивот). **Каркас курса создан в живой БД** (скрипт
`backend/scripts/seed_ctmath_course.js`, идемпотентный): 6 новых тем (id 7277), DRAFT-курс
«ЦЭ/ЦТ — Математика» (`courses.id=13`, не опубликован) + 9 секций (id 27–35). Существующие данные
не тронуты. Миграция 077 (exam-prep) в БД не применялась.
**Отдельный модуль exam-prep `ctmath` (2026-06-15): ПОДНЯТ.** Трек `ctmath` (enabled), дерево тем 41
(9+32), **723 задания** в `exam_tasks` (525 mc + 191 open + 7 long) из реального банка ЦТ-11.
Работает на `/exam-prep/ctmath` (дашборд, темы, практика, слабые темы, пробники). Скрипт-конвертер:
`backend/scripts/seed_ctmath_exam_tasks.js`.
Также (на общих подсистемах): **теория-курс `courses.id=13`** (черновик) — теперь **все 9 секций, 15 уроков**
(`lessons.id=4155`: тригонометрия 41–43, стереометрия 44–47, числа/преобразования/уравнения×2/функции/
прогрессии/планиметрия/продвинутое 48–55) + **4 колоды флешкарт формул** (`flashcard_decks.id=1114`, 49 карт:
тригонометрия/стереометрия/логарифмы-степени/производная) + диагностика `tests.id=164` + новые темы.
Осталось:
- ✅ пункт сайдбара · ✅ уроки всех блоков · ✅ колоды формул.
- выдать доступ ученикам: `content_access` (exam/ctmath) классу + раздать колоды (`flashcard_deck_access`) + опубликовать курс (`is_published=1`); решить видимость;
- мелкий фикс задач `exam_tasks.id=866, 1248` — скрипт `backend/scripts/fix_ctmath_misc.js --apply` (запускает пользователь);
- (опц.) углубить уроки (2-й урок в «лёгких» секциях); дотегировать вопросы под тонкие подтемы.
+132
View File
@@ -0,0 +1,132 @@
# Инвентарь материалов ЦТ/ЦЭ (математика) → привязка к модулям и уровням
> Карта папки `F:\!Рабочие\ЦТ\Математика\Математика\` к модулям программы из [PLAN.md](PLAN.md).
> Уровни: 🟢 База · 🟡 Ядро · 🔴 Продвинутый.
> Назначение: чтобы автор контента знал, из чего брать теорию/задачи для каждого модуля и что оцифровывать первым.
---
## A. Банки реальных заданий (главный тренажёр) — для `exam_tasks` / пробников
| Папка / файл | Что это | Уровень | Применение |
|---|---|---|---|
| `РТ\` (20062007 … 20242025) | Репетиционное тестирование, 3 этапа × 2 варианта, многие с ответами | 🟢🟡🔴 | Основной банк заданий по номерам; пробники. **Приоритет оцифровки: 2022–2025** (текущий формат А1–А10 / В1–В20) |
| `ЦТ-ЦЭ\` (2004 … 2024) + `…\Если плохо видно ответы…DJVU\` | Реальные ЦТ/ЦЭ прошлых лет + ответы | 🟢🟡🔴 | Банк заданий и эталонных решений. `ЦЭ-ЦТ-2024 МАТ.pdf` — эталон текущего формата |
| `ДРТ\` (20152023, 2024 + консультация) | Досрочное РТ + разборы консультаций | 🟡🔴 | Доп. варианты и методические разборы |
> Эти PDF — источник для наполнения таблицы `exam_tasks` (по варианту/номеру/теме/сложности) и сборки `exam_mock_sessions`.
---
## B. Посекционный курс «100 баллов all» (теория + тесты по темам) — каркас теории
Папка `Прочее\100 баллов all\`. Нумерация совпадает с темами — удобно ложится в модули.
| Файл | Модуль(и) | Уровень |
|---|---|---|
| `01 ПОЧИТАТЬ Числа.docx`, `Vychislenia_doc.pdf` | M1M3 | 🟢 |
| `03_Линейные_уравнения_и_неравенства.docx` | M7 | 🟢 |
| `04_Квадратные_уравнения_и_неравенства.docx` | M8 | 🟢🟡 |
| `05_Рациональные_уравнения_и_неравенства.docx`, `Neravenstva.pdf` | M9 | 🟡 |
| `06_Уравнения_и_неравенства_с_модулями.docx` | M10 | 🟡 |
| `12 Степенная и иррациональные.docx`, `Irratsionalnye_Uravnenia.pdf` | M5, M11 | 🟡🔴 |
| `13-14 Показательные и логарифмы.docx`, `13_14_….docx`, `Показательная_и_начала_логарифмов.docx` | M12, M13 | 🟡 |
| `15 Логарифмическая.docx` (+ УПРОЩЕННОЕ), `15-16 Логарифмические и функции.docx` | M13, M15 | 🟡🔴 |
| `16 Функции.docx` | M15, M16 | 🟡 |
| `09-11 Тригонометрия.docx`, `Trigonometrii_1.pdf`, `Trigonometria_2..5.pdf` | M18M20 | 🟢🟡🔴 |
| `Progressii_I_Textovye_Zadachi.pdf` | M21, M22 | 🟢🟡 |
| `17-18 Прямые, углы, треугольник.docx`, `18 Произвольный треугольник.docx`, `13_testy_Planimetria.pdf` | M23 | 🟡 |
| `19-20 РСТТ, параллелограмм.pdf`, `20 Параллелограмм и ромб.docx`, `21_22_Прямоугольник,_квадрат,_трапеция.*` | M24 | 🟡 |
| `23-25 Окружность.docx`, `23_24_Окружность…pdf`, `25_26_Окружность_и_4_угольники…pdf`, `23Ответы.docx` | M24, M25 | 🟡 |
| `26-… Стереометрия.docx`, `27-28 Начала стереометрии.docx` | M26 | 🟡 |
| `Функциональный метод.docx` | M11, M32 | 🔴 |
| `ВводныйТест-24.docx` + `Разбор вводного теста.pdf` | Диагностика | — |
---
## C. «Кедр от Егора» — точечная отработка слабых/частых тем
Папка `Кедр от Егора (бесплатные с тгк)\`.
| Файл | Модуль | Уровень |
|---|---|---|
| `Все_формулы_по_тригонометрии_для_ЦТ (1).png`, `Подборка_заданий_триг_уравнений.png` | M18–M20 (+флешкарты формул) | 🟢🟡🔴 |
| `Шпора_по_свойствам_функций_ct_matem.pdf` | M13, M15 (+флешкарты) | 🟡 |
| `Материал по системам.pdf`, `Операции_с_двойными_неравенствами.pdf` | M7 | 🟢🟡 |
| `Отработка функций.pdf` | M15 | 🟡 |
| `Отработка на сплавы_растворы (1).pdf` | M22 | 🟡 |
| `Свойства четырехугольников.pdf` | M24 | 🟡 |
| `Уравнение окружности _ Материал.pdf` | M25 | 🟡 |
| `ШПОРА по СТЕОМЕ.pdf` | M26 (+флешкарты) | 🟡 |
| `Отработка по Шару.pdf` | M28 | 🟡🔴 |
| `Отработка скрещивающиеся.pdf`, `Отработка__Угол_между_прям_и_плоск.pdf` | M29 | 🔴 |
| `Лабораторная для отработки В20.pdf`, `Отработка В20 из РЦЭ-2025.pdf` | M29 (целевая отработка В20) | 🔴 |
| `Математический_Адвент_Календарь (1).pdf` | смешанная практика | 🟡 |
| `читаем!!.docx` | методич. навигация | — |
---
## D. Сборники и справочники (фундамент / углубление)
Папка `сборники, справочники и не только\` + `Прочее\`.
| Файл | Назначение | Модули | Уровень |
|---|---|---|---|
| `Прочее\ЕГЭ\Ткачук_математика_абитуре.pdf` | «с низов до верхов» — базовый курс абитуриента | M1–M17 | 🟢🟡 |
| `60 уроков.pdf` | антология 60 уроков, систематический базовый курс | M1–M24 | 🟢🟡 |
| `sirotina…posobie_dlya_podgotovki_k_tsentralizo.pdf` | пособие для подготовки к ЦТ (Сиротина) | сквозное | 🟡 |
| `Веременюк.pdf` | пособие/задачник для ЦТ | сквозное | 🟡 |
| `тренинг… Барвенов С.А., Бахтина Т.П.pdf` | тренинг задач именно ЦТ | M31 (смешанная практика) | 🟡🔴 |
| `Федорако Практикум_2016.(pdf/djvu)` | практикум по математике | M31 | 🟡🔴 |
| `Ларченко_2021.pdf` | большой задачник (в т.ч. текстовые) | M22, M31 | 🟡🔴 |
| `Сканави_2013.pdf` | классический сборник для абитуриентов | M9–M31 | 🔴 |
| `Эффективные пути решения неравенств.pdf`, `Функциональные методы решения уравнений.pdf` | продвинутые техники (рационализация и др.) | M9, M11, M14 | 🔴 |
| `Метод Кавальери поиска объема тел.pdf` | объёмы тел | M27 | 🔴 |
| `Решение задач на концентрации матекатика егэ.pdf` | смеси/сплавы/растворы | M22 | 🟡🔴 |
| `Текстовые задачи пути решения Инишева.pdf` | методика текстовых задач | M22 | 🟡 |
| `Калинин А.Ю., Терешин Д.А. Стереометрия 10.djvu` | сильная стереометрия | M26–M29 | 🔴 |
| `Серпинский… О решении уравнений в целых числах.djvu` | уравнения в целых числах (олимп.) | M32 | 🔴 |
| `Зельдович_вышмата для физиков.djvu` | справочник анализа (бонус) | M17 | 🔴 |
---
## E. Папка `Прочее\ЕГЭ\` — углублённая геометрия и параметры (трек 🔴)
| Файл | Модули | Уровень |
|---|---|---|
| `Vysotskiy_Parametr.pdf`, `Задачи с параметрами при подготовке к ЕГЭ_Высоцкий В.С.djvu`, `Задачи с параметрами_Прокофьев А.А.pdf` | M30 (параметры) | 🔴 |
| `ПРОСОЛОВ ПЛАНИМЕТРИЯ.pdf`, `Ponarin … t.1 ПЛАНИМЕТРИЯ.pdf` | M23M25 | 🔴 |
| `ЗАДАЧИ ПО СТЕРЕОМЕТРИИ ПРАСОЛОВ.pdf`, `Ponarin t.2 СТЕРЕОМЕТРИЯ.pdf`, `Stereoma_Gordin.pdf`, `Векторы на экзаменах…Шестаков.djvu` | M26M29 | 🔴 |
| `Gordin_7-9.pdf` | база геометрии (восстановление) | M23–M24 | 🟢🟡 |
| `Сборник задач по аналитической геометрии_Клетеник.pdf` | координатный метод | M25, M29 | 🔴 |
| `Пратусевич … Алгебра и начала мат. анализа 11 (профиль).djvu`, `Пратусевич_метод рекомендации.pdf` | производная/анализ | M17 | 🔴 |
| `ПРАСОЛОВ ЗАДАЧИ ПО АЛГЕБРЕ.djvu`, `основы алгебры Кострикин(1..3).pdf` | углублённая алгебра (бонус) | M31–M32 | 🔴 |
| `Shestakov_S_EGE2019_…18.pdf`, `ege17.pdf`, `me-d15.pdf`, `Книга_Wild-a.pdf`, `Gordin_7-9.pdf` | разборы сложных задач ЕГЭ-уровня | M30–M31 | 🔴 |
> Примечание: материалы ЕГЭ — российский экзамен; берём как **источник методов и сложных задач**, а формат/специфику держим по белорусским РТ/ЦТ.
---
## F. Текстовые задачи (отдельный мини-банк)
Папка `Прочее\СОЧНАЯ подборка ТЕкстовых задач\`: `сборка текстовых задач.docx` + `ответики на сборку.docx` → модуль **M22**, уровни 🟡🔴.
---
## G. Навигационные / методические документы (не контент, а ориентиры)
| Файл | Роль |
|---|---|
| `К прочтению..._.docx` | roadmap автора подборки: философия, ссылки на видеоразборы (Трушин, Wild Mathing, П. Маслов), приоритетные слабые темы |
| `Прочее\Полный Курс по подготовке.docx` | список тем «полного курса» (совпадает с блоками программы) |
| `Кедр…\читаем!!.docx` | навигация по «Кедр»-материалам |
---
## H. Что оцифровывать в первую очередь (сводка приоритета)
1. `ЦТ-ЦЭ\ЦЭ-ЦТ-2024 МАТ.pdf` + `РТ\2022-2023 … 2024-2025` — текущий формат, для `exam_tasks` части А и В1–В10.
2. `Кедр…` отработки стереометрии/тригонометрии — готовые наборы по «дорогим» темам.
3. `100 баллов all` — теория модулей (в `lesson_blocks`) + тематические тесты.
4. Формулы из `Все_формулы_по_тригонометрии`, `ШПОРА по СТЕОМЕ`, `Шпора_по_свойствам_функций``flashcard_decks`.
5. Полные варианты РТ/ЦТ 2017–2025 → `exam_mock_sessions` (пробники).
+135
View File
@@ -0,0 +1,135 @@
# Seed: трек `ctmath` и дерево тем `exam_topics`
> ⚠️ **ВТОРИЧНО (опция exam-prep).** Это seed для подсистемы exam-prep (миграция 077, оставлена как
> опция). **Основной путь** — банк `questions`, см. **[BUILD_ON_QUESTIONS.md](BUILD_ON_QUESTIONS.md)**.
> Этот документ полезен, если в будущем решим использовать exam-prep (варианты/пробники/детектор
> слабых тем). Реальные slug-и учебников отсюда переиспользуются и для ссылок из уроков.
> Готовый к переносу в SQL-миграцию seed дерева тем для курса ЦЭ/ЦТ по математике.
> Соответствует реальной схеме платформы (см. `022_exam_prep.sql`, `024_exam_topics_seed.sql`, `028_exam_topic_textbook_links.sql`).
> Двухуровневая иерархия: **раздел** (`parent_slug=NULL`) → **подтема** (`parent_slug` = slug раздела). Slug — kebab-case.
> Соглашение из существующего `math9`: раздел sort 10/20/30…, подтемы внутри 11,12,13…
>
> ⚠️ `textbook_paragraph` намеренно оставлен NULL почти везде (точные номера § уточняются при маппинге контента — не выдумываем). `textbook_slug` проставлен реальными slug-ами учебников платформы (главы/хабы).
---
## 1. Трек `exam_tracks`
```sql
INSERT INTO exam_tracks (
exam_key, title, subject_slug, grade, duration_min,
tasks_per_variant, variants_count, scoring_json, intro_html, enabled, sort_order
) VALUES (
'ctmath',
'ЦЭ/ЦТ — Математика',
'math',
11,
180,
30,
0, -- variants_count: проставить по числу оцифрованных вариантов
'<scoring_json>', -- см. §4 ниже
'<intro_html>', -- см. §5 ниже
1,
20
);
```
---
## 2. Разделы (sections, `parent_slug = NULL`)
| slug | title | description | sort | textbook_slug |
|---|---|---|---|---|
| `numbers` | Числа и вычисления | Действительные числа, делимость, проценты, преобразование числовых выражений | 10 | `math-6` |
| `expressions` | Алгебраические преобразования | Многочлены, степени и корни, рациональные дроби, ОДЗ | 20 | `algebra-7` |
| `equations` | Уравнения и неравенства | Линейные, квадратные, рациональные, модуль, иррациональные, показательные, логарифмические; метод рационализации | 30 | `algebra-9` |
| `functions` | Функции и производная | Свойства функций, графики, исследование с производной | 40 | `algebra-9-ch2` |
| `trigonometry` | Тригонометрия | Круг, тождества, уравнения и отбор корней | 50 | `algebra-10-ch1` |
| `word-sequences` | Прогрессии и текстовые задачи | Арифметическая/геометрическая прогрессии; проценты, движение, работа, смеси | 60 | `algebra-9-ch4` |
| `planimetry` | Планиметрия | Треугольники, четырёхугольники, окружность; координатный метод | 70 | `geometry-8` |
| `stereometry` | Стереометрия | Расположение, многогранники, тела вращения, углы и расстояния | 80 | `geometry-10` |
| `advanced` | Продвинутое и комбинированное | Параметры, комбинированные задачи, функциональные методы | 90 | NULL |
---
## 3. Подтемы (модули M1–M32, `parent_slug` = раздел)
Колонка «Позиции» — номера заданий теста (из карты §1.2 PLAN.md), помогает классификатору и приоритизации.
| slug | parent | title | sort | позиции | textbook_slug |
|---|---|---|---|---|---|
| `num-real` | numbers | Действительные числа, координатная прямая | 11 | А1, А4 | `math-6` |
| `num-divisibility` | numbers | Делимость, дроби, НОД/НОК | 12 | А4, В3, В12 | `math-5-ch1` |
| `num-expressions` | numbers | Преобразование числовых выражений | 13 | сквозное | `algebra-7-ch2` |
| `expr-polynomials` | expressions | Многочлены, ФСУ, разложение на множители | 21 | сквозное | `algebra-7-ch2` |
| `expr-powers-roots` | expressions | Степени и корни, ОДЗ выражений | 22 | А10 | `algebra-10-ch2` |
| `expr-fractions` | expressions | Рациональные (алгебраические) дроби | 23 | — | `algebra-9-ch1` |
| `eq-linear` | equations | Линейные уравнения/неравенства, системы | 31 | А6, В8 | `algebra-7-ch3` |
| `eq-quadratic` | equations | Квадратные уравнения/неравенства, Виет | 32 | А5 | `algebra-8` |
| `eq-rational` | equations | Рациональные уравнения/неравенства, метод интервалов | 33 | В-уровень | `algebra-9-ch3` |
| `eq-modulus` | equations | Уравнения и неравенства с модулем | 34 | — | `algebra-9` |
| `eq-irrational` | equations | Иррациональные уравнения/неравенства | 35 | В18 | `algebra-10-ch2` |
| `eq-exponential` | equations | Показательные уравнения/неравенства | 36 | В14 | `algebra-11-ch2` |
| `eq-logarithmic` | equations | Логарифмические уравнения/неравенства | 37 | В11, В16 | `algebra-11-ch3` |
| `eq-rationalization` | equations | Метод рационализации (замена множителей) | 38 | В16, В14 | `algebra-11` |
| `fn-properties` | functions | Свойства функций: ОДЗ, чётность, монотонность | 41 | А10, В2, В9 | `algebra-9-ch2` |
| `fn-graphs` | functions | Графики и их преобразования, чтение графиков | 42 | В2, В9 | `algebra-9-ch2` |
| `fn-derivative` | functions | Производная: монотонность, экстремумы, исследование | 43 | В19 | `algebra-10-ch3` |
| `trig-circle` | trigonometry | Тригонометрический круг, значения, простейшие ур-ия | 51 | А3 | `algebra-10-ch1` |
| `trig-identities` | trigonometry | Тождества и формулы (вывод), обратные функции | 52 | А8, В4 | `algebra-10-ch1` |
| `trig-equations` | trigonometry | Триг. уравнения, отбор корней на промежутке | 53 | В15 | `algebra-10-ch1` |
| `seq-progressions` | word-sequences | Арифметическая и геометрическая прогрессии | 61 | В3, В6 | `algebra-9-ch4` |
| `word-problems` | word-sequences | Текстовые: проценты, движение, работа, смеси | 62 | А7, В7 | `math-6-ch2` |
| `plan-triangles` | planimetry | Треугольники, площади, теоремы синусов/косинусов, окружности | 71 | В5 | `geometry-8` |
| `plan-quadrilaterals` | planimetry | Четырёхугольники и правильные многоугольники | 72 | В10 | `geometry-8-ch1` |
| `plan-circle` | planimetry | Окружность: углы, касательные; координатный метод | 73 | В5, В10 | `geometry-8-ch4` |
| `ster-basics` | stereometry | Расположение прямых/плоскостей, сечения | 81 | А2, В1 | `geometry-10` |
| `ster-polyhedra` | stereometry | Многогранники: объёмы, площади, сечения, подобие | 82 | В13, В17 | `geometry-10` |
| `ster-rotation` | stereometry | Тела вращения: цилиндр, конус, шар/сфера | 83 | А9, В13 | `geometry-11` |
| `ster-angles-distances` | stereometry | Углы и расстояния; координатно-векторный метод | 84 | В20, В1 | `geometry-11` |
| `adv-parameters` | advanced | Задачи с параметрами | 91 | — | NULL |
| `adv-combined` | advanced | Комбинированные задачи, нестандартные приёмы | 92 | — | NULL |
| `adv-functional` | advanced | Функциональные методы, целые числа (бонус) | 93 | — | NULL |
---
## 4. `scoring_json` (шкала балла)
⚠️ Платформенный `scoring_json` отображает **число верных → балл**. Реальный ЦЭ/ЦТ: первичный балл (часть В весит больше) → 100 тестовых по официальной таблице РИКЗ. Здесь — **иллюстративный placeholder** на 100-балльную шкалу; заменить на официальную таблицу года, в идеале — учитывая вес В-заданий ≈ 2× А в первичном.
```json
[
{"correct":30,"score":100},{"correct":28,"score":92},{"correct":26,"score":85},
{"correct":24,"score":78},{"correct":22,"score":71},{"correct":20,"score":64},
{"correct":18,"score":57},{"correct":16,"score":50},{"correct":14,"score":43},
{"correct":12,"score":36},{"correct":10,"score":30},{"correct":8,"score":24},
{"correct":6,"score":17},{"correct":4,"score":11},{"correct":2,"score":5},
{"correct":0,"score":0}
]
```
> Точнее: ввести веса (`primary = Σ(A:1) + Σ(B:2)` → max 10+40=50 первичных), затем official primary→test grid. Если будете расширять модель — это правка `exam_tracks.scoring_json` + логики подсчёта в `exam-prep.js` (отдельная задача, не для seed).
---
## 5. `intro_html` (вступление трека)
Готовый текст для `exam_tracks.intro_html` (карта теста + что внутри):
```html
<p><b>Подготовка к ЦЭ/ЦТ по математике.</b> Формат: часть А — 10 заданий с выбором ответа (А1–А10),
часть В — 20 заданий с открытым ответом (В1–В20), всего 30 заданий, ~180 минут, без калькулятора.</p>
<p>Курс устроен по темам с входной диагностикой и тремя уровнями сложности (База / Ядро / Продвинутый):
теория с выводом формул, разбор эталонных задач, тренажёр по темам, карточки формул с интервальным
повторением и пробные экзамены с таймером на реальных вариантах РТ/ЦТ прошлых лет.</p>
```
---
## 6. Заметки по переносу в миграцию
- Файл: новая миграция `backend/src/db/migrations/0XX_ctmath_track_topics.sql` (номер — следующий свободный; в репозитории миграции до 076+, проверить актуальный максимум перед созданием).
- Порядок: `INSERT exam_tracks``INSERT exam_topics` (сначала разделы, потом подтемы — FK на parent нет, но для читаемости) → `UPDATE … SET textbook_slug` по таблицам §2–§3.
- `content_access`: после создания трека открыть его классам/ученикам (`content_type='exam'`, `content_ref='ctmath'`).
- Совместимость: `exam_key='ctmath'` уникален, существующие треки (`math9` и др.) не затрагиваются.
+141
View File
@@ -0,0 +1,141 @@
# Feature Context: Квантик — Законы Мира
## Current State
- Ветка `feature/quantik-game` ответвлена от `feature/sim-builder` (движок P1–P3 там, не в master).
- При ответвлении унаследован **чужой uncommitted WIP** sim-builder: `frontend/js/sim-builder.js`,
`frontend/sim-builder.html`, `.claude/settings.json` + множество untracked `tmp_*`/мусорных файлов.
⛔ НЕ трогать и НЕ коммитить этот WIP — стейджить только свои файлы поимённо.
- **Phase 0 реализован** (pending review): слой целей в движке `_sim_engine.js` (блок `goal`,
компиляция when/fail/stars через SimExpr, состояние результата, HUD-оверлей, API
`onGoal/getResult/resetResult`) + серверный гейт `validateSpec` пропускает `goal`/`game`.
Изменены: `frontend/js/labs/_sim_engine.js`, `backend/src/controllers/customSimController.js`.
Аддитивно: спека без `goal` ведёт себя ровно как раньше (HUD не создаётся, побед не считается).
Смоук 40/40; `npm test` 238 pass / 8 baseline fail; lint:routes 0.
- **Phase 1 реализован** (pending review): сквозной играбельный срез. Страница `/quantik`
(`frontend/quantik.html` + `frontend/js/game/quantik-game.js`) монтирует уровень-спеку через
`SimEngine.mount`; «игровой режим» = HUD из Ф0 (сам по наличию `goal`) + слайдеры params +
play/reset. Уровень `phys-artillery-1` — данные в `frontend/js/game/levels.js`
(`window.QuantikLevels`): physics-гравитация + body-запуск под углом θ/скоростью v, портал-цель,
бонус-звезда. На победу `onGoal``LS.gameProgressSubmit` + DOM-оверлей успеха (звёзды/время/попытки).
Прогресс: таблица `game_progress` (мигр.**076**), API `/api/game/progress` (GET/POST,
`gameController.js`+`routes/game.js`, смонтировано в `server.js` после `/api/custom-sims`),
клиент `LS.gameProgressList/Submit`. Сайдбар: `/quantik` (icon `rocket`) виден всем.
Новые: `076_game_progress.sql`, `gameController.js`, `routes/game.js`, `quantik.html`,
`js/game/levels.js`, `js/game/quantik-game.js`, `tests/game.test.js`. Изменены: `server.js`,
`js/api.js`, `js/sidebar.js`. `npm test` 251 pass / 8 baseline fail (game.test.js 13/13);
lint:routes 0; миграция применяется чисто.
- **Phase 2 реализован** (pending review): одиночный уровень превращён в **играбельный мир**.
Карта-созвездие (`frontend/js/game/map.js`, `window.QuantikMap`) на звёздном фоне: 6 физ-уровней
в 2 главах (Кинематика 1–3, Динамика 4–6), узлы-«звёзды» со статусом (locked/available/completed+
звёзды), линии-связи, поэтапное появление. Шапка: нарратор-Квантик (`PetSprite`), XP-бар + «уровень
Квантика», всего звёзд, скин-пикер (8 скинов, часть за XP/звёзды). Контент уровней расширен в
`levels.js` (метаданные `chapter/order/par_ms/unlockStars`, по 2 звезды: кристалл + норматив времени).
Разблокировка/XP/группировка — ЧИСТЫЕ функции в новом `frontend/js/game/progress-logic.js`
(`window.QuantikProgress`), покрыты тестом. Навигация: карта→интро(нарратор)→уровень→успех
(нарратор по звёздам)→карта; «Дальше» активирована (`nextPlayable`); скин тинтует героя+нарратора
(localStorage `quantik-skin`). **Backend НЕ тронут** — XP клиентская агрегация из `game_progress`.
Новые: `js/game/map.js`, `js/game/progress-logic.js`. Изменены: `quantik.html`, `js/game/levels.js`,
`js/game/quantik-game.js`. `node --check` все OK; смоуки (логика 16/16, рендер 7/7, winnability 6/6
на реальном движке) зелёные и удалены; `npm test` 259/251 pass / 8 baseline fail (без изменений);
lint:routes 0.
- **Phase 3 реализован** (pending review): новый ТИП уровня — Квантик едет по кривой `y=f(x)`,
которую СОБИРАЕТ игрок (слайдеры коэффициентов). Движок (`_sim_engine.js`, аддитивно):
(1) «бегунок по кривой» — на `plot` поле `runner:{duration,hold}` кладёт в env `<id>.runX/.runY/.runDone`;
герой = обычный point на `curve.runX/runY` (f компилируется 1 раз, питает И кривую, И бегунок — нет само-ссылки);
(2) `type:'zone'` (rect/circle, kind forbidden/target/collect, track) → булево env-поле `<zoneId>.hit` (1/0);
goal/fail/stars ссылаются на него. ⛔ Предикаты в грамматику SimExpr НЕ добавлялись. Новая глава-созвездие
`functions` в `levels.js` (5 уровней: луч/синус/парабола/модуль/экспонента, `unlockStars` 9..17 ≤ 18 макс
физ-звёзд → нет дедлока); map.js НЕ тронут (рисует по метаданным). Сервер `validateSpec` принимает
`zone`+`runner` (OBJECT_TYPES + поля). Изменены: `_sim_engine.js`, `levels.js`, `customSimController.js`,
`quantik.html` (per-level бейдж темы). Новые тесты: custom-sims.test.js +2 (приём zone+runner, отказ
unknown type) — 26/26. Headless vm-смоук (per-level solvability + logic 29/29) зелёный и удалён.
`npm test` 261 / 253 pass / 8 baseline fail (без новых); lint:routes 0; все `node --check` OK.
- **Phase 4 реализован** (pending review): фирменные квантовые способности + SR-связка, ВСЁ через
безопасную модель (движок `_sim_engine.js` НЕ тронут). Новый `frontend/js/game/quantik-abilities.js`:
`window.QuantikEnergy` (клиентский ресурс энергии, localStorage `quantik-energy`, 0..99;
grant/spend/canSpend/rewardForQuality; TUNNEL_COST=3, GOOD=1/EASY=2) + `window.QuantikAbilities`
(`mountBar` — HUD энергии + кнопки «Повторение/Туннель/Прицел» оверлеем на сцене; `openRestRoom`
мини-сессия повторения флешкарт в модалке, реюз `LS.fcListDecks/fcStudySession/fcReview`, НЕ iframe).
**Туннель** = тратит энергию → `inst.setParam('tunnel',1)`; барьер = `forbidden`-зона `wall`,
`fail:'wall.hit && tunnel<1'` (tunnel — не слайдер, отсутствует в env → 0 → стена сплошная).
**Прицел** = пауза-тоггл над пунктир-plot предсказанной траектории. **Суперпозиция** = чистый
контент: 2 тела `ball`+`ball2`, `goal.when` с обоими. Глава `quantum` (L12L16) + `CHAPTERS.quantum`
в `levels.js`; карта рисует автоматически (map.js не тронут). `js/api.js` +2 врапера
(`fcStudySession`, `fcReview`). `quantik.html` +script-тег +CSS `.qa-*`. **Backend НЕ тронут.**
Все `node --check` OK (вкл. инлайн quantik.html); headless vm-смоук (РЕАЛЬНЫЕ движки):
энергия + суперпозиция-оба-тела + tunnel-flips-fail + per-level solvability sweep (5/5 выигрываемы,
full-star достижим, L15/L16 без tunnel = 0 win) + регресс 11 существующих уровней — 48/48, удалён.
Контент-фикс: монета L16 (5,6)r0.7 → (5,6.9)r0.85 (была несовместима со 2-й звездой k≥6.8).
`npm test` 261 / 253 pass / 8 baseline fail (без новых); lint:routes 0.
- **Phase 5 реализован** (pending review): авторинг игровых уровней в sim-builder + раздача классу.
⚠️ **ПАРАЛЛЕЛЬНАЯ СЕССИЯ активна** на ветке (правит sim-builder + admin «games»), поэтому все правки
sim-builder.js/.html — строго АДДИТИВНЫЕ (новые методы/панель/CSS-блок, существующие строки почти не
тронуты). sim-builder: панель «Игровой уровень (цель/звёзды)» (`sectionGame` + wiring + `playGame` +
helpers `loadGame`/`buildGoal`/`buildGameMeta`) — тумблер «Это игровой уровень» включает слой goal
(`when/title/hint/hold/fail`) + до 3 звёзд (`when`+`label`) + метаданные (`chapter/order/par_ms`);
выражения проверяются inline через `SimExpr.compile`. `blankState`/`loadFromSim`/`buildSpec`/`validate`
расширены аддитивно (по 1 врезке каждый). Кнопка «Играть» монтирует SimEngine в модалке (HUD/победа
активируются сами наличием `goal` — Ф0). Round-trip goal/game без потерь.
Игра: `QuantikLevels` стал асинхронным — `ensureCustom()` грузит `custom_sims` cat='game' (свои+
published) и мёржит как записи `custom:<dbid>`; `getAsync(id)` резолвит deep-link (own draft через
`LS.customSimGet`). Новая глава `custom` в `CHAPTERS`. quantik.html: `Promise.all([loadProgress,
ensureCustom])` до карты + deep-link `?level=custom:<id>` (без гейта unlockStars). Backend:
`share()` для cat='game' шлёт `game_level_shared` со ссылкой `/quantik?level=custom:<id>` (иначе
`/lab?sim=…`), ответ +`link`. `CATS` уже содержал 'game' (Ф0/Ф3); goal/game уже в validateSpec.
Изменены: `frontend/js/sim-builder.js`, `frontend/sim-builder.html`, `frontend/js/game/levels.js`,
`frontend/quantik.html`, `backend/src/controllers/customSimController.js`. Новый тест:
`tests/quantik-authoring.test.js` (6/6). Headless round-trip-смоук (vm + реальные _sim_expr+sim-builder
+levels) 7/7 — удалён. Все `node --check` OK (вкл. инлайн обоих HTML). `npm test` 267 / 259 pass /
8 baseline fail (без новых); lint:routes 0.
## Key Architecture Decisions
- **«Атом» = блок `goal` в спеке** (булево SimExpr). Любой уровень = спека SimForge + `goal`.
Движок вычисляет `goal.when` каждый кадр; победа → result + callback. Нет `goal` → no-op.
- **Уровни хранятся в `custom_sims`** (cat='game'), а не в новой таблице. Реюз авторинга/шаринга/embed.
Новые таблицы — только под ПРОГРЕСС игрока и лидерборд (мигр.).
- **Уточнение Ф1**: для MVP уровни — ВСТРОЕННЫЕ ДАННЫЕ в `frontend/js/game/levels.js`
(`window.QuantikLevels`, форма `{ id, title, subject?, hint?, spec }`), а не записи `custom_sims`.
`custom_sims` cat='game' остаётся целевым хранилищем для авторённых уровней (Ф5); реестр тогда
станет асинхронным (загрузка опубликованных + слияние со встроенными той же формы записи).
- **Герой Квантик**: в уровне = engine point с `body` + glow + trail (визуал P2). На карте/в
диалогах = `PetSprite.render(level, mood, accessories, colorKey, streak, pattern)` (DOM SVG).
- **Управление = чинить закон**, а не WASD: игрок крутит `params`-слайдеры движка (угол/скорость/
гравитация) или собирает `f(x)`; затем «Запуск» — симуляция проигрывается к цели.
- **Безопасность**: цвета — только в canvas-стоки; текст спеки — escape (`& < >`); выражения —
только длина на сервере, исполняет безопасный SimExpr на клиенте.
## Engine touch-points (Phase 0)
- Спека v1 формат — в шапке `_sim_engine.js`. Добавить `goal`/`game` СЮДА (документировать).
- rAF-цикл `_renderFrame` (вычисляет env). Добавить `_evalGoal()` после построения env.
- `mount()` возвращает инстанс — добавить `onGoal`, `getResult`, `resetResult`.
- HUD — DOM-оверлей `_labelLayer`/новый слой (как readout-бейджи). Без эмодзи, inline SVG.
- Серверный гейт `customSimController.validateSpec` (:93) — разрешить `goal`/`stars`/`hint`/`game`.
## Cross-Phase Dependencies
- Phase 1+ зависят от `goal`/`getResult`/`onGoal` из Phase 0.
- Phase 2 (XP/скины) зависит от прогресса Phase 1.
- Phase 4 (туннелирование) зависит от флешкарт-SR API.
- Phase 5 (авторинг) трогает sim-builder — к этому моменту чужой P4-WIP должен быть смержен в
sim-builder; свериться перед стартом фазы (возможен мерж base-ветки).
- Phase 6 (живая гонка) зависит от моста `sim_state` (Ф7 sim-builder) — он на base-ветке.
## Temporary Workarounds
(пока нет)
## Phase 0 — API/гочи (для следующих фаз)
- Движковое API цели: `inst.onGoal(cb)` (1 раз при победе, cb получает `getResult()`),
`inst.getResult()``{won,failed,timeMs,attempts,stars:{got,total}}` (без goal → `null`),
`inst.resetResult()` (сброс результата, НЕ считается попыткой). `inst.reset()` = полный
перезапуск уровня + `attempts++` (пользовательская попытка; первый авто-reset при mount НЕ считается).
- HUD появляется **автоматически** при наличии `goal` в спеке (отдельного флага «game mode» нет).
- `timeMs` = **мировое время** `t` от старта (`max(1, round(t*1000))`), детерминизм; не wallclock.
- Env цели = весь env кадра + единственный доп.идентификатор **`tries`** (= attempts). Других не вводить.
- Серверный `validateSpec` принимает `goal{when,title,hint,hold,fail,stars≤3}` и `game{...}` (резерв Ф1/5);
выражения не исполняются (только длина ≤500), текст escape+обрезка.
- Победа делает `pause()` в кадре; следующий queued-rAF выходит рано → `onGoal` не задвоится.
## Open Questions / Notes
- Категория `cat='game'` — проверить список `CATS` в customSimController.js, расширить при необходимости.
- Ассеты: разрешены CC0/открытые из интернета (выбор пользователя) — фиксировать источник+лицензию
в коммите/доке; визуальная база остаётся in-house (PetSprite/canvas/FLUX).
- Маршрут страницы игры: clean URL `/quantik` (паттерн `/sim-builder`, `/lab`).
+107
View File
@@ -0,0 +1,107 @@
# Feature: Квантик — Законы Мира (образовательная 2D-игра)
**Branch:** `feature/quantik-game`
**Base branch:** `feature/sim-builder` (движок P1P3 и фазы sim-builder ещё не в master)
**Created:** 2026-06-13
**Status:** ✅ Complete (merged to feature/sim-builder, 2026-06-14)
**Strategy:** Incremental
**Mode:** Automated
**Execution:** Orchestrator
## Summary
2D физика-головоломка-платформер поверх движка **SimForge** (`_sim_engine.js`). Герой —
**Квантик** (существующий питомец `PetSprite`): в уровне он светящаяся точка с glow и
кометной трассой (P2), на карте/в диалогах — SVG-блоб `PetSprite.render`. Игрок не рулит
героем напрямую, а **чинит «закон мира»**: задаёт скорость/угол/гравитацию (физ-уровни на
`SimPhysics`), собирает `f(x)` для движения по кривой (граф-уровни на `plot`/`SimExpr`),
открывает «ворота» уравниванием реакций/дробей. **Условие победы — булев блок `goal`
(SimExpr) в спеке** — это «атом», переиспользуемый всеми типами уровней.
Уровень = спека SimForge + блок `game/goal` → авторится в sim-builder, хранится в
`custom_sims`, открывается тем же конвейером, что и обычные симуляции. Всё новое —
**аддитивно и безопасно** (без `eval`/`Function`; нет блока `goal` → движок ведёт себя
как раньше).
Мета-слой: карта-созвездие, XP/скины Квантика, разблокировка по звёздам, класс-лидерборд
через classroom SSE. Квантовые способности: суперпозиция, коллапс/пауза, туннелирование
(энергия из быстрого SR-повторения флешкарт).
**MVP играбелен после Фазы 2.**
## Build & Test Commands
- **Build:** нет (vanilla JS, без бандлера; статика через Express)
- **Test:** `npm test` в `backend/` (`node --test tests/*.test.js`)
- **Lint:** `npm run lint:routes` в `backend/`
- ⚠️ После роутов/миграций: `npm run migrate` (живая БД `backend/data/learnspace.db`) + рестарт сервера.
- ⚠️ baseline: 3 pre-existing fail (`auth.test.js` — bcrypt/JWT в тест-окружении) + 5 page-тестов (`jsdom` не установлен). Хук толерантен.
## Project Constraints (соблюдают ВСЕ агенты)
- ⛔ Никаких эмодзи в коде — только inline SVG `.ic`.
- ⛔ Никакого `eval`/`new Function`. Выражения — ТОЛЬКО через `SimExpr` (безопасный парсер).
- Поиск по коду: `ast-index` (символы/usages/callers) + `vex` (semantic). НЕ Grep tool.
- БД — встроенный `node:sqlite` (`DatabaseSync`), НЕ better-sqlite3.
- Frontend — vanilla JS, `window.LS.*` (js/api.js), без бандлера.
- Стейджить файлы **поимённо** (НЕ `git add -A` — в репо много мусорных untracked + чужой WIP sim-builder).
- Аддитивность: новые блоки/типы в спеке не ломают существующие симуляции и каталог.
- Ассеты: база — in-house (PetSprite + canvas/SVG + встроенный FLUX `/api/imggen`).
Разрешены внешние **CC0/открытые** ассеты (звук/арт) с указанием источника/лицензии.
## Reuse Map (что переиспользуем)
- `frontend/js/labs/_sim_engine.js` — рантайм (SimPhysics, plot, glow/trails, zoom/pan, drag).
- `frontend/js/labs/_sim_expr.js``SimExpr.compile/evalSafe` для `goal`/`stars`.
- `frontend/js/pet-sprite.js``PetSprite.render(...)` Квантик + палитры → скины/нарратор.
- `custom_sims` + `customSimController.validateSpec` — хранение уровней + серверный гейт.
- `sim-builder.html`/`sim-builder.js` — авторинг уровней (Фаза 5).
- Флешкарты Tier-1 SR (мигр.074) — энергия туннелирования (Фаза 4).
- classroom SSE + мост `sim_state`/`apply_sim_state` (Ф7 sim-builder) — живая гонка (Фаза 6).
- Паттерн раздачи классу + `pushNotif` + `lab_sim_links` (Ф6 sim-builder).
## Phases
- [x] Phase 0: Слой целей в движке (goal/HUD/result) [domain: frontend] → [subplan](./phase-0-objective-layer.md)
- [x] Phase 1: Оболочка игры + 1 физ-уровень + прогресс [domain: fullstack] → [subplan](./phase-1-shell-first-level.md)
- [x] Phase 2: Карта-созвездие + мир физ-уровней + XP/скины [domain: fullstack] → [subplan](./phase-2-map-world-xp.md)
- [x] Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия [domain: fullstack] → [subplan](./phase-3-graph-levels.md)
- [x] Phase 4: Квантовые способности + SR-комнаты [domain: fullstack] → [subplan](./phase-4-quantum-abilities-sr.md)
- [x] Phase 5: Авторинг уровней в sim-builder + раздача классу [domain: fullstack] → [subplan](./phase-5-authoring-sharing.md)
- ~~Phase 6: Класс-лидерборд / живая гонка (classroom SSE)~~**REMOVED** (см. Amendment 1) → [subplan](./phase-6-leaderboard-live.md)
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 0: Слой целей в движке | frontend | ✅ Done | ✅ | ✅ | ✅ |
| Phase 1: Оболочка + 1 уровень + прогресс | fullstack | ✅ Done | ✅ | ✅ | ✅ |
| Phase 2: Карта + мир + XP/скины | fullstack | ✅ Done | ✅ (1 🟡 fixed) | ✅ | ✅ |
| Phase 3: Граф-уровни + зоны | fullstack | ✅ Done | ✅ | ✅ | ✅ |
| Phase 4: Квантовые способности + SR | fullstack | ✅ Done | ✅ | ✅ | ✅ |
| Phase 5: Авторинг + раздача | fullstack | ✅ Done | ✅ | ✅ | ✅ |
| ~~Phase 6: Лидерборд / живая гонка~~ | fullstack | ❌ Removed (Amendment 1) | — | — | — |
## MVP boundary
После **Phase 2** игра играбельна и отгружаема: один полный мир физ-уровней с картой,
прогрессом, XP и скинами. Фазы 3–6 — расширение (новые типы уровней, способности,
авторинг, мультиплеер).
## Amendment Log
### Amendment 1 — 2026-06-14
**Type:** Removed phase
**What changed:** Phase 6 (Класс-лидерборд / живая гонка через classroom SSE) убрана из объёма по решению пользователя.
**Why:** Пользователь решил не реализовывать соревновательный слой; переходим к полировке и финальному ревью после Ф5.
**Impact on existing phases:** Нет. Фазы 0–5 самодостаточны и отгружаемы. `game_progress.level_id` (TEXT) уже готов под будущий лидерборд, если фичу вернут. Subplan `phase-6-leaderboard-live.md` сохранён как архив с пометкой REMOVED.
## Final Review
- [x] Comprehensive code review (final-reviewer) — ✅ READY TO MERGE, 0 блокеров (2026-06-14)
- [x] Security review (новые API/ввод) — ✅ SECURE, 0 critical (2026-06-14)
- [x] Polish-фиксы по ревью применены: game-блок санитизируется (был латентный XSS), prefers-reduced-motion guard, фикс комментария isUnlocked. Тесты 45/45 затронутых, lint 0.
- [x] `npm test` без новых регрессий (8 = baseline: 3 auth + 5 jsdom)
- [x] `npm run lint:routes` baseline 0
- [x] Merged to `feature/sim-builder` — merge commit `dabb370` (--no-ff), 2026-06-14. Post-merge: тесты = baseline, lint:routes 0.
### Deferred / Backlog (не блокеры — из финального ревью)
- `QuantikLevels.ensureCustom` — N+1 `customSimGet` на загрузку /quantik; при росте числа авторённых уровней заменить на bulk-эндпоинт «список game-спек».
- Уровни 3/5/6 (отскок/орбита/манёвр): `fail` — только таймаут, «честная» механика не форсится. Точечно ужесточить `fail`-предикаты (контент-тюнинг).
- `graph-exp-11` капстоун узковат (~36–42/625) — при жалобах на сложность чуть расширить gate.
- Три похожих `starSvg`/`_starIcon` в разных модулях — консолидация не стоит связывания движка с игрой (оставлено).
- Клиентские очки прогресса фальсифицируемы (ожидаемо для single-player). ⚠️ Если когда-нибудь вернут Ф6-лидерборд — валидировать на сервере (replay/подписанные токены).
@@ -0,0 +1,135 @@
# Phase 0: Слой целей в движке (goal / HUD / result)
**Status:** ✅ Done (reviewed — PASS, committed)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Ввести в `_sim_engine.js` **«атом» игры** — декларативный блок `goal` (условие победы как
булево SimExpr-выражение), вычисляемый каждый кадр, с фиксацией результата (победа/время/
попытки/звёзды), callback'ом и HUD-оверлеем. Расширить серверный гейт `validateSpec`, чтобы
блок проходил валидацию. Всё аддитивно: спека без `goal` ведёт себя как раньше.
## Спека (контракт, документировать в шапке `_sim_engine.js`)
```
goal: {
when: '<bool expr>', // SimExpr: победа, когда станет истинным (≠0)
hint?: 'текст подсказки', // показывается в HUD (escape на сервере)
title?: 'Цель уровня', // краткая формулировка цели для HUD
hold?: 0.0, // сек, сколько условие должно держаться (деф. 0 = мгновенно)
stars?: [ // 0..3 доп.условий-«звёзд» (бонусы)
{ when:'<bool expr>', label?:'...' }
],
fail?: '<bool expr>' // опц.: мгновенный проигрыш (вышел за поле/задел шип)
}
```
- `when`/`stars[].when`/`fail` — компилируются ОДИН раз при mount (как все выражения), env тот же.
- Доп. env-поля для целей: `t` (время), `tries` (число reset с начала), плюс всё что уже в env
(`<obj>.x/.y/.vx/.vy`, params, w/h, xmin..ymax). НЕ вводить новых небезопасных идентификаторов.
- Звезда «залипает»: однажды истинное условие звезды остаётся засчитанным до reset (накопитель).
## Tasks
- [x] Task 1: В шапке `_sim_engine.js` задокументировать блок `goal` (формат v1, как сделано для physics/plot).
- [x] Task 2: В `prepare`/mount компилировать `goal.when`, `goal.fail`, каждое `stars[].when`
через `SimExpr.compile` (хранить fn + error; кривое выражение → никогда не бросает).
- [x] Task 3: Добавить состояние результата на инстанс: `_goalState = { won, failed, timeMs,
attempts, starsGot:[], firstWinT }`. Сбрасывать в `reset()` (attempts++ на reset, кроме первого).
- [x] Task 4: В rAF-цикле (`_renderFrame`/`_stepPhysics`-соседство) после построения env и шага:
вычислить звёзды (накопить), `fail` (→ мягкий проигрыш-оверлей, не победа), `when`
(учесть `hold` — таймер удержания) → при победе зафиксировать `timeMs` (мировое t или
wallclock от старта play), выставить `won=true`, остановить (`pause`) и вызвать `onGoal` callback.
- [x] Task 5: HUD-оверлей (DOM, поверх canvas, как слой readout): строка цели (`title`),
индикатор звёзд (inline SVG звезда — заполненная/контур), подсказка (`hint`),
баннер «Победа»/«Ещё раз» с кнопкой Reset. Стиль — тёмная плашка как у readout; без эмодзи.
HUD появляется ТОЛЬКО при наличии `goal` в спеке.
- [x] Task 6: Публичное API инстанса: `onGoal(cb)` (cb получает `getResult()`), `getResult()`
`{ won, failed, timeMs, attempts, stars:{got,total} }`, `resetResult()`.
- [x] Task 7: `destroy()` снимает HUD-узлы/слушатели кнопок (нет утечек).
- [x] Task 8: Сервер `backend/src/controllers/customSimController.js` `validateSpec`:
разрешить ключ `goal` (объект) и `game` (объект, см. Phase 1/5) на верхнем уровне спеки;
проверять `when`/`fail`/`stars[].when` через `checkExpr` (длина ≤ MAX_EXPR_LEN);
`hint`/`title`/`stars[].label``sanitizeText` (escape + обрезка); `stars` ≤ 3; `hold` число.
НЕ исполнять выражения. Обновить whitelist при необходимости.
- [x] Task 9: Headless-смоук (vm + ручной DOM/canvas-стаб + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`,
как в P1–P3): (а) спека с `goal.when` достигает победы → `getResult().won`, `timeMs>0`;
(б) звёзды накапливаются и не сбрасываются до reset; (в) `fail` ставит failed без won;
(г) `hold` требует удержания; (д) спека БЕЗ goal — поведение без изменений, HUD не создан;
(е) `onGoal` зовётся один раз; (ж) destroy снимает HUD-слушатели (баланс add/remove).
Удалить temp-смоук после прогона. → 40/40 PASS, удалён.
## Files to Modify/Create
- `frontend/js/labs/_sim_engine.js` — блок goal: документация, компиляция, eval в цикле, HUD, API.
- `backend/src/controllers/customSimController.js``validateSpec`: разрешить `goal`/`game`.
- (temp) headless-смоук — создать, прогнать, удалить.
## Acceptance Criteria
- Спека с `goal` показывает цель/звёзды/победу; `getResult()` корректен; `onGoal` срабатывает.
- Спека без `goal` рендерится и ведёт себя ровно как раньше (нет HUD, нет накладных вычислений побед).
- `validateSpec` пропускает корректный `goal`, режет переразмер/длинные выражения, экранирует текст.
- `node --check` обоих файлов OK; headless-смоук зелёный; эмодзи нет; `eval`/`new Function` нет.
- `cd backend && npm test` — без новых регрессий; `npm run lint:routes` — без новых ошибок.
## Notes
- Время победы: предпочесть **мировое `t`** (детерминизм, headless-тест), плюс можно хранить wallclock.
- `attempts` = число `reset()` (первый mount/авто-reset не считать попыткой; считать пользовательские).
- HUD не должен перехватывать pan/drag сцены вне своих интерактивных элементов (pointer-events: none
на контейнере, auto — на кнопках), как сделано с overlay-панелью в P1.
- Не вводить новые env-идентификаторы помимо `t`/`tries` — безопасность контракта выражений.
## Review Checklist
- [x] Все задачи выполнены
- [x] Код следует конвенциям движка (хелперы модульного уровня `_truthy`, инстанс-методы, без рисования по canvas — HUD это DOM-оверлей как readout)
- [x] Аддитивность: существующие симуляции/каталог не затронуты (нет goal → `_goal=null`, HUD не создаётся, в rAF ветка `if (self._goal)` пропускается)
- [x] Без эмодзи; без eval/Function (звёзды/иконки — inline SVG; выражения только через `SimExpr.compile`)
- [x] Build (node --check) и тесты проходят (смоук 40/40; `npm test` 238 pass / 8 baseline fail; lint:routes 0)
## Handoff to Next Phase
### Движковое API цели (для Phase 1)
Всё в `frontend/js/labs/_sim_engine.js`. `var inst = SimEngine.mount(host, spec)`:
- **`inst.onGoal(cb)`** — подписка. `cb` получает `getResult()` и вызывается РОВНО ОДИН РАЗ при
первой победе (после `pause()`). Возвращает `inst` (chainable). Можно подписать несколько cb.
- **`inst.getResult()`** → `{ won:bool, failed:bool, timeMs:number, attempts:number, stars:{got:number, total:number} }`.
Для спеки БЕЗ `goal` возвращает **`null`** — проверяйте на null перед использованием.
- **`inst.resetResult()`** — сбросить результат как новый уровень (won/failed/звёзды/таймер обнуляются),
но **НЕ считается попыткой** (attempts сохраняется). Для перезапуска уровня используйте `inst.reset()`
(это И сбрасывает физику/время И инкрементит attempts как пользовательскую попытку).
### Как включить «игровой режим» / HUD
HUD появляется **автоматически**, как только в спеке есть верхнеуровневый блок `goal`. Отдельного
флага «game mode» в движке НЕТ. Никакого вызова не нужно — `mount()` сам создаёт HUD при наличии `goal`.
HUD = DOM-оверлей внутри `inst.el` (контейнер `pointer-events:none`, кнопка «Ещё раз» — `pointer-events:auto`,
дёргает `inst.reset()`). Спрятать HUD = убрать `goal` из спеки.
### Как измеряется timeMs
**Мировое время** `t` от старта уровня (детерминизм для headless/реплеев): при победе
`timeMs = max(1, round(t*1000))`. Это НЕ wallclock — паузы/тротлинг rAF не влияют. `firstWinT`
(сырое мировое `t` победы) хранится внутри в `_goalState`, наружу отдаётся только `timeMs`.
### Форма блока goal/game (что сервер теперь принимает)
`validateSpec` (`backend/src/controllers/customSimController.js`) пропускает на верхнем уровне:
```
goal: {
when: '<bool SimExpr>', // ≤500 симв., НЕ исполняется на сервере
title?: '...', hint?: '...', // sanitizeText: escape & < > + обрезка (title≤120, hint≤300)
hold?: 0, // число (сек удержания when); не-число → 400
fail?: '<bool SimExpr>', // ≤500 симв.
stars?: [ { when:'<bool SimExpr>', label?:'...' } ] // ≤3 (иначе 400); label sanitizeText ≤120
}
game?: { ... } // зарезервирован под мета-слой Ф1/5; проходит как есть
// (под общими лимитами размер/глубина), НЕ исполняется
```
Категория `cat='game'` уже в `CATS` (math/phys/chem/bio/game) — расширять не нужно.
### Env-контракт цели (безопасность)
Выражения `goal.when`/`fail`/`stars[].when` видят ВЕСЬ env кадра (params, `t`, `w/h`, `xmin..ymax`,
`<obj>.x/.y/.vx/.vy`) ПЛЮС единственный доп.идентификатор **`tries`** (= `attempts`). НЕ вводить
новых идентификаторов в env цели — это контракт безопасности шаренных выражений.
### Гочи для Phase 1
- Победа ставит `pause()` внутри rAF-кадра; следующий queued-кадр выходит по `if (!self._running) return`
`onGoal` не задвоится. НЕ вызывайте `play()` в `onGoal`-колбэке без `resetResult()`/`reset()`.
- `goal.when` без `hold` срабатывает на ПЕРВОМ же кадре, где условие истинно (мгновенно).
- Звёзды «залипают»: засчитываются и во время play (rAF), и на паузе/предпросмотре (`_renderFrame`).
- Истинность булева = `_truthy`: конечное ненулевое число (SimExpr возвращает 0 при NaN/∞/ошибке).
@@ -0,0 +1,88 @@
# Phase 1: Оболочка игры + 1 физ-уровень + прогресс (MVP)
**Status:** ✅ Done (reviewed — PASS, committed)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Сквозной играбельный срез: страница `/quantik` грузит уровень-спеку, монтирует движок в
«игровом режиме» (управление = слайдеры закона + кнопка «Запуск»), на победу шлёт результат
на сервер, показывает экран успеха со звёздами/временем. Прогресс сохраняется в БД.
Первый уровень — «Артиллерия Квантика»: угол+скорость, попасть в портал, собрать звезду.
## Tasks
- [x] Task 1: Миграция `076_game_progress.sql` `game_progress`: `id, user_id, level_id TEXT,
best_time_ms INTEGER, best_stars INTEGER, attempts INTEGER, completed_at`. UNIQUE(user_id, level_id).
- [x] Task 2: Контроллер `gameController.js` + роутер `game.js`, смонтирован в `server.js`
(после `/api/custom-sims`). `GET /api/game/progress` (свой прогресс), `POST /api/game/progress`
`{level_id, time_ms, stars}` (upsert: min time / max stars; attempts++). auth-only; валидация входа.
- [x] Task 3: Клиент `LS.gameProgressList()` / `LS.gameProgressSubmit(levelId, {time_ms, stars})` в js/api.js.
- [x] Task 4: Уровень как ДАННЫЕ: `frontend/js/game/levels.js` (`window.QuantikLevels`), встроенная
спека `phys-artillery-1` (physics gravity + body launch + goal + 1 star + portal). Источник
уровней зафиксирован в CONTEXT.md (встроенные данные сейчас; custom_sims в Ф5).
- [x] Task 5: `frontend/quantik.html` + `frontend/js/game/quantik-game.js`: доступ всем авторизованным
(LS.initPage()); подключает `_sim_expr.js`+`_sim_engine.js` тем же путём, что lab/sim-builder.
Монтирует уровень, `onGoal` → submit + экран успеха.
- [x] Task 6: «Игровой режим» — HUD из Ф0 включается сам наличием `goal`; управление = слайдеры params
движка + кнопки play/reset (встроены в `inst.el`). Редакторских панелей нет.
- [x] Task 7: Экран успеха (DOM-оверлей страницы): звёзды (inline SVG), время, попытки, «Ещё раз»
(inst.reset) / «Дальше» (disabled-заглушка для MVP). Без эмодзи.
- [x] Task 8: Пункт сайдбара `js/sidebar.js``/quantik` в группе practice (icon `rocket`), видим всем.
`isActive('/quantik')` подсветка работает на clean URL.
- [x] Task 9: Тест `backend/tests/game.test.js` (паттерн lab-links.test.js): submit создаёт строку,
лучший перезаписывает / худший нет, attempts++, per-user, требует auth (401), валидирует вход (400).
13/13 PASS.
## Files to Modify/Create
- `backend/src/db/migrations/0NN_game_progress.sql` — таблица прогресса.
- `backend/src/controllers/gameController.js`, `backend/src/routes/game.js` — API.
- `backend/src/server.js` — монтаж роутера.
- `frontend/quantik.html`, `frontend/js/game/quantik-game.js`, `frontend/js/game/levels.js` — клиент+уровень.
- `frontend/js/api.js``LS.gameProgress*`.
- `frontend/js/sidebar.js` — пункт меню.
- `backend/tests/game.test.js` — тест.
## Acceptance Criteria
- `/quantik` грузится, монтирует уровень, цель видна; «Запуск» проигрывает физику.
- Попадание в портал (+звезда) → экран успеха с временем/звёздами; результат записан в `game_progress`.
- Повторный худший результат не перезаписывает лучший; attempts растёт.
- `npm run migrate` применяет миграцию; `npm test` зелёный (+ новый тест); `lint:routes` baseline 0.
## Notes
- Маршрутизация `/js/game/*`: помнить гочу sim-builder — `/js` мапится на корневой `js/`, а файлы
лежат во `frontend/js/game/` → отдаются через `express.static(frontendDir)`. Не трогать server.js static.
- Роуты `:id` прикрыть `authMiddleware` на уровне роутера (lint:routes baseline 0).
- Время — из `getResult().timeMs` (Ф0).
## Review Checklist
- [x] Все задачи; конвенции (ownership/auth как studentMaterials/customSim); без эмодзи/eval
- [x] Миграция применяется; API безопасен; тест зелёный; lint baseline 0; existing тесты не сломаны
## Handoff to Next Phase
### Реестр уровней (форма данных)
`frontend/js/game/levels.js``window.QuantikLevels`:
- `QuantikLevels.list()` → массив записей уровней (копия); `QuantikLevels.get(id)` → одна запись или null; `QuantikLevels.LEVELS` — сырой массив.
- **Запись уровня**: `{ id, title, subject?, hint?, spec }`. `id` == `level_id` для API прогресса.
`spec` — обычная спека SimForge с верхнеуровневым блоком `goal` (Ф0). Сейчас один уровень `phys-artillery-1`.
- **Добавить уровень** = добавить запись в `LEVELS` (или, в Ф5, подгрузить опубликованные `custom_sims` cat='game' и смержить в реестр — той же формы записи). Источник = данные, не код.
- Уровень мог бы прийти и из `custom_sims` (cat='game'): `spec` уже валидируется сервером (validateSpec пропускает goal/game). Реестр в Ф2/Ф5 может стать асинхронным (загрузка + слияние со встроенными).
### Контракт API прогресса
- `GET /api/game/progress` (auth) → `{ progress: [ { level_id, best_time_ms, best_stars, attempts, completed_at } ] }` — все уровни текущего игрока.
- `POST /api/game/progress` (auth) body `{ level_id, time_ms, stars }``{ ok:true, progress:{...одна строка...} }`. Upsert: best_time_ms=min, best_stars=max, attempts++. Валидация: level_id строка ≤120; time_ms/stars неотрицательные целые; stars 0..3 (иначе 400).
- Клиент: `LS.gameProgressList()`, `LS.gameProgressSubmit(levelId, { time_ms, stars })`.
- Таблица `game_progress` — миграция **076**, UNIQUE(user_id, level_id), user_id ON DELETE CASCADE.
- На Ф6 (лидерборд) — этой таблицы достаточно для «лучшее время по уровню»; агрегаты по классу — JOIN на class_members.
### Где живёт экран успеха / как монтируется уровень
- Монтаж: `QuantikGame.start({ host, level })``SimEngine.mount(host, level.spec)` → возвращает `inst`. «Игровой режим» включается САМ (HUD появляется, т.к. в спеке есть `goal`). Управление — слайдеры params + play/reset движка (внутри `inst.el`).
- Победа: `inst.onGoal(res => …)` (Ф0; срабатывает 1 раз). В колбэке: `LS.gameProgressSubmit(level.id, { time_ms: res.timeMs, stars: res.stars.got })` (best-effort, .catch офлайн) + экран успеха.
- **Экран успеха** = DOM-оверлей `.qg-overlay`, добавляется в `host` (=`#qg-stage`), `QuantikGame.buildSuccessOverlay(state)` строит карточку (звёзды inline SVG, время/звёзды/попытки, кнопки). «Ещё раз» → убрать оверлей + `inst.reset()`. «Дальше» — disabled-заглушка (нет следующего уровня в MVP); Ф2 (карта/мир) активирует её переходом к следующему узлу.
- CSS оверлея — в `<style>` `quantik.html` (`.qg-*`). Ф2 переиспользует `buildSuccessOverlay` (можно расширить параметром «следующий уровень»).
### Гочи для Ф2
- `inst.onGoal` срабатывает 1 раз и делает `pause()`. Перезапуск — `inst.reset()` (это И физика, И attempts++). Не звать `play()` в onGoal-колбэке.
- `res.timeMs` — мировое время (детерминизм), не wallclock. `res.stars.got`/`res.stars.total` — счётчики звёзд.
- Страница не разрушает `inst` явно при навигации; Ф2 при смене уровня без перезагрузки должна вызвать `inst.destroy()` перед монтированием нового (или перезагружать `?level=`).
- Сайдбар-пункт `/quantik` видим ВСЕМ (без `hidden`), в отличие от teacher-only `/sim-builder`.
+113
View File
@@ -0,0 +1,113 @@
# Phase 2: Карта-созвездие + мир физ-уровней + XP/скины (MVP-мир)
**Status:** ✅ Done (reviewed — PASS w/ notes; «Дальше» stale-hasNext 🟡 fixed; committed)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Превратить одиночный уровень в **играбельный мир**: карта-созвездие из ~5–6 физ-уровней,
разблокировка по звёздам, XP, выбор скина Квантика, нарратор-Квантик (`PetSprite`) на интро/
победе. После этой фазы игра полноценно отгружаема.
## Tasks
- [x] Task 1: Контент — ~5–6 физ-уровней-спек (данные в `levels.js`), нарастающая сложность:
артиллерия → перелёт через стену → отскок (restitution) → пружина/маятник → орбита/гравитация.
Каждый: `goal` + 1–3 звезды + норматив времени (`par_ms`) для 3-й звезды.
- [x] Task 2: Структура «мир/глава»: метаданные уровня (id, title, chapter, order, par_ms, hint).
Карта группирует по главам (созвездиям).
- [x] Task 3: Карта-созвездие `frontend/js/game/map.js` (+ разметка в quantik.html): узлы-уровни
на SVG/canvas-фоне, линии-связи, статус (заблокирован/доступен/пройден + число звёзд).
Разблокировка: уровень открыт, если набрано ≥ threshold звёзд в предыдущих (правило в данных).
- [x] Task 4: XP/уровень игрока: XP = сумма звёзд × коэффициент (+ бонус за par). Хранить в
прогрессе (расширить `game_progress` агрегацией на клиенте ИЛИ доб. поле/таблицу `game_player`).
Полоса XP + «уровень Квантика» в шапке карты.
- [x] Task 5: Скины Квантика: выбор `colorKey` из палитр `PetSprite` (+ позже паттерны). Скин
влияет на цвет glow-точки героя в уровне (param/проп движка) и на `PetSprite` на карте.
Хранить выбор (localStorage сейчас; серверно — опц.). Разблокировка скинов по XP/звёздам.
- [x] Task 6: Нарратор: `PetSprite.render(...)` в интро уровня (краткая формулировка «почини закон…»)
и на экране победы (реакция по числу звёзд: happy/ecstatic). Реюз mood из pet-sprite.js.
- [x] Task 7: Навигация: карта → уровень → результат → возврат на карту с обновлённым статусом/XP.
- [x] Task 8: Тесты: разблокировка (логика чистой функцией — юнит-тест), агрегация XP; смоук карты.
## Files to Modify/Create
- `frontend/js/game/levels.js` — контент мира (расширить).
- `frontend/js/game/map.js` — карта-созвездие.
- `frontend/js/game/quantik-game.js` — навигация карта↔уровень, XP/скин в шапке.
- `frontend/quantik.html` — разметка карты/шапки.
- (опц.) `backend` — поле/агрегация игрока, если решим серверно; иначе клиентская агрегация прогресса.
- тест(ы) разблокировки/XP.
## Acceptance Criteria
- Карта показывает мир, статусы и звёзды; пройденные уровни открывают следующие.
- XP/уровень Квантика растут; смена скина видна и на карте, и в уровне.
- Нарратор-Квантик появляется на интро/победе с корректным настроением.
- Тесты разблокировки/XP зелёные; lint baseline 0; existing тесты не сломаны.
## Notes
- Без эмодзи — звёзды/иконки только inline SVG (`.ic`).
- Разблокировку держать **данными/чистой функцией** (легко тестировать и переносить на сервер).
- Не плодить серверные таблицы без нужды: прогресс уже в `game_progress` (Ф1); XP можно агрегировать.
## Review Checklist
- [ ] Все задачи; чистая функция разблокировки покрыта тестом; без эмодзи/eval
- [ ] Карта/навигация работают; existing тесты целы; lint baseline 0
## Handoff to Next Phase
### Архитектура карты (`frontend/js/game/map.js`)
- `window.QuantikMap.create({ host, headerHost, onPlay(level), getSkin()->key, onSkin(key) }) -> { render(progressMap), destroy() }`.
- `render(progressMap)` рисует шапку (нарратор + XP-бар + всего звёзд + скин-пикер) в `headerHost`
и созвездия в `host`. `progressMap``{ [level_id]: row }` (см. `QuantikProgress.fromProgressList`).
- Узел созвездия (`buildNode`) — `<button class="qm-node qm-{locked|available|completed}">` с ядром
(`.qm-node-core`), подписью, звёздами/порогом. Позиция в % через `layoutNodes(levels)` (зигзаг-дуга).
- Статус узла = `QuantikProgress.nodeStatus`. Клик по available/completed → `onPlay(level)`.
- Звёздное небо — SVG `<circle class="qm-tw">` (мерцание CSS), линии-связи `<line class="qm-link[.on]">`.
- Поэтапное появление: `staggerReveal` снимает `.qm-pre`/ставит `.qm-in` через setTimeout (70 мс шаг).
### Как добавить главу (созвездие)
- В `levels.js`: дать новым уровням `chapter:'<key>'` + добавить запись в `CHAPTERS`
(`{ key, title, subtitle, accent }`). Карта группирует автоматически (`groupByChapter` сохраняет
порядок появления глав). Узлы внутри главы сортируются по `order`. Никаких правок map.js не нужно.
- **Фаза 3 (граф-уровни) = НОВАЯ глава** (напр. `chapter:'functions'`): добавить уровни-спеки с
`objects:[{type:'plot',...}]` + `goal.when` по форме функции; `unlockStars` гейтит её за Динамику.
Узел рисуется тем же `buildNode` (тип спеки карте безразличен — она читает только метаданные).
### Модуль логики прогресса (`frontend/js/game/progress-logic.js`, `window.QuantikProgress`)
Чистые функции (без DOM/сети/eval) — переносимы на сервер, покрыты тестом:
- `fromProgressList(list)` → карта `{level_id: row}` из ответа `/api/game/progress`.
- `starsFor(id, map)` / `isCompleted(id, map)` / `totalStars(levels, map)`.
- `isUnlocked(level, map, levels)` — уровень открыт, если Σ звёзд во ВСЕХ уровнях с меньшим `order`
`level.unlockStars` (порог в данных уровня). `unlockStars:0` (или нет) → всегда открыт.
- `nodeStatus` / `starsToUnlock` — для карты.
- `computeXp(levels, map)` = Σ(звёзды·`STAR_XP`=100 + `COMPLETE_XP`=40 за пройденный).
- `playerLevel(xp)``{ level, xp, xpInto, xpForNext, progress01 }`. Шкала: `xpForLevel(L)=240·(L-1)L/2`.
- `groupByChapter(levels)``[{ chapter, levels:[…sorted by order] }]`.
- `nextPlayable(curId, levels, map)` → след. разблокированный уровень (для кнопки «Дальше») или null.
### Скины
- localStorage ключ **`quantik-skin`** (экспортирован `QuantikGame.SKIN_KEY`). Значение = `colorKey`
из `PetSprite.PALETTES` (валидируется при чтении, иначе fallback `'cyan'`).
- `QuantikGame.getSkin()/setSkin(key)/skinColor(key)`. Тинт героя — `tintHeroSpec(spec, key)`:
глубокая копия спеки (JSON), переписывает `color/glowColor/trailColor` объекта с `id:'ball'`.
Гейты скинов — массив `SKIN_GATES` в map.js (needStars/needXp). 8 скинов.
### Нарратор
- `PetSprite.render(level, mood, [], colorKey, 0, 'none')` (DOM SVG-строка). Вызовы:
- Карта-шапка: `QuantikMap.renderHeader` (mood по уровню игрока: ecstatic≥5 / happy≥2 / neutral).
- Интро уровня: `QuantikGame.buildIntro(level, skin)` (mood `happy`).
- Экран успеха: `QuantikGame.buildSuccessOverlay(state, {skin, hasNext})` — mood `ecstatic`, если все
звёзды (got≥total и total≥2), иначе `happy`.
### Навигация (inline-bootstrap в quantik.html)
- Два вида: `#qg-map-view` (карта) и `#qg-level-view` (`#qg-stage` под движок). Переключение
классом `.show`. `showMap()` перезагружает прогресс (`LS.gameProgressList`) → `map.render`.
`openLevel→интро→launchLevel→onGoal→успех→onNext(nextPlayable)|onMap`. При смене уровня
ВСЕГДА `destroyLevel()` (= `inst.destroy()` + очистка `#qg-stage`) до нового mount (гоча Ф1).
- Deep-link `?level=<id>` открывает уровень, если он разблокирован; иначе карта.
### Решения/гочи (для ревью и Ф3+)
- **XP/прогресс игрока — чисто клиентская агрегация** из `game_progress` (Ф1). Новых таблиц/роутов НЕТ
→ lint:routes baseline 0 не тронут, бэкенд-тесты не изменились (259, 251 pass / 8 baseline fail).
- Уровни 3/5/6 имеют «лёгкий» выигрышный путь, попутно дающий обе звезды; «честная» механика
(отскок/орбита/колодец) присутствует, но не единственно-возможна — НЕ блокер MVP (см. winnability).
- На сервер агрегацию XP перенести легко: те же чистые функции в `progress-logic.js` (без DOM).
@@ -0,0 +1,96 @@
# Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия
**Status:** ✅ Done (reviewed — PASS, committed)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Новый тип уровня: Квантик движется по кривой `y=f(x)`, которую **собирает игрок** (настраивает
параметры/выбирает выражение). Препятствия — «запретные зоны»; цель/звёзды/проигрыш — выражения.
Реюз `plot` + `SimExpr`. Сид граф-главы.
## Tasks
- [x] Task 1: «Бегунок по кривой»: герой-точка с `x` = функция t (напр. линейный проход xmin→xmax),
`y = f(x)` через ту же скомпилированную функцию, что у `plot`. Кривая рисуется (P3 plot),
герой едет по ней с glow/trail. Без физики (кинематический проход), либо мягкая физика — на выбор уровня.
`plot.runner:{duration,hold}` кладёт в env `<plotId>.runX/.runY/.runDone`; герой = обычный point
с `x:'curve.runX', y:'curve.runY'`, glow+trail. f компилируется 1 раз и питает И кривую, И бегунок.
- [x] Task 2: Тип объекта/поле «зона» (forbidden/target): прямоугольник/круг в мире + удобные
env-предикаты (или документированный паттерн: `fail:'inzone(...)'`). Реализовать helper-предикаты
БЕЗ расширения небезопасного синтаксиса — предпочесть готовить булевы поля зон в env
(напр. `zone1.hit`) на основе позиции героя, чтобы `goal`/`fail` ссылались на них.
`type:'zone'` (shape rect/circle, kind forbidden/target/collect, track). Движок кладёт `<zoneId>.hit`
(1/0) в env. ⛔ Никаких inzone()-предикатов в грамматике — только именованные булевы env-поля.
- [x] Task 3: Цель = добраться до конца/в целевую зону, не задев запретные (`fail`). Звёзды: пройти
под нормативом, собрать бонус-точки (зоны-сборы).
→ goal.when=`'gate.hit'`, fail=`'pit.hit'`, stars=[collect-zone hit, доп. условие формы кривой].
- [x] Task 4: Управление: слайдеры коэффициентов `f(x)` (a·sin(b·x+c)+d и т.п.) ИЛИ выбор/набор
выражения с inline-проверкой `SimExpr.compile(...).error` (как в sim-builder). Безопасно.
→ коэффициенты = обычные `params`-слайдеры движка; крутишь → кривая+путь героя перестраиваются.
Свободный ввод выражения не понадобился (слайдеры коэффициентов достаточны для MVP-главы).
- [x] Task 5: Контент: сид граф-главы (~4–5 уровней): синус под мостом, парабола над ямой,
кусочная подгонка, экспонента/логарифм — растущая сложность, привязка к темам алгебры.
→ 5 уровней в `functions`: луч (a·x+b), синус (A·sin(k·x)), парабола (a·(x5)²+k),
модуль (a·|x−m|+1), экспонента (c·e^(r·x)). Все solvable (см. Concerns).
- [x] Task 6: Интеграция в карту (Ф2): новая глава-созвездие; общий конвейер результата/XP.
→ глава `functions` в `CHAPTERS`; map.js НЕ тронут (рисует по метаданным). Бейдж темы в quantik.html
стал per-level (`subject` → Физика/Алгебра) — аддитивно.
- [x] Task 7: Тесты: проход по кривой достигает цели; задевание зоны → fail; смоук рендера кривой+героя.
→ headless vm-смоук (логика+per-level solvability, 29/29, удалён); серверный тест приёма
zone+runner спеки (custom-sims.test.js, +2 теста, остаётся).
## Files to Modify/Create
- `frontend/js/labs/_sim_engine.js` — поддержка «бегунка по кривой» (если не выразимо текущими полями)
и подготовка булевых полей зон в env. Аддитивно, документировать в шапке.
- `frontend/js/game/levels.js` — граф-глава.
- `frontend/js/game/quantik-game.js` / `map.js` — новая глава, управление коэффициентами.
- тест(ы).
## Acceptance Criteria
- Квантик едет по собранной игроком кривой; правильная `f(x)` проводит между препятствиями к цели.
- Задевание запретной зоны → проигрыш; норматив/сборы дают звёзды.
- Кривая безопасна (SimExpr, без eval); existing симуляции/уровни не затронуты; тесты зелёные.
## Notes
- НЕ вводить произвольные функции-предикаты в синтаксис выражений (безопасность). Зоны → булевы env-поля.
- Переиспользовать P3 plot (несколько кривых, заливка, маркеры) для визуала «земли»/препятствий.
## Review Checklist
- [x] Все задачи; аддитивность движка; без эмодзи/eval; тесты зелёные; lint baseline 0
## Handoff to Next Phase
### Контракт «бегунка по кривой» (движок, `_sim_engine.js`)
- На объекте `plot`: `runner:{ duration?:8, hold?:true }`. Делает из ПЕРВОЙ кривой plot дорожку.
- Движок кладёт в env (в `_buildEnv`, ДО формульных центров): `<plotId>.runX` (= `a + (ba)·clamp(t/duration,0,1)`),
`<plotId>.runY` (= f(runX) ТОЙ ЖЕ скомпил. функции, что рисует кривую), `<plotId>.runDone` (1 при t≥duration).
- Герой = обычный `point` с `x:'curve.runX', y:'curve.runY'` + glow + trail. НЕ тело → нет само-ссылки
(f компилируется один раз, питает И кривую, И бегунок). `hold:true` — остаётся на конце; иначе зацикливание по `time.loop`.
- ⛔ Никакого eval: f — обычное SimExpr-выражение кривой.
### Контракт зон (движок)
- `type:'zone'`, `id`, `shape:'rect'|'circle'`, `kind:'forbidden'|'target'|'collect'` (цвет/семантика),
геометрия (rect: x,y центр + w,h; circle: x,y + r — числа ИЛИ выражения), `track?:'ball'` (чью позицию тестить), `label?`, `color?`.
- Движок кладёт `<zoneId>.hit` (1/0) в env (последним — нужна актуальная позиция героя). `goal.when/fail/stars[].when` ссылаются на него.
- ⛔ Предикаты в синтаксис выражений НЕ добавлялись — только именованные булевы env-поля (модель безопасности `t`/`tries` из Ф0).
- Рисуется в `_drawObject`/`_drawZone`: forbidden=красный пунктир, target=зелёный, collect=золотой пунктир. Цвета — только canvas-стоки.
- Зона НЕ кладёт `<zoneId>.x/.y` как центр объекта (`hasCenter` пропущен для type==='zone').
### Как определяется граф-уровень (данные, `levels.js`)
- Хелперы: `road(exprStr,a,b,dur)` (plot+runner, id 'curve'), `graphHero()` (point ball на curve.runX/runY),
`rectZone/circZone(id,kind,...)`, `startMarker`. Уровень = спека с этими объектами + `goal{when:'gate.hit',fail:'<forb>.hit',stars}`.
- ⚠️ ГОЧА: имена param `t/w/h/pi/e/E/PI/tau` зарезервированы движком (`h`=высота вьюпорта!). abs-уровень
использует `m` (вершина), НЕ `h`. При добавлении уровней проверять имена коэффициентов.
- `time:{duration,loop:false}` синхронизирован с `runner.duration` — герой доезжает до конца за один проход.
### Карта / запуск
- Глава `functions` добавлена в `CHAPTERS` (key/title/subtitle/accent). map.js НЕ тронут — узлы рисуются по метаданным,
тип спеки карте безразличен. Разблокировка: `unlockStars` 9/11/13/15/17 (≤ 18 макс. звёзд физ-глав → нет дедлока).
- Запуск тот же (`QuantikGame.start``SimEngine.mount`); граф-уровни используют те же слайдеры params, спец-вайринг
НЕ нужен. Бейдж темы в quantik.html — per-level по `level.subject` (аддитивно).
### Для Ф4 (квантовые способности)
- `runDone`/`runX`/`.hit` — готовые env-поля для условий способностей (напр. «туннель» = временно игнорить forbidden.hit
в `fail`). Способность может менять `params` (коэффициенты) или подменять выражение кривой — всё через тот же SimExpr-конвейер.
- Зоны kind:'collect' уже «залипают» через механизм stars (Ф0). Новая способность = новый env-флаг + условие, БЕЗ eval.
- Сервер уже принимает `zone`+`runner` (validateSpec, OBJECT_TYPES) — авторённые граф-уровни (Ф5) пройдут гейт.
@@ -0,0 +1,88 @@
# Phase 4: Квантовые способности + SR-комнаты
**Status:** ✅ Done (reviewed — PASS, committed)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Фирменные «квантовые» механики, дающие герою идентичность, плюс связка с флешкарт-SR:
**суперпозиция** (раздвоение), **коллапс/пауза** (точный прицел), **туннелирование**
(проход сквозь тонкую стену за «энергию», которую даёт быстрое SR-повторение).
## Tasks
- [x] Task 1: Суперпозиция: уровни L12/L13 с двумя телами `ball`+`ball2`, общий закон (theta,v)
рулит обеими зеркально; `goal.when` ссылается на `ball.*` И `ball2.*` (победа — только обе).
Реюз мульти-body физики. ball2 — полупрозрачный «фантом» (tintHeroSpec в quantik-game.js).
- [x] Task 2: Коллапс/пауза-прицел: уровень L14 несёт пунктир-`plot` (id 'aim') с предсказанной
параболой текущего закона; способность «Прицел» = пауза-тоггл (`inst.pause/play`) для прицела.
- [x] Task 3: Туннелирование: барьер = `forbidden`-зона `wall`; `fail:'wall.hit && tunnel<1'`.
Способность «Туннель» тратит TUNNEL_COST энергии → `inst.setParam('tunnel',1)` (стена
проницаема). `tunnel` — НЕ слайдер (только способность); отсутствует в env → 0 → стена сплошная.
- [x] Task 4: SR-комната: `QuantikAbilities.openRestRoom` — мини-сессия повторения в модалке
(НЕ iframe). `LS.fcListDecks → fcStudySession → fcReview`; «Знаю/Легко» начисляют энергию.
Пусто (нет колод/нет due) → дружелюбное окно + ссылка `/flashcards`.
- [x] Task 5: Контент — глава `quantum` (5 уровней): L12/L13 суперпозиция, L14 прицел, L15/L16 туннель.
- [x] Task 6: Тесты (headless vm + РЕАЛЬНЫЕ движки): суперпозиция (оба тела), энергия (grant/spend/
reward чистая логика), tunnel flips fail, per-level solvability sweep (5/5 выигрываемы,
full-star достижим), регресс существующих 11 уровней без throw. 48/48; harness удалён.
## Files to Modify/Create
- `frontend/js/labs/_sim_engine.js` — поле `tunnelable` у стены + расход энергии (аддитивно, документировать).
- `frontend/js/game/quantik-game.js` — способности, HUD энергии, SR-комната-модалка.
- интеграция с флешкарт-SR (клиентский модуль повторения / `LS` API).
- `frontend/js/game/levels.js` — уровни способностей.
- тест(ы).
## Acceptance Criteria
- Суперпозиция: победа только когда обе копии выполнили условие.
- Коллапс: на паузе виден предсказанный путь.
- Туннелирование тратит энергию; SR-повторение её пополняет; стена проницаема только при заряде.
- Без eval/эмодзи; existing симуляции/SR не сломаны; тесты зелёные; lint baseline 0.
## Notes
- Имя param `e` зарезервировано (число Эйлера в SimExpr) — для энергии брать `energy`/`charge`.
- SR-движок повторения уже существует — переиспользовать, не дублировать расписание.
## Review Checklist
- [x] Все задачи; аддитивность; без эмодзи/eval; тесты зелёные; lint baseline 0
## Handoff to Next Phase
### Что добавлено (файлы)
- **Новый** `frontend/js/game/quantik-abilities.js` (`window.QuantikEnergy` + `window.QuantikAbilities`).
- `frontend/js/game/levels.js` — глава `quantum` (L12L16) + `CHAPTERS.quantum`.
- `frontend/js/game/quantik-game.js``tintHeroSpec` тинтует `ball2` (фантом); `start()` монтирует
`QuantikAbilities.mountBar` (оборачивает `inst.destroy` для снятия панели); `openRest()` + сброс
`abilities.resetAbilities()` на «Ещё раз».
- `frontend/quantik.html``<script src="/js/game/quantik-abilities.js">` (после map.js, до game.js)
+ CSS `.qa-*` (панель/HUD/тост/SR-модалка/карточка/оценки).
- `js/api.js``fcStudySession(deckId)` (GET `/flashcards/decks/:id/study`) и
`fcReview(cardId, quality)` (POST `/flashcards/cards/:id/review` body `{quality}`).
- ⛔ Движок `_sim_engine.js` НЕ тронут — туннель/прицел/суперпозиция выражены через безопасную
модель спеки (зона+undefined-param / pause-toggle / два тела). Engine touch = 0.
### Энергия (клиентский ресурс)
- localStorage ключ **`quantik-energy`** (целое 0..`ENERGY_MAX`=99). `window.QuantikEnergy`:
`getEnergy/setEnergy/grantEnergy/spendEnergy/canSpend/rewardForQuality/onEnergyChange`.
`TUNNEL_COST=3`, `REWARD_GOOD=1` (q=4 «Знаю»), `REWARD_EASY=2` (q=5 «Легко»).
- Туннель тратит энергию ОДИН раз за попытку (`tunnelUsed`); «Ещё раз»/новый mount сбрасывают
`tunnel→0` (стена снова сплошная).
### Контракты для Phase 5 (авторинг)
- **Способность «Туннель»** активируется, если `goal.fail/when/stars` упоминают слово `tunnel`
(`QuantikAbilities.levelHasTunnel`). Авторский UI должен уметь добавить `forbidden`-зону `wall`
и `fail:'wall.hit && tunnel<1'`. `tunnel` — служебный param (не слайдер).
- **Способность «Прицел»** появляется, если на сцене есть `plot` с `id:'aim'` ИЛИ `lineStyle:'dashed'`
(`levelHasAim`). Авторский UI может предложить «предсказанную траекторию» как пунктир-plot.
- **Суперпозиция** = чистый контент: второе тело `id:'ball2'` (point/circle с `body`), `goal.when`
с обоими `ball.*`+`ball2.*`. `tintHeroSpec` уже тинтует `ball`/`ball2`; авторские id вне этих
двух тинтоваться скином не будут (Phase 5 при желании расширит).
- **Глава = метаданные**: `quantum` появилась на карте без правок map.js (`groupByChapter` +
`Levels.chapter`). Новая глава = новый `chapter`-ключ + `CHAPTERS`-запись (контракт Phase 2).
### Solvability (проверено на реальном движке, harness удалён)
- L12 Раздвоение: 52 win-комбо (есть выигрышная v почти для любой θ), full-star напр. θ70/v10.
- L13 Две двери: full-star θ45/v11. L14 Прицел: full-star θ60/v12 (2/2 звезды).
- L15 барьер: с tunnel=1 — 4 win, full-star a0/b3.7; БЕЗ tunnel — 0 win (гейт работает).
- L16 капстоун: с tunnel=1 — full-star a-0.25/k7.2 (монета сдвинута на (5,6.9) r0.85 — была (5,6)
r0.7, конфликтовала со 2-й звездой k≥6.8); БЕЗ tunnel — 0 win.
@@ -0,0 +1,84 @@
# Phase 5: Авторинг уровней в sim-builder + раздача классу
**Status:** ✅ Done (reviewed — PASS, committed)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Дать учителю собирать **игровые уровни без кода** в существующем sim-builder: задать цель/звёзды/
подсказку/главу/норматив, сохранить как `custom_sims` с `cat='game'`, опубликовать и раздать
классу. Игра начинает грузить уровни из БД (а не только встроенные).
⚠️ ПЕРЕД СТАРТОМ: свериться с base-веткой `feature/sim-builder` — чужой P4-WIP билдера должен
быть смержен. При необходимости влить base в `feature/quantik-game` и разрешить конфликты.
## Tasks
- [x] Task 1: Режим «Игровой уровень» в `sim-builder.js`/`.html`: панель цели (`goal.when`,
`title`, `hint`, `hold`, `fail`), список звёзд (add/del: `when`+`label`), глава/порядок/`par_ms`.
Inline-проверка выражений через `SimExpr.compile().error` (как остальные поля билдера).
- [x] Task 2: `buildSpec()` материализует блок `goal`/`game`; `loadFromSim()` раскладывает обратно
(round-trip), как сделано с plot-range в Ф4 билдера.
- [x] Task 3: Кнопка «Играть» в билдере — открыть текущую спеку в игровом режиме (тест уровня автором).
- [x] Task 4: Каталог уровней: игра грузит `custom_sims` c `cat='game'` (свои+published) — реюз
`LS.customSimsList`/`Get`. Категория `game` в списке `CATS` (customSimController) + фильтр.
- [x] Task 5: Раздача классу: реюз паттерна Ф6 sim-builder (авто-публикация + `pushNotif` ученикам,
ссылка `/quantik?level=custom:<id>`); привязка к программе через `lab_sim_links` (`sim_id='custom:<id>'`).
- [x] Task 6: Deep-link `/quantik?level=custom:<id>` (паттерн Ф5/Ф7 sim-builder, доступ own|published|admin).
- [x] Task 7: Тесты: round-trip goal в билдере (headless как Ф4 sim-builder); доступ к чужому
draft запрещён; published-уровень виден; раздача шлёт уведомление.
## Files to Modify/Create
- `frontend/sim-builder.html`, `frontend/js/sim-builder.js` — режим игрового уровня (аддитивно).
- `backend/src/controllers/customSimController.js``CATS` += 'game'; (goal уже в validateSpec из Ф0).
- `frontend/js/game/quantik-game.js` — загрузка уровней из custom_sims + deep-link.
- тест(ы).
## Acceptance Criteria
- Учитель собирает уровень с целью/звёздами, тестирует «Играть», сохраняет/публикует.
- Игра грузит уровни из БД; deep-link открывает конкретный уровень с проверкой доступа.
- Раздача классу публикует + уведомляет; round-trip спеки без потерь; тесты зелёные; lint baseline 0.
## Notes
- Билдер — зона, где мог идти параллельный P4-WIP; правки строго аддитивны, свериться с base.
- Санитизация goal-полей — уже на сервере (Ф0). Клиентская валидация зеркалит её (как в Ф4 билдера).
## Review Checklist
- [x] Все задачи; аддитивность билдера; ownership/доступ корректны; без эмодзи/eval; тесты зелёные
## Handoff to Next Phase
### Как авторённый уровень попадает в реестр игры
- Хранилище: `custom_sims` с `cat='game'`. Спека = обычная SimForge-спека + блок
`goal{when,title,hint,hold,fail,stars[]}` + блок `game{chapter,order,par_ms,unlockStars?}`.
- `window.QuantikLevels` стал «асинхронным»: встроенные `LEVELS` доступны сразу (offline),
а опубликованные/свои игровые спеки подмешиваются через **`QuantikLevels.ensureCustom()`**
(Promise, кэш): `LS.customSimsList()` → фильтр `cat==='game'``LS.customSimGet(id)` каждой →
`customToLevel(row)` → запись реестра. `list()` = `LEVELS.concat(CUSTOM)`; `get(id)` ищет в обоих.
- **Форма записи авторённого уровня** (`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` отбрасывается (не уровень).
- Новая глава-созвездие **`custom`** в `CHAPTERS` (levels.js) — авторённые уровни без явной главы
группируются в неё; map.js рисует автоматически (по метаданным, не тронут). Если автор задал
`game.chapter='kinematics'` и т.п. — уровень встанет в соответствующее созвездие.
### Deep-link контракт
- `/quantik?level=custom:<dbid>``QuantikLevels.getAsync('custom:<dbid>')`: если уже в кэше —
синхронно; иначе `LS.customSimGet(dbid)` (сервер: доступ own|published|admin → иначе 404/403 → карта).
Авторённый уровень по deep-link открывается БЕЗ гейта `unlockStars` (получатель ссылки заходит прямо).
Встроенный `?level=<id>` — как раньше (через `isUnlocked`).
- Прогресс игрока по авторённым уровням пишется так же: `LS.gameProgressSubmit('custom:<dbid>', ...)`
(`game_progress.level_id` — TEXT ≤120, двоеточие проходит; бэкенд НЕ менялся).
### Share-flow
- Реюз контроллера `customSimController.share` (Ф6). Для `cat==='game'` ссылка/тип уведомления
переключены: link `/quantik?level=custom:<id>`, тип `game_level_shared` (обычная sim — `/lab?sim=…`,
`sim_shared`). Авто-публикация + durable `pushNotif` ученикам класса. Ответ теперь содержит `link`.
- Раздача игрового уровня из билдера — той же кнопкой «Раздать» (`openShareModal``LS.customSimShare`),
отдельный UI не нужен. Курикулумная привязка — `lab_sim_links` `sim_id='custom:<id>'` (Ф6, не трогалось).
### Для Phase 6 (лидерборд / живая гонка)
- Лидерборд может агрегировать `game_progress` по `level_id` (включая `custom:<dbid>`). Уровень-метаданные
(title/chapter) для custom доступны через `QuantikLevels.getAsync` или прямой `LS.customSimGet`.
- Живая гонка (мост `sim_state`) — он на base-ветке sim-builder Ф7; авторённый игровой уровень уже
монтируется тем же `SimEngine`, что и встроенные, поэтому мост применим без изменений в этой фазе.
- Авторинг-панель пишет `goal`/`game` только при `st.game.enabled` — обычные симуляции не затронуты.
@@ -0,0 +1,46 @@
# Phase 6: Класс-лидерборд / живая гонка (classroom SSE)
> **REMOVED (Amendment 1, 2026-06-14)** — фаза не реализуется по решению пользователя.
> Архивный subplan. `game_progress.level_id` (TEXT) уже готов под лидерборд, если фичу вернут.
**Status:** ❌ Removed
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Соревновательный слой: лидерборды по уровню/классу и опциональная **живая гонка** в онлайн-уроке
(реюз classroom SSE + моста `sim_state`/`apply_sim_state` из Ф7 sim-builder).
## Tasks
- [ ] Task 1: API лидерборда: `GET /api/game/leaderboard?level_id=...&scope=class|global` — топ по
времени/звёздам. Источник — `game_progress` (best per user). Доступ: класс — только своему классу.
- [ ] Task 2: UI лидерборда: на экране уровня/победы и на карте — топ класса (имена/время/звёзды),
позиция игрока. Inline SVG-медали, без эмодзи.
- [ ] Task 3: Живая гонка (опц.): учитель в classroom запускает уровень классу; ученики решают
одновременно; прогресс/финиши транслируются через существующий SSE-relay. Реюз iframe-конвейера
`/lab?embed=...` НЕ требуется — гонка может жить на `/quantik` с гоночной комнатой по classId.
- [ ] Task 4: Сервер: relay результатов гонки (минимальный, поверх существующего SSE), без новых
тяжёлых таблиц — эфемерное состояние гонки в памяти/коротком хранилище.
- [ ] Task 5: Тесты: лидерборд отдаёт корректный топ и режет чужой класс; submit обновляет позицию;
смоук UI.
## Files to Modify/Create
- `backend/src/controllers/gameController.js`, `routes/game.js` — leaderboard (+ гонка-relay).
- `frontend/js/game/quantik-game.js` / `map.js` — UI лидерборда + гоночная комната.
- (опц.) интеграция кнопки запуска гонки в classroom.html (аддитивно, как Ф7 sim-builder).
- тест(ы).
## Acceptance Criteria
- Лидерборд по классу/глобально корректен и изолирован по классу; позиция игрока видна.
- Живая гонка (если включена) синхронит финиши классу через SSE; закрытие чистое.
- Без эмодзи/eval; existing функционал цел; тесты зелёные; lint baseline 0.
## Notes
- Реюз durable-уведомлений `pushNotif` для приглашения в гонку; эфемерный прогресс — через SSE.
- classroom.html — большой; искать через vex по DOM-id, точечный Read (ast-index не индексит inline-script).
## Review Checklist
- [ ] Все задачи; изоляция по классу; аддитивность classroom; без эмодзи/eval; тесты зелёные
## Handoff to Next Phase
<!-- Финальная фаза — далее комплексное ревью и мерж в feature/sim-builder. -->
+85 -3
View File
@@ -1,6 +1,85 @@
# Feature Context: Конструктор симуляций (SimForge)
## Current State
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) ЗАВЕРШЁН — P5 «Прямое манипулирование + история» РЕАЛИЗОВАН**
(рабочее дерево, не закоммичено; ветка `feature/sim-builder`). Файл: ТОЛЬКО `frontend/js/sim-builder.js`.
`_sim_engine.js` НЕ тронут — `_toWorld`/`_toPx`/`_niceStep` уже публичны на инстансе движка, хука не
потребовалось (в IMPROVEMENTS.md P5 предполагались правки движка — не понадобились).
- **Прямое манипулирование** (`bindPreviewDrag` переписан): «ручки» через `handlesOf(obj)` для ВСЕХ
позиционируемых типов — point/circle/label/readout/rect (одна ручка x,y), segment/vector (origin x1,y1 +
end x2,y2 ИЛИ origin+dx/dy), polyline/path (по ручке на числовую вершину `points`). Хит-тест `pickHandle`
(14px, через `_toPx`); режимы pointerdown: `handle`/`place` (единств. ручка — клик ставит)/`body`
(несколько ручек — относительный сдвиг)/`none`. Поля-выражения `blocked` (не затираются). `refreshObjFields`
расширен на x1/y1/x2/y2/dx/dy/points.
- **Snap-к-сетке**: тумблер в тулбаре (`_snap`, `toggleSnap`, иконка `ICON.grid`, активность — инлайн
`SNAP_ACTIVE_CSS`); округление к `_niceStep(34)` (минорный шаг сетки; fallback 0.5). Выравнивание к чужим
координатам не делалось (бонус; snap достаточно — отмечено как частичное).
- **Undo/Redo**: стек `JSON.stringify(this.st)` (глубина 50), `pushHistory` (до мутации, без дублей, сброс
redo), `snapField` (один снапшот на сессию правки поля через focusin/`_fieldSnapTaken`). Структурные
операции — снапшот сразу; drag — один на сессию (no-op откатывается). Кнопки undo/redo (SVG `.ic`) +
Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (`bindKeyboardShortcuts`, игнорит фокус в полях). `loadFromSim` обнуляет
историю; `_restoreSnapshot` → renderPanels + scheduleRemount.
- Верификация: `node --check` OK; эмодзи/eval — 0; vm-смоук 38/38 PASS (drag всех типов + body-move; snap;
защита выражений; undo/redo drag+add; лимит стека; round-trip идемпотентен). buildSpec/валидация не тронуты.
git status: тронут только sim-builder.js (`_sim_engine.js` в статусе — чужой коммит параллельной сессии
«goal/game», мной НЕ редактировался).
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P4 «UI билдера + контролы стиля» РЕАЛИЗОВАН** (рабочее дерево, не
закоммичено; ветка `feature/sim-builder`). Файлы: только `frontend/sim-builder.html` + `frontend/js/sim-builder.js`.
`_sim_engine.js`/`js/api.js`/lab.* НЕ тронуты — билдер лишь генерит спеку, которую движок (P2/P3) уже умеет рисовать.
- **Контролы стиля объекта** (блок «Стиль», `STYLE_FOR[type]`): слайдер непрозр.(`opacity` 0..1),
select `lineStyle`(solid/dashed/dotted), `pointStyle`(только point), тумблер `glow`, тумблер градиент-
заливки(circle/rect → `gradient:[c0,c1]`). Цвета — `colorCtl`: нативный `<input type=color>` + текст
(источник истины, держит rgba/named) + очистка для fill/trailColor. Синхрон — `wireColorControls`,
`toHexColor``#rrggbb`. Per-объект уже были width/color в OBJ_FIELDS — переведены на color-пикеры.
- **Редактор кривых plot** (`plotEditor`/`curveEditor`): UI-модель `{var,range_a/b,samples,trace,legend,
plotFill,plotMarker,curves:[{expr,color,label,width,lineStyle,opacity,fill,fillColor,marker}]}`. Список
кривых (add/del, минимум 1), на кривую все P3-поля + fx-палитра, plot-уровневые fill/marker/легенда.
`loadPlot` (spec→UI: curves[]→exprs[]→legacy expr; легаси plot-level width/lineStyle/opacity → в кривую),
`normalizePlotForSpec`+`stripCurve` (UI→spec). Одиночная простая кривая → легаси `{expr,color}`, иначе
`curves:[...]`. `legend:false` эмитится только при выкл.
- **Список объектов/графиков**: z-order вверх/вниз (порядок массива = порядок отрисовки), видимость
(`hidden:true` — чисто билдерский флаг, фильтруется в `buildSpec`, движок не знает), дублировать
(deep-clone+новый `_uid`, `id+'_copy'`), удалить. Иконки — новые inline SVG `.ic` (up/down/copy/eye/eyeOff/clearX).
- **Минимизация спеки + стабильный round-trip**: `stripObj.isDefaultStyle` выбрасывает дефолты
(glow:false, lineStyle:'solid', pointStyle:'filled', opacity:1, trail/closed:false) и `hidden`. Save→load→
save идемпотентен (loadFromSim восстанавливает дефолты из контролов).
- **Дизайн/мобайл**: новые CSS-классы в ls.css-стиле (`.sbu-obj-style`/`.sbu-style-row`/`.sbu-color-*`/
`.sbu-range`/`.sbu-curve(s)`/`.is-hidden`/`.sbu-grad-row`); заголовок объекта flex-wrap + 26px-кнопки;
медиа ≤920px (раскладка) + новый ≤560px (поля/стили в один столбец). Пустые состояния дополнены.
- **Безопасность**: выражения только через `SimExpr.compile`; цвета попадают лишь в спеку (canvas-стоки
движка), DOM-style с польз.цветом не используется; eval/new Function — нет.
- Верификация: `node --check` sim-builder.js + извлечённого инлайна html — OK; эмодзи нет (скан кодпойнтов
обоих файлов — 0); eval/new Function — 0; headless vm-смоук (DOM/SimExpr-стаб) 27+12 PASS: стили объекта в
спеке, round-trip объектов идемпотентен ×2, plot с 2 кривыми (label/marker/lineStyle/opacity/fill-цвет/
range/samples) + round-trip ×2, легаси-одиночная кривая → легаси-форма + round-trip, hidden исключает из
спеки, z-order=порядок массива, дефолты-стрип; +шаблонные легаси-plot save→load→save стабильны (2 PASS).
Temp удалены. git status: тронуты только sim-builder.html и sim-builder.js.
- **Следующее (P5):** прямое манипулирование на сцене (drag всех типов + snap-к-сетке + выравнивание) и
undo/redo. Потребуются правки `_sim_engine.js` (хит-тесты/ручки) + `sim-builder.js` (стек снапшотов `this.st`).
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P3 «Графики/диаграммы» РЕАЛИЗОВАН** (рабочее дерево, не
закоммичено; ветка `feature/sim-builder`). Файл: только `frontend/js/labs/_sim_engine.js`. Расширен
`_drawPlot` + ветка `type==='plot'` в `_prepareObjects`. Оси/сетка/подписи уже из P1 — не дублировались.
- **Несколько кривых**: нормализуются в `prep.curves[]`, приоритет источника `curves:[{...}]`
`exprs:['sin(x)','x^2']` → одиночный `expr` (легаси, обратная совместимость сохранена). Каждой кривой
свой цвет (явный `color` или `DEFAULT_PALETTE[i%8]`). `prep.exprFn` = первой кривой (для trace-режима).
- **Поля кривой** (`curves[i]`): `expr`, `color`, `label`(→легенда), `width`, `lineStyle`, `opacity`,
`fill`(true→полупрозр. цвет / строка), `marker`(none|dot|ring). Не заданные наследуют plot-уровень.
**Plot-уровневые `fill`/`marker`** — дефолт для всех кривых.
- **Заливка под кривой** `_fillUnderCurve` (между кривой и y=0, посегментно — разрывы у не-finite не
сливаются; baseY клиппится к canvas). **Маркеры** `_drawCurveMarkers` (переиспользует `_drawPoint`,
прорежены ~28px). **Легенда** `_drawLegend` на canvas (тёмная плашка + свотч + светлый текст, верх-право,
авто при `label`, `legend:false` отключает). Новые модульные хелперы `_markerStyle`/`_fillAlpha`.
- **Безопасность**: цвета только в canvas-стоки (strokeStyle/fillStyle/fillText фикс-цвет легенды);
DOM-style с пользовательским цветом не используется; eval нет. Каждая кривая в своём save/restore,
легенда на внешнем уровне.
- Верификация: `node --check` OK; headless vm-смоук (canvas-стаб со счётчиком save/restore + РЕАЛЬНЫЕ
`_sim_expr`+`_sim_engine`) 10/10: легаси/exprs[]/curves+fill+marker+legend/наследование/не-finite
(1/x,tan)/legend:false/trace±range/fillUnder+markers с null/регресс point-vector-circle-rect — все PASS;
ctx сбалансирован (depth→0, нет underflow). Эмодзи нет (только пре-существующие → в комментариях); eval=0.
Temp-смоук удалён. git status: тронут только `_sim_engine.js`.
- **Следующее (P4):** UI билдера + контролы стиля (`sim-builder.html`/`sim-builder.js`) — дать новым полям
plot контролы: список кривых (add/del, expr+color+label+width+lineStyle+opacity+fill+marker), plot-fill/
marker, тумблер легенды; плюс per-объект color/opacity/width/dash, z-order, дублирование, мобайл.
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P2 «Качество графики объектов» РЕАЛИЗОВАН** (рабочее дерево, не
закоммичено; ветка `feature/sim-builder`). Файл: только `frontend/js/labs/_sim_engine.js`. Один движок →
эффект и в билдере, и в /lab, и на доске.
@@ -25,9 +104,7 @@
масштаб; все поля прочитаны; палитра применена + явный color сохранён; трасса накоплена; destroy чист.
Эмодзи нет (скан кодпойнтов: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только
в комментарии стр.15. git status: тронут только `_sim_engine.js`.
- **Следующее (P3):** графики/диаграммы (`_drawPlot`): оси-деления plot, несколько кривых, заливка под
кривой, маркеры точек (переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/
`_drawPoint` готовы к переиспользованию.
- **Следующее (P3):** РЕАЛИЗОВАНО (см. блок P3 выше) — несколько кривых, заливка, маркеры, легенда.
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P1 «Рабочее поле» РЕАЛИЗОВАН** (рабочее дерево, не закоммичено;
ветка `feature/sim-builder`, общая с параллельной сессией materials/quota). Файл: только
`frontend/js/labs/_sim_engine.js` (sim-builder.html НЕ потребовался). Один движок → эффект и в билдере, и в /lab, и на доске.
@@ -213,6 +290,11 @@
- Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`.
## RESUME STATE
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md):** P1+P2+P3 закоммичены; **P4 «UI билдера + контролы стиля»
РЕАЛИЗОВАН** (рабочее дерево, не закоммичено — ждёт ревьюера/оркестратора). Файлы: только
`frontend/sim-builder.html` + `frontend/js/sim-builder.js`. Дальше — независимый ревью P4, затем P5
(прямое манипулирование на сцене для всех типов + snap/выравнивание + undo/redo; правки `_sim_engine.js`
+ `sim-builder.js`). Контракт стилей/кривых из P2/P3-handoff полностью покрыт контролами билдера.
- Последний коммит фичи: — (Ф0..Ф7 ВСЕ реализованы, ещё не закоммичены — ждут оркестратора)
- Текущая фаза: Phase 7 — Доска онлайн-урока (✅ Implemented, pending commit) — ФИНАЛЬНАЯ.
Дальше: Final Review (final-reviewer + security review) → коммит всех фаз → merge в master.
+92 -9
View File
@@ -68,20 +68,103 @@
кривых). Для P3 расширять `_drawPlot` — оси-делений plot, несколько кривых, заливка под кривой, маркеры
точек (можно переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint`
готовы к переиспользованию.
- [ ] **P3 — Графики/диаграммы (визуал charts).** Для plot: оси с делениями/подписями, несколько кривых,
заливка под кривой, маркеры точек, легенда; аккуратный стиль диаграмм. Файл: `_sim_engine.js` (+ билдер
поля plot).
- [ ] **P4 — UI билдера + контролы стиля.** Дизайн-полировка панелей/тулбара (ls.css), нативные color-
- [x] **P3 — Графики/диаграммы (визуал charts).** Для plot: несколько кривых, заливка под кривой,
маркеры точек, легенда; аккуратный стиль диаграмм (оси/сетка/подписи — уже из P1). Файл: `_sim_engine.js`.
**Handoff (P3 → P4): новые поля plot-объекта** (контракт для контролов билдера в P4). Все читаются в
`_prepareObjects` (ветка `type==='plot'`), рендерятся ТОЛЬКО на canvas (без DOM-style/eval). Старый
одиночный `expr`/`var`/`range`/`samples`/`trace` работает как раньше (обратная совместимость):
- **Несколько кривых.** Источник (приоритет): `curves:[{...}]``exprs:['sin(x)','x^2']``expr`
(легаси). Нормализуются в `prep.curves[]`. Каждой кривой свой цвет: явный `color` или
`DEFAULT_PALETTE[i%8]`. `prep.exprFn` = первая кривая (для trace-режима).
- **Поля кривой** (`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` — дефолт для всех кривых (если у кривой не задано).
- **Заливка под кривой** — между кривой и осью `y=0`, посегментно (разрывы у не-finite точек не сливаются),
`_fillUnderCurve`. Прозрачность через `_fillAlpha(color, 0.18)` для `fill:true`.
- **Маркеры узлов**`_drawCurveMarkers` (переиспользует `_drawPoint`), прорежены ~28px по экрану
(не рисуем сотни точек). `dot` → filled, `ring` → hollow.
- **Легенда**`_drawLegend` (на canvas: тёмная плашка + цветной свотч + светлый текст), верх-право,
не наезжает на бар кнопок вида. Включается авто при наличии `label`; `legend:false` отключает.
- **Качество кривой** — пропуск не-finite (разрывы), переиспользован существующий equidistant sampling
(`samples`, деф. 200, макс 2000), `_applyStroke` (dash/opacity/glow/lineJoin/cap).
- **На P4 (билдер):** дать этим полям контролы — список кривых (добавить/удалить, expr + color-picker +
label + width + lineStyle + opacity + fill toggle/color + marker select), plot-уровневые fill/marker,
тумблер легенды. Хелпер `_markerStyle`/`_fillAlpha` — модульного уровня, рядом с `_dashFor`/`_opacity`.
- [x] **P4 — UI билдера + контролы стиля.** Дизайн-полировка панелей/тулбара (ls.css), нативные color-
пикеры + opacity/width/dash/линестиль на объект, z-order/дублирование/видимость объектов, пустые
состояния, мобайл. Файлы: `frontend/sim-builder.html`, `frontend/js/sim-builder.js`.
- [ ] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle),
snap-к-сетке, выравнивание; undo/redo в билдере. Файлы: `_sim_engine.js`, `frontend/js/sim-builder.js`.
**Handoff (P4 → P5):**
- **Контролы стиля объекта** (блок «Стиль» в каждом редакторе, `STYLE_FOR[type]` решает набор):
`rangeCtl` непрозр. (слайдер 0..1 → `opacity`), `selectCtl` линия (`lineStyle` solid/dashed/dotted),
стиль точки (`pointStyle`, только point), тумблер `glow`, тумблер «Градиент-заливка» (circle/rect →
`gradient:[c0,c1]`, две пары color-инпутов). Цвета — новый `colorCtl`: нативный `<input type=color>`
+ текстовое поле (источник истины, поддерживает rgba/named) + кнопка очистки для fill/trailColor
(«нет заливки»). Синхрон пикер↔текст — `Builder.wireColorControls(row)` (текст диспатчит `input`,
основной `data-of`/`data-cvf` обработчик ловит). `toHexColor` приводит к `#rrggbb` для нативного пикера.
- **Редактор кривых plot** (`plotEditor`/`curveEditor`): UI-модель plot = `{var, range_a/b, samples,
trace, legend, plotFill, plotMarker, curves:[...]}`. Кривая = `{_uid, expr, color, label, width,
lineStyle, opacity, fill(bool), fillColor, marker}`. Список кривых (добавить `[data-curveadd]` /
удалить `[data-curvedel]`, минимум 1), на кривую — expr+fx, color, label, width, lineStyle, marker,
opacity, fill+цвет. Plot-уровневые `plotFill`/`plotMarker`/легенда. `loadPlot` нормализует
spec→UI (curves[]→exprs[]→legacy expr; легаси plot-level width/lineStyle/opacity наследуются кривой),
`normalizePlotForSpec`+`stripCurve` собирают обратно: **одиночная «простая» кривая (только expr+color,
нет plot-fill/marker) → легаси-форма** `{expr,color}`; иначе `curves:[...]`. `legend:false` эмитится
только при выключенной легенде.
- **Список объектов**: в шапке каждого — z-order вверх/вниз (`[data-oup]`/`[data-odown]`, порядок в
массиве = порядок отрисовки; крайние disabled), видимость (`[data-ohide]``o.hidden=true`),
дублировать (`[data-odup]`, deep-clone + новый `_uid`, `id+'_copy'`), удалить. Аналогично у plot.
- **hidden — чисто на стороне билдера** (движок не трогали): `buildSpec` фильтрует объекты/plot с
`hidden`; `stripObj.isDefaultStyle` гарантирует, что `hidden`/дефолты стиля (glow:false, lineStyle:
'solid', pointStyle:'filled', opacity:1, trail/closed:false) НЕ попадают в спеку → спека минимальна,
round-trip save→load→save идемпотентен (проверено vm-смоуком 27+12+2 PASS).
- **На P5 (прямое манипулирование + история):** в билдере сейчас есть только drag x/y point/circle/label/
readout/rect и конца segment/vector (`bindPreviewDrag` через `inst._toWorld`). Расширять до всех типов
+ snap-к-сетке + выравнивание (нужны правки `_sim_engine.js` — хит-тесты/ручки). Undo/redo: состояние
= `this.st` (сериализуемо JSON); снимать снапшот при `onAdd`/удалении/правке (debounce) — стек в
Builder, перерисовка `renderPanels`+`scheduleRemount`. Идентичность спеки между билдами уже гарантирована.
- [x] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle),
snap-к-сетке; undo/redo в билдере. Файл: `frontend/js/sim-builder.js` (движок НЕ тронут — `_toWorld`/
`_toPx`/`_niceStep` уже публичны на инстансе, хука не потребовалось).
**Итог / Handoff (P5 — финал раунда):**
- **Прямое манипулирование (`bindPreviewDrag`, переписан).** «Ручки» объекта строит `handlesOf(obj)`:
точка/окружность/подпись/показатель/прямоугольник → одна ручка `pos`(x,y); отрезок/вектор → две
ручки `origin`(x1,y1)+`end`(x2,y2 ИЛИ origin+dx/dy — определяется по наличию полей); ломаная/путь →
по ручке на каждую числовую вершину `points`. Каждая ручка несёт `set(x,y)` и флаг `blocked`. Хит-тест
`pickHandle` (допуск 14px через `inst._toPx`) выбирает ближайшую ручку. Режимы pointerdown:
`handle` (попали в ручку — двигаем её), `place` (единственная ручка, клик ставит точку — сохранён
исходный смысл «клик ставит»), `body` (несколько ручек — двигаем всё тело относительным сдвигом от
стартовой мир-точки), `none` (нет двигаемых ручек). Поля-ВЫРАЖЕНИЯ не трогаются: `numField` вернёт
`null` для нечислового значения → ручка `blocked` (не двигается, не сериализуется молча).
- **Snap-к-сетке.** Тумблер в тулбаре (иконка `ICON.grid`, флаг `this._snap`, переключатель `toggleSnap`,
активное состояние — инлайн-стиль `SNAP_ACTIVE_CSS`, без зависимости от CSS-класса). При включённом
drag округляет мир-координаты к шагу `inst._niceStep(34)` (минорный шаг сетки движка; fallback 0.5).
Выключенный — `round2`.
- **Выравнивание** — реализован минимум (snap-к-сетке движка). Прилипание к координатам других объектов
НЕ делалось (бонус; достаточно snap для зачёта). Зафиксировано как частичное.
- **Undo/Redo.** Стек снапшотов `JSON.stringify(this.st)` (глубина `_undoMax=50`). `pushHistory` снимает
снапшот ПЕРЕД мутацией (без дублей верхушки; сбрасывает redo). `snapField` — один снапшот на сессию
правки поля (focusin сбрасывает флаг `_fieldSnapTaken`, первый input/change снимает) → Ctrl+Z откатывает
значение целиком, а не посимвольно. Структурные операции (add/delete/z-order/duplicate/hide/toggle,
включая plot/curve/wall/spring и физ-тумблер) — снапшот сразу. Drag — один снапшот на сессию (пустые
no-op-снапшоты откатываются в `end()`). Кнопки undo/redo в тулбаре (SVG `.ic`), горячие клавиши
Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (`bindKeyboardShortcuts`, вешается один раз, игнорит фокус в полях ввода).
`loadFromSim` обнуляет историю. `_restoreSnapshot``renderPanels`+`scheduleRemount`.
- **Совместимость.** `buildSpec`/round-trip/валидация не тронуты; идемпотентность спеки сохранена.
`refreshObjFields` расширен на x1/y1/x2/y2/dx/dy/points. Проверено vm-смоуком: 38/38 PASS
(drag point/circle/segment-оба-конца/vector-dx,dy/polyline-vertex + body-move polyline/segment; snap к
0.5; выражение не затирается; undo/redo drag и add; стек ограничен; round-trip идемпотентен; no-op
drag не плодит историю). `node --check` OK, эмодзи/eval нет.
## Progress
| Phase | Status | Review | Committed |
|-------|--------|--------|-----------|
| P1 Working field | Done | ✅ PASS | ✅ |
| P2 Object graphics | Done | ✅ PASS | ✅ |
| P3 Charts | ⬜ | ⬜ | |
| P4 Builder UI | ⬜ | ⬜ | |
| P5 Direct manip + history | ⬜ | ⬜ | |
| P3 Charts | Done | ✅ PASS | |
| P4 Builder UI | Done | ✅ PASS | |
| P5 Direct manip + history | Done | ✅ PASS | |