@
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:
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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) ── */
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user