Files
Learn_System/js/xp.js
T
Maxim Dolgolyov 64bd44088d 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); далее сервер — источник
  правды
2026-05-27 15:56:36 +03:00

185 lines
7.8 KiB
JavaScript
Raw 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) {}
}
/* ── Загрузка с сервера + 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 };
})();