diff --git a/CLAUDE.md b/CLAUDE.md index 6e5c15f..2813140 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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)` 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. diff --git a/backend/src/controllers/gameController.js b/backend/src/controllers/gameController.js new file mode 100644 index 0000000..ad7878d --- /dev/null +++ b/backend/src/controllers/gameController.js @@ -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 }; diff --git a/backend/src/db/migrations/076_game_progress.sql b/backend/src/db/migrations/076_game_progress.sql new file mode 100644 index 0000000..822e29c --- /dev/null +++ b/backend/src/db/migrations/076_game_progress.sql @@ -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); diff --git a/backend/src/routes/game.js b/backend/src/routes/game.js new file mode 100644 index 0000000..8331a28 --- /dev/null +++ b/backend/src/routes/game.js @@ -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; diff --git a/backend/src/server.js b/backend/src/server.js index cdce198..be2061f 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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) ── */ diff --git a/backend/tests/game.test.js b/backend/tests/game.test.js new file mode 100644 index 0000000..eff1ab4 --- /dev/null +++ b/backend/tests/game.test.js @@ -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}`); + }); +}); diff --git a/frontend/js/game/levels.js b/frontend/js/game/levels.js new file mode 100644 index 0000000..2ec6884 --- /dev/null +++ b/frontend/js/game/levels.js @@ -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); diff --git a/frontend/js/game/quantik-game.js b/frontend/js/game/quantik-game.js new file mode 100644 index 0000000..6971ca4 --- /dev/null +++ b/frontend/js/game/quantik-game.js @@ -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 '' + + ''; + } + + 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', + 'Время' + fmtTime(state && state.timeMs) + '')); + stats.appendChild(el('div', 'qg-stat', + 'Звёзды' + got + ' / ' + (total || slots) + '')); + stats.appendChild(el('div', 'qg-stat', + 'Попытки' + ((state && state.attempts) || 0) + '')); + 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); diff --git a/frontend/quantik.html b/frontend/quantik.html new file mode 100644 index 0000000..ea1cad7 --- /dev/null +++ b/frontend/quantik.html @@ -0,0 +1,119 @@ + + + + + + Квантик — Законы Мира + + + + + + + + +
+ +
+
+
+ Квантик — Законы Мира + + Физика +
+
+
+
+
+ + + + + + + + + + + + + + + + + diff --git a/js/api.js b/js/api.js index 4f19df5..b129340 100644 --- a/js/api.js +++ b/js/api.js @@ -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 }); } diff --git a/js/sidebar.js b/js/sidebar.js index 4828464..6b108e1 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -87,6 +87,7 @@ ${G('practice', 'Практика и игры', ` ${L('/lab', 'atom', 'Лаборатория')} + ${L('/quantik', 'rocket', 'Квантик: Законы Мира')} ${L('/sim-builder', 'pencil-ruler', 'Конструктор симуляций', { cls: 'sb-teacher-only', hidden: !isTch })} ${L('/biochem', 'flask-conical', 'Биохимия')} ${L('/red-book', 'leaf', 'Красная книга')} diff --git a/plans/quantik-game/CONTEXT.md b/plans/quantik-game/CONTEXT.md index 01c977d..51b0e60 100644 --- a/plans/quantik-game/CONTEXT.md +++ b/plans/quantik-game/CONTEXT.md @@ -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`-слайдеры движка (угол/скорость/ diff --git a/plans/quantik-game/PLAN.md b/plans/quantik-game/PLAN.md index 38af6fb..636ce24 100644 --- a/plans/quantik-game/PLAN.md +++ b/plans/quantik-game/PLAN.md @@ -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 | ⬜ | ⬜ | ⬜ | diff --git a/plans/quantik-game/phase-1-shell-first-level.md b/plans/quantik-game/phase-1-shell-first-level.md index 2d06091..6799cb9 100644 --- a/plans/quantik-game/phase-1-shell-first-level.md +++ b/plans/quantik-game/phase-1-shell-first-level.md @@ -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 оверлея — в `