feat(gamification): Phase 3 — 38 new achievements + triggers + 'exam' group
Adds achievement coverage for every feature shipped since the original
seed: exam-prep (math9), textbooks, classroom/board, biochemistry,
live-quiz, flashcards, hangman/crossword, pet, plus a new 'social' group
for class & leaderboard wins and 'consistency' extensions (streak_100,
goal_30, early_bird, night_owl).
74 achievements now (was 36), grouped into 7 sections:
onboarding (3) → volume (8) → mastery (16) → consistency (7) →
exam (9) → exploration (21) → social (10)
A new top-level group 'exam' slots between consistency and exploration
in the profile UI.
What's wired in service.checkPhase3Achievements (called from
checkAchievements):
• streak_100 — extends the existing streak track
• goal_30 — 30 days with daily_goals fully met (SUM check)
• early_bird / night_owl — strftime('%H', xp_log.created_at)
• exam_first / 25 / 100 — exam_attempts where is_correct=1
• exam_variant_clear / 5_variants — perfect mock-variant sessions
• exam_topic_master — ≥10 attempts at ≥90% on a single subtopic
• exam_mock_done / pass / perfect — exam_mock_sessions.score
• tb_first_para — textbook_progress
• fc_first_deck / 100_cards / 1000_cards — flashcard_reviews
• bc_first_molecule / 5_challenges / 20_challenges — bio_user_*
• game_win_5 / 25 — xp_log reason IN (hangman_win, crossword_win)
• pet_streak_7 / 30 — users.pet_petting_streak
• lq_first / 3_quizzes — live_answers grouped by session
• cr_first_join / 5 / 25_lessons — classroom_attendance
• class_5_members / 25 — teacher's biggest class
• parent_link — parent_links presence
• lb_top10 / lb_top1 — weekly XP rank among students
What's deferred (catalog entry only, no trigger yet):
• tb_chapter_done / tb_book_done / tb_3_books — need to parse
textbook_progress.paragraphs_read JSON against textbook structure
Every block is wrapped in its own try/catch so a missing table on a
legacy install can't take down the whole achievement sweep.
Verified end-to-end: admin user picked up 7 new unlocks on first
checkAchievements call after seed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -82,14 +82,65 @@ const ACHIEVEMENT_DEFS = [
|
||||
{ slug: 'rb_quest_first', title: 'Первый квест КК', icon: 'map', cat: 'redbook', group: 'exploration', track: 'red_book_quest', tier: 1, sort_order: 620, desc: 'Выполнить первый квест Красной книги' },
|
||||
{ slug: 'rb_quest_5', title: 'Квестмастер КК', icon: 'map-pin', cat: 'redbook', group: 'exploration', track: 'red_book_quest', tier: 2, sort_order: 630, desc: 'Выполнить 5 квестов Красной книги' },
|
||||
{ slug: 'rb_sighting', title: 'Наблюдатель', icon: 'eye', cat: 'redbook', group: 'exploration', track: 'red_book_obs', tier: 1, sort_order: 640, desc: 'Добавить первое наблюдение вида' },
|
||||
|
||||
// ── Phase 3 additions (migration 031) ──────────────────────────
|
||||
// Consistency extensions
|
||||
{ slug: 'streak_100', title: 'Сто дней подряд', icon: 'flame', cat: 'streak', group: 'consistency', track: 'streak', tier: 4, sort_order: 440, desc: 'Невероятная серия — 100 дней активности подряд' },
|
||||
{ slug: 'goal_30', title: 'Цель: 30 дней', icon: 'target', cat: 'streak', group: 'consistency', track: 'goal_streak', tier: 1, sort_order: 450, desc: '30 дней подряд выполнять цель дня' },
|
||||
{ slug: 'early_bird', title: 'Ранняя пташка', icon: 'sunrise', cat: 'streak', group: 'consistency', track: 'time', tier: 1, sort_order: 460, desc: 'Активность до 9 утра' },
|
||||
{ slug: 'night_owl', title: 'Ночная сова', icon: 'moon', cat: 'streak', group: 'consistency', track: 'time', tier: 2, sort_order: 470, desc: 'Активность после 23:00' },
|
||||
|
||||
// Exam-prep
|
||||
{ slug: 'exam_first', title: 'Старт экзамена', icon: 'flag', cat: 'exam', group: 'exam', track: 'exam_attempts', tier: 1, sort_order: 700, desc: 'Решена первая задача экзамена 9 класса' },
|
||||
{ slug: 'exam_25_attempts', title: '25 задач экзамена', icon: 'list-checks', cat: 'exam', group: 'exam', track: 'exam_attempts', tier: 2, sort_order: 710, desc: '25 решённых попыток в банке экзамена' },
|
||||
{ slug: 'exam_100_attempts', title: '100 задач экзамена', icon: 'check-square', cat: 'exam', group: 'exam', track: 'exam_attempts', tier: 3, sort_order: 720, desc: '100 решённых попыток в банке экзамена' },
|
||||
{ slug: 'exam_variant_clear',title: 'Чистый вариант', icon: 'crown', cat: 'exam', group: 'exam', track: 'variants', tier: 1, sort_order: 730, desc: 'Все 10 задач варианта решены верно' },
|
||||
{ slug: 'exam_5_variants', title: '5 вариантов', icon: 'layers', cat: 'exam', group: 'exam', track: 'variants', tier: 2, sort_order: 740, desc: '5 разных вариантов закрыто целиком' },
|
||||
{ slug: 'exam_topic_master', title: 'Мастер темы', icon: 'target', cat: 'exam', group: 'exam', track: 'topic', tier: 1, sort_order: 750, desc: '≥90% точности на одной из подтем (≥10 попыток)' },
|
||||
{ slug: 'exam_mock_done', title: 'Первый пробник', icon: 'timer', cat: 'exam', group: 'exam', track: 'mock', tier: 1, sort_order: 760, desc: 'Завершён первый пробный экзамен' },
|
||||
{ slug: 'exam_mock_pass', title: 'Пробник сдан', icon: 'badge-check', cat: 'exam', group: 'exam', track: 'mock', tier: 2, sort_order: 770, desc: 'Балл ≥ 7 на пробном экзамене' },
|
||||
{ slug: 'exam_mock_perfect', title: 'Пробник на 10', icon: 'trophy', cat: 'exam', group: 'exam', track: 'mock', tier: 3, sort_order: 780, desc: 'Идеальные 10 баллов на пробном экзамене' },
|
||||
|
||||
// Mastery extensions — textbooks + flashcards
|
||||
{ slug: 'tb_first_para', title: 'Открыл параграф', icon: 'book-marked', cat: 'theory', group: 'mastery', track: 'tb_progress', tier: 1, sort_order: 800, desc: 'Прочитан первый параграф учебника' },
|
||||
{ slug: 'tb_chapter_done', title: 'Глава закрыта', icon: 'book-check', cat: 'theory', group: 'mastery', track: 'tb_progress', tier: 2, sort_order: 810, desc: 'Все параграфы одной главы пройдены' },
|
||||
{ slug: 'tb_book_done', title: 'Учебник целиком', icon: 'book-up', cat: 'theory', group: 'mastery', track: 'tb_progress', tier: 3, sort_order: 820, desc: 'Все параграфы учебника пройдены' },
|
||||
{ slug: 'tb_3_books', title: 'Полка из трёх', icon: 'library-big', cat: 'theory', group: 'mastery', track: 'tb_progress', tier: 4, sort_order: 830, desc: '3 учебника закрыто полностью' },
|
||||
{ slug: 'fc_first_deck', title: 'Первая колода', icon: 'layers', cat: 'theory', group: 'mastery', track: 'flashcards', tier: 1, sort_order: 840, desc: 'Все карточки одной колоды разок повторены' },
|
||||
{ slug: 'fc_100_cards', title: '100 карточек', icon: 'rotate-cw', cat: 'theory', group: 'mastery', track: 'flashcards', tier: 2, sort_order: 850, desc: '100 повторов карточек в сумме' },
|
||||
{ slug: 'fc_1000_cards', title: '1000 карточек', icon: 'sparkles', cat: 'theory', group: 'mastery', track: 'flashcards', tier: 3, sort_order: 860, desc: '1000 повторов карточек в сумме' },
|
||||
|
||||
// Exploration extensions
|
||||
{ slug: 'bc_first_molecule', title: 'Первая молекула', icon: 'flask-round', cat: 'lab', group: 'exploration', track: 'biochem', tier: 1, sort_order: 870, desc: 'Собрана первая молекула в био-конструкторе' },
|
||||
{ slug: 'bc_5_challenges', title: '5 био-задач', icon: 'beaker', cat: 'lab', group: 'exploration', track: 'biochem', tier: 2, sort_order: 880, desc: '5 биохимических задач решено' },
|
||||
{ slug: 'bc_20_challenges', title: '20 био-задач', icon: 'atom', cat: 'lab', group: 'exploration', track: 'biochem', tier: 3, sort_order: 890, desc: '20 биохимических задач решено' },
|
||||
{ slug: 'game_win_5', title: '5 побед в играх', icon: 'gamepad-2', cat: 'volume',group: 'exploration', track: 'minigames', tier: 1, sort_order: 900, desc: '5 побед в Виселице или Кроссворде суммарно' },
|
||||
{ slug: 'game_win_25', title: '25 побед в играх',icon: 'gamepad', cat: 'volume',group: 'exploration', track: 'minigames', tier: 2, sort_order: 910, desc: '25 побед в Виселице или Кроссворде суммарно' },
|
||||
{ slug: 'pet_streak_7', title: 'Заботливый', icon: 'heart', cat: 'volume',group: 'exploration', track: 'pet', tier: 1, sort_order: 920, desc: '7 дней подряд гладить питомца' },
|
||||
{ slug: 'pet_streak_30', title: 'Лучший друг', icon: 'paw-print', cat: 'volume',group: 'exploration', track: 'pet', tier: 2, sort_order: 930, desc: '30 дней подряд гладить питомца' },
|
||||
|
||||
// Social
|
||||
{ slug: 'lb_top10', title: 'Топ-10 недели', icon: 'trending-up', cat: 'volume', group: 'social', track: 'leaderboard', tier: 1, sort_order: 1000, desc: 'Попасть в топ-10 лидерборда недели' },
|
||||
{ slug: 'lb_top1', title: 'Первое место', icon: 'medal', cat: 'volume', group: 'social', track: 'leaderboard', tier: 2, sort_order: 1010, desc: '№1 в лидерборде недели' },
|
||||
{ slug: 'lq_first', title: 'Первый live-quiz', icon: 'radio', cat: 'volume', group: 'social', track: 'live_quiz', tier: 1, sort_order: 1020, desc: 'Принять участие в первом live-квизе' },
|
||||
{ slug: 'lq_3_quizzes', title: '3 live-квиза', icon: 'megaphone', cat: 'volume', group: 'social', track: 'live_quiz', tier: 2, sort_order: 1030, desc: 'Принять участие в 3 live-квизах' },
|
||||
{ slug: 'cr_first_join', title: 'Первое подключение', icon: 'door-open', cat: 'volume', group: 'social', track: 'classroom', tier: 1, sort_order: 1040, desc: 'Первое подключение к уроку в classroom' },
|
||||
{ slug: 'cr_5_lessons', title: '5 уроков', icon: 'presentation', cat: 'volume', group: 'social', track: 'classroom', tier: 2, sort_order: 1050, desc: 'Посещено 5 уроков classroom' },
|
||||
{ slug: 'cr_25_lessons', title: '25 уроков', icon: 'school', cat: 'volume', group: 'social', track: 'classroom', tier: 3, sort_order: 1060, desc: 'Посещено 25 уроков classroom' },
|
||||
{ slug: 'class_5_members', title: 'Учитель: 5 учеников', icon: 'users', cat: 'start', group: 'social', track: 'teacher', tier: 1, sort_order: 1070, desc: 'Учитель: класс из ≥5 учеников' },
|
||||
{ slug: 'class_25_members', title: 'Учитель: 25 учеников',icon: 'users-2', cat: 'start', group: 'social', track: 'teacher', tier: 2, sort_order: 1080, desc: 'Учитель: класс из ≥25 учеников' },
|
||||
{ slug: 'parent_link', title: 'Связан с родителем', icon: 'user-plus', cat: 'start', group: 'social', track: 'family', tier: 1, sort_order: 1090, desc: 'Связь с родителем установлена' },
|
||||
];
|
||||
|
||||
/* Display metadata for top-level groups. Order = render order in UI. */
|
||||
/* Display metadata for top-level groups. Order = render order in UI.
|
||||
'exam' was added in migration 031 (Phase 3) — it slots between
|
||||
consistency and exploration in display order. */
|
||||
const ACHIEVEMENT_GROUPS = [
|
||||
{ slug: 'onboarding', title: 'Старт', icon: 'flag', sort_order: 100 },
|
||||
{ slug: 'volume', title: 'Объём', icon: 'bar-chart-2', sort_order: 200 },
|
||||
{ slug: 'mastery', title: 'Качество', icon: 'award', sort_order: 300 },
|
||||
{ slug: 'consistency', title: 'Постоянство', icon: 'flame', sort_order: 400 },
|
||||
{ slug: 'exam', title: 'Экзамен 9', icon: 'clipboard-list', sort_order: 450 },
|
||||
{ slug: 'exploration', title: 'Исследование', icon: 'compass', sort_order: 500 },
|
||||
{ slug: 'social', title: 'Социальное', icon: 'users', sort_order: 600 },
|
||||
];
|
||||
|
||||
@@ -191,6 +191,209 @@ function checkAchievements(userId) {
|
||||
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 */ }
|
||||
|
||||
// ── 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) {
|
||||
|
||||
Reference in New Issue
Block a user