feat(quantik-game): фаза 1 — оболочка игры + физ-уровень + прогресс (MVP)

Страница /quantik монтирует уровень-спеку в SimEngine (игровой режим: HUD из
Ф0 + слайдеры закона + play/reset), на победу шлёт результат и показывает
экран успеха (звёзды/время/попытки, inline SVG). Уровень phys-artillery-1
как данные (levels.js): гравитация + запуск тела из угла/скорости, портал,
бонус-звезда. Бэкенд: миграция 076 game_progress (UNIQUE user+level),
/api/game/progress (GET свой / POST upsert best time/stars, attempts++,
auth-only, валидация входа), клиент LS.gameProgress*, пункт сайдбара.
game.test.js 13/13; npm test 251 pass/8 baseline; lint:routes 0.
Уровень проверен на реальном интеграторе (311 выигрышных комбо, 31 на 3★).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
Maxim Dolgolyov
2026-06-13 15:31:25 +03:00
parent 4b5c8077d3
commit 351251d652
14 changed files with 679 additions and 28 deletions
+12
View File
@@ -213,3 +213,15 @@ git push origin master
- **Сервер** `customSimController.validateSpec`: `goal` (объект) + `game` (резерв Ф1/5) разрешены на верхнем уровне. `when`/`fail`/`stars[].when``checkExpr` (длина ≤500, НЕ исполняются); `title`/`hint`/`stars[].label``sanitizeText` (escape `& < >` + обрезка); `stars`>3 → 400; `hold` не-число → 400. `cat='game'` уже в `CATS`. Санитизированный `goal`/`game` пишется в `clean`.
- **Верификация P0**: `node --check` обоих файлов OK; headless vm-смоук (ручной DOM/canvas-стаб + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`, rAF-очередь степается вручную, `performance.now()` = виртуальные часы) **40/40 PASS**: when→win+timeMs>0, звёзды копятся+залипают+сброс на reset, fail без won, hold требует удержания + сброс при лапсе, спека без goal без HUD/без throw, onGoal ровно 1 раз, destroy баланс add/remove, серверный validateSpec (escape/>3 звезды/длина/hold/без-goal). `npm test` 238 pass / 8 baseline fail; lint:routes 0. Temp удалён. Эмодзи/eval/new Function — 0 (new Function только в пре-существующем комментарии стр.15).
- **На Phase 1**: использовать `onGoal`/`getResult`/`resetResult`; HUD включается сам наличием `goal`. Уровни хранятся в `custom_sims` (cat='game'). `game{}`-блок зарезервирован под мета (узел карты/мир/XP).
### Phase 1 — Learnings (Оболочка игры + 1 уровень + прогресс)
- **Сквозной MVP-срез играбелен.** Страница `/quantik` (`frontend/quantik.html` + `frontend/js/game/quantik-game.js`): `QuantikGame.start({host, level})``SimEngine.mount(host, level.spec)``inst`. «Игровой режим» НЕ требует флага — HUD из Ф0 появляется сам по наличию `goal` в спеке; управление = собственные слайдеры params движка + play/reset (внутри `inst.el`). Победа: `inst.onGoal(res => { LS.gameProgressSubmit(level.id, {time_ms:res.timeMs, stars:res.stars.got}); showSuccess(res); })`.
- **Уровни = ДАННЫЕ, встроенные (MVP).** `frontend/js/game/levels.js``window.QuantikLevels.{list,get,LEVELS}`. Запись `{ id, title, subject?, hint?, spec }`, `id`==`level_id`. Один уровень `phys-artillery-1`: physics-гравитация + body-запуск (`point` с `body.vx='v*cos(theta*pi/180)'`, `vy='v*sin(...)'`), портал-цель (`goal.when:'hypot(ball.x-PX,ball.y-PY)<R'`), бонус-звезда (`stars[].when`), `fail` при промахе за поле. Подобран ПРОХОДИМЫМ в пределах слайдеров (θ 10..80°, v 5..20 м/с; портал x=8, дальность v²·sin2θ/g ≈ 6..10 м). custom_sims cat='game' остаётся для авторённых уровней (Ф5) — реестр тогда станет асинхронным со слиянием.
- **API прогресса**: таблица `game_progress` (мигр.**076**, UNIQUE(user_id,level_id), user_id ON DELETE CASCADE), контроллер `gameController.js` + роутер `routes/game.js` (`router.use(authMiddleware)` → lint:routes 0), смонтировано в `server.js` после `/api/custom-sims`. `GET /api/game/progress``{progress:[…]}`; `POST` `{level_id,time_ms,stars}` → upsert best (min time / max stars) + attempts++. Валидация: level_id строка ≤120, time_ms/stars неотрицательные ЦЕЛЫЕ (`Number.isInteger`, отвергает дробь/NaN/∞), stars 0..3. Прогресс всегда `req.user` — нет межпользовательских роутов, ownership-проверка не нужна. Клиент `LS.gameProgressList()`/`LS.gameProgressSubmit(levelId,{time_ms,stars})` (стиль customSim*-врапперов в js/api.js).
- **Маршрутизация без правок server.js**: `/quantik``quantik.html` через `express.static(frontendDir,{extensions:['html']})` (как все clean URL). `/js/game/*` и `/js/labs/*` отдаются тем же static (гоча `/js`→корневой `js/` касается только api.js/sidebar.js, не подпапок). Подключение движка — копия sim-builder.html: `/js/labs/_sim_expr.js` + `/js/labs/_sim_engine.js`.
- **Экран успеха** = DOM-оверлей страницы `.qg-overlay` (НЕ HUD движка), `QuantikGame.buildSuccessOverlay(state)` строит карточку: звёзды inline SVG (заполн./контур, без эмодзи), время/звёзды/попытки, «Ещё раз» (убрать оверлей + `inst.reset()`) / «Дальше» (disabled-заглушка MVP — Ф2 активирует). CSS `.qg-*` в `<style>` quantik.html. Кнопки — классы `btn-primary`/`btn-ghost` (НЕ `ls-btn-*` — таких в ls.css нет).
- **Сайдбар**: `/quantik` (icon `rocket`) в группе practice ПЕРЕД `/sim-builder`, БЕЗ `hidden` (видно ученикам — это игра, в отличие от teacher-only sim-builder). `isActive('/quantik')` подсвечивает на clean URL.
- **Доступ страницы**: `LS.initPage()` (без `{requireLogin:false}`) сам редиректит на `/login` если не авторизован и возвращает null → бутстрап выходит. Любой авторизованный играет.
- **Верификация P1**: `node --check` всех новых/изменённых JS — OK; `npm run migrate` 076 применяется чисто; `npm test` 251 pass / 8 baseline fail (3 auth + 5 jsdom page-тестов — пре-существующие; **game.test.js 13/13 PASS**); `lint:routes` 247 :id-роутов, 0 unprotected (baseline 0). Эмодзи в коде нет (флагуются только `→`/`⛔` в комментариях — конвенция проекта); eval/new Function — 0. Спека без goal по-прежнему работает (Ф0 не задет).
- **На Phase 2 (карта/мир/XP)**: реестр уровней расширяемый (добавить запись в `LEVELS`); `game_progress`-API готов; экран успеха `buildSuccessOverlay` переиспользуем (расширить «следующим уровнем», активировать «Дальше»); при смене уровня без перезагрузки — `inst.destroy()` перед новым mount.
+84
View File
@@ -0,0 +1,84 @@
'use strict';
/* Game progress ("Квантик — Законы Мира", Фаза 1).
*
* Прогресс игрока по уровням. Уровень = спека SimForge с блоком goal;
* идентифицируется строковым level_id. На победу клиент шлёт результат
* (time_ms, stars); сервер делает upsert, сохраняя ЛУЧШИЙ результат
* (минимальное время, максимум звёзд) и инкрементируя attempts.
*
* Стиль следует customSimController / studentMaterialsController:
* node:sqlite db.prepare, auth-only (роутер ставит authMiddleware),
* валидация входа без исполнения, статусы 400.
*/
const db = require('../db/db');
const MAX_LEVEL_ID = 120; // длина level_id (TEXT)
const MAX_TIME_MS = 24 * 60 * 60 * 1000; // санитарный потолок: сутки в мс
/* Целое неотрицательное число (отвергаем NaN/Infinity/дробь/отрицательное). */
function isNonNegInt(v) {
return typeof v === 'number' && Number.isInteger(v) && v >= 0;
}
/* GET /api/game/progress — прогресс текущего пользователя по всем уровням. */
function listProgress(req, res) {
const uid = req.user.id;
const rows = db.prepare(`
SELECT level_id, best_time_ms, best_stars, attempts, completed_at
FROM game_progress
WHERE user_id = ?
ORDER BY completed_at DESC, id DESC
`).all(uid);
res.json({ progress: rows });
}
/* POST /api/game/progress body: { level_id, time_ms, stars }
* Upsert: сохраняем ЛУЧШИЙ результат (min time_ms, max stars); attempts++.
* Валидация: level_id строка ≤120; time_ms/stars — неотрицательные целые;
* stars 0..3. БЕЗ исполнения чего-либо. */
function submitProgress(req, res) {
const uid = req.user.id;
const b = req.body || {};
const levelId = typeof b.level_id === 'string' ? b.level_id.trim() : '';
if (!levelId) return res.status(400).json({ error: 'level_id обязателен' });
if (levelId.length > MAX_LEVEL_ID) {
return res.status(400).json({ error: `level_id длиннее ${MAX_LEVEL_ID} символов` });
}
const timeMs = b.time_ms;
const stars = b.stars;
if (!isNonNegInt(timeMs)) return res.status(400).json({ error: 'time_ms должно быть неотрицательным целым' });
if (timeMs > MAX_TIME_MS) return res.status(400).json({ error: 'time_ms вне допустимого диапазона' });
if (!isNonNegInt(stars)) return res.status(400).json({ error: 'stars должно быть неотрицательным целым' });
if (stars > 3) return res.status(400).json({ error: 'stars вне диапазона 0..3' });
const existing = db.prepare(
'SELECT id, best_time_ms, best_stars FROM game_progress WHERE user_id = ? AND level_id = ?'
).get(uid, levelId);
if (!existing) {
db.prepare(`
INSERT INTO game_progress (user_id, level_id, best_time_ms, best_stars, attempts)
VALUES (?, ?, ?, ?, 1)
`).run(uid, levelId, timeMs, stars);
} else {
// Лучшее время = минимум (null трактуем как «нет результата»); лучшие звёзды = максимум.
const bestTime = (existing.best_time_ms == null)
? timeMs
: Math.min(existing.best_time_ms, timeMs);
const bestStars = Math.max(existing.best_stars || 0, stars);
db.prepare(`
UPDATE game_progress
SET best_time_ms = ?, best_stars = ?, attempts = attempts + 1
WHERE id = ?
`).run(bestTime, bestStars, existing.id);
}
const row = db.prepare(
'SELECT level_id, best_time_ms, best_stars, attempts, completed_at FROM game_progress WHERE user_id = ? AND level_id = ?'
).get(uid, levelId);
res.json({ ok: true, progress: row });
}
module.exports = { listProgress, submitProgress };
@@ -0,0 +1,25 @@
-- ═══════════════════════════════════════════════════════════════
-- 076: Game progress (Квантик — Законы Мира, Фаза 1).
--
-- Прогресс игрока по уровням игры «Квантик». Уровень идентифицируется
-- строковым level_id (напр. 'phys-artillery-1'); сами уровни — это спеки
-- SimForge (встроенные данные сейчас, custom_sims cat='game' в Ф5).
--
-- Upsert хранит ЛУЧШИЙ результат: best_time_ms (минимальное время прохождения),
-- best_stars (максимум собранных звёзд 0..3). attempts растёт на каждый submit.
-- UNIQUE(user_id, level_id) — одна строка прогресса на пару игрок-уровень.
-- user_id ON DELETE CASCADE — прогресс удаляется вместе с игроком.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS game_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
level_id TEXT NOT NULL, -- идентификатор уровня (спека)
best_time_ms INTEGER, -- лучшее (минимальное) время, мс
best_stars INTEGER NOT NULL DEFAULT 0, -- лучшее число звёзд 0..3
attempts INTEGER NOT NULL DEFAULT 0, -- число попыток (++ на submit)
completed_at TEXT DEFAULT (datetime('now')), -- время первого прохождения
UNIQUE (user_id, level_id)
);
CREATE INDEX IF NOT EXISTS idx_game_progress_user ON game_progress (user_id);
+16
View File
@@ -0,0 +1,16 @@
'use strict';
/* /api/game — прогресс игрока в игре «Квантик — Законы Мира» (Фаза 1).
* Все роуты — auth-only (играют и ученики). router.use(authMiddleware)
* → lint:routes baseline 0. Прогресс всегда принадлежит req.user — нет
* межпользовательских роутов, проверка владения не требуется. */
const express = require('express');
const router = express.Router();
const { authMiddleware } = require('../middleware/auth');
const c = require('../controllers/gameController');
router.use(authMiddleware);
router.get('/progress', c.listProgress);
router.post('/progress', c.submitProgress);
module.exports = router;
+1
View File
@@ -197,6 +197,7 @@ app.use('/api/teacher-students', teacherStudentsRoutes);
app.use('/api/lab', labRoutes);
app.use('/api/materials', require('./routes/materials'));
app.use('/api/custom-sims', require('./routes/customSims'));
app.use('/api/game', require('./routes/game'));
app.use('/api/dashboard', require('./routes/dashboard'));
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
+108
View File
@@ -0,0 +1,108 @@
'use strict';
/**
* Integration tests: /api/game — прогресс игрока «Квантик» (Фаза 1).
* Covers: submit создаёт строку; лучший результат перезаписывает, худший — нет;
* attempts++; auth-only (401 без токена); валидация входа (400).
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, inject, getToken, cleanup } = require('./setup');
// Mount /api/game on the shared test app (setup.js не монтирует новые роуты).
app.use('/api/game', require('../src/routes/game'));
after(() => cleanup());
const LVL = 'phys-artillery-1';
describe('/api/game progress', () => {
let token;
before(async () => {
token = (await getToken('student')).token;
});
it('GET /progress requires auth (401)', async () => {
const res = await inject('GET', '/api/game/progress', null, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
it('POST /progress requires auth (401)', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 1000, stars: 1 }, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
it('submit creates a progress row', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 5000, stars: 1 }, token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.ok, true);
assert.equal(res.body.progress.level_id, LVL);
assert.equal(res.body.progress.best_time_ms, 5000);
assert.equal(res.body.progress.best_stars, 1);
assert.equal(res.body.progress.attempts, 1);
});
it('GET /progress lists the row', async () => {
const res = await inject('GET', '/api/game/progress', null, token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.ok(Array.isArray(res.body.progress), 'progress is array');
const row = res.body.progress.find(r => r.level_id === LVL);
assert.ok(row, 'level row present');
assert.equal(row.best_time_ms, 5000);
});
it('better result (less time, more stars) overwrites best; attempts++', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 3200, stars: 2 }, token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.progress.best_time_ms, 3200, 'time improved');
assert.equal(res.body.progress.best_stars, 2, 'stars improved');
assert.equal(res.body.progress.attempts, 2, 'attempts incremented');
});
it('worse result does NOT overwrite best, but still counts an attempt', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 9999, stars: 0 }, token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.progress.best_time_ms, 3200, 'best time kept');
assert.equal(res.body.progress.best_stars, 2, 'best stars kept');
assert.equal(res.body.progress.attempts, 3, 'attempts still incremented');
});
it('progress is per-user (другой игрок начинает с нуля)', async () => {
const other = (await getToken('student')).token;
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 7000, stars: 1 }, other);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.progress.attempts, 1, 'fresh user has attempts=1');
assert.equal(res.body.progress.best_time_ms, 7000);
});
it('validation: missing level_id → 400', async () => {
const res = await inject('POST', '/api/game/progress', { time_ms: 1000, stars: 1 }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('validation: negative time_ms → 400', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: -5, stars: 1 }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('validation: non-integer time_ms → 400', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 12.5, stars: 1 }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('validation: stars out of range (>3) → 400', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 1000, stars: 4 }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('validation: negative stars → 400', async () => {
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 1000, stars: -1 }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('validation: level_id too long → 400', async () => {
const res = await inject('POST', '/api/game/progress',
{ level_id: 'x'.repeat(200), time_ms: 1000, stars: 1 }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
});
+107
View File
@@ -0,0 +1,107 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · Реестр уровней (Фаза 1, MVP).
Уровень = СПЕКА SimForge (данные, не код) + блок `goal` (победа), который
движок (_sim_engine.js) умеет с Фазы 0. Игрок не управляет героем напрямую —
он «чинит закон мира»: крутит слайдеры params (угол/скорость), затем «Запуск»,
и симуляция проигрывается к цели.
ИСТОЧНИК УРОВНЕЙ (решение зафиксировано в CONTEXT.md):
— СЕЙЧАС (Фаза 1): встроенные данные здесь, window.QuantikLevels.
— ПОЗЖЕ (Фаза 5): уровни авторятся в sim-builder и хранятся в custom_sims
(cat='game'); реестр пополнится загрузкой опубликованных спек с сервера.
Форма записи уровня:
{ id, title, subject?, hint?, spec }
где spec — обычная спека SimForge с блоком goal. id == level_id для
/api/game/progress (LS.gameProgressSubmit(id, ...)).
⛔ Без eval/Function. Все «числовые» поля могут быть числом ИЛИ строкой-
выражением (их безопасно вычисляет SimExpr на клиенте).
════════════════════════════════════════════════════════════════════════ */
(function (global) {
/* ── Уровень 1: «Артиллерия Квантика» ──────────────────────────────────
Герой — светящаяся точка-тело (body) с кометной трассой (P2). Запускается
из начала координат под углом θ со скоростью v; гравитация тянет вниз.
Цель — попасть в портал; бонус-звезда — собрать кристалл по дороге.
Параметры подобраны так, чтобы уровень был ПРОХОДИМ в пределах слайдеров. */
var PORTAL_X = 8; // центр портала по X (мир)
var PORTAL_Y = 0; // центр портала по Y (на «земле» y=0)
var PORTAL_R = 0.7; // радиус попадания
var STAR_X = 4; // бонус-кристалл (на восходящей ветви хорошей дуги)
var STAR_Y = 2.6;
var STAR_R = 0.65;
var artillery1 = {
id: 'phys-artillery-1',
title: 'Артиллерия Квантика',
subject: 'physics',
hint: 'Подберите угол и скорость, чтобы Квантик долетел до портала. Соберите кристалл по дороге — это бонусная звезда.',
spec: {
specVersion: 1,
meta: { title: 'Артиллерия Квантика', desc: 'Закон движения: бросок под углом к горизонту.' },
viewport: { xmin: -1, xmax: 12, ymin: -1.2, ymax: 7, grid: true, axes: true, bg: '#0D0D1A' },
params: [
{ name: 'theta', label: 'Угол', min: 10, max: 80, step: 1, value: 45, unit: '°' },
{ name: 'v', label: 'Скорость', min: 5, max: 20, step: 0.5, value: 10, unit: 'м/с' }
],
physics: {
enabled: true,
gravity: { x: 0, y: -9.8 }
},
objects: [
// «Земля» — линия y=0 для ориентира.
{ type: 'segment', x1: -1, y1: 0, x2: 12, y2: 0, color: '#334155', width: 2 },
// Бонус-кристалл (звезда). Контурный кружок-маркер.
{ type: 'circle', x: STAR_X, y: STAR_Y, r: STAR_R, color: '#FBBF24', width: 2, glow: true },
{ type: 'label', x: STAR_X, y: STAR_Y + 0.7, text: 'кристалл', color: '#FBBF24', size: 12 },
// Портал — цель. Светящийся кружок.
{ type: 'circle', x: PORTAL_X, y: PORTAL_Y + PORTAL_R, r: PORTAL_R, color: '#22D3EE', width: 3, glow: true, glowColor: '#22D3EE' },
{ type: 'label', x: PORTAL_X, y: PORTAL_Y + 2.0, text: 'портал', color: '#22D3EE', size: 12 },
// Герой Квантик — физ-тело, стартует из (0,0) со скоростью (vx,vy).
// glow + кометная трасса (P2).
{
id: 'ball', type: 'point', r: 7, color: '#06D6E0',
x: 0, y: 0,
glow: true, glowColor: '#06D6E0', trail: true, trailColor: '#06D6E0',
body: {
mass: 1,
vx: 'v*cos(theta*pi/180)',
vy: 'v*sin(theta*pi/180)'
}
},
// Живые показания скорости (бейдж-оверлей).
{ type: 'readout', label: 'v', expr: 'v', unit: 'м/с', precision: 1 },
{ type: 'readout', label: 'θ', expr: 'theta', unit: '°', precision: 0 }
],
goal: {
title: 'Попади в портал',
hint: 'Квантик должен достичь портала. Бонус: собери кристалл по дороге.',
// Победа: герой в радиусе портала.
when: 'hypot(ball.x - ' + PORTAL_X + ', ball.y - ' + (PORTAL_Y + PORTAL_R) + ') < ' + PORTAL_R,
// Мягкий проигрыш: улетел далеко за поле (промах) — можно перезапустить.
fail: 'ball.x > 11.5 || ball.y < -1.0',
stars: [
{ when: 'hypot(ball.x - ' + STAR_X + ', ball.y - ' + STAR_Y + ') < ' + STAR_R, label: 'Собрал кристалл' }
]
}
}
};
var LEVELS = [artillery1];
function list() { return LEVELS.slice(); }
function get(id) {
for (var i = 0; i < LEVELS.length; i++) if (LEVELS[i].id === id) return LEVELS[i];
return null;
}
global.QuantikLevels = { list: list, get: get, LEVELS: LEVELS };
})(typeof window !== 'undefined' ? window : this);
+133
View File
@@ -0,0 +1,133 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · логика игровой страницы (Фаза 1, MVP).
Монтирует уровень-спеку через SimEngine.mount (тот же движок, что lab.html
и sim-builder.html). «Игровой режим» включается САМ наличием блока goal в
спеке (Фаза 0: HUD с целью/звёздами появляется автоматически). Управление —
собственные слайдеры params движка + кнопки Запуск/Сброс. На победу
(inst.onGoal) шлём результат на сервер и показываем экран успеха.
window.QuantikGame.start({ host, level }) -> инстанс движка (или null).
⛔ Без eval/Function. Уровни — данные из window.QuantikLevels.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
var doc = global.document;
function el(tag, cls, html) {
var n = doc.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
/* Inline SVG звезды (заполненная / контур) — без эмодзи (правило проекта). */
function starSvg(filled) {
var fill = filled ? '#FBBF24' : 'none';
var stroke = filled ? '#FBBF24' : '#64748B';
return '<svg class="ic qg-star-svg" viewBox="0 0 24 24" width="34" height="34" fill="' + fill +
'" stroke="' + stroke + '" stroke-width="1.6" stroke-linejoin="round">' +
'<polygon points="12 2 15.1 8.6 22 9.3 17 14.1 18.2 21 12 17.6 5.8 21 7 14.1 2 9.3 8.9 8.6"/></svg>';
}
function fmtTime(ms) {
if (!ms && ms !== 0) return '—';
var s = ms / 1000;
return s.toFixed(2) + ' с';
}
/* ── Экран успеха (DOM-оверлей страницы, поверх сцены) ─────────────────── */
function buildSuccessOverlay(state) {
var got = (state && state.stars && state.stars.got) || 0;
var total = (state && state.stars && state.stars.total) || 0;
var overlay = el('div', 'qg-overlay');
var card = el('div', 'qg-card');
card.appendChild(el('div', 'qg-card-title', 'Уровень пройден!'));
// звёзды: total «слотов», got заполнено
var starsBox = el('div', 'qg-stars');
var slots = Math.max(total, got, 1);
for (var i = 0; i < slots; i++) {
var w = el('span', 'qg-star');
w.innerHTML = starSvg(i < got);
starsBox.appendChild(w);
}
card.appendChild(starsBox);
var stats = el('div', 'qg-stats');
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Время</span><span class="qg-stat-val">' + fmtTime(state && state.timeMs) + '</span>'));
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Звёзды</span><span class="qg-stat-val">' + got + ' / ' + (total || slots) + '</span>'));
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Попытки</span><span class="qg-stat-val">' + ((state && state.attempts) || 0) + '</span>'));
card.appendChild(stats);
var actions = el('div', 'qg-actions');
var btnAgain = el('button', 'btn-primary qg-btn', 'Ещё раз');
btnAgain.type = 'button';
var btnNext = el('button', 'btn-ghost qg-btn', 'Дальше');
btnNext.type = 'button';
btnNext.disabled = true; // MVP: следующий уровень появится в Фазе 2
btnNext.title = 'Скоро: больше уровней';
actions.appendChild(btnAgain);
actions.appendChild(btnNext);
card.appendChild(actions);
overlay.appendChild(card);
return { overlay: overlay, btnAgain: btnAgain, btnNext: btnNext };
}
/* ── Старт уровня ───────────────────────────────────────────────────────
host — DOM-контейнер сцены. level — запись из QuantikLevels (с .spec/.id). */
function start(opts) {
opts = opts || {};
var host = opts.host;
var level = opts.level;
if (!host || !level || !level.spec) return null;
if (!global.SimEngine || !global.SimExpr) return null;
var inst = global.SimEngine.mount(host, level.spec);
var overlayRef = null;
function clearOverlay() {
if (overlayRef && overlayRef.overlay && overlayRef.overlay.parentNode) {
overlayRef.overlay.parentNode.removeChild(overlayRef.overlay);
}
overlayRef = null;
}
function showSuccess(state) {
clearOverlay();
overlayRef = buildSuccessOverlay(state);
overlayRef.btnAgain.addEventListener('click', function () {
clearOverlay();
try { inst.reset(); } catch (_e) {}
});
// Дальше — заглушка для MVP (нет следующего уровня).
host.appendChild(overlayRef.overlay);
}
inst.onGoal(function (res) {
if (!res || !res.won) return;
var got = (res.stars && res.stars.got) || 0;
// Время победы — мировое t из движка (Ф0): res.timeMs.
var payload = { time_ms: res.timeMs, stars: got };
// Submit best-effort: экран успеха показываем независимо от сети.
try {
if (global.LS && global.LS.gameProgressSubmit) {
global.LS.gameProgressSubmit(level.id, payload).catch(function () { /* офлайн — ок */ });
}
} catch (_e) { /* нет клиента — всё равно показываем успех */ }
showSuccess(res);
});
return inst;
}
global.QuantikGame = { start: start, buildSuccessOverlay: buildSuccessOverlay };
})(typeof window !== 'undefined' ? window : this);
+119
View File
@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Квантик — Законы Мира</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/css/ls.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
/* ── Раскладка игровой страницы ── */
.qg-wrap { display: flex; flex-direction: column; height: 100vh; min-height: 0; }
.qg-top {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--surface);
flex-shrink: 0;
}
.qg-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.05rem; color: var(--text); white-space: nowrap; }
.qg-sub { font-size: .8rem; color: var(--text-3); flex: 1; min-width: 0; }
.qg-pill { font-size: .68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; padding: 3px 10px; border-radius: 99px; background: rgba(34,211,238,0.14); color: #0e7c8a; }
/* сцена: на всю площадь, тёмный фон (full-bleed root движка растягивается inset:0) */
.qg-stage { flex: 1; min-height: 0; position: relative; background: #0D0D1A; overflow: hidden; }
.qg-stage .sim-spec-root { position: absolute; inset: 0; }
.qg-fallback { padding: 40px; color: #cbd5e1; font-family: 'Manrope', sans-serif; max-width: 520px; }
/* ── Экран успеха (оверлей) ── */
.qg-overlay {
position: absolute; inset: 0; z-index: 20;
display: flex; align-items: center; justify-content: center;
background: rgba(7, 7, 18, 0.72); backdrop-filter: blur(4px);
}
.qg-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 18px;
padding: 28px 30px; width: min(420px, 90vw); text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.45);
animation: qg-pop .22s ease;
}
@keyframes qg-pop { from { transform: scale(.92); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.qg-card-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.3rem; color: var(--text); margin-bottom: 14px; }
.qg-stars { display: flex; justify-content: center; gap: 6px; margin-bottom: 18px; }
.qg-star { display: inline-flex; }
.qg-star-svg { filter: drop-shadow(0 2px 6px rgba(251,191,36,0.4)); }
.qg-stats { display: flex; justify-content: center; gap: 22px; margin-bottom: 22px; }
.qg-stat { display: flex; flex-direction: column; gap: 3px; }
.qg-stat-lbl { font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-3); }
.qg-stat-val { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.05rem; color: var(--text); font-variant-numeric: tabular-nums; }
.qg-actions { display: flex; justify-content: center; gap: 10px; }
.qg-btn { min-width: 110px; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<main class="sb-content">
<div class="qg-wrap">
<div class="qg-top">
<span class="qg-title" id="qg-title">Квантик — Законы Мира</span>
<span class="qg-sub" id="qg-sub"></span>
<span class="qg-pill">Физика</span>
</div>
<div class="qg-stage" id="qg-stage"></div>
</div>
</main>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
<!-- движок спек-симуляций (тот же путь, что lab.html / sim-builder.html) -->
<script src="/js/labs/_sim_expr.js"></script>
<script src="/js/labs/_sim_engine.js"></script>
<!-- KaTeX для подписей сцены -->
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<!-- уровни (данные) + логика игры -->
<script src="/js/game/levels.js"></script>
<script src="/js/game/quantik-game.js"></script>
<script>
(function () {
// Доступ: любой авторизованный пользователь (играют и ученики).
if (!LS.initPage()) { return; } // initPage сам редиректит на /login, если не авторизован
var stage = document.getElementById('qg-stage');
if (!window.SimEngine || !window.SimExpr || !window.QuantikLevels || !window.QuantikGame) {
stage.innerHTML = '<div class="qg-fallback">Движок игры не загрузился. Обновите страницу.</div>';
return;
}
// Уровень: ?level=<id> или первый из реестра (MVP — один уровень).
var params = new URLSearchParams(location.search);
var wantId = params.get('level');
var level = wantId ? window.QuantikLevels.get(wantId) : null;
if (!level) level = window.QuantikLevels.list()[0] || null;
if (!level) {
stage.innerHTML = '<div class="qg-fallback">Уровень не найден.</div>';
return;
}
document.getElementById('qg-title').textContent = level.title || 'Квантик';
document.getElementById('qg-sub').textContent = level.hint || '';
var inst = window.QuantikGame.start({ host: stage, level: level });
if (!inst) {
stage.innerHTML = '<div class="qg-fallback">Не удалось запустить уровень.</div>';
return;
}
window.__quantik = inst; // для отладки
})();
</script>
</body>
</html>
+3
View File
@@ -1041,6 +1041,7 @@ 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,
@@ -1271,6 +1272,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 }); }
+1
View File
@@ -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', 'Красная книга')}
+17
View File
@@ -11,12 +11,29 @@
Изменены: `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; миграция применяется чисто.
## 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`-слайдеры движка (угол/скорость/
+2 -2
View File
@@ -59,7 +59,7 @@
## Phases
- [x] Phase 0: Слой целей в движке (goal/HUD/result) [domain: frontend] → [subplan](./phase-0-objective-layer.md)
- [ ] Phase 1: Оболочка игры + 1 физ-уровень + прогресс [domain: fullstack] → [subplan](./phase-1-shell-first-level.md)
- [x] Phase 1: Оболочка игры + 1 физ-уровень + прогресс [domain: fullstack] → [subplan](./phase-1-shell-first-level.md)
- [ ] Phase 2: Карта-созвездие + мир физ-уровней + XP/скины [domain: fullstack] → [subplan](./phase-2-map-world-xp.md)
- [ ] Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия [domain: fullstack] → [subplan](./phase-3-graph-levels.md)
- [ ] Phase 4: Квантовые способности + SR-комнаты [domain: fullstack] → [subplan](./phase-4-quantum-abilities-sr.md)
@@ -71,7 +71,7 @@
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 0: Слой целей в движке | frontend | ✅ Done | ✅ | ✅ | ✅ |
| Phase 1: Оболочка + 1 уровень + прогресс | fullstack | ⬜ Not Started | | | |
| Phase 1: Оболочка + 1 уровень + прогресс | fullstack | ✅ Done | | | |
| Phase 2: Карта + мир + XP/скины | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 3: Граф-уровни + зоны | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 4: Квантовые способности + SR | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
+51 -26
View File
@@ -1,6 +1,6 @@
# Phase 1: Оболочка игры + 1 физ-уровень + прогресс (MVP)
**Status:** ⬜ Not Started
**Status:** ✅ Done (reviewed — PASS, committed)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
@@ -11,28 +11,27 @@
Первый уровень — «Артиллерия Квантика»: угол+скорость, попасть в портал, собрать звезду.
## Tasks
- [ ] Task 1: Миграция (следующий свободный номер) `game_progress`: `id, user_id, level_id TEXT,
best_time_ms INTEGER, best_stars INTEGER, attempts INTEGER, completed_at`. Индекс по (user_id, level_id) UNIQUE.
- [ ] 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; валидация входа.
- [ ] Task 3: Клиент `LS.gameProgressList()` / `LS.gameProgressSubmit(levelId, {time_ms, stars})` в js/api.js.
- [ ] Task 4: Уровень как ДАННЫЕ: модуль `frontend/js/game/levels.js` (или сид в `custom_sims`).
Для MVP — встроенная спека уровня `phys-artillery-1` (physics + goal + 1 star + portal/star объекты).
Решение источника уровней зафиксировать в CONTEXT.md (встроенные данные сейчас; custom_sims в Ф5).
- [ ] Task 5: Страница `frontend/quantik.html` + `frontend/js/game/quantik-game.js`:
доступ всем авторизованным (LS.initPage()); подключает `_sim_expr.js`+`_sim_engine.js`
тем же путём, что lab.html/sim-builder.html. Монтирует уровень, ставит `onGoal` → submit + экран успеха.
- [ ] Task 6: «Игровой режим» движка/обёртки: цель видна (HUD из Ф0), управление = существующие
слайдеры params; кнопки «Запуск»(play)/«Сброс»(reset). Без редакторских панелей.
- [ ] Task 7: Экран успеха (DOM-оверлей страницы): звёзды, время, попытки, кнопки «Ещё раз»/«Дальше»
(для MVP «Дальше» неактивна/возврат). Inline SVG, без эмодзи.
- [ ] Task 8: Пункт в сайдбаре `js/sidebar.js` — `/quantik` в группе practice (по примеру `/sim-builder`),
видимость по роли (доступно ученикам — это игра). `isActive('/quantik')` подсветка.
- [ ] Task 9: Тест бэкенда `backend/tests/game.test.js` (паттерн lab-links.test.js: свой app.use
нового роутера, getToken/inject): submit пишет лучший результат, не ухудшает, attempts++,
требует auth, валидирует вход. Headless-смоук страницы по возможности (vm + стаб), иначе ручная проверка логики.
- [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` — таблица прогресса.
@@ -56,8 +55,34 @@
- Время — из `getResult().timeMs` (Ф0).
## Review Checklist
- [ ] Все задачи; конвенции (ownership/auth как studentMaterials/customSim); без эмодзи/eval
- [ ] Миграция применяется; API безопасен; тест зелёный; lint baseline 0; existing тесты не сломаны
- [x] Все задачи; конвенции (ownership/auth как studentMaterials/customSim); без эмодзи/eval
- [x] Миграция применяется; API безопасен; тест зелёный; lint baseline 0; existing тесты не сломаны
## Handoff to Next Phase
<!-- Заполняет агент-имплементер. -->
### Реестр уровней (форма данных)
`frontend/js/game/levels.js` → `window.QuantikLevels`:
- `QuantikLevels.list()` → массив записей уровней (копия); `QuantikLevels.get(id)` → одна запись или null; `QuantikLevels.LEVELS` — сырой массив.
- **Запись уровня**: `{ id, title, subject?, hint?, spec }`. `id` == `level_id` для API прогресса.
`spec` — обычная спека SimForge с верхнеуровневым блоком `goal` (Ф0). Сейчас один уровень `phys-artillery-1`.
- **Добавить уровень** = добавить запись в `LEVELS` (или, в Ф5, подгрузить опубликованные `custom_sims` cat='game' и смержить в реестр — той же формы записи). Источник = данные, не код.
- Уровень мог бы прийти и из `custom_sims` (cat='game'): `spec` уже валидируется сервером (validateSpec пропускает goal/game). Реестр в Ф2/Ф5 может стать асинхронным (загрузка + слияние со встроенными).
### Контракт API прогресса
- `GET /api/game/progress` (auth) → `{ progress: [ { level_id, best_time_ms, best_stars, attempts, completed_at } ] }` — все уровни текущего игрока.
- `POST /api/game/progress` (auth) body `{ level_id, time_ms, stars }` → `{ ok:true, progress:{...одна строка...} }`. Upsert: best_time_ms=min, best_stars=max, attempts++. Валидация: level_id строка ≤120; time_ms/stars неотрицательные целые; stars 0..3 (иначе 400).
- Клиент: `LS.gameProgressList()`, `LS.gameProgressSubmit(levelId, { time_ms, stars })`.
- Таблица `game_progress` — миграция **076**, UNIQUE(user_id, level_id), user_id ON DELETE CASCADE.
- На Ф6 (лидерборд) — этой таблицы достаточно для «лучшее время по уровню»; агрегаты по классу — JOIN на class_members.
### Где живёт экран успеха / как монтируется уровень
- Монтаж: `QuantikGame.start({ host, level })` → `SimEngine.mount(host, level.spec)` → возвращает `inst`. «Игровой режим» включается САМ (HUD появляется, т.к. в спеке есть `goal`). Управление — слайдеры params + play/reset движка (внутри `inst.el`).
- Победа: `inst.onGoal(res => …)` (Ф0; срабатывает 1 раз). В колбэке: `LS.gameProgressSubmit(level.id, { time_ms: res.timeMs, stars: res.stars.got })` (best-effort, .catch офлайн) + экран успеха.
- **Экран успеха** = DOM-оверлей `.qg-overlay`, добавляется в `host` (=`#qg-stage`), `QuantikGame.buildSuccessOverlay(state)` строит карточку (звёзды inline SVG, время/звёзды/попытки, кнопки). «Ещё раз» → убрать оверлей + `inst.reset()`. «Дальше» — disabled-заглушка (нет следующего уровня в MVP); Ф2 (карта/мир) активирует её переходом к следующему узлу.
- CSS оверлея — в `<style>` `quantik.html` (`.qg-*`). Ф2 переиспользует `buildSuccessOverlay` (можно расширить параметром «следующий уровень»).
### Гочи для Ф2
- `inst.onGoal` срабатывает 1 раз и делает `pause()`. Перезапуск — `inst.reset()` (это И физика, И attempts++). Не звать `play()` в onGoal-колбэке.
- `res.timeMs` — мировое время (детерминизм), не wallclock. `res.stars.got`/`res.stars.total` — счётчики звёзд.
- Страница не разрушает `inst` явно при навигации; Ф2 при смене уровня без перезагрузки должна вызвать `inst.destroy()` перед монтированием нового (или перезагружать `?level=`).
- Сайдбар-пункт `/quantik` видим ВСЕМ (без `hidden`), в отличие от teacher-only `/sim-builder`.