refactor: split gamificationController.js (859L) → 5 файлов
По образцу classroom-split:
backend/src/controllers/gamificationController.js 859L → 31L (фасад)
backend/src/controllers/gamification/
_shared.js 194L — db, helpers (xpToLevel/levelMinXp/levelMaxXp/
rankName/RANKS), GOAL_TIERS, ACHIEVEMENT_DEFS,
AVATAR_FRAMES, stmts (все prepared statements)
service.js 393L — бизнес-логика: awardXP/awardCoins/getXPInfo/
updateStreak, seedAchievements/
unlockAchievement/pushAchievementNotif/
checkAchievements/checkRedBookAchievements,
hooks (onLessonComplete/onTestFinished/
onClassJoined/onLabExperiment),
daily (getDailyGoal/updateDailyGoal),
challenges (_currentWeek/ensureChallenges/
updateChallenges)
api.js 152L — HTTP handlers /api/gamification/*: getMe,
getFrames, setFrame, setGoalTier,
getAchievements, getLeaderboard, getXPHistory,
getChallenges, claimChallenge
admin.js 70L — /api/gamification/admin/*: adminAward,
adminReset, adminGamStats, adminGetUser
Фасад gamificationController.js перереэкспортирует ВСЕ 24 функции,
которые были в оригинале. Никаких изменений в:
- routes/* (импорты не менялись)
- biochemController, classController, gamesController,
lessonController, petController, redBookController,
sessionController, db/seed-permissions, db/legacy-migrate
(все 10+ внешних импортов 'gamificationController' работают)
Проверено: node --check OK, server restart, /api/gamification/*
возвращает 401 (auth req'd) — маршруты живые. Объект module.exports
содержит все 24 функции (тест: Object.keys чтения фасада).
Самый большой контроллер в проекте теперь хорошо структурирован:
любой разработчик мгновенно находит нужный кусок.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user