feat(quantik-game): фаза 5 — авторинг игровых уровней в sim-builder + раздача

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
Maxim Dolgolyov
2026-06-14 16:09:10 +03:00
parent 8db8171b97
commit c780b6fd96
10 changed files with 602 additions and 30 deletions
+84 -5
View File
@@ -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:<dbid>', метаданные — из
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:<dbid>`, когда он
может ещё не быть в смёрженном списке (напр. свой 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);