diff --git a/backend/src/controllers/gamification/api.js b/backend/src/controllers/gamification/api.js
index 640a3bf..4191fd9 100644
--- a/backend/src/controllers/gamification/api.js
+++ b/backend/src/controllers/gamification/api.js
@@ -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,
};
diff --git a/backend/src/routes/gamification.js b/backend/src/routes/gamification.js
index 6b207ac..5db65ee 100644
--- a/backend/src/routes/gamification.js
+++ b/backend/src/routes/gamification.js
@@ -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;
diff --git a/frontend/textbooks/algebra_8.html b/frontend/textbooks/algebra_8.html
index 382bdaa..9e69f88 100644
--- a/frontend/textbooks/algebra_8.html
+++ b/frontend/textbooks/algebra_8.html
@@ -12,6 +12,8 @@
+
+