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
+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`.