diff --git a/CLAUDE.md b/CLAUDE.md index fc1df3d..c9a8b77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,3 +185,91 @@ git push origin master - **Новые 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)` 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`), узлы — ` + Физика + + + +
+
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/sim-builder.html b/frontend/sim-builder.html index 5f62851..71daaa4 100644 --- a/frontend/sim-builder.html +++ b/frontend/sim-builder.html @@ -133,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; } diff --git a/js/api.js b/js/api.js index f71eaa2..fc37a0d 100644 --- a/js/api.js +++ b/js/api.js @@ -1042,10 +1042,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, @@ -1272,6 +1273,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 }); } @@ -1294,6 +1297,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); } diff --git a/js/sidebar.js b/js/sidebar.js index 4828464..6b108e1 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -87,6 +87,7 @@ ${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', 'Красная книга')} diff --git a/plans/quantik-game/CONTEXT.md b/plans/quantik-game/CONTEXT.md new file mode 100644 index 0000000..a03c5ed --- /dev/null +++ b/plans/quantik-game/CONTEXT.md @@ -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 `.runX/.runY/.runDone`; + герой = обычный point на `curve.runX/runY` (f компилируется 1 раз, питает И кривую, И бегунок — нет само-ссылки); + (2) `type:'zone'` (rect/circle, kind forbidden/target/collect, track) → булево env-поле `.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` (L12–L16) + `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:`; `getAsync(id)` резолвит deep-link (own draft через + `LS.customSimGet`). Новая глава `custom` в `CHAPTERS`. quantik.html: `Promise.all([loadProgress, + ensureCustom])` до карты + deep-link `?level=custom:` (без гейта unlockStars). Backend: + `share()` для cat='game' шлёт `game_level_shared` со ссылкой `/quantik?level=custom:` (иначе + `/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`). diff --git a/plans/quantik-game/PLAN.md b/plans/quantik-game/PLAN.md new file mode 100644 index 0000000..2c64212 --- /dev/null +++ b/plans/quantik-game/PLAN.md @@ -0,0 +1,107 @@ +# Feature: Квантик — Законы Мира (образовательная 2D-игра) + +**Branch:** `feature/quantik-game` +**Base branch:** `feature/sim-builder` (движок P1–P3 и фазы sim-builder ещё не в master) +**Created:** 2026-06-13 +**Status:** 🟡 In Progress +**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 +- [ ] Merged to `feature/sim-builder` (ожидает одобрения пользователя) + +### 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/подписанные токены). diff --git a/plans/quantik-game/phase-0-objective-layer.md b/plans/quantik-game/phase-0-objective-layer.md new file mode 100644 index 0000000..ae4492d --- /dev/null +++ b/plans/quantik-game/phase-0-objective-layer.md @@ -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: '', // SimExpr: победа, когда станет истинным (≠0) + hint?: 'текст подсказки', // показывается в HUD (escape на сервере) + title?: 'Цель уровня', // краткая формулировка цели для HUD + hold?: 0.0, // сек, сколько условие должно держаться (деф. 0 = мгновенно) + stars?: [ // 0..3 доп.условий-«звёзд» (бонусы) + { when:'', label?:'...' } + ], + fail?: '' // опц.: мгновенный проигрыш (вышел за поле/задел шип) +} +``` +- `when`/`stars[].when`/`fail` — компилируются ОДИН раз при mount (как все выражения), env тот же. +- Доп. env-поля для целей: `t` (время), `tries` (число reset с начала), плюс всё что уже в env + (`.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: '', // ≤500 симв., НЕ исполняется на сервере + title?: '...', hint?: '...', // sanitizeText: escape & < > + обрезка (title≤120, hint≤300) + hold?: 0, // число (сек удержания when); не-число → 400 + fail?: '', // ≤500 симв. + stars?: [ { when:'', 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`, +`.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/∞/ошибке). diff --git a/plans/quantik-game/phase-1-shell-first-level.md b/plans/quantik-game/phase-1-shell-first-level.md new file mode 100644 index 0000000..6799cb9 --- /dev/null +++ b/plans/quantik-game/phase-1-shell-first-level.md @@ -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 оверлея — в `