From c780b6fd965a0b2dc1006d2468c6e95d5831d630 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sun, 14 Jun 2026 16:09:10 +0300 Subject: [PATCH] =?UTF-8?q?@=20feat(quantik-game):=20=D1=84=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0=205=20=E2=80=94=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=20=D0=B8=D0=B3=D1=80=D0=BE=D0=B2=D1=8B=D1=85=20?= =?UTF-8?q?=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D0=B5=D0=B9=20=D0=B2=20sim-build?= =?UTF-8?q?er=20+=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B0=D1=87=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Учитель собирает игровой уровень без кода: новая (аддитивная, сворачиваемая) панель в 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:), offline-safe, строки без goal отбрасываются; deep-link /quantik?level=custom: с серверной проверкой доступа (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) @ --- CLAUDE.md | 11 + .../src/controllers/customSimController.js | 15 +- backend/tests/quantik-authoring.test.js | 136 +++++++++ frontend/js/game/levels.js | 89 +++++- frontend/js/sim-builder.js | 264 +++++++++++++++++- frontend/quantik.html | 32 ++- frontend/sim-builder.html | 6 + plans/quantik-game/CONTEXT.md | 20 ++ plans/quantik-game/PLAN.md | 4 +- .../quantik-game/phase-5-authoring-sharing.md | 55 +++- 10 files changed, 602 insertions(+), 30 deletions(-) create mode 100644 backend/tests/quantik-authoring.test.js diff --git a/CLAUDE.md b/CLAUDE.md index f8ec4d8..c9a8b77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -262,3 +262,14 @@ git push origin master - **ГОЧА харнесса solvability (физ-уровни): mount планирует ОТЛОЖЕННЫЙ rAF, который делает `_fit`+`reset`(+autoplay).** Если не «слить» его ДО своего `play()`, он выстрелит в середине прогона, вызовет `reset→pause→cancelAnimationFrame` и убьёт кадровый цикл (тело стоит на старте, `t=0`, 0 wins у ЗАВЕДОМО решаемого уровня). Фикс: после mount слить отложенный callback БЕЗ продвижения часов, затем `pause()`, конфиг params, `reset()`, `play()`, гнать кадры с виртуальными часами (8.33мс/кадр, `performance.now` синхронен с таймстампом rAF). Headless-смоук физики обязан гнать РЕАЛЬНУЮ физику (`SimPhysics` экспортится из `_sim_engine.js`). - **Контент-фикс L16 (поймал sweep)**: монета `(5,6)` r0.7 у параболы `a·(x−5)²+k` (вершина в `(5,k)`) собиралась при `5.3` + тип `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:`→`LS.customSimGet(dbid)` (сервер-доступ own|published|admin), резолвнутый уровень подмешивается в `CUSTOM` (повторное открытие/«Дальше» синхронны). Встроенные уровни — offline, как раньше. +- **Запись авторённого уровня (`customToLevel`)**: `{ id:'custom:', 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:` открывается БЕЗ гейта `unlockStars`** (получатель ссылки/автор заходит прямо в уровень); встроенный `?level=` — через `isUnlocked` как раньше. quantik.html: `Promise.all([loadProgress(), ensureCustom()])` до первого `map.render`, deep-link через `getAsync`. Прогресс по custom-уровням: `gameProgressSubmit('custom:',…)` — `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`). diff --git a/backend/src/controllers/customSimController.js b/backend/src/controllers/customSimController.js index f4b929e..67f188b 100644 --- a/backend/src/controllers/customSimController.js +++ b/backend/src/controllers/customSimController.js @@ -454,17 +454,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:), + // обычная симуляция — в лаборатории (/lab?sim=custom:). Фаза 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. diff --git a/backend/tests/quantik-authoring.test.js b/backend/tests/quantik-authoring.test.js new file mode 100644 index 0000000..0b88c47 --- /dev/null +++ b/backend/tests/quantik-authoring.test.js @@ -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: (тип 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}`); + }); +}); diff --git a/frontend/js/game/levels.js b/frontend/js/game/levels.js index 56da4ce..106ec43 100644 --- a/frontend/js/game/levels.js +++ b/frontend/js/game/levels.js @@ -961,19 +961,98 @@ kinematics: { key: 'kinematics', title: 'Кинематика', subtitle: 'Полёт и гравитация', accent: '#22D3EE' }, dynamics: { key: 'dynamics', title: 'Динамика', subtitle: 'Силы, пружины, орбиты', accent: '#A78BFA' }, functions: { key: 'functions', title: 'Функции', subtitle: 'Едем по кривой y = f(x)', accent: '#67E8F9' }, - quantum: { key: 'quantum', title: 'Квантовые законы', subtitle: 'Суперпозиция · прицел · туннель', accent: '#C4B5FD' } + quantum: { key: 'quantum', title: 'Квантовые законы', subtitle: 'Суперпозиция · прицел · туннель', accent: '#C4B5FD' }, + // Авторённые учителями уровни (custom_sims cat='game') без явной главы — сюда. + custom: { key: 'custom', title: 'Уровни учителей', subtitle: 'Авторённые уровни сообщества', accent: '#F472B6' } }; - function list() { return LEVELS.slice(); } + /* ── Авторённые уровни (Фаза 5): custom_sims с cat='game' ─────────────────── + Реестр становится «асинхронным»: встроенные уровни доступны сразу (offline), + а опубликованные/свои игровые спеки подмешиваются после ensureCustom(). + Запись уровня из строки custom_sims: id='custom:', метаданные — из + spec.game (chapter/order/par_ms/unlockStars), spec — как есть. */ + var CUSTOM = []; // смёрженные записи авторённых уровней + var _customPromise = null; // кэш промиса загрузки (грузим один раз) + + /* Строка из LS.customSimsList/Get -> запись реестра уровня (или null). */ + function customToLevel(row) { + if (!row || !row.spec || typeof row.spec !== 'object') return null; + var spec = row.spec; + if (!spec.goal) return null; // не игровой уровень — пропускаем + var gm = (spec.game && typeof spec.game === 'object') ? spec.game : {}; + var dbid = row.id; + return { + id: 'custom:' + dbid, + dbid: dbid, + title: row.title || (spec.meta && spec.meta.title) || 'Уровень', + chapter: gm.chapter || 'custom', + order: (typeof gm.order === 'number') ? gm.order : (1000 + Number(dbid)), + unlockStars: (typeof gm.unlockStars === 'number') ? gm.unlockStars : 0, + par_ms: (typeof gm.par_ms === 'number') ? gm.par_ms : undefined, + subject: row.subject || (spec.goal && spec.goal.subject) || undefined, + hint: (spec.goal && spec.goal.hint) || '', + spec: spec, + _custom: true + }; + } + + /* Загрузить опубликованные + свои игровые custom_sims и смёржить. + Возвращает Promise (кэшируется). Тихо игнорирует ошибки/отсутствие LS. */ + function ensureCustom() { + if (_customPromise) return _customPromise; + var LS = global.LS; + if (!LS || !LS.customSimsList || !LS.customSimGet) { + _customPromise = Promise.resolve([]); + return _customPromise; + } + _customPromise = LS.customSimsList().then(function (r) { + var sims = (r && r.sims) || []; + // только игровая категория (список не содержит spec — его берём отдельно) + var games = sims.filter(function (s) { return s && s.cat === 'game'; }); + return Promise.all(games.map(function (s) { + return LS.customSimGet(s.id).then(function (g) { + return customToLevel(g && g.sim); + }).catch(function () { return null; }); + })); + }).then(function (records) { + CUSTOM = records.filter(Boolean); + return CUSTOM.slice(); + }).catch(function () { CUSTOM = []; return []; }); + return _customPromise; + } + + function list() { return LEVELS.concat(CUSTOM); } function get(id) { - for (var i = 0; i < LEVELS.length; i++) if (LEVELS[i].id === id) return LEVELS[i]; + var all = list(); + for (var i = 0; i < all.length; i++) if (all[i].id === id) return all[i]; return null; } + + /* Достать уровень по id асинхронно — для deep-link `custom:`, когда он + может ещё не быть в смёрженном списке (напр. свой draft). Резолвит через + LS.customSimGet с проверкой доступа на сервере (own|published|admin). */ + function getAsync(id) { + var found = get(id); + if (found) return Promise.resolve(found); + var m = /^custom:(\d+)$/.exec(String(id || '')); + var LS = global.LS; + if (!m || !LS || !LS.customSimGet) return Promise.resolve(null); + return LS.customSimGet(m[1]).then(function (g) { + var lvl = customToLevel(g && g.sim); + if (lvl) { + // подмешать в кэш, чтобы повторное открытие/«Дальше» нашло его синхронно + if (!CUSTOM.some(function (c) { return c.id === lvl.id; })) CUSTOM.push(lvl); + } + return lvl; + }).catch(function () { return null; }); + } + function chapter(key) { return CHAPTERS[key] || { key: key, title: key, subtitle: '', accent: '#22D3EE' }; } global.QuantikLevels = { - list: list, get: get, chapter: chapter, - LEVELS: LEVELS, CHAPTERS: CHAPTERS + list: list, get: get, getAsync: getAsync, ensureCustom: ensureCustom, chapter: chapter, + LEVELS: LEVELS, CHAPTERS: CHAPTERS, + customToLevel: customToLevel }; })(typeof window !== 'undefined' ? window : this); diff --git a/frontend/js/sim-builder.js b/frontend/js/sim-builder.js index 886325f..f80df46 100644 --- a/frontend/js/sim-builder.js +++ b/frontend/js/sim-builder.js @@ -74,7 +74,15 @@ params: [], objects: [], plots: [], // храним plot-объекты отдельно для удобства UI, при сборке мерджим в objects - physics: { enabled: false, gx: 0, gy: -9.8, friction: 0, restitution: 0.9, walls: [], springs: [] } + physics: { enabled: false, gx: 0, gy: -9.8, friction: 0, restitution: 0.9, walls: [], springs: [] }, + // P5-Квантик: игровой слой (goal + игровые метаданные). enabled=false → goal/game + // не попадают в спеку (обычная симуляция ведёт себя ровно как раньше). + game: { + enabled: false, + when: '', title: '', hint: '', hold: '', fail: '', + stars: [], // [{ when, label }], макс 3 + chapter: '', order: '', par_ms: '' + } }; } @@ -91,7 +99,7 @@ this._remountTimer = null; this._selObjId = null; // выбранный для drag-on-preview объект this._placing = false; // режим «поставить объект кликом» - this._open = { meta: true, params: true, objects: true, plots: true }; + this._open = { meta: true, params: true, objects: true, plots: true, game: false }; this._lastSpec = null; // P5: прямое манипулирование + история this._snap = false; // привязка к сетке при drag @@ -226,6 +234,8 @@ walls: (Array.isArray(ph.walls) ? ph.walls : []).map(function (w) { return Object.assign({ _uid: uid('w') }, w); }), springs: (Array.isArray(ph.springs) ? ph.springs : []).map(function (s) { return Object.assign({ _uid: uid('s') }, s); }) }; + // game/goal (P5-Квантик): раскладываем spec.goal + spec.game обратно в st.game. + st.game = loadGame(spec.goal, spec.game); this.st = st; // свежая загрузка (открытие симуляции / шаблон) — история начинается заново this._undo.length = 0; this._redo.length = 0; this._fieldSnapTaken = false; @@ -272,6 +282,15 @@ }; spec.physics = ph; } + + // game/goal (P5-Квантик): материализуем игровой слой, если он включён. + // goal{when,title,hint,hold,fail,stars[]} и game{chapter,order,par_ms}. + if (st.game && st.game.enabled) { + var goal = buildGoal(st.game); + if (goal) spec.goal = goal; + var game = buildGameMeta(st.game); + if (game) spec.game = game; + } return spec; }; @@ -321,6 +340,71 @@ return s; } + /* ── Игровой слой (P5-Квантик): goal/game ⇄ UI-состояние st.game ─────────── + st.game = { enabled, when, title, hint, hold, fail, stars:[{when,label}], + chapter, order, par_ms }. Хранит «как введено» (строки/числа) — + материализация в spec.goal/spec.game на сборке, разбор обратно на загрузке. */ + + /* spec.goal + spec.game -> st.game (для loadFromSim). Включаем игровой режим, + если в спеке присутствует goal ИЛИ game. */ + function loadGame(goal, game) { + var g = { + enabled: false, + when: '', title: '', hint: '', hold: '', fail: '', + stars: [], chapter: '', order: '', par_ms: '' + }; + if (goal && typeof goal === 'object') { + g.enabled = true; + g.when = goal.when == null ? '' : String(goal.when); + g.title = goal.title == null ? '' : String(goal.title); + g.hint = goal.hint == null ? '' : String(goal.hint); + g.fail = goal.fail == null ? '' : String(goal.fail); + g.hold = (goal.hold == null || goal.hold === '') ? '' : goal.hold; + g.stars = (Array.isArray(goal.stars) ? goal.stars : []).map(function (s) { + s = s || {}; + return { + _uid: uid('star'), + when: s.when == null ? '' : String(s.when), + label: s.label == null ? '' : String(s.label) + }; + }); + } + if (game && typeof game === 'object') { + g.enabled = true; + g.chapter = game.chapter == null ? '' : String(game.chapter); + g.order = (game.order == null || game.order === '') ? '' : game.order; + g.par_ms = (game.par_ms == null || game.par_ms === '') ? '' : game.par_ms; + } + return g; + } + + /* st.game -> spec.goal (или null, если нет ни одного содержательного поля). */ + function buildGoal(gm) { + var out = {}; + if (trimStr(gm.when)) out.when = trimStr(gm.when); + if (trimStr(gm.title)) out.title = trimStr(gm.title); + if (trimStr(gm.hint)) out.hint = trimStr(gm.hint); + if (trimStr(gm.fail)) out.fail = trimStr(gm.fail); + if (gm.hold !== '' && gm.hold != null && isFinite(parseFloat(gm.hold))) out.hold = parseFloat(gm.hold); + var stars = (Array.isArray(gm.stars) ? gm.stars : []).map(function (s) { + var os = {}; + if (trimStr(s.when)) os.when = trimStr(s.when); + if (trimStr(s.label)) os.label = trimStr(s.label); + return os; + }).filter(function (s) { return s.when || s.label; }).slice(0, 3); + if (stars.length) out.stars = stars; + return Object.keys(out).length ? out : null; + } + + /* st.game -> spec.game (метаданные уровня; null, если все пусты). */ + function buildGameMeta(gm) { + var out = {}; + if (trimStr(gm.chapter)) out.chapter = trimStr(gm.chapter); + if (gm.order !== '' && gm.order != null && isFinite(parseFloat(gm.order))) out.order = parseFloat(gm.order); + if (gm.par_ms !== '' && gm.par_ms != null && isFinite(parseFloat(gm.par_ms))) out.par_ms = parseFloat(gm.par_ms); + return Object.keys(out).length ? out : null; + } + /* ════════════════════════ ЖИВОЕ ПРЕВЬЮ ════════════════════════ */ Builder.prototype.scheduleRemount = function (immediate) { @@ -609,6 +693,41 @@ if (action === 'snap') { this.toggleSnap(); return; } }; + /* «Играть»: открыть текущую (в работе) спеку в игровом режиме для теста уровня. + Монтируем тот же SimEngine в модалке — слой цели (HUD/победа/звёзды) активируется + САМ наличием блока goal (Фаза 0 движка), как и в /quantik. Без сохранения/сети — + тестируем прямо черновик. Если goal не задан, подсказываем включить игровой слой. */ + Builder.prototype.playGame = function () { + var self = this; + var spec = this.buildSpec(); + if (!spec.goal || !spec.goal.when) { + global.LS.toast('Задайте цель (поле «победа») и включите игровой уровень', 'warn', 2600); + return; + } + if (!global.SimEngine) { global.LS.toast('Движок не загружен', 'error'); return; } + // Модалка с хост-узлом сцены; SimEngine монтируется после открытия. Инстанс + // уничтожается в onClose — он срабатывает на ЛЮБОЕ закрытие (X / оверлей / Escape / + // кнопка «Закрыть»), поэтому отдельный destroy в onClick кнопки не нужен. + var host = global.document.createElement('div'); + host.style.cssText = 'position:relative;width:100%;height:min(70vh,560px);background:#0D0D1A;border-radius:10px;overflow:hidden'; + var inst = null; + var m = global.LS.modal({ + title: 'Тест уровня', size: 'lg', content: '', + onClose: function () { if (inst) { try { inst.destroy(); } catch (e) {} inst = null; } }, + actions: [ + { label: 'Сброс', onClick: function () { if (inst && inst.reset) { try { inst.reset(); } catch (e) {} } } }, + { label: 'Закрыть', primary: true, onClick: function () { m.close(); } } + ] + }); + m.body.appendChild(host); + try { + inst = global.SimEngine.mount(host, spec); + if (inst && inst.play) inst.play(); + } catch (e) { + host.innerHTML = '
Ошибка запуска: ' + esc(e.message || e) + '
'; + } + }; + /* Переключить привязку к сетке (drag будет округлять к шагу сетки). */ Builder.prototype.toggleSnap = function () { this._snap = !this._snap; @@ -764,6 +883,18 @@ if (r < 0 || r > 1) errs.push('Упругость (restitution) должна быть в диапазоне 0..1.'); } + // game/goal (P5-Квантик): проверяем выражения цели/проигрыша/звёзд + if (st.game && st.game.enabled) { + checkExpr(typeof st.game.when === 'string' ? st.game.when : '', 'Цель: условие победы (when)'); + checkExpr(typeof st.game.fail === 'string' ? st.game.fail : '', 'Цель: условие проигрыша (fail)'); + if (!trimStr(st.game.when)) errs.push('Игровой уровень: укажите условие победы (when).'); + var starList = Array.isArray(st.game.stars) ? st.game.stars : []; + if (starList.length > 3) errs.push('Максимум 3 звезды.'); + starList.forEach(function (s, i) { + checkExpr(typeof s.when === 'string' ? s.when : '', 'Звезда #' + (i + 1) + ', условие'); + }); + } + // размер JSON try { var bytes = new global.Blob([JSON.stringify(this.buildSpec())]).size; @@ -833,7 +964,8 @@ this.sectionMeta() + this.sectionParams() + this.sectionObjects() + - this.sectionPlotsPhysics(); + this.sectionPlotsPhysics() + + this.sectionGame(); this.wirePanels(); }; @@ -1134,6 +1266,72 @@ section('physics', 'Физика', physBody, !!ph.enabled); }; + /* ── Игровой уровень (P5-Квантик) ───────────────────────────────────────── + Панель «Цель» собирает блок goal (when/title/hint/hold/fail) + список звёзд + (макс 3) + игровые метаданные (chapter/order/par_ms). Тумблер «Это игровой + уровень» включает слой; выключенный — goal/game НЕ попадают в спеку. + Выражения (when/fail/звёзды) проверяются inline через SimExpr.compile. */ + Builder.prototype.sectionGame = function () { + var gm = this.st.game || {}; + var on = !!gm.enabled; + // строка-выражение цели/проигрыша с inline-ошибкой + function exprRow(key, label, val, ph) { + var err = exprError(val); + return '
' + + '' + + '' + + (err ? '' + esc(err) + '' : '') + + '
'; + } + var stars = Array.isArray(gm.stars) ? gm.stars : []; + var starRows = stars.map(function (s, i) { + var err = exprError(s.when); + return '
' + + '
' + + 'Звезда ' + (i + 1) + '' + + '' + + '' + + '
' + + '
' + + '' + + '' + + (err ? '' + esc(err) + '' : '') + + '
' + + miniField('подпись', '') + + '
'; + }).join(''); + + var inner = + '' + + '
' + + '
Цель
' + + exprRow('when', 'победа (when)', gm.when, 'напр. gate.hit или hypot(ball.x-8,ball.y-1)<0.8') + + exprRow('fail', 'проигрыш (fail) — опц.', gm.fail, 'напр. ball.y < -1 || t > 8') + + '
' + + field('Заголовок цели', '') + + miniField('удержать, с (hold)', '') + + '
' + + field('Подсказка', '') + + '
Звёзды (макс 3)
' + + '
' + (starRows || '
Нет звёзд-бонусов. Победа = 1-я звезда автоматически.
') + '
' + + (stars.length < 3 ? '' : '') + + '
' + + '
Метаданные уровня
' + + '
' + + miniField('глава', '') + + miniField('порядок', '') + + miniField('норматив, мс', '') + + '' + + '
' + + '' + + '
'; + return section('game', 'Игровой уровень (цель/звёзды)', inner, this._open.game); + }; + /* ════════════════════════ ПРИВЯЗКА СОБЫТИЙ ПАНЕЛЕЙ ════════════════════════ */ Builder.prototype.wirePanels = function () { @@ -1468,6 +1666,61 @@ }); }); + // ── Игровой слой (P5-Квантик) ── + var gameOn = p.querySelector('[data-game="enabled"]'); + if (gameOn) gameOn.addEventListener('change', function () { + self.pushHistory(); + self.st.game.enabled = gameOn.checked; + self.renderPanels(); self.scheduleRemount(false); + }); + // goal/game поля (when/fail/title/hint/hold/chapter/order/par_ms) + p.querySelectorAll('[data-gf]').forEach(function (el) { + el.addEventListener('input', function () { + self.snapField(); + var k = el.getAttribute('data-gf'); + self.st.game[k] = el.value; + self.updateFieldFeedback(el, null); // inline-ошибка выражения (when/fail) + self.scheduleRemount(false); + }); + }); + // звёзды: поля when/label + p.querySelectorAll('.sbu-star').forEach(function (row) { + var i = parseInt(row.getAttribute('data-si'), 10); + row.querySelectorAll('[data-sf]').forEach(function (el) { + el.addEventListener('input', function () { + self.snapField(); + var k = el.getAttribute('data-sf'); + if (self.st.game.stars[i]) self.st.game.stars[i][k] = el.value; + self.updateFieldFeedback(el, null); + self.scheduleRemount(false); + }); + }); + }); + p.querySelectorAll('[data-stardel]').forEach(function (b) { + b.addEventListener('click', function () { + self.pushHistory(); + self.st.game.stars.splice(parseInt(b.getAttribute('data-stardel'), 10), 1); + self.renderPanels(); self.scheduleRemount(false); + }); + }); + // fx-палитра для goal-выражений и условий звёзд + p.querySelectorAll('[data-gfx]').forEach(function (b) { + b.addEventListener('click', function () { + var key = b.getAttribute('data-gfx'); + self.openPalette(p.querySelector('[data-gf="' + key + '"]')); + }); + }); + p.querySelectorAll('[data-sfx]').forEach(function (b) { + b.addEventListener('click', function () { + var row = b.closest('.sbu-star'); + self.openPalette(row && row.querySelector('[data-sf="when"]')); + }); + }); + // «Играть (тест уровня)» внутри панели + p.querySelectorAll('[data-a2="play-game"]').forEach(function (b) { + b.addEventListener('click', function () { self.playGame(); }); + }); + // add buttons p.querySelectorAll('[data-add]').forEach(function (b) { b.addEventListener('click', function () { self.onAdd(b.getAttribute('data-add')); }); @@ -1533,6 +1786,11 @@ if (this.st.physics.springs.length >= LIMITS.springs) { global.LS.toast('Достигнут лимит пружин', 'warn'); return; } this.pushHistory(); this.st.physics.springs.push({ _uid: uid('s'), a: '', b: '', k: 40, length: 2, damping: 0.5 }); + } else if (what === 'star') { + this.st.game.stars = Array.isArray(this.st.game.stars) ? this.st.game.stars : []; + if (this.st.game.stars.length >= 3) { global.LS.toast('Максимум 3 звезды', 'warn'); return; } + this.pushHistory(); + this.st.game.stars.push({ _uid: uid('star'), when: '', label: '' }); } this.renderPanels(); this.scheduleRemount(false); diff --git a/frontend/quantik.html b/frontend/quantik.html index 65ee670..e794e0b 100644 --- a/frontend/quantik.html +++ b/frontend/quantik.html @@ -521,17 +521,37 @@ 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= в URL и уровень доступен — открыть его, иначе карта. - loadProgress().then(function () { + // Сначала грузим прогресс И авторённые уровни (параллельно), затем deep-link. + Promise.all([loadProgress(), ensureCustomLevels()]).then(function () { map.render(progressMap); var params = new URLSearchParams(location.search); var wantId = params.get('level'); if (wantId) { - var lvl = window.QuantikLevels.get(wantId); - if (lvl && window.QuantikProgress.isUnlocked(lvl, progressMap, window.QuantikLevels.list())) { - openLevel(lvl); - return; - } + // custom: может быть свой 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(); }); 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/plans/quantik-game/CONTEXT.md b/plans/quantik-game/CONTEXT.md index 5fd1151..a03c5ed 100644 --- a/plans/quantik-game/CONTEXT.md +++ b/plans/quantik-game/CONTEXT.md @@ -67,6 +67,26 @@ 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`. diff --git a/plans/quantik-game/PLAN.md b/plans/quantik-game/PLAN.md index 9ad80cb..53cb101 100644 --- a/plans/quantik-game/PLAN.md +++ b/plans/quantik-game/PLAN.md @@ -63,7 +63,7 @@ - [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) -- [ ] Phase 5: Авторинг уровней в sim-builder + раздача классу [domain: fullstack] → [subplan](./phase-5-authoring-sharing.md) +- [x] Phase 5: Авторинг уровней в sim-builder + раздача классу [domain: fullstack] → [subplan](./phase-5-authoring-sharing.md) - [ ] Phase 6: Класс-лидерборд / живая гонка (classroom SSE) [domain: fullstack] → [subplan](./phase-6-leaderboard-live.md) ## Phase Progress Log @@ -75,7 +75,7 @@ | Phase 2: Карта + мир + XP/скины | fullstack | ✅ Done | ✅ (1 🟡 fixed) | ✅ | ✅ | | Phase 3: Граф-уровни + зоны | fullstack | ✅ Done | ✅ | ✅ | ✅ | | Phase 4: Квантовые способности + SR | fullstack | ✅ Done | ✅ | ✅ | ✅ | -| Phase 5: Авторинг + раздача | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 5: Авторинг + раздача | fullstack | ✅ Done | ✅ | ✅ | ✅ | | Phase 6: Лидерборд / живая гонка | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | ## MVP boundary diff --git a/plans/quantik-game/phase-5-authoring-sharing.md b/plans/quantik-game/phase-5-authoring-sharing.md index 543fdd3..2d9234b 100644 --- a/plans/quantik-game/phase-5-authoring-sharing.md +++ b/plans/quantik-game/phase-5-authoring-sharing.md @@ -1,6 +1,6 @@ # Phase 5: Авторинг уровней в sim-builder + раздача классу -**Status:** ⬜ Not Started +**Status:** ✅ Done (reviewed — PASS, committed) **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack @@ -13,18 +13,18 @@ быть смержен. При необходимости влить base в `feature/quantik-game` и разрешить конфликты. ## Tasks -- [ ] Task 1: Режим «Игровой уровень» в `sim-builder.js`/`.html`: панель цели (`goal.when`, +- [x] Task 1: Режим «Игровой уровень» в `sim-builder.js`/`.html`: панель цели (`goal.when`, `title`, `hint`, `hold`, `fail`), список звёзд (add/del: `when`+`label`), глава/порядок/`par_ms`. Inline-проверка выражений через `SimExpr.compile().error` (как остальные поля билдера). -- [ ] Task 2: `buildSpec()` материализует блок `goal`/`game`; `loadFromSim()` раскладывает обратно +- [x] Task 2: `buildSpec()` материализует блок `goal`/`game`; `loadFromSim()` раскладывает обратно (round-trip), как сделано с plot-range в Ф4 билдера. -- [ ] Task 3: Кнопка «Играть» в билдере — открыть текущую спеку в игровом режиме (тест уровня автором). -- [ ] Task 4: Каталог уровней: игра грузит `custom_sims` c `cat='game'` (свои+published) — реюз +- [x] Task 3: Кнопка «Играть» в билдере — открыть текущую спеку в игровом режиме (тест уровня автором). +- [x] Task 4: Каталог уровней: игра грузит `custom_sims` c `cat='game'` (свои+published) — реюз `LS.customSimsList`/`Get`. Категория `game` в списке `CATS` (customSimController) + фильтр. -- [ ] Task 5: Раздача классу: реюз паттерна Ф6 sim-builder (авто-публикация + `pushNotif` ученикам, +- [x] Task 5: Раздача классу: реюз паттерна Ф6 sim-builder (авто-публикация + `pushNotif` ученикам, ссылка `/quantik?level=custom:`); привязка к программе через `lab_sim_links` (`sim_id='custom:'`). -- [ ] Task 6: Deep-link `/quantik?level=custom:` (паттерн Ф5/Ф7 sim-builder, доступ own|published|admin). -- [ ] Task 7: Тесты: round-trip goal в билдере (headless как Ф4 sim-builder); доступ к чужому +- [x] Task 6: Deep-link `/quantik?level=custom:` (паттерн Ф5/Ф7 sim-builder, доступ own|published|admin). +- [x] Task 7: Тесты: round-trip goal в билдере (headless как Ф4 sim-builder); доступ к чужому draft запрещён; published-уровень виден; раздача шлёт уведомление. ## Files to Modify/Create @@ -43,7 +43,42 @@ - Санитизация goal-полей — уже на сервере (Ф0). Клиентская валидация зеркалит её (как в Ф4 билдера). ## Review Checklist -- [ ] Все задачи; аддитивность билдера; ownership/доступ корректны; без эмодзи/eval; тесты зелёные +- [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, 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:` → `QuantikLevels.getAsync('custom:')`: если уже в кэше — + синхронно; иначе `LS.customSimGet(dbid)` (сервер: доступ own|published|admin → иначе 404/403 → карта). + Авторённый уровень по deep-link открывается БЕЗ гейта `unlockStars` (получатель ссылки заходит прямо). + Встроенный `?level=` — как раньше (через `isUnlocked`). +- Прогресс игрока по авторённым уровням пишется так же: `LS.gameProgressSubmit('custom:', ...)` + (`game_progress.level_id` — TEXT ≤120, двоеточие проходит; бэкенд НЕ менялся). + +### Share-flow +- Реюз контроллера `customSimController.share` (Ф6). Для `cat==='game'` ссылка/тип уведомления + переключены: link `/quantik?level=custom:`, тип `game_level_shared` (обычная sim — `/lab?sim=…`, + `sim_shared`). Авто-публикация + durable `pushNotif` ученикам класса. Ответ теперь содержит `link`. +- Раздача игрового уровня из билдера — той же кнопкой «Раздать» (`openShareModal` → `LS.customSimShare`), + отдельный UI не нужен. Курикулумная привязка — `lab_sim_links` `sim_id='custom:'` (Ф6, не трогалось). + +### Для Phase 6 (лидерборд / живая гонка) +- Лидерборд может агрегировать `game_progress` по `level_id` (включая `custom:`). Уровень-метаданные + (title/chapter) для custom доступны через `QuantikLevels.getAsync` или прямой `LS.customSimGet`. +- Живая гонка (мост `sim_state`) — он на base-ветке sim-builder Ф7; авторённый игровой уровень уже + монтируется тем же `SimEngine`, что и встроенные, поэтому мост применим без изменений в этой фазе. +- Авторинг-панель пишет `goal`/`game` только при `st.game.enabled` — обычные симуляции не затронуты.