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
+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) ── */