'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, isGamificationEnabled, } = 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; if (!isGamificationEnabled()) return; // master kill-switch stmts.incrCoins.run(amount, userId); // Always record into coin_log for traceability + the upcoming // "coin history" UI. Old callers that omitted reason fall back to // the generic 'xp_bonus' tag (since awardXP forwards its own reason). try { db.prepare('INSERT INTO coin_log (user_id, amount, reason) VALUES (?, ?, ?)') .run(userId, amount, reason || 'xp_bonus'); } catch { /* coin_log table missing — pre-032 install */ } } /* awardCoinsOnce — same as awardCoins but only fires if no row with the given reason already exists in the dedup window. window = 'day' → only once per UTC calendar day 'week' → only once per ISO week (Mon-Sun) 'forever' → only once ever Returns true if the bonus was awarded, false if it was deduped. */ function awardCoinsOnce(userId, amount, reason, window = 'day') { if (!amount || amount <= 0) return false; if (!isGamificationEnabled()) return false; try { let whereClause; if (window === 'forever') { whereClause = ''; } else if (window === 'week') { // ISO week — same %Y-%W as sqlite's strftime. whereClause = "AND strftime('%Y-%W', created_at) = strftime('%Y-%W', 'now')"; } else { // 'day' whereClause = "AND DATE(created_at) = DATE('now')"; } const exists = db.prepare(` SELECT 1 FROM coin_log WHERE user_id = ? AND reason = ? ${whereClause} LIMIT 1 `).get(userId, reason); if (exists) return false; } catch { /* table missing — proceed without dedup so coins still arrive */ } awardCoins(userId, amount, reason); return true; } /* ── XP ────────────────────────────────────────────────────────────── */ function awardXP(userId, amount, reason) { if (!amount || amount <= 0) return; if (!isGamificationEnabled()) return; // master kill-switch 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) { if (!isGamificationEnabled()) return; 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'); // Daily-login coin — once per day, on top of the streak XP. awardCoinsOnce(userId, 10, 'daily_login', 'day'); return newStreak; } /* ── Achievements ──────────────────────────────────────────────────── */ /* Derive the required_feature flag for an achievement when its def doesn't spell one out explicitly. Centralizes the mapping (track + group → feature name) so future inserts in ACHIEVEMENT_DEFS inherit the gating automatically — mirrors migration 034's backfill. */ function _requiredFeatureFor(a) { if (a.required_feature !== undefined) return a.required_feature; // explicit wins if (a.group === 'exam') return 'exam9'; if (a.track === 'red_book' || a.track === 'red_book_quest' || a.track === 'red_book_obs') return 'red_book'; if (a.track === 'biochem') return 'biochem'; if (a.track === 'lab' || a.track === 'lab_reactions') return 'lab'; if (a.track === 'classroom' || a.track === 'teacher') return 'classroom'; if (a.track === 'live_quiz') return 'live_quiz'; if (a.track === 'flashcards') return 'flashcards'; if (a.track === 'pet') return 'pet'; if (a.track === 'tb_progress') return 'textbooks'; return null; } function seedAchievements() { // INSERT for missing rows — supplies legacy + new taxonomy fields. const ins = db.prepare(` INSERT OR IGNORE INTO achievements (slug, title, icon, category, description, group_slug, track, tier, sort_order, required_feature) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); // UPDATE for existing rows — keep title/icon/desc/category fresh AND // backfill the taxonomy columns added by migration 030 (so installs // that ran the SEED before the migration get fixed on next boot). const upd = db.prepare(` UPDATE achievements SET icon = ?, category = ?, title = ?, description= ?, group_slug = COALESCE(?, group_slug), track = COALESCE(?, track), tier = COALESCE(?, tier), sort_order = ?, required_feature = COALESCE(?, required_feature) WHERE slug = ? `); for (const a of ACHIEVEMENT_DEFS) { const reqFeat = _requiredFeatureFor(a); ins.run(a.slug, a.title, a.icon, a.cat, a.desc, a.group ?? null, a.track ?? null, a.tier ?? null, a.sort_order ?? 0, reqFeat); upd.run(a.icon, a.cat, a.title, a.desc, a.group ?? null, a.track ?? null, a.tier ?? null, a.sort_order ?? 0, reqFeat, a.slug); } } 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) { if (!isGamificationEnabled()) return false; 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) { if (!isGamificationEnabled()) return; 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); } // Phase 3 — extended triggers from feature-specific tables checkPhase3Achievements(userId, row); } /* Phase 3 triggers — pulls counts from feature-specific tables. Each block is wrapped in its own try/catch so a missing table on an older install can't take down the whole sweep. */ function checkPhase3Achievements(userId, userRow) { // ── streak_100 + extended streaks ───────────────────────────── const streak = userRow.streak_current || 0; if (streak >= 100) unlockAchievement(userId, 'streak_100'); // ── goal_30 — 30 days of fully-met daily goals ──────────────── try { const n = db.prepare(` SELECT COUNT(*) AS n FROM daily_goals WHERE user_id = ? AND tests_target > 0 AND tests_done >= tests_target AND xp_target > 0 AND xp_earned >= xp_target `).get(userId)?.n || 0; if (n >= 30) unlockAchievement(userId, 'goal_30'); } catch (e) { /* table missing on legacy install */ } // ── early_bird / night_owl — first activity at hour 0-8 / 23 ─ try { const eb = db.prepare(` SELECT 1 FROM xp_log WHERE user_id = ? AND CAST(strftime('%H', created_at) AS INTEGER) < 9 LIMIT 1 `).get(userId); if (eb) unlockAchievement(userId, 'early_bird'); const no = db.prepare(` SELECT 1 FROM xp_log WHERE user_id = ? AND CAST(strftime('%H', created_at) AS INTEGER) >= 23 LIMIT 1 `).get(userId); if (no) unlockAchievement(userId, 'night_owl'); } catch (e) { /* xp_log missing — shouldn't happen */ } // ── exam-prep attempts ──────────────────────────────────────── try { const examCorrect = db.prepare(` SELECT COUNT(*) AS n FROM exam_attempts WHERE user_id = ? AND is_correct = 1 `).get(userId)?.n || 0; if (examCorrect >= 1) unlockAchievement(userId, 'exam_first'); if (examCorrect >= 25) unlockAchievement(userId, 'exam_25_attempts'); if (examCorrect >= 100) unlockAchievement(userId, 'exam_100_attempts'); // ── exam_variant_clear / exam_5_variants — perfect mock variants const cleared = db.prepare(` SELECT COUNT(DISTINCT variant) AS n FROM exam_mock_sessions WHERE user_id = ? AND source = 'variant' AND status = 'finished' AND total_tasks > 0 AND total_correct = total_tasks `).get(userId)?.n || 0; if (cleared >= 1) unlockAchievement(userId, 'exam_variant_clear'); if (cleared >= 5) unlockAchievement(userId, 'exam_5_variants'); // ── exam_topic_master — any subtopic with ≥10 attempts at ≥90% acc. const master = db.prepare(` SELECT 1 FROM ( SELECT t.subtopic, COUNT(*) AS attempts, SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END) AS correct FROM exam_attempts a JOIN exam_tasks t ON t.id = a.exam_task_id WHERE a.user_id = ? AND a.is_correct IS NOT NULL AND t.subtopic IS NOT NULL GROUP BY t.subtopic HAVING attempts >= 10 AND (CAST(correct AS REAL) / attempts) >= 0.9 ) LIMIT 1 `).get(userId); if (master) unlockAchievement(userId, 'exam_topic_master'); // ── mock sessions: done / pass / perfect const mockRow = db.prepare(` SELECT COUNT(*) AS done, COALESCE(MAX(score), 0) AS best FROM exam_mock_sessions WHERE user_id = ? AND status = 'finished' `).get(userId); if (mockRow.done >= 1) unlockAchievement(userId, 'exam_mock_done'); if (mockRow.best >= 7) unlockAchievement(userId, 'exam_mock_pass'); if (mockRow.best >= 10) unlockAchievement(userId, 'exam_mock_perfect'); } catch (e) { console.error('[ach] exam:', e.message); } // ── textbook progress: tb_first_para only (chapter/book aggregates // require parsing paragraphs_read JSON — left for a later phase). */ try { const tb = db.prepare(` SELECT 1 FROM textbook_progress WHERE user_id = ? LIMIT 1 `).get(userId); if (tb) unlockAchievement(userId, 'tb_first_para'); } catch (e) { /* table missing */ } // ── biochem: решённые задачи + первая собранная молекула ─────── try { const bc = db.prepare(` SELECT COUNT(*) AS n FROM bio_user_challenges WHERE user_id = ? `).get(userId)?.n || 0; if (bc >= 5) unlockAchievement(userId, 'bc_5_challenges'); if (bc >= 20) unlockAchievement(userId, 'bc_20_challenges'); const mol = db.prepare(` SELECT 1 FROM bio_user_molecules WHERE user_id = ? LIMIT 1 `).get(userId); if (mol) unlockAchievement(userId, 'bc_first_molecule'); } catch (e) { /* bio tables missing on legacy install */ } // ── flashcards: total reviews ────────────────────────────────── try { const fc = db.prepare(` SELECT COUNT(*) AS reviews, COUNT(DISTINCT card_id) AS unique_cards FROM flashcard_reviews WHERE user_id = ? `).get(userId); if (fc.reviews >= 1) unlockAchievement(userId, 'fc_first_deck'); if (fc.reviews >= 100) unlockAchievement(userId, 'fc_100_cards'); if (fc.reviews >= 1000) unlockAchievement(userId, 'fc_1000_cards'); } catch (e) { /* table missing */ } // ── biochem ─────────────────────────────────────────────────── try { const mol = db.prepare('SELECT COUNT(*) AS n FROM bio_user_molecules WHERE user_id = ?').get(userId)?.n || 0; if (mol >= 1) unlockAchievement(userId, 'bc_first_molecule'); const chal = db.prepare('SELECT COUNT(*) AS n FROM bio_user_challenges WHERE user_id = ?').get(userId)?.n || 0; if (chal >= 5) unlockAchievement(userId, 'bc_5_challenges'); if (chal >= 20) unlockAchievement(userId, 'bc_20_challenges'); } catch (e) { /* tables missing */ } // ── minigame wins via xp_log reasons ────────────────────────── try { const wins = db.prepare(` SELECT COUNT(*) AS n FROM xp_log WHERE user_id = ? AND reason IN ('hangman_win', 'crossword_win') `).get(userId)?.n || 0; if (wins >= 5) unlockAchievement(userId, 'game_win_5'); if (wins >= 25) unlockAchievement(userId, 'game_win_25'); } catch (e) { /* xp_log missing */ } // ── pet petting streak ──────────────────────────────────────── try { const ps = db.prepare('SELECT pet_petting_streak AS s FROM users WHERE id = ?').get(userId)?.s || 0; if (ps >= 7) unlockAchievement(userId, 'pet_streak_7'); if (ps >= 30) unlockAchievement(userId, 'pet_streak_30'); } catch (e) { /* column missing on very old install */ } // ── live-quiz participation (count distinct sessions answered) ─ try { const lq = db.prepare(` SELECT COUNT(DISTINCT session_id) AS n FROM live_answers WHERE user_id = ? `).get(userId)?.n || 0; if (lq >= 1) unlockAchievement(userId, 'lq_first'); if (lq >= 3) unlockAchievement(userId, 'lq_3_quizzes'); } catch (e) { /* tables missing */ } // ── classroom attendance ────────────────────────────────────── try { const cr = db.prepare(` SELECT COUNT(DISTINCT session_id) AS n FROM classroom_attendance WHERE user_id = ? `).get(userId)?.n || 0; if (cr >= 1) unlockAchievement(userId, 'cr_first_join'); if (cr >= 5) unlockAchievement(userId, 'cr_5_lessons'); if (cr >= 25) unlockAchievement(userId, 'cr_25_lessons'); } catch (e) { /* table missing */ } // ── teacher: largest class membership ───────────────────────── try { const cm = db.prepare(` SELECT COALESCE(MAX(member_count), 0) AS n FROM ( SELECT c.id, COUNT(cm.user_id) AS member_count FROM classes c LEFT JOIN class_members cm ON cm.class_id = c.id WHERE c.teacher_id = ? GROUP BY c.id ) `).get(userId)?.n || 0; if (cm >= 5) unlockAchievement(userId, 'class_5_members'); if (cm >= 25) unlockAchievement(userId, 'class_25_members'); } catch (e) { /* table missing */ } // ── parent link ─────────────────────────────────────────────── try { const pl = db.prepare('SELECT 1 FROM parent_links WHERE student_id = ? LIMIT 1').get(userId); if (pl) unlockAchievement(userId, 'parent_link'); } catch (e) { /* table missing */ } // ── leaderboard placement (weekly XP) ───────────────────────── // Compute on the fly: rank user by week XP among all students. try { const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString(); const rank = db.prepare(` SELECT (1 + ( SELECT COUNT(*) FROM ( SELECT u2.id, COALESCE(SUM(xl2.amount), 0) AS wxp FROM users u2 LEFT JOIN xp_log xl2 ON xl2.user_id = u2.id AND xl2.created_at >= ? WHERE u2.role = 'student' AND u2.id != ? GROUP BY u2.id HAVING wxp > ( SELECT COALESCE(SUM(amount), 0) FROM xp_log WHERE user_id = ? AND created_at >= ? ) ) )) AS r `).get(weekAgo, userId, userId, weekAgo)?.r; // Only credit if the user actually contributed XP this week. const myXp = db.prepare(` SELECT COALESCE(SUM(amount), 0) AS s FROM xp_log WHERE user_id = ? AND created_at >= ? `).get(userId, weekAgo)?.s || 0; if (myXp > 0) { if (rank <= 10) unlockAchievement(userId, 'lb_top10'); if (rank === 1) unlockAchievement(userId, 'lb_top1'); } } catch (e) { console.error('[ach] leaderboard:', 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) { if (!isGamificationEnabled()) return; // master kill-switch 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 tierKey = (pref && pref.goal_tier) || 'medium'; const tier = GOAL_TIERS[tierKey] || GOAL_TIERS.medium; awardXP(userId, tier.bonus, 'daily_goal'); // Standalone coin bonus on top of XP: easy=15, medium=25, hard=40. // Once-per-day dedup is implicit (checkGoalBonus already guards XP). const coinByTier = { easy: 15, medium: 25, hard: 40 }; awardCoinsOnce(userId, coinByTier[tierKey] || 25, 'daily_goal', 'day'); 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) { if (!isGamificationEnabled()) return; // master kill-switch 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, awardCoinsOnce, getXPInfo, updateStreak, // Achievements seedAchievements, unlockAchievement, checkAchievements, checkRedBookAchievements, pushAchievementNotif, // Hooks (for other controllers) onLessonComplete, onTestFinished, onClassJoined, onLabExperiment, // Daily goals getDailyGoal, updateDailyGoal, // Challenges _currentWeek, ensureChallenges, updateChallenges, };