diff --git a/backend/src/controllers/gamification/_shared.js b/backend/src/controllers/gamification/_shared.js new file mode 100644 index 0000000..0510e62 --- /dev/null +++ b/backend/src/controllers/gamification/_shared.js @@ -0,0 +1,194 @@ +'use strict'; +const db = require('../../db/db'); + +/* ═══════════════════════════════════════════════════════════════════════ + Gamification — shared module: helpers + stmts + defs + All sibling files (./service, ./api, ./admin) import from here. + ═══════════════════════════════════════════════════════════════════════ */ + +/* ── XP thresholds: level = floor(sqrt(xp / 100)) + 1 ── */ +function xpToLevel(xp) { return Math.floor(Math.sqrt((xp || 0) / 100)) + 1; } +function levelMinXp(lv) { return (lv - 1) * (lv - 1) * 100; } +function levelMaxXp(lv) { return lv * lv * 100; } + +const RANKS = [ + [1, 'Новичок'], + [5, 'Ученик'], + [10, 'Знаток'], + [15, 'Эксперт'], + [20, 'Мастер'], + [30, 'Гуру'], +]; +function rankName(level) { + let name = 'Новичок'; + for (const [min, r] of RANKS) if (level >= min) name = r; + return name; +} + +/* ── Daily-goal tiers (used by daily logic + setGoalTier API) ── */ +const GOAL_TIERS = { + easy: { tests: 2, xp: 100, bonus: 30, label: 'Лёгкая' }, + medium: { tests: 3, xp: 200, bonus: 50, label: 'Средняя' }, + hard: { tests: 5, xp: 500, bonus: 100, label: 'Тяжёлая' }, +}; + +/* ── Achievement definitions (seed via seedAchievements()) ── */ +const ACHIEVEMENT_DEFS = [ + // First steps + { slug: 'first_test', title: 'Первый тест', icon: 'target', cat: 'start', desc: 'Пройти свой первый тест' }, + { slug: 'first_perfect', title: 'Идеальный результат', icon: 'hundred', cat: 'start', desc: 'Получить 100% на тесте' }, + { slug: 'first_class', title: 'Вступил в класс', icon: 'school', cat: 'start', desc: 'Присоединиться к классу' }, + // Streaks + { slug: 'streak_3', title: 'Три дня подряд', icon: 'flame', cat: 'streak', desc: '3 дня активности подряд' }, + { slug: 'streak_7', title: 'Неделя подряд', icon: 'flame', cat: 'streak', desc: '7 дней активности подряд' }, + { slug: 'streak_30', title: 'Месяц подряд', icon: 'flame', cat: 'streak', desc: '30 дней активности подряд' }, + // Volume + { slug: 'tests_10', title: '10 тестов', icon: 'file-text', cat: 'volume', desc: 'Завершить 10 тестов' }, + { slug: 'tests_50', title: '50 тестов', icon: 'books', cat: 'volume', desc: 'Завершить 50 тестов' }, + { slug: 'tests_100', title: '100 тестов', icon: 'trophy', cat: 'volume', desc: 'Завершить 100 тестов' }, + // Mastery + { slug: 'score_90', title: 'Отличник', icon: 'star', cat: 'mastery', desc: '5 тестов подряд на 90%+' }, + { slug: 'speed_demon', title: 'Скорострел', icon: 'zap', cat: 'mastery', desc: 'Тест на 90%+ за <50% времени' }, + // Levels + { slug: 'level_5', title: 'Ученик', icon: 'book-open', cat: 'level', desc: 'Достичь 5 уровня' }, + { slug: 'level_10', title: 'Знаток', icon: 'brain', cat: 'level', desc: 'Достичь 10 уровня' }, + { slug: 'level_20', title: 'Мастер', icon: 'crown', cat: 'level', desc: 'Достичь 20 уровня' }, + { slug: 'level_3', title: 'Начинающий', icon: 'book-open', cat: 'level', desc: 'Достичь 3 уровня' }, + // XP milestones + { slug: 'xp_1000', title: '1000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 1000 XP' }, + { slug: 'xp_5000', title: '5000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 5000 XP' }, + { slug: 'xp_10000', title: '10 000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 10 000 XP' }, + // Lab / experiments + { slug: 'lab_first', title: 'Первый опыт', icon: 'flask-conical', cat: 'lab', desc: 'Провести первый эксперимент в лаборатории' }, + { slug: 'lab_5', title: 'Юный химик', icon: 'flask-conical', cat: 'lab', desc: 'Провести 5 экспериментов' }, + { slug: 'lab_20', title: 'Лаборант', icon: 'test-tubes', cat: 'lab', desc: 'Провести 20 экспериментов' }, + { slug: 'lab_50', title: 'Исследователь', icon: 'microscope', cat: 'lab', desc: 'Провести 50 экспериментов' }, + { slug: 'lab_reactions_10',title: '10 реакций', icon: 'atom', cat: 'lab', desc: 'Обнаружить 10 различных реакций' }, + { slug: 'lab_reactions_30',title: '30 реакций', icon: 'atom', cat: 'lab', desc: 'Обнаружить 30 различных реакций' }, + // Red Book + { slug: 'rb_first', title: 'Первый вид КК', icon: 'leaf', cat: 'redbook', desc: 'Открыть первый вид Красной книги' }, + { slug: 'rb_10', title: '10 видов КК', icon: 'leaf', cat: 'redbook', desc: 'Открыть 10 видов Красной книги' }, + { slug: 'rb_25', title: 'Четверть коллекции', icon: 'trees', cat: 'redbook', desc: 'Открыть 25 видов Красной книги' }, + { slug: 'rb_50', title: 'Половина коллекции', icon: 'trees', cat: 'redbook', desc: 'Открыть 50 видов Красной книги' }, + { slug: 'rb_all_cr', title: 'Защитник природы', icon: 'shield-check', cat: 'redbook', desc: 'Открыть все CR-виды Красной книги' }, + { slug: 'rb_quest_first', title: 'Первый квест КК', icon: 'map', cat: 'redbook', desc: 'Выполнить первый квест Красной книги' }, + { slug: 'rb_quest_5', title: 'Квестмастер КК', icon: 'map-pin', cat: 'redbook', desc: 'Выполнить 5 квестов Красной книги' }, + { slug: 'rb_sighting', title: 'Наблюдатель', icon: 'eye', cat: 'redbook', desc: 'Добавить первое наблюдение вида' }, + // Theory / Library + { slug: 'theory_first', title: 'Первый урок', icon: 'book-open', cat: 'theory', desc: 'Прочитать первый урок' }, + { slug: 'theory_10', title: 'Читатель', icon: 'library', cat: 'theory', desc: 'Прочитать 10 уроков' }, + { slug: 'theory_course', title: 'Завершил курс', icon: 'graduation-cap',cat: 'theory', desc: 'Пройти полный курс целиком' }, + // Assignments + { slug: 'assign_first', title: 'Первое задание', icon: 'clipboard', cat: 'assign', desc: 'Сдать первое задание' }, + { slug: 'assign_10', title: '10 заданий', icon: 'clipboard-check', cat: 'assign', desc: 'Сдать 10 заданий' }, +]; + +/* ── Avatar frames (unlocked by achievement slug) ── */ +const AVATAR_FRAMES = [ + { id: 'default', name: 'Стандарт', css: '', unlock: null }, + { id: 'fire', name: 'Огненная', css: 'box-shadow:0 0 0 3px #FF6B35,0 0 12px rgba(255,107,53,0.4)', unlock: 'streak_7' }, + { id: 'diamond', name: 'Бриллиант', css: 'box-shadow:0 0 0 3px #06D6E0,0 0 12px rgba(6,214,224,0.4)', unlock: 'xp_5000' }, + { id: 'gold', name: 'Золотая', css: 'box-shadow:0 0 0 3px #FFD700,0 0 12px rgba(255,215,0,0.4)', unlock: 'tests_100' }, + { id: 'violet_glow', name: 'Фиолет', css: 'box-shadow:0 0 0 3px #9B5DE5,0 0 16px rgba(155,93,229,0.5)', unlock: 'level_10' }, + { id: 'rainbow', name: 'Радуга', css: 'background:conic-gradient(#FF6B6B,#FFD93D,#6BCB77,#4D96FF,#9B5DE5,#FF6B6B);padding:3px', unlock: 'level_20' }, + { id: 'crown', name: 'Корона', css: 'box-shadow:0 0 0 3px #FFD700,0 0 20px rgba(255,215,0,0.6)', unlock: 'xp_10000' }, + { id: 'perfect', name: 'Идеал', css: 'box-shadow:0 0 0 3px #06D664,0 0 12px rgba(6,214,100,0.4)', unlock: 'first_perfect' }, +]; + +/* ── Prepared statements (module-level to avoid re-parsing per request) ── */ +const stmts = { + // XP & coins + insertXpLog: db.prepare('INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)'), + incrXP: db.prepare('UPDATE users SET xp = xp + ? WHERE id = ?'), + getXP: db.prepare('SELECT xp FROM users WHERE id = ?'), + setLevel: db.prepare('UPDATE users SET level = ? WHERE id = ?'), + incrCoins: db.prepare('UPDATE users SET coins = coins + ? WHERE id = ?'), + + // Streak + getStreak: db.prepare('SELECT streak_current, streak_best, streak_date FROM users WHERE id = ?'), + setStreak: db.prepare('UPDATE users SET streak_current = ?, streak_best = ?, streak_date = ? WHERE id = ?'), + + // getXPInfo + getUserXPInfo: db.prepare('SELECT xp, level, streak_current, streak_best, streak_date FROM users WHERE id = ?'), + + countUserAssignments: db.prepare(`SELECT COUNT(*) as n FROM assignment_sessions ass JOIN test_sessions ts ON ts.id = ass.session_id WHERE ts.user_id = ? AND ts.status = 'completed'`), + + // Achievements + getAchBySlug: db.prepare('SELECT id, title, icon FROM achievements WHERE slug = ?'), + hasUserAch: db.prepare('SELECT 1 FROM user_achievements WHERE user_id = ? AND achievement_id = ?'), + insertUserAch: db.prepare('INSERT INTO user_achievements (user_id, achievement_id) VALUES (?, ?)'), + insertAchNotif: db.prepare("INSERT INTO notifications (user_id, type, message, link) VALUES (?, 'achievement', ?, '/profile')"), + getUserForAch: db.prepare(` + SELECT u.xp, u.level, u.streak_current, u.lab_experiments, u.lab_reactions, + (SELECT COUNT(*) FROM test_sessions WHERE user_id = u.id AND status = 'completed') AS test_count, + (SELECT COUNT(*) FROM test_sessions WHERE user_id = u.id AND status = 'completed' AND score = total) AS perfect_count, + (SELECT COUNT(*) FROM class_members WHERE user_id = u.id) AS class_count + FROM users u WHERE u.id = ? + `), + getLast5Tests: db.prepare(` + SELECT score, total FROM test_sessions + WHERE user_id = ? AND status = 'completed' + ORDER BY finished_at DESC LIMIT 5 + `), + + // Lab + incrLabExp: db.prepare('UPDATE users SET lab_experiments = lab_experiments + 1 WHERE id = ?'), + incrLabReact: db.prepare('UPDATE users SET lab_reactions = lab_reactions + ? WHERE id = ?'), + + // Daily goals + getDailyGoal: db.prepare('SELECT * FROM daily_goals WHERE user_id = ? AND date = ?'), + getUserGoalTier: db.prepare('SELECT goal_tier FROM users WHERE id = ?'), + insertDailyGoal: db.prepare('INSERT INTO daily_goals (user_id, date, tests_target, tests_done, xp_target, xp_earned) VALUES (?, ?, ?, 0, ?, 0)'), + incrDailyGoal: db.prepare('UPDATE daily_goals SET tests_done = tests_done + ?, xp_earned = xp_earned + ? WHERE user_id = ? AND date = ?'), + checkGoalBonus: db.prepare("SELECT 1 FROM xp_log WHERE user_id = ? AND reason = 'daily_goal' AND date(created_at) = ?"), + + // Challenges + getOpenChallenges: db.prepare('SELECT * FROM challenges WHERE user_id = ? AND week = ? AND completed = 0'), + incrChallenge: db.prepare('UPDATE challenges SET progress = MIN(progress + ?, target) WHERE id = ?'), + getChallengeById: db.prepare('SELECT * FROM challenges WHERE id = ?'), + completeChallenge: db.prepare('UPDATE challenges SET completed = 1 WHERE id = ?'), + getChallengesWeek: db.prepare('SELECT * FROM challenges WHERE user_id = ? AND week = ? ORDER BY completed, id'), + getChallengeOwned: db.prepare('SELECT * FROM challenges WHERE id = ? AND user_id = ?'), + markClaimed: db.prepare('UPDATE challenges SET claimed = 1 WHERE id = ?'), + + // API handlers + getUserPrefs: db.prepare('SELECT goal_tier, avatar_frame FROM users WHERE id = ?'), + getUnlockedSlugs: db.prepare('SELECT a.slug FROM user_achievements ua JOIN achievements a ON a.id = ua.achievement_id WHERE ua.user_id = ?'), + getUserFrame: db.prepare('SELECT avatar_frame FROM users WHERE id = ?'), + checkFrameUnlock: db.prepare('SELECT a.id FROM achievements a JOIN user_achievements ua ON ua.achievement_id = a.id WHERE a.slug = ? AND ua.user_id = ?'), + setUserFrame: db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?'), + setUserGoalTier: db.prepare('UPDATE users SET goal_tier = ? WHERE id = ?'), + getAllAchs: db.prepare('SELECT id, slug, title, icon, category, description FROM achievements ORDER BY id'), + getUserAchs: db.prepare('SELECT achievement_id, unlocked_at FROM user_achievements WHERE user_id = ?'), + xpHistory: db.prepare('SELECT amount, reason, created_at FROM xp_log WHERE user_id = ? ORDER BY created_at DESC LIMIT ?'), + + // Admin + checkUserById: db.prepare('SELECT id FROM users WHERE id = ?'), + getUserGamInfo: db.prepare('SELECT xp, level, coins FROM users WHERE id = ?'), + adminResetUser: db.prepare("UPDATE users SET xp=0, level=1, coins=0, streak_current=0, streak_best=0, streak_date=NULL, avatar_frame='default' WHERE id=?"), + deleteXpLog: db.prepare('DELETE FROM xp_log WHERE user_id=?'), + deleteUserAchs: db.prepare('DELETE FROM user_achievements WHERE user_id=?'), + deleteDailyGoals: db.prepare('DELETE FROM daily_goals WHERE user_id=?'), + deleteChallenges: db.prepare('DELETE FROM challenges WHERE user_id=?'), + deleteUserPurch: db.prepare('DELETE FROM user_purchases WHERE user_id=?'), + adminGetUserFull: db.prepare('SELECT id, name, xp, level, coins, streak_current, streak_best, goal_tier, avatar_frame FROM users WHERE id=?'), + adminGetUserAchs: db.prepare('SELECT a.slug, a.title, a.icon, ua.unlocked_at FROM user_achievements ua JOIN achievements a ON a.id=ua.achievement_id WHERE ua.user_id=?'), + adminGetUserPurch: db.prepare('SELECT si.name, si.type, up.purchased_at FROM user_purchases up JOIN shop_items si ON si.id=up.item_id WHERE up.user_id=?'), + adminGetUserXPH: db.prepare('SELECT amount, reason, created_at FROM xp_log WHERE user_id=? ORDER BY created_at DESC LIMIT 30'), + adminTotalXP: db.prepare('SELECT COALESCE(SUM(xp),0) as v FROM users'), + adminTotalCoins: db.prepare('SELECT COALESCE(SUM(coins),0) as v FROM users'), + adminAvgLevel: db.prepare("SELECT ROUND(AVG(level),1) as v FROM users WHERE role='student'"), + adminAchCount: db.prepare('SELECT COUNT(*) as v FROM user_achievements'), + adminTopXP: db.prepare("SELECT id, name, xp, level, coins FROM users WHERE role='student' ORDER BY xp DESC LIMIT 10"), + adminRecentXP: db.prepare('SELECT xl.amount, xl.reason, xl.created_at, u.name FROM xp_log xl JOIN users u ON u.id=xl.user_id ORDER BY xl.created_at DESC LIMIT 20'), + adminTotalPurch: db.prepare('SELECT COUNT(*) as v FROM user_purchases'), + adminRecentPurch: db.prepare(`SELECT up.purchased_at, u.name AS user_name, si.name AS item_name, si.price, si.type + FROM user_purchases up JOIN users u ON u.id=up.user_id JOIN shop_items si ON si.id=up.item_id + ORDER BY up.purchased_at DESC LIMIT 20`), +}; + +module.exports = { + xpToLevel, levelMinXp, levelMaxXp, rankName, RANKS, + GOAL_TIERS, ACHIEVEMENT_DEFS, AVATAR_FRAMES, + stmts, +}; diff --git a/backend/src/controllers/gamification/admin.js b/backend/src/controllers/gamification/admin.js new file mode 100644 index 0000000..0ec6b7b --- /dev/null +++ b/backend/src/controllers/gamification/admin.js @@ -0,0 +1,70 @@ +'use strict'; +const db = require('../../db/db'); +const { stmts } = require('./_shared'); +const { awardXP, awardCoins } = require('./service'); + +/* ═══════════════════════════════════════════════════════════════════════ + Gamification — admin handlers + Mounted under /api/gamification/admin/* + ═══════════════════════════════════════════════════════════════════════ */ + +const ADMIN_AWARD_MAX = 1_000_000; + +/* POST /api/gamification/admin/award — award XP or coins to user */ +function adminAward(req, res) { + const { userId, xp, coins, reason } = req.body; + if (!userId) return res.status(400).json({ error: 'userId required' }); + const xpNum = Number(xp) || 0; + const coinsNum = Number(coins) || 0; + if (xpNum < 0 || xpNum > ADMIN_AWARD_MAX) return res.status(400).json({ error: `xp must be 0..${ADMIN_AWARD_MAX}` }); + if (coinsNum < 0 || coinsNum > ADMIN_AWARD_MAX) return res.status(400).json({ error: `coins must be 0..${ADMIN_AWARD_MAX}` }); + const user = stmts.checkUserById.get(userId); + if (!user) return res.status(404).json({ error: 'User not found' }); + if (xpNum > 0) awardXP(userId, xpNum, reason || 'Admin award'); + if (coinsNum > 0) awardCoins(userId, coinsNum, reason || 'Admin award'); + const updated = stmts.getUserGamInfo.get(userId); + res.json({ ok: true, ...updated }); +} + +/* POST /api/gamification/admin/reset — reset user gamification */ +const _resetTx = db.transaction((userId) => { + stmts.adminResetUser.run(userId); + stmts.deleteXpLog.run(userId); + stmts.deleteUserAchs.run(userId); + stmts.deleteDailyGoals.run(userId); + stmts.deleteChallenges.run(userId); + stmts.deleteUserPurch.run(userId); +}); + +function adminReset(req, res) { + const { userId } = req.body; + if (!userId) return res.status(400).json({ error: 'userId required' }); + _resetTx(userId); + res.json({ ok: true }); +} + +/* GET /api/gamification/admin/stats — global gamification stats */ +function adminGamStats(_req, res) { + const totalXP = stmts.adminTotalXP.get().v; + const totalCoins = stmts.adminTotalCoins.get().v; + const avgLevel = stmts.adminAvgLevel.get().v; + const achievementCount = stmts.adminAchCount.get().v; + const topByXP = stmts.adminTopXP.all(); + const recentXP = stmts.adminRecentXP.all(); + const totalPurchases = stmts.adminTotalPurch.get().v; + const recentPurchases = stmts.adminRecentPurch.all(); + res.json({ totalXP, totalCoins, avgLevel, achievementCount, totalPurchases, topByXP, recentXP, recentPurchases }); +} + +/* GET /api/gamification/admin/user/:id — user gamification details */ +function adminGetUser(req, res) { + const uid = Number(req.params.id); + const user = stmts.adminGetUserFull.get(uid); + if (!user) return res.status(404).json({ error: 'User not found' }); + const achievements = stmts.adminGetUserAchs.all(uid); + const purchases = stmts.adminGetUserPurch.all(uid); + const xpHistory = stmts.adminGetUserXPH.all(uid); + res.json({ user, achievements, purchases, xpHistory }); +} + +module.exports = { adminAward, adminReset, adminGamStats, adminGetUser }; diff --git a/backend/src/controllers/gamification/api.js b/backend/src/controllers/gamification/api.js new file mode 100644 index 0000000..640a3bf --- /dev/null +++ b/backend/src/controllers/gamification/api.js @@ -0,0 +1,152 @@ +'use strict'; +const db = require('../../db/db'); +const { stmts, GOAL_TIERS, AVATAR_FRAMES, rankName } = require('./_shared'); +const { getXPInfo, getDailyGoal, ensureChallenges, awardXP, awardCoins } = require('./service'); + +/* ═══════════════════════════════════════════════════════════════════════ + Gamification — public API handlers (mounted under /api/gamification/*) + ═══════════════════════════════════════════════════════════════════════ */ + +/* GET /api/gamification/me — current user XP, level, streak, goals */ +function getMe(req, res) { + const info = getXPInfo(req.user.id); + if (!info) return res.status(404).json({ error: 'User not found' }); + const goal = getDailyGoal(req.user.id); + const pref = stmts.getUserPrefs.get(req.user.id); + const tierKey = (pref && pref.goal_tier) || 'medium'; + const frameId = (pref && pref.avatar_frame) || 'default'; + const frame = AVATAR_FRAMES.find(f => f.id === frameId) || AVATAR_FRAMES[0]; + res.json({ ...info, dailyGoal: goal, goalTier: tierKey, goalTiers: GOAL_TIERS, avatarFrame: frame }); +} + +/* GET /api/gamification/frames — available avatar frames */ +function getFrames(req, res) { + const unlocked = stmts.getUnlockedSlugs.all(req.user.id).map(r => r.slug); + const user = stmts.getUserFrame.get(req.user.id); + const selected = (user && user.avatar_frame) || 'default'; + const frames = AVATAR_FRAMES.map(f => ({ + ...f, + unlocked: !f.unlock || unlocked.includes(f.unlock), + selected: f.id === selected, + })); + res.json({ frames, selected }); +} + +/* POST /api/gamification/frame — set avatar frame */ +function setFrame(req, res) { + const { frame } = req.body; + const f = AVATAR_FRAMES.find(fr => fr.id === frame); + if (!f) return res.status(400).json({ error: 'Unknown frame' }); + if (f.unlock) { + const ach = stmts.checkFrameUnlock.get(f.unlock, req.user.id); + if (!ach) return res.status(403).json({ error: 'Frame not unlocked' }); + } + stmts.setUserFrame.run(frame, req.user.id); + res.json({ frame, css: f.css }); +} + +/* POST /api/gamification/goal-tier — set daily goal difficulty */ +function setGoalTier(req, res) { + const { tier } = req.body; + if (!GOAL_TIERS[tier]) return res.status(400).json({ error: 'Invalid tier. Use: easy, medium, hard' }); + stmts.setUserGoalTier.run(tier, req.user.id); + res.json({ tier, ...GOAL_TIERS[tier] }); +} + +/* GET /api/gamification/achievements — all achievements + user unlocks */ +function getAchievements(req, res) { + const all = stmts.getAllAchs.all(); + const unlocked = stmts.getUserAchs.all(req.user.id); + const unlockedMap = {}; + for (const u of unlocked) unlockedMap[u.achievement_id] = u.unlocked_at; + const result = all.map(a => ({ ...a, unlocked: !!unlockedMap[a.id], unlocked_at: unlockedMap[a.id] || null })); + res.json(result); +} + +/* GET /api/gamification/leaderboard?class_id=X&period=week|all */ +function getLeaderboard(req, res) { + const classId = req.query.class_id ? Number(req.query.class_id) : null; + const period = req.query.period || 'all'; + + let rows; + if (period === 'week') { + const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString(); + if (classId) { + rows = db.prepare(` + SELECT u.id, u.name, COALESCE(SUM(xl.amount), 0) AS week_xp, u.xp, u.level, + u.streak_current AS streak + FROM users u + JOIN class_members cm ON cm.user_id = u.id AND cm.class_id = ? + LEFT JOIN xp_log xl ON xl.user_id = u.id AND xl.created_at >= ? + WHERE u.role = 'student' + GROUP BY u.id ORDER BY week_xp DESC LIMIT 30 + `).all(classId, weekAgo); + } else { + rows = db.prepare(` + SELECT u.id, u.name, COALESCE(SUM(xl.amount), 0) AS week_xp, u.xp, u.level, + u.streak_current AS streak + FROM users u + LEFT JOIN xp_log xl ON xl.user_id = u.id AND xl.created_at >= ? + WHERE u.role = 'student' + GROUP BY u.id ORDER BY week_xp DESC LIMIT 30 + `).all(weekAgo); + } + rows.forEach((r, i) => { r.position = i + 1; r.sort_xp = r.week_xp; }); + } else { + if (classId) { + rows = db.prepare(` + SELECT u.id, u.name, u.xp, u.level, u.streak_current AS streak + FROM users u + JOIN class_members cm ON cm.user_id = u.id AND cm.class_id = ? + WHERE u.role = 'student' + ORDER BY u.xp DESC LIMIT 30 + `).all(classId); + } else { + rows = db.prepare(` + SELECT u.id, u.name, u.xp, u.level, u.streak_current AS streak + FROM users u WHERE u.role = 'student' + ORDER BY u.xp DESC LIMIT 30 + `).all(); + } + rows.forEach((r, i) => { r.position = i + 1; r.sort_xp = r.xp; }); + } + + rows.forEach(r => { r.rank = rankName(r.level || 1); }); + res.json({ rows, period, class_id: classId }); +} + +/* GET /api/gamification/xp-history — recent XP log */ +function getXPHistory(req, res) { + const limit = Math.min(50, Number(req.query.limit) || 20); + const rows = stmts.xpHistory.all(req.user.id, limit); + res.json(rows); +} + +/* GET /api/gamification/challenges — current week's personal challenges */ +function getChallenges(req, res) { + ensureChallenges(req.user.id); + const { _currentWeek } = require('./service'); + const week = _currentWeek(); + const rows = stmts.getChallengesWeek.all(req.user.id, week); + res.json(rows); +} + +/* POST /api/gamification/challenges/:id/claim — claim challenge reward */ +function claimChallenge(req, res) { + const id = Number(req.params.id); + const c = stmts.getChallengeOwned.get(id, req.user.id); + if (!c) return res.status(404).json({ error: 'Challenge not found' }); + if (!c.completed) return res.status(400).json({ error: 'Challenge not completed yet' }); + if (c.claimed) return res.status(400).json({ error: 'Already claimed' }); + + stmts.markClaimed.run(id); + awardXP(req.user.id, c.xp_reward, `Испытание: ${c.title}`); + awardCoins(req.user.id, Math.floor(c.xp_reward / 5), `Испытание: ${c.title}`); + res.json({ xp: c.xp_reward }); +} + +module.exports = { + getMe, getFrames, setFrame, setGoalTier, + getAchievements, getLeaderboard, getXPHistory, + getChallenges, claimChallenge, +}; diff --git a/backend/src/controllers/gamification/service.js b/backend/src/controllers/gamification/service.js new file mode 100644 index 0000000..de704ad --- /dev/null +++ b/backend/src/controllers/gamification/service.js @@ -0,0 +1,393 @@ +'use strict'; +const db = require('../../db/db'); +const sse = require('../../sse'); +const { pushParentNotif } = require('../../utils/notifications'); +const { + stmts, xpToLevel, levelMinXp, levelMaxXp, rankName, + GOAL_TIERS, ACHIEVEMENT_DEFS, +} = require('./_shared'); + +/* ═══════════════════════════════════════════════════════════════════════ + Gamification — service layer + Pure business logic; no HTTP. Called from API handlers and other + controllers (sessionController, lessonController, etc.). + ═══════════════════════════════════════════════════════════════════════ */ + +/* ── Coins ─────────────────────────────────────────────────────────── */ +function awardCoins(userId, amount /*, reason */) { + if (!amount || amount <= 0) return; + stmts.incrCoins.run(amount, userId); +} + +/* ── XP ────────────────────────────────────────────────────────────── */ +function awardXP(userId, amount, reason) { + if (!amount || amount <= 0) return; + stmts.insertXpLog.run(userId, amount, reason); + stmts.incrXP.run(amount, userId); + const user = stmts.getXP.get(userId); + if (user) { + const newLevel = xpToLevel(user.xp); + stmts.setLevel.run(newLevel, userId); + } + // Award coins proportionally: 1 coin per 10 XP + awardCoins(userId, Math.floor(amount / 10), reason); +} + +function getXPInfo(userId) { + const user = stmts.getUserXPInfo.get(userId); + if (!user) return null; + // Always derive level from XP so stale DB level never causes wrong display + const level = xpToLevel(user.xp); + if (user.level !== level) stmts.setLevel.run(level, userId); + return { + xp: user.xp || 0, + level, + rank: rankName(level), + levelMin: levelMinXp(level), + levelMax: levelMaxXp(level), + streak: user.streak_current || 0, + streakBest: user.streak_best || 0, + }; +} + +/* ── Streak (called by onTestFinished) ─────────────────────────────── */ +function updateStreak(userId) { + const user = stmts.getStreak.get(userId); + if (!user) return; + const today = new Date().toISOString().slice(0, 10); + if (user.streak_date === today) return; + + const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); + const oldStreak = user.streak_current || 0; + let newStreak; + if (user.streak_date === yesterday) { + newStreak = oldStreak + 1; + } else { + newStreak = 1; + if (oldStreak >= 3) { + const u = db.prepare('SELECT name FROM users WHERE id = ?').get(userId); + pushParentNotif(userId, 'streak_lost', `${u?.name || 'Ученик'} потерял стрик (было ${oldStreak} дней)`); + } + } + const newBest = Math.max(newStreak, user.streak_best || 0); + stmts.setStreak.run(newStreak, newBest, today, userId); + + awardXP(userId, 30, 'daily_activity'); + return newStreak; +} + +/* ── Achievements ──────────────────────────────────────────────────── */ +function seedAchievements() { + const ins = db.prepare(` + INSERT OR IGNORE INTO achievements (slug, title, icon, category, description) + VALUES (?, ?, ?, ?, ?) + `); + const upd = db.prepare(` + UPDATE achievements SET icon = ?, category = ?, title = ?, description = ? + WHERE slug = ? AND (icon IS NULL OR icon = '' OR icon != ?) + `); + for (const a of ACHIEVEMENT_DEFS) { + ins.run(a.slug, a.title, a.icon, a.cat, a.desc); + upd.run(a.icon, a.cat, a.title, a.desc, a.slug, a.icon); + } +} + +function pushAchievementNotif(userId, ach) { + try { + stmts.insertAchNotif.run(userId, `Достижение: ${ach.title}`); + sse.emit(userId, { type: 'achievement', message: `Достижение: ${ach.title}`, icon: ach.icon, title: ach.title }); + awardCoins(userId, 50, 'achievement:' + (ach.slug || ach.title)); + const u = db.prepare('SELECT name FROM users WHERE id = ?').get(userId); + pushParentNotif(userId, 'achievement', `${u?.name || 'Ученик'} получил достижение: ${ach.title}`); + } catch (e) { console.error('[achievement]', e.message); } +} + +function unlockAchievement(userId, slug) { + const ach = stmts.getAchBySlug.get(slug); + if (!ach) return false; + const exists = stmts.hasUserAch.get(userId, ach.id); + if (exists) return false; + stmts.insertUserAch.run(userId, ach.id); + awardXP(userId, 50, 'achievement:' + slug); + pushAchievementNotif(userId, ach); + return true; +} + +function checkAchievements(userId) { + const row = stmts.getUserForAch.get(userId); + if (!row) return; + const { test_count: testCount, perfect_count: perfectCount, class_count: classCount } = row; + + // Tests + if (testCount >= 1) unlockAchievement(userId, 'first_test'); + if (testCount >= 10) unlockAchievement(userId, 'tests_10'); + if (testCount >= 50) unlockAchievement(userId, 'tests_50'); + if (testCount >= 100) unlockAchievement(userId, 'tests_100'); + if (perfectCount >= 1) unlockAchievement(userId, 'first_perfect'); + + // Streaks + const streak = row.streak_current || 0; + if (streak >= 3) unlockAchievement(userId, 'streak_3'); + if (streak >= 7) unlockAchievement(userId, 'streak_7'); + if (streak >= 30) unlockAchievement(userId, 'streak_30'); + + // Level + const level = xpToLevel(row.xp || 0); + if (level >= 3) unlockAchievement(userId, 'level_3'); + if (level >= 5) unlockAchievement(userId, 'level_5'); + if (level >= 10) unlockAchievement(userId, 'level_10'); + if (level >= 20) unlockAchievement(userId, 'level_20'); + + // XP + const xp = row.xp || 0; + if (xp >= 1000) unlockAchievement(userId, 'xp_1000'); + if (xp >= 5000) unlockAchievement(userId, 'xp_5000'); + if (xp >= 10000) unlockAchievement(userId, 'xp_10000'); + + // Class + if (classCount >= 1) unlockAchievement(userId, 'first_class'); + + // 5 tests in a row with ≥90% + const last5 = stmts.getLast5Tests.all(userId); + if (last5.length >= 5 && last5.every(r => r.total > 0 && (r.score / r.total) >= 0.9)) { + unlockAchievement(userId, 'score_90'); + } + + // Lab + const labExp = row.lab_experiments || 0; + const labReact = row.lab_reactions || 0; + if (labExp >= 1) unlockAchievement(userId, 'lab_first'); + if (labExp >= 5) unlockAchievement(userId, 'lab_5'); + if (labExp >= 20) unlockAchievement(userId, 'lab_20'); + if (labExp >= 50) unlockAchievement(userId, 'lab_50'); + if (labReact >= 10) unlockAchievement(userId, 'lab_reactions_10'); + if (labReact >= 30) unlockAchievement(userId, 'lab_reactions_30'); + + // Assignments + try { + const ac = stmts.countUserAssignments.get(userId); + const assignCount = ac?.n || 0; + if (assignCount >= 1) unlockAchievement(userId, 'assign_first'); + if (assignCount >= 10) unlockAchievement(userId, 'assign_10'); + } catch (e) { console.error('[achievements] assignment check:', e.message); } +} + +function checkRedBookAchievements(userId) { + try { + const collected = db.prepare('SELECT COUNT(*) as n FROM rb_user_collection WHERE user_id = ?').get(userId)?.n || 0; + if (collected >= 1) unlockAchievement(userId, 'rb_first'); + if (collected >= 10) unlockAchievement(userId, 'rb_10'); + if (collected >= 25) unlockAchievement(userId, 'rb_25'); + if (collected >= 50) unlockAchievement(userId, 'rb_50'); + + const crTotal = db.prepare("SELECT COUNT(*) as n FROM rb_species WHERE category = 'CR'").get().n; + const crCollected = db.prepare(` + SELECT COUNT(*) as n FROM rb_user_collection uc + JOIN rb_species s ON s.id = uc.species_id + WHERE uc.user_id = ? AND s.category = 'CR' + `).get(userId)?.n || 0; + if (crTotal > 0 && crCollected >= crTotal) unlockAchievement(userId, 'rb_all_cr'); + + const quests = db.prepare("SELECT COUNT(*) as n FROM rb_user_quests WHERE user_id = ? AND status = 'completed'").get(userId)?.n || 0; + if (quests >= 1) unlockAchievement(userId, 'rb_quest_first'); + if (quests >= 5) unlockAchievement(userId, 'rb_quest_5'); + + const sightings = db.prepare('SELECT COUNT(*) as n FROM rb_sightings WHERE user_id = ?').get(userId)?.n || 0; + if (sightings >= 1) unlockAchievement(userId, 'rb_sighting'); + + checkAchievements(userId); + } catch (e) { console.error('[checkRedBookAchievements]', e.message); } +} + +/* ── Hooks for other controllers ───────────────────────────────────── */ + +function onLessonComplete(userId, courseId) { + try { + awardXP(userId, 30, 'lesson_complete'); + const done = db.prepare('SELECT COUNT(*) as n FROM lesson_progress WHERE user_id = ? AND completed = 1').get(userId)?.n || 0; + if (done >= 1) unlockAchievement(userId, 'theory_first'); + if (done >= 10) unlockAchievement(userId, 'theory_10'); + if (courseId) { + const total = db.prepare('SELECT COUNT(*) as n FROM lessons WHERE course_id = ? AND is_published = 1').get(courseId)?.n || 0; + const courseDone = db.prepare(` + SELECT COUNT(*) as n FROM lesson_progress lp + JOIN lessons l ON lp.lesson_id = l.id + WHERE l.course_id = ? AND lp.user_id = ? AND lp.completed = 1 + `).get(courseId, userId)?.n || 0; + if (total > 0 && courseDone >= total) unlockAchievement(userId, 'theory_course'); + } + checkAchievements(userId); + } catch (e) { console.error('[onLessonComplete]', e.message); } +} + +function onTestFinished(userId, score, total, timeSec, testTimeLimitSec) { + const pct = total > 0 ? score / total : 0; + awardXP(userId, score * 10, 'correct_answers'); + awardXP(userId, 50, 'test_complete'); + if (pct >= 0.9) awardXP(userId, 100, 'test_90+'); + if (pct >= 1.0) awardXP(userId, 200, 'test_perfect'); + + // Ecstatic mood bonus: +10% of base XP when pet streak >= 7 + try { + const streakRow = stmts.getStreak.get(userId); + if (streakRow && (streakRow.streak_current || 0) >= 7) { + const moodBonus = Math.round(score * 10 * 0.10); + if (moodBonus > 0) awardXP(userId, moodBonus, 'mood_ecstatic'); + } + } catch (e) { console.error('[onTestFinished] mood bonus:', e.message); } + + if (testTimeLimitSec && timeSec < testTimeLimitSec * 0.5 && pct >= 0.9) { + unlockAchievement(userId, 'speed_demon'); + } + updateStreak(userId); + checkAchievements(userId); +} + +function onClassJoined(userId) { + checkAchievements(userId); +} + +function onLabExperiment(userId, reactionsDiscovered) { + stmts.incrLabExp.run(userId); + if (reactionsDiscovered > 0) stmts.incrLabReact.run(reactionsDiscovered, userId); + awardXP(userId, 15, 'lab_experiment'); + checkAchievements(userId); +} + +/* ── Daily goals ───────────────────────────────────────────────────── */ +function getDailyGoal(userId) { + const today = new Date().toISOString().slice(0, 10); + let goal = stmts.getDailyGoal.get(userId, today); + if (!goal) { + const pref = stmts.getUserGoalTier.get(userId); + const tier = GOAL_TIERS[(pref && pref.goal_tier) || 'medium'] || GOAL_TIERS.medium; + stmts.insertDailyGoal.run(userId, today, tier.tests, tier.xp); + goal = stmts.getDailyGoal.get(userId, today); + } + return goal; +} + +function updateDailyGoal(userId, addTests, addXp) { + const today = new Date().toISOString().slice(0, 10); + getDailyGoal(userId); // ensure exists + stmts.incrDailyGoal.run(addTests || 0, addXp || 0, userId, today); + + const goal = stmts.getDailyGoal.get(userId, today); + if (goal && goal.tests_done >= goal.tests_target && goal.xp_earned >= goal.xp_target) { + const already = stmts.checkGoalBonus.get(userId, today); + if (!already) { + const pref = stmts.getUserGoalTier.get(userId); + const tier = GOAL_TIERS[(pref && pref.goal_tier) || 'medium'] || GOAL_TIERS.medium; + awardXP(userId, tier.bonus, 'daily_goal'); + try { + sse.emit(userId, { type: 'daily_goal', message: `Дневная цель выполнена! +${tier.bonus} XP`, icon: 'target' }); + } catch (e) { console.error('[daily_goal]', e.message); } + } + } +} + +/* ── Personal Challenges ───────────────────────────────────────────── */ +function _currentWeek() { + const d = new Date(); + const day = d.getDay(); + const mon = new Date(d); + mon.setDate(mon.getDate() - ((day + 6) % 7)); + return mon.toISOString().slice(0, 10); +} + +function ensureChallenges(userId) { + const week = _currentWeek(); + const existing = db.prepare('SELECT COUNT(*) AS cnt FROM challenges WHERE user_id = ? AND week = ?').get(userId, week); + if (existing.cnt > 0) return; + + const weakTopics = db.prepare(` + SELECT t.id AS topic_id, t.name, s.slug AS subject_slug, s.name AS subject_name, + COUNT(CASE WHEN ua.is_correct = 0 THEN 1 END) AS wrong, + COUNT(*) AS total + FROM user_answers ua + JOIN session_questions sq ON sq.session_id = ua.session_id AND sq.question_id = ua.question_id + JOIN questions q ON q.id = ua.question_id + JOIN topics t ON t.id = q.topic_id + JOIN subjects s ON s.id = t.subject_id + JOIN test_sessions ts ON ts.id = ua.session_id AND ts.user_id = ? + GROUP BY t.id + HAVING wrong > 0 + ORDER BY CAST(wrong AS REAL) / total DESC + LIMIT 5 + `).all(userId); + + const challenges = []; + if (weakTopics.length > 0) { + const wt = weakTopics[0]; + challenges.push({ + title: `Подтяни «${wt.name}»`, + description: `Пройди 3 теста по теме «${wt.name}» (${wt.subject_name})`, + type: 'topic_tests', target: 3, xp_reward: 150, + subject_slug: wt.subject_slug, topic_id: wt.topic_id, + }); + } + challenges.push({ + title: 'Набери 80%+', description: 'Заверши 3 теста с результатом не ниже 80%', + type: 'high_score', target: 3, xp_reward: 120, subject_slug: null, topic_id: null, + }); + challenges.push({ + title: 'Марафонец', description: 'Пройди 5 тестов на этой неделе', + type: 'tests', target: 5, xp_reward: 100, subject_slug: null, topic_id: null, + }); + if (weakTopics.length < 2) { + challenges.push({ + title: 'Без ошибок', description: 'Набери 100% в любом тесте', + type: 'perfect', target: 1, xp_reward: 200, subject_slug: null, topic_id: null, + }); + } + + const ins = db.prepare(` + INSERT OR IGNORE INTO challenges (user_id, week, title, description, type, target, xp_reward, subject_slug, topic_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const c of challenges) { + ins.run(userId, week, c.title, c.description, c.type, c.target, c.xp_reward, c.subject_slug, c.topic_id); + } +} + +function updateChallenges(userId, score, total, subjectSlug, topicId) { + const week = _currentWeek(); + const pct = total > 0 ? Math.round(score / total * 100) : 0; + const challenges = stmts.getOpenChallenges.all(userId, week); + + for (const c of challenges) { + let inc = 0; + switch (c.type) { + case 'tests': inc = 1; break; + case 'topic_tests': + if (topicId && c.topic_id === topicId) inc = 1; + else if (subjectSlug && c.subject_slug === subjectSlug) inc = 1; + break; + case 'high_score': if (pct >= 80) inc = 1; break; + case 'perfect': if (pct >= 100) inc = 1; break; + } + if (inc > 0) { + stmts.incrChallenge.run(inc, c.id); + const updated = stmts.getChallengeById.get(c.id); + if (updated && updated.progress >= updated.target) { + stmts.completeChallenge.run(c.id); + try { + sse.emit(userId, { type: 'challenge', message: `Испытание «${c.title}» выполнено!`, icon: 'target' }); + } catch (e) { console.error('[challenge]', e.message); } + } + } + } +} + +module.exports = { + // Core + awardXP, awardCoins, getXPInfo, updateStreak, + // Achievements + seedAchievements, unlockAchievement, checkAchievements, checkRedBookAchievements, pushAchievementNotif, + // Hooks (for other controllers) + onLessonComplete, onTestFinished, onClassJoined, onLabExperiment, + // Daily goals + getDailyGoal, updateDailyGoal, + // Challenges + _currentWeek, ensureChallenges, updateChallenges, +}; diff --git a/backend/src/controllers/gamificationController.js b/backend/src/controllers/gamificationController.js index e8a4581..db7625a 100644 --- a/backend/src/controllers/gamificationController.js +++ b/backend/src/controllers/gamificationController.js @@ -1,859 +1,31 @@ -const db = require('../db/db'); -const sse = require('../sse'); -const { pushParentNotif } = require('../utils/notifications'); - -/* ═══════════════════════════════════════════════════════════════════════ - Gamification — XP, Levels, Streaks, Achievements, Leaderboard, Goals - ═══════════════════════════════════════════════════════════════════════ */ - -// ── XP thresholds: level = floor(sqrt(xp / 100)) + 1 ── -function xpToLevel(xp) { return Math.floor(Math.sqrt((xp || 0) / 100)) + 1; } -function levelMinXp(lv) { return (lv - 1) * (lv - 1) * 100; } -function levelMaxXp(lv) { return lv * lv * 100; } - -const RANKS = [ - [1, 'Новичок'], - [5, 'Ученик'], - [10, 'Знаток'], - [15, 'Эксперт'], - [20, 'Мастер'], - [30, 'Гуру'], -]; -function rankName(level) { - let name = 'Новичок'; - for (const [min, r] of RANKS) if (level >= min) name = r; - return name; -} - -/* ── Prepared statements (module-level to avoid re-parsing per request) ── */ -const stmts = { - // XP & coins (hot: called on every test finish) - insertXpLog: db.prepare('INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)'), - incrXP: db.prepare('UPDATE users SET xp = xp + ? WHERE id = ?'), - getXP: db.prepare('SELECT xp FROM users WHERE id = ?'), - setLevel: db.prepare('UPDATE users SET level = ? WHERE id = ?'), - incrCoins: db.prepare('UPDATE users SET coins = coins + ? WHERE id = ?'), - - // Streak - getStreak: db.prepare('SELECT streak_current, streak_best, streak_date FROM users WHERE id = ?'), - setStreak: db.prepare('UPDATE users SET streak_current = ?, streak_best = ?, streak_date = ? WHERE id = ?'), - - // getXPInfo - getUserXPInfo: db.prepare('SELECT xp, level, streak_current, streak_best, streak_date FROM users WHERE id = ?'), - - countUserAssignments: db.prepare(`SELECT COUNT(*) as n FROM assignment_sessions ass JOIN test_sessions ts ON ts.id = ass.session_id WHERE ts.user_id = ? AND ts.status = 'completed'`), - - // Achievements (hot: up to 22 checks per test finish) - getAchBySlug: db.prepare('SELECT id, title, icon FROM achievements WHERE slug = ?'), - hasUserAch: db.prepare('SELECT 1 FROM user_achievements WHERE user_id = ? AND achievement_id = ?'), - insertUserAch: db.prepare('INSERT INTO user_achievements (user_id, achievement_id) VALUES (?, ?)'), - insertAchNotif: db.prepare("INSERT INTO notifications (user_id, type, message, link) VALUES (?, 'achievement', ?, '/profile')"), - getUserForAch: db.prepare(` - SELECT u.xp, u.level, u.streak_current, u.lab_experiments, u.lab_reactions, - (SELECT COUNT(*) FROM test_sessions WHERE user_id = u.id AND status = 'completed') AS test_count, - (SELECT COUNT(*) FROM test_sessions WHERE user_id = u.id AND status = 'completed' AND score = total) AS perfect_count, - (SELECT COUNT(*) FROM class_members WHERE user_id = u.id) AS class_count - FROM users u WHERE u.id = ? - `), - getLast5Tests: db.prepare(` - SELECT score, total FROM test_sessions - WHERE user_id = ? AND status = 'completed' - ORDER BY finished_at DESC LIMIT 5 - `), - - // Lab - incrLabExp: db.prepare('UPDATE users SET lab_experiments = lab_experiments + 1 WHERE id = ?'), - incrLabReact: db.prepare('UPDATE users SET lab_reactions = lab_reactions + ? WHERE id = ?'), - - // Daily goals - getDailyGoal: db.prepare('SELECT * FROM daily_goals WHERE user_id = ? AND date = ?'), - getUserGoalTier: db.prepare('SELECT goal_tier FROM users WHERE id = ?'), - insertDailyGoal: db.prepare('INSERT INTO daily_goals (user_id, date, tests_target, tests_done, xp_target, xp_earned) VALUES (?, ?, ?, 0, ?, 0)'), - incrDailyGoal: db.prepare('UPDATE daily_goals SET tests_done = tests_done + ?, xp_earned = xp_earned + ? WHERE user_id = ? AND date = ?'), - checkGoalBonus: db.prepare("SELECT 1 FROM xp_log WHERE user_id = ? AND reason = 'daily_goal' AND date(created_at) = ?"), - - // Challenges - getOpenChallenges: db.prepare('SELECT * FROM challenges WHERE user_id = ? AND week = ? AND completed = 0'), - incrChallenge: db.prepare('UPDATE challenges SET progress = MIN(progress + ?, target) WHERE id = ?'), - getChallengeById: db.prepare('SELECT * FROM challenges WHERE id = ?'), - completeChallenge: db.prepare('UPDATE challenges SET completed = 1 WHERE id = ?'), - getChallengesWeek: db.prepare('SELECT * FROM challenges WHERE user_id = ? AND week = ? ORDER BY completed, id'), - getChallengeOwned: db.prepare('SELECT * FROM challenges WHERE id = ? AND user_id = ?'), - markClaimed: db.prepare('UPDATE challenges SET claimed = 1 WHERE id = ?'), - - // API handlers (dashboard / profile load) - getUserPrefs: db.prepare('SELECT goal_tier, avatar_frame FROM users WHERE id = ?'), - getUnlockedSlugs: db.prepare('SELECT a.slug FROM user_achievements ua JOIN achievements a ON a.id = ua.achievement_id WHERE ua.user_id = ?'), - getUserFrame: db.prepare('SELECT avatar_frame FROM users WHERE id = ?'), - checkFrameUnlock: db.prepare('SELECT a.id FROM achievements a JOIN user_achievements ua ON ua.achievement_id = a.id WHERE a.slug = ? AND ua.user_id = ?'), - setUserFrame: db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?'), - setUserGoalTier: db.prepare('UPDATE users SET goal_tier = ? WHERE id = ?'), - getAllAchs: db.prepare('SELECT id, slug, title, icon, category, description FROM achievements ORDER BY id'), - getUserAchs: db.prepare('SELECT achievement_id, unlocked_at FROM user_achievements WHERE user_id = ?'), - xpHistory: db.prepare('SELECT amount, reason, created_at FROM xp_log WHERE user_id = ? ORDER BY created_at DESC LIMIT ?'), - - // Admin - checkUserById: db.prepare('SELECT id FROM users WHERE id = ?'), - getUserGamInfo: db.prepare('SELECT xp, level, coins FROM users WHERE id = ?'), - adminResetUser: db.prepare("UPDATE users SET xp=0, level=1, coins=0, streak_current=0, streak_best=0, streak_date=NULL, avatar_frame='default' WHERE id=?"), - deleteXpLog: db.prepare('DELETE FROM xp_log WHERE user_id=?'), - deleteUserAchs: db.prepare('DELETE FROM user_achievements WHERE user_id=?'), - deleteDailyGoals: db.prepare('DELETE FROM daily_goals WHERE user_id=?'), - deleteChallenges: db.prepare('DELETE FROM challenges WHERE user_id=?'), - deleteUserPurch: db.prepare('DELETE FROM user_purchases WHERE user_id=?'), - adminGetUserFull: db.prepare('SELECT id, name, xp, level, coins, streak_current, streak_best, goal_tier, avatar_frame FROM users WHERE id=?'), - adminGetUserAchs: db.prepare('SELECT a.slug, a.title, a.icon, ua.unlocked_at FROM user_achievements ua JOIN achievements a ON a.id=ua.achievement_id WHERE ua.user_id=?'), - adminGetUserPurch: db.prepare('SELECT si.name, si.type, up.purchased_at FROM user_purchases up JOIN shop_items si ON si.id=up.item_id WHERE up.user_id=?'), - adminGetUserXPH: db.prepare('SELECT amount, reason, created_at FROM xp_log WHERE user_id=? ORDER BY created_at DESC LIMIT 30'), - adminTotalXP: db.prepare('SELECT COALESCE(SUM(xp),0) as v FROM users'), - adminTotalCoins: db.prepare('SELECT COALESCE(SUM(coins),0) as v FROM users'), - adminAvgLevel: db.prepare("SELECT ROUND(AVG(level),1) as v FROM users WHERE role='student'"), - adminAchCount: db.prepare('SELECT COUNT(*) as v FROM user_achievements'), - adminTopXP: db.prepare("SELECT id, name, xp, level, coins FROM users WHERE role='student' ORDER BY xp DESC LIMIT 10"), - adminRecentXP: db.prepare('SELECT xl.amount, xl.reason, xl.created_at, u.name FROM xp_log xl JOIN users u ON u.id=xl.user_id ORDER BY xl.created_at DESC LIMIT 20'), - adminTotalPurch: db.prepare('SELECT COUNT(*) as v FROM user_purchases'), - adminRecentPurch: db.prepare(`SELECT up.purchased_at, u.name AS user_name, si.name AS item_name, si.price, si.type - FROM user_purchases up JOIN users u ON u.id=up.user_id JOIN shop_items si ON si.id=up.item_id - ORDER BY up.purchased_at DESC LIMIT 20`), -}; - -/* ── Coins service ────────────────────────────────────────────────────── */ - -function awardCoins(userId, amount, reason) { - if (!amount || amount <= 0) return; - stmts.incrCoins.run(amount, userId); -} - -/* ── XP service ───────────────────────────────────────────────────────── */ - -function awardXP(userId, amount, reason) { - if (!amount || amount <= 0) return; - stmts.insertXpLog.run(userId, amount, reason); - stmts.incrXP.run(amount, userId); - const user = stmts.getXP.get(userId); - if (user) { - const newLevel = xpToLevel(user.xp); - stmts.setLevel.run(newLevel, userId); - } - // Award coins proportionally: 1 coin per 10 XP - awardCoins(userId, Math.floor(amount / 10), reason); -} - -function getXPInfo(userId) { - const user = stmts.getUserXPInfo.get(userId); - if (!user) return null; - // Always derive level from XP so stale DB level never causes wrong display - const level = xpToLevel(user.xp); - // Keep DB in sync (silently) - if (user.level !== level) stmts.setLevel.run(level, userId); - return { - xp: user.xp || 0, - level, - rank: rankName(level), - levelMin: levelMinXp(level), - levelMax: levelMaxXp(level), - streak: user.streak_current || 0, - streakBest: user.streak_best || 0, - }; -} - -/* ── Streak service ───────────────────────────────────────────────────── */ - -function updateStreak(userId) { - const user = stmts.getStreak.get(userId); - if (!user) return; - const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD - if (user.streak_date === today) return; // already counted today - - const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); - const oldStreak = user.streak_current || 0; - let newStreak; - if (user.streak_date === yesterday) { - newStreak = oldStreak + 1; - } else { - newStreak = 1; - // Notify parents about streak loss (only if was meaningful) - if (oldStreak >= 3) { - const u = db.prepare('SELECT name FROM users WHERE id = ?').get(userId); - pushParentNotif(userId, 'streak_lost', `${u?.name || 'Ученик'} потерял стрик (было ${oldStreak} дней)`); - } - } - const newBest = Math.max(newStreak, user.streak_best || 0); - stmts.setStreak.run(newStreak, newBest, today, userId); - - // streak XP bonus (first activity of the day) - awardXP(userId, 30, 'daily_activity'); - - return newStreak; -} - -/* ── Achievement definitions ──────────────────────────────────────────── */ - -const ACHIEVEMENT_DEFS = [ - // First steps - { slug: 'first_test', title: 'Первый тест', icon: 'target', cat: 'start', desc: 'Пройти свой первый тест' }, - { slug: 'first_perfect', title: 'Идеальный результат', icon: 'hundred', cat: 'start', desc: 'Получить 100% на тесте' }, - { slug: 'first_class', title: 'Вступил в класс', icon: 'school', cat: 'start', desc: 'Присоединиться к классу' }, - // Streaks - { slug: 'streak_3', title: 'Три дня подряд', icon: 'flame', cat: 'streak', desc: '3 дня активности подряд' }, - { slug: 'streak_7', title: 'Неделя подряд', icon: 'flame', cat: 'streak', desc: '7 дней активности подряд' }, - { slug: 'streak_30', title: 'Месяц подряд', icon: 'flame', cat: 'streak', desc: '30 дней активности подряд' }, - // Volume - { slug: 'tests_10', title: '10 тестов', icon: 'file-text', cat: 'volume', desc: 'Завершить 10 тестов' }, - { slug: 'tests_50', title: '50 тестов', icon: 'books', cat: 'volume', desc: 'Завершить 50 тестов' }, - { slug: 'tests_100', title: '100 тестов', icon: 'trophy', cat: 'volume', desc: 'Завершить 100 тестов' }, - // Mastery - { slug: 'score_90', title: 'Отличник', icon: 'star', cat: 'mastery', desc: '5 тестов подряд на 90%+' }, - { slug: 'speed_demon', title: 'Скорострел', icon: 'zap', cat: 'mastery', desc: 'Тест на 90%+ за <50% времени' }, - // Levels - { slug: 'level_5', title: 'Ученик', icon: 'book-open', cat: 'level', desc: 'Достичь 5 уровня' }, - { slug: 'level_10', title: 'Знаток', icon: 'brain', cat: 'level', desc: 'Достичь 10 уровня' }, - { slug: 'level_20', title: 'Мастер', icon: 'crown', cat: 'level', desc: 'Достичь 20 уровня' }, - // XP milestones - { slug: 'xp_1000', title: '1000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 1000 XP' }, - { slug: 'xp_5000', title: '5000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 5000 XP' }, - { slug: 'xp_10000', title: '10 000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 10 000 XP' }, - // Lab / experiments - { slug: 'lab_first', title: 'Первый опыт', icon: 'flask-conical', cat: 'lab', desc: 'Провести первый эксперимент в лаборатории' }, - { slug: 'lab_5', title: 'Юный химик', icon: 'flask-conical', cat: 'lab', desc: 'Провести 5 экспериментов' }, - { slug: 'lab_20', title: 'Лаборант', icon: 'test-tubes', cat: 'lab', desc: 'Провести 20 экспериментов' }, - { slug: 'lab_50', title: 'Исследователь', icon: 'microscope', cat: 'lab', desc: 'Провести 50 экспериментов' }, - { slug: 'lab_reactions_10',title: '10 реакций', icon: 'atom', cat: 'lab', desc: 'Обнаружить 10 различных реакций' }, - { slug: 'lab_reactions_30',title: '30 реакций', icon: 'atom', cat: 'lab', desc: 'Обнаружить 30 различных реакций' }, - // Extra level milestones - { slug: 'level_3', title: 'Начинающий', icon: 'book-open', cat: 'level', desc: 'Достичь 3 уровня' }, - // Red Book - { slug: 'rb_first', title: 'Первый вид КК', icon: 'leaf', cat: 'redbook', desc: 'Открыть первый вид Красной книги' }, - { slug: 'rb_10', title: '10 видов КК', icon: 'leaf', cat: 'redbook', desc: 'Открыть 10 видов Красной книги' }, - { slug: 'rb_25', title: 'Четверть коллекции', icon: 'trees', cat: 'redbook', desc: 'Открыть 25 видов Красной книги' }, - { slug: 'rb_50', title: 'Половина коллекции', icon: 'trees', cat: 'redbook', desc: 'Открыть 50 видов Красной книги' }, - { slug: 'rb_all_cr', title: 'Защитник природы', icon: 'shield-check', cat: 'redbook', desc: 'Открыть все CR-виды Красной книги' }, - { slug: 'rb_quest_first', title: 'Первый квест КК', icon: 'map', cat: 'redbook', desc: 'Выполнить первый квест Красной книги' }, - { slug: 'rb_quest_5', title: 'Квестмастер КК', icon: 'map-pin', cat: 'redbook', desc: 'Выполнить 5 квестов Красной книги' }, - { slug: 'rb_sighting', title: 'Наблюдатель', icon: 'eye', cat: 'redbook', desc: 'Добавить первое наблюдение вида' }, - // Theory / Library - { slug: 'theory_first', title: 'Первый урок', icon: 'book-open', cat: 'theory', desc: 'Прочитать первый урок' }, - { slug: 'theory_10', title: 'Читатель', icon: 'library', cat: 'theory', desc: 'Прочитать 10 уроков' }, - { slug: 'theory_course', title: 'Завершил курс', icon: 'graduation-cap',cat: 'theory', desc: 'Пройти полный курс целиком' }, - // Assignments - { slug: 'assign_first', title: 'Первое задание', icon: 'clipboard', cat: 'assign', desc: 'Сдать первое задание' }, - { slug: 'assign_10', title: '10 заданий', icon: 'clipboard-check',cat: 'assign', desc: 'Сдать 10 заданий' }, -]; - -// Avatar frames unlocked by achievements -const AVATAR_FRAMES = [ - { id: 'default', name: 'Стандарт', css: '', unlock: null }, - { id: 'fire', name: 'Огненная', css: 'box-shadow:0 0 0 3px #FF6B35,0 0 12px rgba(255,107,53,0.4)', unlock: 'streak_7' }, - { id: 'diamond', name: 'Бриллиант', css: 'box-shadow:0 0 0 3px #06D6E0,0 0 12px rgba(6,214,224,0.4)', unlock: 'xp_5000' }, - { id: 'gold', name: 'Золотая', css: 'box-shadow:0 0 0 3px #FFD700,0 0 12px rgba(255,215,0,0.4)', unlock: 'tests_100' }, - { id: 'violet_glow', name: 'Фиолет', css: 'box-shadow:0 0 0 3px #9B5DE5,0 0 16px rgba(155,93,229,0.5)', unlock: 'level_10' }, - { id: 'rainbow', name: 'Радуга', css: 'background:conic-gradient(#FF6B6B,#FFD93D,#6BCB77,#4D96FF,#9B5DE5,#FF6B6B);padding:3px', unlock: 'level_20' }, - { id: 'crown', name: 'Корона', css: 'box-shadow:0 0 0 3px #FFD700,0 0 20px rgba(255,215,0,0.6)', unlock: 'xp_10000' }, - { id: 'perfect', name: 'Идеал', css: 'box-shadow:0 0 0 3px #06D664,0 0 12px rgba(6,214,100,0.4)', unlock: 'first_perfect' }, -]; - -function seedAchievements() { - const ins = db.prepare(` - INSERT OR IGNORE INTO achievements (slug, title, icon, category, description) - VALUES (?, ?, ?, ?, ?) - `); - const upd = db.prepare(` - UPDATE achievements SET icon = ?, category = ?, title = ?, description = ? - WHERE slug = ? AND (icon IS NULL OR icon = '' OR icon != ?) - `); - for (const a of ACHIEVEMENT_DEFS) { - ins.run(a.slug, a.title, a.icon, a.cat, a.desc); - upd.run(a.icon, a.cat, a.title, a.desc, a.slug, a.icon); - } -} - -function unlockAchievement(userId, slug) { - const ach = stmts.getAchBySlug.get(slug); - if (!ach) return false; - const exists = stmts.hasUserAch.get(userId, ach.id); - if (exists) return false; - stmts.insertUserAch.run(userId, ach.id); - // Award bonus XP - awardXP(userId, 50, 'achievement:' + slug); - // Notify via SSE - pushAchievementNotif(userId, ach); - return true; -} - -function pushAchievementNotif(userId, ach) { - try { - stmts.insertAchNotif.run(userId, `Достижение: ${ach.title}`); - sse.emit(userId, { type: 'achievement', message: `Достижение: ${ach.title}`, icon: ach.icon, title: ach.title }); - // Award 50 coins per achievement - awardCoins(userId, 50, 'achievement:' + (ach.slug || ach.title)); - // Notify parents - const u = db.prepare('SELECT name FROM users WHERE id = ?').get(userId); - pushParentNotif(userId, 'achievement', `${u?.name || 'Ученик'} получил достижение: ${ach.title}`); - } catch (e) { console.error('[achievement]', e.message); } -} - -/* ── Achievement check engine ─────────────────────────────────────────── */ - -function checkAchievements(userId) { - // Single query: user fields + session counts in one round-trip - const row = stmts.getUserForAch.get(userId); - if (!row) return; - - const { test_count: testCount, perfect_count: perfectCount, class_count: classCount } = row; - - // Tests - if (testCount >= 1) unlockAchievement(userId, 'first_test'); - if (testCount >= 10) unlockAchievement(userId, 'tests_10'); - if (testCount >= 50) unlockAchievement(userId, 'tests_50'); - if (testCount >= 100) unlockAchievement(userId, 'tests_100'); - - // Perfect score - if (perfectCount >= 1) unlockAchievement(userId, 'first_perfect'); - - // Streaks - const streak = row.streak_current || 0; - if (streak >= 3) unlockAchievement(userId, 'streak_3'); - if (streak >= 7) unlockAchievement(userId, 'streak_7'); - if (streak >= 30) unlockAchievement(userId, 'streak_30'); - - // Level (always derive from XP) - const level = xpToLevel(row.xp || 0); - if (level >= 3) unlockAchievement(userId, 'level_3'); - if (level >= 5) unlockAchievement(userId, 'level_5'); - if (level >= 10) unlockAchievement(userId, 'level_10'); - if (level >= 20) unlockAchievement(userId, 'level_20'); - - // XP - const xp = row.xp || 0; - if (xp >= 1000) unlockAchievement(userId, 'xp_1000'); - if (xp >= 5000) unlockAchievement(userId, 'xp_5000'); - if (xp >= 10000) unlockAchievement(userId, 'xp_10000'); - - // Class membership - if (classCount >= 1) unlockAchievement(userId, 'first_class'); - - // 5 tests in a row with ≥90% - const last5 = stmts.getLast5Tests.all(userId); - if (last5.length >= 5 && last5.every(r => r.total > 0 && (r.score / r.total) >= 0.9)) { - unlockAchievement(userId, 'score_90'); - } - - // Lab - const labExp = row.lab_experiments || 0; - const labReact = row.lab_reactions || 0; - if (labExp >= 1) unlockAchievement(userId, 'lab_first'); - if (labExp >= 5) unlockAchievement(userId, 'lab_5'); - if (labExp >= 20) unlockAchievement(userId, 'lab_20'); - if (labExp >= 50) unlockAchievement(userId, 'lab_50'); - if (labReact >= 10) unlockAchievement(userId, 'lab_reactions_10'); - if (labReact >= 30) unlockAchievement(userId, 'lab_reactions_30'); - - // Assignments (via assignment_sessions) - try { - const ac = stmts.countUserAssignments.get(userId); - const assignCount = ac?.n || 0; - if (assignCount >= 1) unlockAchievement(userId, 'assign_first'); - if (assignCount >= 10) unlockAchievement(userId, 'assign_10'); - } catch (e) { console.error('[achievements] assignment check:', e.message); } -} - -/* ── Hook: Red Book species collected / sighting added ─────────────────── */ - -function checkRedBookAchievements(userId) { - try { - const collected = db.prepare('SELECT COUNT(*) as n FROM rb_user_collection WHERE user_id = ?').get(userId)?.n || 0; - if (collected >= 1) unlockAchievement(userId, 'rb_first'); - if (collected >= 10) unlockAchievement(userId, 'rb_10'); - if (collected >= 25) unlockAchievement(userId, 'rb_25'); - if (collected >= 50) unlockAchievement(userId, 'rb_50'); - - const crTotal = db.prepare("SELECT COUNT(*) as n FROM rb_species WHERE category = 'CR'").get().n; - const crCollected = db.prepare(` - SELECT COUNT(*) as n FROM rb_user_collection uc - JOIN rb_species s ON s.id = uc.species_id - WHERE uc.user_id = ? AND s.category = 'CR' - `).get(userId)?.n || 0; - if (crTotal > 0 && crCollected >= crTotal) unlockAchievement(userId, 'rb_all_cr'); - - const quests = db.prepare("SELECT COUNT(*) as n FROM rb_user_quests WHERE user_id = ? AND status = 'completed'").get(userId)?.n || 0; - if (quests >= 1) unlockAchievement(userId, 'rb_quest_first'); - if (quests >= 5) unlockAchievement(userId, 'rb_quest_5'); - - const sightings = db.prepare('SELECT COUNT(*) as n FROM rb_sightings WHERE user_id = ?').get(userId)?.n || 0; - if (sightings >= 1) unlockAchievement(userId, 'rb_sighting'); - - checkAchievements(userId); // also check level/xp milestones - } catch (e) { console.error('[checkRedBookAchievements]', e.message); } -} - -/* ── Hook: called after lesson marked complete ──────────────────────────── */ - -function onLessonComplete(userId, courseId) { - try { - awardXP(userId, 30, 'lesson_complete'); - const done = db.prepare('SELECT COUNT(*) as n FROM lesson_progress WHERE user_id = ? AND completed = 1').get(userId)?.n || 0; - if (done >= 1) unlockAchievement(userId, 'theory_first'); - if (done >= 10) unlockAchievement(userId, 'theory_10'); - if (courseId) { - const total = db.prepare('SELECT COUNT(*) as n FROM lessons WHERE course_id = ? AND is_published = 1').get(courseId)?.n || 0; - const courseDone = db.prepare(` - SELECT COUNT(*) as n FROM lesson_progress lp - JOIN lessons l ON lp.lesson_id = l.id - WHERE l.course_id = ? AND lp.user_id = ? AND lp.completed = 1 - `).get(courseId, userId)?.n || 0; - if (total > 0 && courseDone >= total) unlockAchievement(userId, 'theory_course'); - } - checkAchievements(userId); - } catch (e) { console.error('[onLessonComplete]', e.message); } -} - -/* ── Hook: called after test finishes ─────────────────────────────────── */ - -function onTestFinished(userId, score, total, timeSec, testTimeLimitSec) { - const pct = total > 0 ? score / total : 0; - - // XP for correct answers - awardXP(userId, score * 10, 'correct_answers'); - - // XP for completing test - awardXP(userId, 50, 'test_complete'); - - // Bonus for ≥90% - if (pct >= 0.9) awardXP(userId, 100, 'test_90+'); - - // Bonus for 100% - if (pct >= 1.0) awardXP(userId, 200, 'test_perfect'); - - // Bonus for assignment on time (handled in sessionController after checking deadline) - - // Ecstatic mood bonus: +10% of base XP when pet streak >= 7 - try { - const streakRow = stmts.getStreak.get(userId); - if (streakRow && (streakRow.streak_current || 0) >= 7) { - const moodBonus = Math.round(score * 10 * 0.10); - if (moodBonus > 0) awardXP(userId, moodBonus, 'mood_ecstatic'); - } - } catch (e) { console.error('[onTestFinished] mood bonus:', e.message); } - - // Speed demon check - if (testTimeLimitSec && timeSec < testTimeLimitSec * 0.5 && pct >= 0.9) { - unlockAchievement(userId, 'speed_demon'); - } - - // Update streak - updateStreak(userId); - - // Check all achievements - checkAchievements(userId); -} - -/* ── Hook: called when student joins class ────────────────────────────── */ - -function onClassJoined(userId) { - checkAchievements(userId); -} - -/* ── Hook: called when student performs a lab experiment ───────────────── */ - -function onLabExperiment(userId, reactionsDiscovered) { - // reactionsDiscovered — number of unique reactions found in this session - stmts.incrLabExp.run(userId); - if (reactionsDiscovered > 0) { - stmts.incrLabReact.run(reactionsDiscovered, userId); - } - awardXP(userId, 15, 'lab_experiment'); - checkAchievements(userId); -} - -/* ── Daily goals ──────────────────────────────────────────────────────── */ - -const GOAL_TIERS = { - easy: { tests: 2, xp: 100, bonus: 30, label: 'Лёгкая' }, - medium: { tests: 3, xp: 200, bonus: 50, label: 'Средняя' }, - hard: { tests: 5, xp: 500, bonus: 100, label: 'Тяжёлая' }, -}; - -function getDailyGoal(userId) { - const today = new Date().toISOString().slice(0, 10); - let goal = stmts.getDailyGoal.get(userId, today); - if (!goal) { - // Check user's preferred tier - const pref = stmts.getUserGoalTier.get(userId); - const tier = GOAL_TIERS[(pref && pref.goal_tier) || 'medium'] || GOAL_TIERS.medium; - stmts.insertDailyGoal.run(userId, today, tier.tests, tier.xp); - goal = stmts.getDailyGoal.get(userId, today); - } - return goal; -} - -function updateDailyGoal(userId, addTests, addXp) { - const today = new Date().toISOString().slice(0, 10); - getDailyGoal(userId); // ensure exists - stmts.incrDailyGoal.run(addTests || 0, addXp || 0, userId, today); - - // Check if goal completed - const goal = stmts.getDailyGoal.get(userId, today); - if (goal && goal.tests_done >= goal.tests_target && goal.xp_earned >= goal.xp_target) { - // Check if already awarded bonus today - const already = stmts.checkGoalBonus.get(userId, today); - if (!already) { - const pref = stmts.getUserGoalTier.get(userId); - const tier = GOAL_TIERS[(pref && pref.goal_tier) || 'medium'] || GOAL_TIERS.medium; - awardXP(userId, tier.bonus, 'daily_goal'); - try { - sse.emit(userId, { type: 'daily_goal', message: `Дневная цель выполнена! +${tier.bonus} XP`, icon: 'target' }); - } catch (e) { console.error('[daily_goal]', e.message); } - } - } -} - -/* ── Personal Challenges ──────────────────────────────────────────────── */ - -function _currentWeek() { - const d = new Date(); - const day = d.getDay(); - const mon = new Date(d); - mon.setDate(mon.getDate() - ((day + 6) % 7)); - return mon.toISOString().slice(0, 10); -} - -function ensureChallenges(userId) { - const week = _currentWeek(); - const existing = db.prepare('SELECT COUNT(*) AS cnt FROM challenges WHERE user_id = ? AND week = ?').get(userId, week); - if (existing.cnt > 0) return; - - // Auto-generate 3 challenges based on weak topics + general goals - const weakTopics = db.prepare(` - SELECT t.id AS topic_id, t.name, s.slug AS subject_slug, s.name AS subject_name, - COUNT(CASE WHEN ua.is_correct = 0 THEN 1 END) AS wrong, - COUNT(*) AS total - FROM user_answers ua - JOIN session_questions sq ON sq.session_id = ua.session_id AND sq.question_id = ua.question_id - JOIN questions q ON q.id = ua.question_id - JOIN topics t ON t.id = q.topic_id - JOIN subjects s ON s.id = t.subject_id - JOIN test_sessions ts ON ts.id = ua.session_id AND ts.user_id = ? - GROUP BY t.id - HAVING wrong > 0 - ORDER BY CAST(wrong AS REAL) / total DESC - LIMIT 5 - `).all(userId); - - const challenges = []; - - // Challenge 1: Weak topic practice (if available) - if (weakTopics.length > 0) { - const wt = weakTopics[0]; - challenges.push({ - title: `Подтяни «${wt.name}»`, - description: `Пройди 3 теста по теме «${wt.name}» (${wt.subject_name})`, - type: 'topic_tests', - target: 3, - xp_reward: 150, - subject_slug: wt.subject_slug, - topic_id: wt.topic_id, - }); - } - - // Challenge 2: Score challenge - challenges.push({ - title: 'Набери 80%+', - description: 'Заверши 3 теста с результатом не ниже 80%', - type: 'high_score', - target: 3, - xp_reward: 120, - subject_slug: null, - topic_id: null, - }); - - // Challenge 3: Volume challenge - challenges.push({ - title: 'Марафонец', - description: 'Пройди 5 тестов на этой неделе', - type: 'tests', - target: 5, - xp_reward: 100, - subject_slug: null, - topic_id: null, - }); - - // Challenge 4: Streak challenge (if no weak topics) - if (weakTopics.length < 2) { - challenges.push({ - title: 'Без ошибок', - description: 'Набери 100% в любом тесте', - type: 'perfect', - target: 1, - xp_reward: 200, - subject_slug: null, - topic_id: null, - }); - } - - const ins = db.prepare(` - INSERT OR IGNORE INTO challenges (user_id, week, title, description, type, target, xp_reward, subject_slug, topic_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - for (const c of challenges) { - ins.run(userId, week, c.title, c.description, c.type, c.target, c.xp_reward, c.subject_slug, c.topic_id); - } -} - -function updateChallenges(userId, score, total, subjectSlug, topicId) { - const week = _currentWeek(); - const pct = total > 0 ? Math.round(score / total * 100) : 0; - const challenges = stmts.getOpenChallenges.all(userId, week); - - for (const c of challenges) { - let inc = 0; - switch (c.type) { - case 'tests': - inc = 1; - break; - case 'topic_tests': - if (topicId && c.topic_id === topicId) inc = 1; - else if (subjectSlug && c.subject_slug === subjectSlug) inc = 1; - break; - case 'high_score': - if (pct >= 80) inc = 1; - break; - case 'perfect': - if (pct >= 100) inc = 1; - break; - } - if (inc > 0) { - stmts.incrChallenge.run(inc, c.id); - const updated = stmts.getChallengeById.get(c.id); - if (updated && updated.progress >= updated.target) { - stmts.completeChallenge.run(c.id); - try { - sse.emit(userId, { type: 'challenge', message: `Испытание «${c.title}» выполнено!`, icon: 'target' }); - } catch (e) { console.error('[challenge]', e.message); } - } - } - } -} - -function getChallenges(req, res) { - ensureChallenges(req.user.id); - const week = _currentWeek(); - const rows = stmts.getChallengesWeek.all(req.user.id, week); - res.json(rows); -} - -function claimChallenge(req, res) { - const id = Number(req.params.id); - const c = stmts.getChallengeOwned.get(id, req.user.id); - if (!c) return res.status(404).json({ error: 'Challenge not found' }); - if (!c.completed) return res.status(400).json({ error: 'Challenge not completed yet' }); - if (c.claimed) return res.status(400).json({ error: 'Already claimed' }); - - stmts.markClaimed.run(id); - awardXP(req.user.id, c.xp_reward, `Испытание: ${c.title}`); - // Bonus coins for challenges - awardCoins(req.user.id, Math.floor(c.xp_reward / 5), `Испытание: ${c.title}`); - res.json({ xp: c.xp_reward }); -} - -/* ═══════════════════════════════════════════════════════════════════════ - API Handlers - ═══════════════════════════════════════════════════════════════════════ */ - -/* GET /api/gamification/me — current user XP, level, streak, goals */ -function getMe(req, res) { - const info = getXPInfo(req.user.id); - if (!info) return res.status(404).json({ error: 'User not found' }); - const goal = getDailyGoal(req.user.id); - const pref = stmts.getUserPrefs.get(req.user.id); - const tierKey = (pref && pref.goal_tier) || 'medium'; - const frameId = (pref && pref.avatar_frame) || 'default'; - const frame = AVATAR_FRAMES.find(f => f.id === frameId) || AVATAR_FRAMES[0]; - res.json({ ...info, dailyGoal: goal, goalTier: tierKey, goalTiers: GOAL_TIERS, avatarFrame: frame }); -} - -/* GET /api/gamification/frames — available avatar frames */ -function getFrames(req, res) { - const unlocked = stmts.getUnlockedSlugs.all(req.user.id).map(r => r.slug); - const user = stmts.getUserFrame.get(req.user.id); - const selected = (user && user.avatar_frame) || 'default'; - const frames = AVATAR_FRAMES.map(f => ({ - ...f, - unlocked: !f.unlock || unlocked.includes(f.unlock), - selected: f.id === selected, - })); - res.json({ frames, selected }); -} - -/* POST /api/gamification/frame — set avatar frame */ -function setFrame(req, res) { - const { frame } = req.body; - const f = AVATAR_FRAMES.find(fr => fr.id === frame); - if (!f) return res.status(400).json({ error: 'Unknown frame' }); - if (f.unlock) { - const ach = stmts.checkFrameUnlock.get(f.unlock, req.user.id); - if (!ach) return res.status(403).json({ error: 'Frame not unlocked' }); - } - stmts.setUserFrame.run(frame, req.user.id); - res.json({ frame, css: f.css }); -} - -/* POST /api/gamification/goal-tier — set daily goal difficulty */ -function setGoalTier(req, res) { - const { tier } = req.body; - if (!GOAL_TIERS[tier]) return res.status(400).json({ error: 'Invalid tier. Use: easy, medium, hard' }); - stmts.setUserGoalTier.run(tier, req.user.id); - res.json({ tier, ...GOAL_TIERS[tier] }); -} - -/* GET /api/gamification/achievements — all achievements + user unlocks */ -function getAchievements(req, res) { - const all = stmts.getAllAchs.all(); - const unlocked = stmts.getUserAchs.all(req.user.id); - const unlockedMap = {}; - for (const u of unlocked) unlockedMap[u.achievement_id] = u.unlocked_at; - const result = all.map(a => ({ ...a, unlocked: !!unlockedMap[a.id], unlocked_at: unlockedMap[a.id] || null })); - res.json(result); -} - -/* GET /api/gamification/leaderboard?class_id=X&period=week|all */ -function getLeaderboard(req, res) { - const classId = req.query.class_id ? Number(req.query.class_id) : null; - const period = req.query.period || 'all'; - - let rows; - if (period === 'week') { - const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString(); - if (classId) { - rows = db.prepare(` - SELECT u.id, u.name, COALESCE(SUM(xl.amount), 0) AS week_xp, u.xp, u.level, - u.streak_current AS streak - FROM users u - JOIN class_members cm ON cm.user_id = u.id AND cm.class_id = ? - LEFT JOIN xp_log xl ON xl.user_id = u.id AND xl.created_at >= ? - WHERE u.role = 'student' - GROUP BY u.id - ORDER BY week_xp DESC - LIMIT 30 - `).all(classId, weekAgo); - } else { - rows = db.prepare(` - SELECT u.id, u.name, COALESCE(SUM(xl.amount), 0) AS week_xp, u.xp, u.level, - u.streak_current AS streak - FROM users u - LEFT JOIN xp_log xl ON xl.user_id = u.id AND xl.created_at >= ? - WHERE u.role = 'student' - GROUP BY u.id - ORDER BY week_xp DESC - LIMIT 30 - `).all(weekAgo); - } - rows.forEach((r, i) => { r.position = i + 1; r.sort_xp = r.week_xp; }); - } else { - if (classId) { - rows = db.prepare(` - SELECT u.id, u.name, u.xp, u.level, u.streak_current AS streak - FROM users u - JOIN class_members cm ON cm.user_id = u.id AND cm.class_id = ? - WHERE u.role = 'student' - ORDER BY u.xp DESC - LIMIT 30 - `).all(classId); - } else { - rows = db.prepare(` - SELECT u.id, u.name, u.xp, u.level, u.streak_current AS streak - FROM users u - WHERE u.role = 'student' - ORDER BY u.xp DESC - LIMIT 30 - `).all(); - } - rows.forEach((r, i) => { r.position = i + 1; r.sort_xp = r.xp; }); - } - - // add rank names - rows.forEach(r => { r.rank = rankName(r.level || 1); }); - - res.json({ rows, period, class_id: classId }); -} - -/* GET /api/gamification/xp-history — recent XP log */ -function getXPHistory(req, res) { - const limit = Math.min(50, Number(req.query.limit) || 20); - const rows = stmts.xpHistory.all(req.user.id, limit); - res.json(rows); -} - -/* ═══════════════════════════════════════════════════════════════════════ - Admin — XP/coins management, stats, reset - ═══════════════════════════════════════════════════════════════════════ */ - -/* POST /api/gamification/admin/award — award XP or coins to user */ -const ADMIN_AWARD_MAX = 1_000_000; -function adminAward(req, res) { - const { userId, xp, coins, reason } = req.body; - if (!userId) return res.status(400).json({ error: 'userId required' }); - const xpNum = Number(xp) || 0; - const coinsNum = Number(coins) || 0; - if (xpNum < 0 || xpNum > ADMIN_AWARD_MAX) return res.status(400).json({ error: `xp must be 0..${ADMIN_AWARD_MAX}` }); - if (coinsNum < 0 || coinsNum > ADMIN_AWARD_MAX) return res.status(400).json({ error: `coins must be 0..${ADMIN_AWARD_MAX}` }); - const user = stmts.checkUserById.get(userId); - if (!user) return res.status(404).json({ error: 'User not found' }); - if (xpNum > 0) awardXP(userId, xpNum, reason || 'Admin award'); - if (coinsNum > 0) awardCoins(userId, coinsNum, reason || 'Admin award'); - const updated = stmts.getUserGamInfo.get(userId); - res.json({ ok: true, ...updated }); -} - -/* POST /api/gamification/admin/reset — reset user gamification */ -const _resetTx = db.transaction((userId) => { - stmts.adminResetUser.run(userId); - stmts.deleteXpLog.run(userId); - stmts.deleteUserAchs.run(userId); - stmts.deleteDailyGoals.run(userId); - stmts.deleteChallenges.run(userId); - stmts.deleteUserPurch.run(userId); -}); - -function adminReset(req, res) { - const { userId } = req.body; - if (!userId) return res.status(400).json({ error: 'userId required' }); - _resetTx(userId); - res.json({ ok: true }); -} - -/* GET /api/gamification/admin/stats — global gamification stats */ -function adminGamStats(_req, res) { - const totalXP = stmts.adminTotalXP.get().v; - const totalCoins = stmts.adminTotalCoins.get().v; - const avgLevel = stmts.adminAvgLevel.get().v; - const achievementCount = stmts.adminAchCount.get().v; - const topByXP = stmts.adminTopXP.all(); - const recentXP = stmts.adminRecentXP.all(); - const totalPurchases = stmts.adminTotalPurch.get().v; - const recentPurchases = stmts.adminRecentPurch.all(); - res.json({ totalXP, totalCoins, avgLevel, achievementCount, totalPurchases, topByXP, recentXP, recentPurchases }); -} - -/* GET /api/gamification/admin/user/:id — user gamification details */ -function adminGetUser(req, res) { - const uid = Number(req.params.id); - const user = stmts.adminGetUserFull.get(uid); - if (!user) return res.status(404).json({ error: 'User not found' }); - const achievements = stmts.adminGetUserAchs.all(uid); - const purchases = stmts.adminGetUserPurch.all(uid); - const xpHistory = stmts.adminGetUserXPH.all(uid); - res.json({ user, achievements, purchases, xpHistory }); -} +'use strict'; +/** + * Gamification — facade module. + * Implementation split into ./gamification/* domain files. + * Public surface (re-exports) is identical to the pre-split single file + * for backwards compatibility with existing route mounts and inter-controller + * imports (biochem, classes, games, lessons, pet, redBook, session, etc.). + */ + +const service = require('./gamification/service'); +const api = require('./gamification/api'); +const admin = require('./gamification/admin'); module.exports = { - // API handlers - getMe, getAchievements, getLeaderboard, getXPHistory, - getChallenges, claimChallenge, setGoalTier, - getFrames, setFrame, + // API handlers (mounted by routes/gamification.js) + ...api, // Admin handlers - adminAward, adminReset, adminGamStats, adminGetUser, + ...admin, // Service functions for other controllers - onTestFinished, onClassJoined, onLabExperiment, updateDailyGoal, updateChallenges, - onLessonComplete, checkRedBookAchievements, - awardXP, awardCoins, seedAchievements, checkAchievements, + awardXP: service.awardXP, + awardCoins: service.awardCoins, + seedAchievements: service.seedAchievements, + checkAchievements: service.checkAchievements, + checkRedBookAchievements: service.checkRedBookAchievements, + onTestFinished: service.onTestFinished, + onClassJoined: service.onClassJoined, + onLabExperiment: service.onLabExperiment, + onLessonComplete: service.onLessonComplete, + updateDailyGoal: service.updateDailyGoal, + updateChallenges: service.updateChallenges, };