feat(xp): textbook XP синхронизируется с системной геймификацией

- backend: POST /api/gamification/self-award (rate-limited, validated)
- frontend/js/xp.js: load/add/flush/on клиент, ~150 LOC, дебаунс 300мс,
  keepalive fetch на unload/visibilitychange hidden
- algebra_8.html и algebra_8_ch2.html: XP_LEVELS заменён на единую
  формулу с сервером; addXp/loadProgress подключены к window.LS.xp
- При первой загрузке: merge max(local, server); далее сервер — источник
  правды
This commit is contained in:
Maxim Dolgolyov
2026-05-27 15:56:36 +03:00
parent 9199427dfd
commit 64bd44088d
5 changed files with 254 additions and 32 deletions
@@ -145,8 +145,24 @@ function claimChallenge(req, res) {
res.json({ xp: c.xp_reward });
}
/* POST /api/gamification/self-award — начисление XP из учебника */
function selfAward(req, res) {
const amount = Number(req.body.amount);
const source = String(req.body.source || '');
if (!Number.isInteger(amount) || amount < 1 || amount > 50) {
return res.status(400).json({ error: 'amount должен быть целым числом от 1 до 50' });
}
if (!/^[a-z0-9_-]{1,60}$/i.test(source)) {
return res.status(400).json({ error: 'source неверный формат (a-z, 0-9, _, -, до 60 символов)' });
}
awardXP(req.user.id, amount, 'tb:' + source);
const info = getXPInfo(req.user.id);
res.json(info);
}
module.exports = {
getMe, getFrames, setFrame, setGoalTier,
getAchievements, getLeaderboard, getXPHistory,
getChallenges, claimChallenge,
selfAward,
};
+13 -1
View File
@@ -5,13 +5,22 @@ const rateLimit = require('../middleware/rateLimit');
const {
getMe, getAchievements, getLeaderboard, getXPHistory,
getChallenges, claimChallenge, setGoalTier, getFrames, setFrame,
onLabExperiment,
onLabExperiment, selfAward,
adminAward, adminReset, adminGamStats, adminGetUser
} = require('../controllers/gamificationController');
const labLimiter = rateLimit({ windowMs: 60_000, max: 30, message: 'Слишком частые запросы лаборатории' });
const labSchema = { body: { reactionsDiscovered: { type: 'number', min: 0, max: 100, integer: true } } };
const tbLimiter = rateLimit({ windowMs: 60_000, max: 30, message: 'Слишком частые начисления XP' });
const selfAwardSchema = {
body: {
amount: { type: 'number', required: true, min: 1, max: 50, integer: true },
source: { type: 'string', required: true, minLen: 1, maxLen: 60,
match: /^[a-z0-9_-]{1,60}$/i },
},
};
router.use(authMiddleware);
router.get('/me', getMe);
@@ -24,6 +33,9 @@ router.post('/goal-tier', requirePermission('gamification.challenges'), setGoalT
router.get('/frames', getFrames);
router.post('/frame', requirePermission('shop.purchase'), setFrame);
/* Учебник — начисление XP от пользователя */
router.post('/self-award', tbLimiter, validate(selfAwardSchema), selfAward);
/* Lab experiment tracking */
router.post('/lab-activity', requirePermission('simulations.access'), labLimiter, validate(labSchema), (req, res) => {
const discovered = Number(req.body.reactionsDiscovered) || 0;
+20 -16
View File
@@ -12,6 +12,8 @@
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false})"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@400;700;800;900&display=swap" rel="stylesheet">
<script src="/js/api.js" defer></script>
<script src="/js/xp.js" defer></script>
<style>
:root{
--pri:#e91e63; --pri2:#c2185b; --pri-soft:#fce7f3;
@@ -1460,6 +1462,18 @@ function init(){
initMobileSidebar();
goTo('p1'); // строит только §1, остальные — лениво при переходе
setTimeout(()=>achievement('start','Начало пути по корням!'), 800);
// Sync XP с сервером: если серверный XP выше — обновляем локальный прогресс
if(window.LS && window.LS.xp){
window.LS.xp.load().then(function(s){
if(s && s.xp > STATE.xp){
STATE.xp = s.xp;
STATE.level = calcLevel(STATE.xp);
saveProgress();
refreshProgressUI();
if(STATE.current) buildSidebar(STATE.current);
}
});
}
}
document.addEventListener('DOMContentLoaded', init);
@@ -5902,22 +5916,11 @@ document.addEventListener('DOMContentLoaded', ()=>setTimeout(initWave3, 100));
WAVE 4 — GAMIFICATION
════════════════════════════════════════════════════════ */
/* ── XP levels ── */
const XP_LEVELS = [0, 50, 120, 220, 350, 520, 740, 1000, 1300, 1700, 2200];
/* ── XP levels — единая формула с сервером ── */
const XP_LEVELS = null; // legacy — теперь уровень считается формулой
function calcLevel(xp){
let lv = 1;
for(let i = 0; i < XP_LEVELS.length; i++){
if(xp >= XP_LEVELS[i]) lv = i + 1;
else break;
}
return Math.min(lv, XP_LEVELS.length);
}
function _xpForLevel(lv){
const idx = Math.max(0, Math.min(lv - 1, XP_LEVELS.length - 1));
return XP_LEVELS[idx];
}
function calcLevel(xp){ return Math.floor(Math.sqrt((xp || 0) / 100)) + 1; }
function _xpForLevel(lv){ return (lv - 1) * (lv - 1) * 100; }
function addXp(amount, source){
if(!amount || amount <= 0) return;
@@ -5926,6 +5929,7 @@ function addXp(amount, source){
STATE.xp += amount;
STATE.level = calcLevel(STATE.xp);
saveProgress();
if(window.LS && window.LS.xp) window.LS.xp.add(amount, 'algebra8-ch1-' + (source || 'misc'));
refreshProgressUI(); // обновляет XP-бейдж в hero
if(STATE.level > prevLevel){
@@ -5951,7 +5955,7 @@ function addXp(amount, source){
if(fill) fill.style.width = xpPct + '%';
const xpNums = box.querySelectorAll('.xp-nums span');
if(xpNums[0]) xpNums[0].textContent = STATE.xp + ' XP';
if(xpNums[1]) xpNums[1].textContent = STATE.level < 10 ? xpNext + ' XP' : 'MAX';
if(xpNums[1]) xpNums[1].textContent = STATE.level < 30 ? xpNext + ' XP' : 'MAX';
const lvEl = box.querySelector('.xp-level');
if(lvEl) lvEl.textContent = 'Ур. ' + STATE.level;
}
+21 -15
View File
@@ -12,6 +12,8 @@
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false})"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@400;700;800;900&display=swap" rel="stylesheet">
<script src="/js/api.js" defer></script>
<script src="/js/xp.js" defer></script>
<style>
:root{
--pri:#e91e63; --pri2:#c2185b; --pri-soft:#fce7f3;
@@ -454,20 +456,11 @@ const STATE = {
level: 1,
};
/* Уровни — общая таблица с главой 1 */
const XP_LEVELS = [0, 50, 120, 220, 350, 520, 740, 1000, 1300, 1700, 2200];
function calcLevel(xp){
let lv = 1;
for(let i = 0; i < XP_LEVELS.length; i++){
if(xp >= XP_LEVELS[i]) lv = i + 1;
else break;
}
return Math.min(lv, XP_LEVELS.length);
}
function _xpForLevel(lv){
const idx = Math.max(0, Math.min(lv - 1, XP_LEVELS.length - 1));
return XP_LEVELS[idx];
}
/* Уровни — единая формула с сервером (xpToLevel из _shared.js) */
const XP_LEVELS = null; // legacy — теперь уровень считается формулой
function calcLevel(xp){ return Math.floor(Math.sqrt((xp || 0) / 100)) + 1; }
function _xpForLevel(lv){ return (lv - 1) * (lv - 1) * 100; }
const ACH_LABELS = {
start: 'Начало главы 2!',
@@ -555,6 +548,7 @@ function addXp(n, src){
STATE.xp = Math.max(0, (STATE.xp || 0) + n);
STATE.level = calcLevel(STATE.xp);
saveProgress();
if(window.LS && window.LS.xp) window.LS.xp.add(n, 'algebra8-ch2-' + (src || 'misc'));
refreshProgressUI();
if(STATE.level > prev){
const pop = document.getElementById('ach-popup');
@@ -729,7 +723,7 @@ function buildSidebar(id){
<span class="xp-level">Ур. ${STATE.level}</span>
</div>
<div class="xp-bar"><div class="xp-fill" style="width:${xpPct}%"></div></div>
<div class="xp-nums"><span>${STATE.xp} XP</span><span>${STATE.level < XP_LEVELS.length ? xpNext + ' XP' : 'MAX'}</span></div>
<div class="xp-nums"><span>${STATE.xp} XP</span><span>${STATE.level < 30 ? xpNext + ' XP' : 'MAX'}</span></div>
</div>`;
// Шпаргалка
@@ -1214,6 +1208,18 @@ function init(){
refreshProgressUI();
goTo('p7');
setTimeout(()=>achievement('start','Начало главы 2!'), 600);
// Sync XP с сервером: если серверный XP выше — обновляем локальный прогресс
if(window.LS && window.LS.xp){
window.LS.xp.load().then(function(s){
if(s && s.xp > STATE.xp){
STATE.xp = s.xp;
STATE.level = calcLevel(STATE.xp);
saveProgress();
refreshProgressUI();
if(STATE.current) buildSidebar(STATE.current);
}
});
}
}
document.addEventListener('DOMContentLoaded', init);
+184
View File
@@ -0,0 +1,184 @@
'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) {}
}
/* ── Загрузка с сервера + merge ── */
async function load() {
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; // не авторизован — тихо пропускаем
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;
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;
// Немедленно обновляем локальный 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 };
})();