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:
Maxim Dolgolyov
2026-05-29 20:26:59 +03:00
parent 90c8464356
commit b005226e2c
4 changed files with 324 additions and 1 deletions
@@ -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) {
@@ -0,0 +1,68 @@
-- ═══════════════════════════════════════════════════════════════
-- 031: Phase 3 — new achievement catalog
--
-- Adds 38 achievements covering features that landed since the original
-- 36 were seeded: exam-prep (exam9), textbooks, classroom/board,
-- biochemistry, live-quiz, flashcards, hangman/crossword, plus a new
-- 'social' group for class & leaderboard wins and a few stretch goals
-- in 'consistency' (streak_100, goal_30, early-bird / night-owl).
--
-- Inserts use INSERT OR IGNORE so re-runs are safe; backend
-- seedAchievements() will keep title/icon/desc/sort_order in sync on
-- every boot via UPDATE.
--
-- A new top-level group 'exam' slots between 'consistency' (400) and
-- 'exploration' (500). Group display metadata is hard-coded in both
-- backend (gamification/_shared.ACHIEVEMENT_GROUPS) and frontend
-- (profile.html ACH_GROUPS); keep them in sync.
-- ═══════════════════════════════════════════════════════════════
INSERT OR IGNORE INTO achievements
(slug, title, icon, category, description, group_slug, track, tier, sort_order)
VALUES
-- ── consistency extensions ─────────────────────────────────
('streak_100', 'Сто дней подряд', 'flame', 'streak', 'Невероятная серия — 100 дней активности подряд', 'consistency', 'streak', 4, 440),
('goal_30', 'Цель: 30 дней', 'target', 'streak', '30 дней подряд выполнять цель дня', 'consistency', 'goal_streak', 1, 450),
('early_bird', 'Ранняя пташка', 'sunrise', 'streak', 'Активность до 9 утра', 'consistency', 'time', 1, 460),
('night_owl', 'Ночная сова', 'moon', 'streak', 'Активность после 23:00', 'consistency', 'time', 2, 470),
-- ── exam-prep (math9) ──────────────────────────────────────
('exam_first', 'Старт экзамена', 'flag', 'exam', 'Решена первая задача экзамена 9 класса', 'exam', 'exam_attempts', 1, 700),
('exam_25_attempts', '25 задач экзамена', 'list-checks', 'exam', '25 решённых попыток в банке экзамена', 'exam', 'exam_attempts', 2, 710),
('exam_100_attempts', '100 задач экзамена', 'check-square', 'exam', '100 решённых попыток в банке экзамена', 'exam', 'exam_attempts', 3, 720),
('exam_variant_clear', 'Чистый вариант', 'crown', 'exam', 'Все 10 задач варианта решены верно', 'exam', 'variants', 1, 730),
('exam_5_variants', '5 вариантов', 'layers', 'exam', '5 разных вариантов закрыто целиком', 'exam', 'variants', 2, 740),
('exam_topic_master', 'Мастер темы', 'target', 'exam', '≥90% точности на одной из подтем (≥10 попыток)', 'exam', 'topic', 1, 750),
('exam_mock_done', 'Первый пробник', 'timer', 'exam', 'Завершён первый пробный экзамен', 'exam', 'mock', 1, 760),
('exam_mock_pass', 'Пробник сдан', 'badge-check', 'exam', 'Балл ≥ 7 на пробном экзамене', 'exam', 'mock', 2, 770),
('exam_mock_perfect', 'Пробник на 10', 'trophy', 'exam', 'Идеальные 10 баллов на пробном экзамене', 'exam', 'mock', 3, 780),
-- ── mastery extensions (textbooks + flashcards) ────────────
('tb_first_para', 'Открыл параграф', 'book-marked', 'theory', 'Прочитан первый параграф учебника', 'mastery', 'tb_progress', 1, 800),
('tb_chapter_done','Глава закрыта', 'book-check', 'theory', 'Все параграфы одной главы пройдены', 'mastery', 'tb_progress', 2, 810),
('tb_book_done', 'Учебник целиком', 'book-up', 'theory', 'Все параграфы учебника пройдены', 'mastery', 'tb_progress', 3, 820),
('tb_3_books', 'Полка из трёх', 'library-big', 'theory', '3 учебника закрыто полностью', 'mastery', 'tb_progress', 4, 830),
('fc_first_deck', 'Первая колода', 'layers', 'theory', 'Все карточки одной колоды разок повторены', 'mastery', 'flashcards', 1, 840),
('fc_100_cards', '100 карточек', 'rotate-cw', 'theory', '100 повторов карточек в сумме', 'mastery', 'flashcards', 2, 850),
('fc_1000_cards', '1000 карточек', 'sparkles', 'theory', '1000 повторов карточек в сумме', 'mastery', 'flashcards', 3, 860),
-- ── exploration extensions (biochem / games / pet) ─────────
('bc_first_molecule', 'Первая молекула', 'flask-round', 'lab', 'Собрана первая молекула в био-конструкторе', 'exploration', 'biochem', 1, 870),
('bc_5_challenges', '5 био-задач', 'beaker', 'lab', '5 биохимических задач решено', 'exploration', 'biochem', 2, 880),
('bc_20_challenges', '20 био-задач', 'atom', 'lab', '20 биохимических задач решено', 'exploration', 'biochem', 3, 890),
('game_win_5', '5 побед в играх', 'gamepad-2', 'volume','5 побед в Виселице или Кроссворде суммарно', 'exploration', 'minigames', 1, 900),
('game_win_25', '25 побед в играх', 'gamepad', 'volume','25 побед в Виселице или Кроссворде суммарно', 'exploration', 'minigames', 2, 910),
('pet_streak_7', 'Заботливый', 'heart', 'volume','7 дней подряд гладить питомца', 'exploration', 'pet', 1, 920),
('pet_streak_30', 'Лучший друг', 'paw-print', 'volume','30 дней подряд гладить питомца', 'exploration', 'pet', 2, 930),
-- ── social (NEW group) ─────────────────────────────────────
('lb_top10', 'Топ-10 недели', 'trending-up', 'volume', 'Попасть в топ-10 лидерборда недели', 'social', 'leaderboard', 1, 1000),
('lb_top1', 'Первое место', 'medal', 'volume', '№1 в лидерборде недели', 'social', 'leaderboard', 2, 1010),
('lq_first', 'Первый live-quiz', 'radio', 'volume', 'Принять участие в первом live-квизе', 'social', 'live_quiz', 1, 1020),
('lq_3_quizzes', '3 live-квиза', 'megaphone', 'volume', 'Принять участие в 3 live-квизах', 'social', 'live_quiz', 2, 1030),
('cr_first_join', 'Первое подключение','door-open', 'volume', 'Первое подключение к уроку в classroom', 'social', 'classroom', 1, 1040),
('cr_5_lessons', '5 уроков', 'presentation', 'volume', 'Посещено 5 уроков classroom', 'social', 'classroom', 2, 1050),
('cr_25_lessons', '25 уроков', 'school', 'volume', 'Посещено 25 уроков classroom', 'social', 'classroom', 3, 1060),
('class_5_members', 'Учитель: 5 учеников','users', 'start', 'Учитель: класс из ≥5 учеников', 'social', 'teacher', 1, 1070),
('class_25_members', 'Учитель: 25 учеников','users-2', 'start', 'Учитель: класс из ≥25 учеников', 'social', 'teacher', 2, 1080),
('parent_link', 'Связан с родителем','user-plus', 'start', 'Связь с родителем установлена', 'social', 'family', 1, 1090);