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;
|
||||
|
||||
Reference in New Issue
Block a user