69df2f8190
chore(quantik-game): полировка по финальному ревью + security-review
Финальное ревью: READY TO MERGE (0 блокеров). Security: SECURE (0 critical).
Применены дешёвые фиксы из ревью:
- validateSpec: блок game{} санитизируется ПОИМЁННО (chapter/subject →
sanitizeText, order/par_ms/unlockStars → проверка типа, неизвестные ключи
отбрасываются) — закрыт латентный хранимый XSS (раньше clean.game=spec.game).
- quantik.html: @media (prefers-reduced-motion) делает анимации мгновенными
(не выключает — иначе forwards-появление узлов оставило бы их скрытыми).
- progress-logic.js: фикс комментария isUnlocked (сумма звёзд по ВСЕМ уровням
с меньшим глобальным order, а не «той же главы»).
План: Ф6 (лидерборд/гонка) удалена (Amendment 1, решение пользователя);
финальные гейты отмечены; deferred-бэклог зафиксирован.
Затронутые тесты 45/45; lint:routes 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
195 lines
9.5 KiB
JavaScript
195 lines
9.5 KiB
JavaScript
'use strict';
|
||
/* ════════════════════════════════════════════════════════════════════════
|
||
Квантик — Законы Мира · ЧИСТАЯ логика прогресса (Фаза 2).
|
||
|
||
Никакого DOM/сети/движка — только функции над данными. Это делает их
|
||
тривиально тестируемыми (headless vm) и переносимыми на сервер позже.
|
||
|
||
ВХОД везде:
|
||
levels — массив записей уровней (форма QuantikLevels): { id, chapter,
|
||
order, par_ms?, unlockStars?, ... }.
|
||
progressMap — объект { [level_id]: { best_stars, best_time_ms, attempts } },
|
||
агрегируется из LS.gameProgressList() (см. fromProgressList).
|
||
|
||
⛔ Без eval/Function. Без побочных эффектов.
|
||
════════════════════════════════════════════════════════════════════════ */
|
||
(function (global) {
|
||
|
||
/* Превратить ответ /api/game/progress ([{level_id, best_stars, ...}]) в карту. */
|
||
function fromProgressList(list) {
|
||
var map = {};
|
||
if (!Array.isArray(list)) return map;
|
||
for (var i = 0; i < list.length; i++) {
|
||
var row = list[i];
|
||
if (row && row.level_id != null) map[row.level_id] = row;
|
||
}
|
||
return map;
|
||
}
|
||
|
||
/* Лучшее число звёзд по уровню (0, если не пройден). */
|
||
function starsFor(levelId, progressMap) {
|
||
var p = progressMap && progressMap[levelId];
|
||
var s = p ? p.best_stars : 0;
|
||
return (typeof s === 'number' && s > 0) ? s : 0;
|
||
}
|
||
|
||
/* Пройден ли уровень (есть хотя бы одна звезда == достигнута цель). */
|
||
function isCompleted(levelId, progressMap) {
|
||
return starsFor(levelId, progressMap) > 0;
|
||
}
|
||
|
||
/* Сумма лучших звёзд по всем уровням. */
|
||
function totalStars(levels, progressMap) {
|
||
var sum = 0;
|
||
for (var i = 0; i < levels.length; i++) sum += starsFor(levels[i].id, progressMap);
|
||
return sum;
|
||
}
|
||
|
||
/* ── Разблокировка ────────────────────────────────────────────────────────
|
||
Уровень открыт, если СУММА звёзд во ВСЕХ уровнях с меньшим ГЛОБАЛЬНЫМ order
|
||
(по всем главам, не только текущей) ≥ level.unlockStars. Уровень с
|
||
unlockStars==0 (или без него) открыт всегда. Так первый уровень главы
|
||
гейтится суммой звёзд всех предыдущих глав через свой порог unlockStars.
|
||
|
||
Чистая функция: вход — уровень + карта прогресса + ВЕСЬ список (для подсчёта
|
||
«предыдущих» по order). Возвращает bool. */
|
||
function isUnlocked(level, progressMap, levels) {
|
||
if (!level) return false;
|
||
var need = (typeof level.unlockStars === 'number') ? level.unlockStars : 0;
|
||
if (need <= 0) return true; // нет порога — всегда доступен
|
||
// звёзды, набранные во всех уровнях с меньшим глобальным order
|
||
var have = 0;
|
||
for (var i = 0; i < levels.length; i++) {
|
||
var L = levels[i];
|
||
if (L.id === level.id) continue;
|
||
if ((L.order || 0) < (level.order || 0)) {
|
||
have += starsFor(L.id, progressMap);
|
||
}
|
||
}
|
||
return have >= need;
|
||
}
|
||
|
||
/* Статус узла для карты: 'completed' | 'available' | 'locked'. */
|
||
function nodeStatus(level, progressMap, levels) {
|
||
if (isCompleted(level.id, progressMap)) return 'completed';
|
||
if (isUnlocked(level, progressMap, levels)) return 'available';
|
||
return 'locked';
|
||
}
|
||
|
||
/* ── XP ────────────────────────────────────────────────────────────────────
|
||
XP = сумма (звёзды × STAR_XP) + бонус за каждый пройденный уровень
|
||
(COMPLETE_XP) + бонус за «par» (3-я звезда == уложился в норматив времени,
|
||
она и так считается звездой; дополнительный PAR_BONUS за первое прохождение
|
||
уровня в принципе). Детерминированная функция от карты прогресса. */
|
||
var STAR_XP = 100; // за каждую звезду
|
||
var COMPLETE_XP = 40; // за факт прохождения уровня (≥1 звезда)
|
||
|
||
function computeXp(levels, progressMap) {
|
||
var xp = 0;
|
||
for (var i = 0; i < levels.length; i++) {
|
||
var id = levels[i].id;
|
||
var s = starsFor(id, progressMap);
|
||
if (s > 0) {
|
||
xp += s * STAR_XP + COMPLETE_XP;
|
||
}
|
||
}
|
||
return xp;
|
||
}
|
||
|
||
/* ── Уровень игрока ──────────────────────────────────────────────────────
|
||
Порог уровня растёт линейно-нарастающе: уровень N требует кумулятивно
|
||
XP_PER_LEVEL_BASE·N·(N+1)/2 … упрощаем до квадратичной обратной формулы.
|
||
playerLevel(xp) -> { level, xpInto, xpForNext, progress01, totalForLevel }.
|
||
level начинается с 1. */
|
||
var XP_STEP = 240; // базовый шаг XP (level n требует n*XP_STEP суммарно для перехода)
|
||
|
||
// Кумулятивный XP, нужный чтобы ДОСТИЧЬ уровня L (L>=1). level 1 = 0 XP.
|
||
function xpForLevel(L) {
|
||
if (L <= 1) return 0;
|
||
// сумма k=1..L-1 of k*XP_STEP = XP_STEP * (L-1)*L/2
|
||
return XP_STEP * (L - 1) * L / 2;
|
||
}
|
||
|
||
function playerLevel(xp) {
|
||
if (!(xp > 0)) xp = 0;
|
||
var L = 1;
|
||
// найти максимальный L, чей порог <= xp
|
||
while (xpForLevel(L + 1) <= xp) L++;
|
||
var base = xpForLevel(L);
|
||
var next = xpForLevel(L + 1);
|
||
var span = next - base;
|
||
var into = xp - base;
|
||
return {
|
||
level: L,
|
||
xp: xp,
|
||
xpInto: into,
|
||
xpForNext: span,
|
||
totalForNext: next,
|
||
progress01: span > 0 ? Math.min(1, into / span) : 1
|
||
};
|
||
}
|
||
|
||
/* ── Группировка по главам ────────────────────────────────────────────────
|
||
Возвращает массив { chapter, levels:[...] } в порядке появления глав;
|
||
уровни внутри главы сортируются по order. */
|
||
function groupByChapter(levels) {
|
||
var order = [];
|
||
var byKey = {};
|
||
for (var i = 0; i < levels.length; i++) {
|
||
var L = levels[i];
|
||
var key = L.chapter || 'misc';
|
||
if (!byKey[key]) { byKey[key] = { chapter: key, levels: [] }; order.push(key); }
|
||
byKey[key].levels.push(L);
|
||
}
|
||
return order.map(function (k) {
|
||
var g = byKey[k];
|
||
g.levels = g.levels.slice().sort(function (a, b) { return (a.order || 0) - (b.order || 0); });
|
||
return g;
|
||
});
|
||
}
|
||
|
||
/* Следующий разблокированный непройденный уровень после данного (по глоб. order),
|
||
или null. Используется кнопкой «Дальше». */
|
||
function nextPlayable(currentId, levels, progressMap) {
|
||
var sorted = levels.slice().sort(function (a, b) { return (a.order || 0) - (b.order || 0); });
|
||
var idx = -1;
|
||
for (var i = 0; i < sorted.length; i++) if (sorted[i].id === currentId) { idx = i; break; }
|
||
// сначала ищем следующий по порядку доступный (предпочтительно непройденный)
|
||
for (var j = idx + 1; j < sorted.length; j++) {
|
||
if (isUnlocked(sorted[j], progressMap, levels)) return sorted[j];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/* Сколько ещё звёзд нужно, чтобы открыть уровень (для подсказки на замке). */
|
||
function starsToUnlock(level, progressMap, levels) {
|
||
var need = (typeof level.unlockStars === 'number') ? level.unlockStars : 0;
|
||
if (need <= 0) return 0;
|
||
var have = 0;
|
||
for (var i = 0; i < levels.length; i++) {
|
||
var L = levels[i];
|
||
if (L.id === level.id) continue;
|
||
if ((L.order || 0) < (level.order || 0)) have += starsFor(L.id, progressMap);
|
||
}
|
||
return Math.max(0, need - have);
|
||
}
|
||
|
||
global.QuantikProgress = {
|
||
fromProgressList: fromProgressList,
|
||
starsFor: starsFor,
|
||
isCompleted: isCompleted,
|
||
totalStars: totalStars,
|
||
isUnlocked: isUnlocked,
|
||
nodeStatus: nodeStatus,
|
||
computeXp: computeXp,
|
||
playerLevel: playerLevel,
|
||
xpForLevel: xpForLevel,
|
||
groupByChapter: groupByChapter,
|
||
nextPlayable: nextPlayable,
|
||
starsToUnlock: starsToUnlock,
|
||
// константы (для отображения/тестов)
|
||
STAR_XP: STAR_XP, COMPLETE_XP: COMPLETE_XP, XP_STEP: XP_STEP
|
||
};
|
||
|
||
})(typeof window !== 'undefined' ? window : this);
|