dc71d7b4d9
Аудит выключателя геймификации выявил элементы, НЕ покрытые body.no-gamification: испытания недели (#ch-section/.ch-widget), календарь стриков (.streak-cal), стат-кольцо стрика (#sr-streak), монеты в профиле (#p-coins-row), чипы стрик/цель на карточке питомца. Добавлены в CSS kill-switch (ls.css). Бэкенд: updateChallenges и onLabExperiment писали прогресс/счётчики без проверки флага — добавлен гейт isGamificationEnabled() (XP/coins/achievements уже гейтились в award*-функциях). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
695 lines
30 KiB
JavaScript
695 lines
30 KiB
JavaScript
'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,
|
|
};
|