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
+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);