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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user