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