Files
Maxim Dolgolyov 660e7e2747 feat(gamification): Phase 1 — full kill-switch + textbook XP wrapping
Until now the 'gamification' feature flag did nothing: it had no row in
app_settings, the admin couldn't toggle it, awardXP/awardCoins ignored
it, and the CSS only hid three dashboard widgets — XP bars in textbooks
stayed visible regardless.

Phase 1 closes every hole.

Backend (source of truth):
  • migration 029 seeds feature_gamification_enabled=1
  • new isGamificationEnabled() helper in gamification/_shared.js with a
    30s cache + invalidateGamificationCache() for instant admin toggles
  • awardXP / awardCoins / updateStreak / unlockAchievement /
    checkAchievements all bail out when the flag is off
  • /api/gamification/* and /api/shop/* (user routes) return 404 when
    disabled; admin routes remain open so the switch itself is reachable
  • adminController.updateFeatures gains 'gamification' in the allow-list
    and invalidates the cache on flip

Frontend:
  • LS.isGamificationEnabled() (synchronous, populated by loadFeatures)
    so xp.js + applyCosmetics can bail without a round-trip
  • xp.js load/add/flush become no-ops when the flag is off
  • applyCosmetics skips the round-trip when off
  • CSS .no-gamification rule expanded to cover .hero-xp-badge, .po-xp,
    .xp-card, .xp-bar, #frames-section, and a universal [data-gamified]
    hook for future blocks

Textbooks (Variant 2 of the plan):
  • backend/scripts/wrap_textbook_xp.py — idempotent script that adds
    data-gamified to 167 XP tags across 63 textbook files (chapters +
    hubs, all subjects/grades). Single CSS rule now hides everything.

Verified end-to-end: with the flag off, awardXP/awardCoins write nothing;
flipping back restores normal behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:43:24 +03:00

199 lines
8.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* window.LS.xp — синхронизация XP учебника с системной геймификацией.
*
* API:
* LS.xp.load() — async, GET /api/gamification/me, merge с localStorage
* LS.xp.add(amount, source) — sync, обновляет localStorage и ставит в очередь
* LS.xp.flush() — force-send очереди
* LS.xp.on('change', fn) — подписка на изменения
* LS.xp.off('change', fn) — отписка
* LS.xp.level(xp) — floor(sqrt(xp/100)) + 1
* LS.xp.xpForLevel(lv) — (lv-1)^2 * 100
*/
(function () {
const LS_KEY = 'algebra8_xp';
const ENDPOINT_ME = '/api/gamification/me';
const ENDPOINT_AWARD = '/api/gamification/self-award';
const MAX_CHUNK = 50; // сервер принимает не более 50 XP за раз
const DEBOUNCE_MS = 300;
let _state = null; // { xp, level } от сервера
let _pending = 0; // накопленный несброшенный XP
let _debounceTimer = null;
const _listeners = { change: [] };
/* ── Формулы (идентичны серверному _shared.js) ── */
function level(xp) {
return Math.floor(Math.sqrt((xp || 0) / 100)) + 1;
}
function xpForLevel(lv) {
return (lv - 1) * (lv - 1) * 100;
}
/* ── Токен аутентификации ── */
function _token() {
return localStorage.getItem('ls_token') || '';
}
/* ── Базовый fetch с авторизацией ── */
function _apiFetch(path, opts) {
// Если загружен api.js — используем его apiFetch через LS.api
if (window.LS && typeof window.LS.api === 'function') {
return window.LS.api(path, opts);
}
// Fallback: самостоятельный fetch
const token = _token();
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
return fetch(path, Object.assign({}, opts, { headers })).then(function (res) {
if (!res.ok) return res.json().catch(function () { return {}; }).then(function (d) {
throw Object.assign(new Error(d.error || 'Request failed'), { status: res.status });
});
return res.json();
});
}
/* ── Событийная модель ── */
function on(evt, fn) {
if (_listeners[evt]) _listeners[evt].push(fn);
}
function off(evt, fn) {
if (_listeners[evt]) _listeners[evt] = _listeners[evt].filter(function (f) { return f !== fn; });
}
function _emit(evt, data) {
(_listeners[evt] || []).forEach(function (fn) { try { fn(data); } catch (e) {} });
}
/* ── Чтение/запись localStorage ── */
function _localXp() {
return +(localStorage.getItem(LS_KEY) || 0) || 0;
}
function _saveLocal(xp) {
try { localStorage.setItem(LS_KEY, String(xp)); } catch (e) {}
}
/* ── Master kill-switch ──
If the gamification feature is globally disabled, every public
method becomes a no-op so textbooks don't display XP bars and
don't burn CPU/network on now-pointless syncs. The body class
`.no-gamification` (set by api.js) hides the DOM in parallel. */
function _gamOff() {
return window.LS && typeof window.LS.isGamificationEnabled === 'function'
&& window.LS.isGamificationEnabled() === false;
}
/* ── Загрузка с сервера + merge ── */
async function load() {
if (_gamOff()) return null;
try {
const data = await _apiFetch(ENDPOINT_ME, { method: 'GET' });
const serverXp = data.xp || 0;
const localXp = _localXp();
// Берём максимум — защита от потери прогресса при оффлайн-работе
const merged = Math.max(serverXp, localXp);
_saveLocal(merged);
_state = { xp: merged, level: level(merged) };
_emit('change', _state);
return _state;
} catch (e) {
if (e && e.status === 401) return null; // не авторизован — тихо пропускаем
if (e && e.status === 404) return null; // геймификация выключена — тихо
return null; // сетевая ошибка — не ломаем учебник
}
}
/* ── Отправка накопленного XP на сервер ── */
async function _sendPending(amount, source) {
if (!amount || amount <= 0) return;
try {
// Разбиваем на чанки не более MAX_CHUNK
let remaining = amount;
let lastInfo = null;
while (remaining > 0) {
const chunk = Math.min(remaining, MAX_CHUNK);
remaining -= chunk;
lastInfo = await _apiFetch(ENDPOINT_AWARD, {
method: 'POST',
body: JSON.stringify({ amount: chunk, source: source }),
});
}
if (lastInfo) {
const sxp = lastInfo.xp || 0;
const localXp = _localXp();
// Если локальный XP выше (юзер продолжал накапливать пока шёл запрос) — не откатываем
const merged = Math.max(sxp, localXp);
_saveLocal(merged);
_state = { xp: merged, level: level(merged) };
_emit('change', _state);
}
} catch (e) {
// Не авторизован или недоступен сервер — XP уже сохранён локально, не трогаем
}
}
/* ── flush: немедленная отправка очереди ── */
function flush() {
if (_debounceTimer) { clearTimeout(_debounceTimer); _debounceTimer = null; }
if (_pending <= 0) return;
if (_gamOff()) { _pending = 0; return; } // drop pending — feature off
const amount = _pending;
_pending = 0;
// source для flush — generic, реальный source записывается в каждом add()
// Flush вызывается при unload, поэтому используем sendBeacon если возможно
if (navigator.sendBeacon) {
const token = _token();
const headers = { type: 'application/json' };
const body = JSON.stringify({ amount: Math.min(amount, MAX_CHUNK), source: 'flush' });
// sendBeacon не поддерживает заголовки Authorization — используем token в теле не допускается
// Вместо этого используем fetch с keepalive (если нет sendBeacon fallback)
// Примечание: sendBeacon с кастомными заголовками не работает в браузерах,
// используем keepalive fetch вместо beacon для корректной авторизации
_apiFetch(ENDPOINT_AWARD, {
method: 'POST',
body: JSON.stringify({ amount: Math.min(amount, MAX_CHUNK), source: 'flush' }),
keepalive: true,
}).catch(function () {});
} else {
_apiFetch(ENDPOINT_AWARD, {
method: 'POST',
body: JSON.stringify({ amount: Math.min(amount, MAX_CHUNK), source: 'flush' }),
}).catch(function () {});
}
}
/* ── add: sync обновление localStorage + постановка в очередь ── */
function add(amount, source) {
if (!amount || amount <= 0) return;
if (_gamOff()) return; // no-op when gamification globally off
// Немедленно обновляем локальный XP
const newXp = _localXp() + amount;
_saveLocal(newXp);
if (_state) {
_state = { xp: newXp, level: level(newXp) };
_emit('change', _state);
}
// Накапливаем для отправки
_pending += amount;
const src = (source || 'misc').replace(/[^a-z0-9_-]/gi, '-').substring(0, 60);
// Сбрасываем дебаунс и устанавливаем новый
if (_debounceTimer) clearTimeout(_debounceTimer);
_debounceTimer = setTimeout(function () {
_debounceTimer = null;
const toSend = _pending;
_pending = 0;
_sendPending(toSend, src);
}, DEBOUNCE_MS);
}
/* ── Flush при закрытии страницы ── */
window.addEventListener('beforeunload', function () { flush(); });
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'hidden') flush();
});
/* ── Публичный интерфейс ── */
window.LS = window.LS || {};
window.LS.xp = { load: load, add: add, flush: flush, on: on, off: off, level: level, xpForLevel: xpForLevel };
})();