From 268ea31bb8888271b3442c4c4b14abbf4c803341 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 29 May 2026 20:30:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(gamification):=20Phase=204=20=E2=80=94=20s?= =?UTF-8?q?tandalone=20coin=20events=20+=20coin=5Flog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coins were always 1:10 of XP. Now they have their own event log + a helper that dedups by reason within a configurable window. Backend: • migration 032 creates coin_log (user_id, amount, reason, created_at) with indices for the 'fired today?' check • awardCoins now records into coin_log on every call (reason defaults to 'xp_bonus' for the legacy XP-proportional path) • awardCoinsOnce(userId, amount, reason, window) — fires the bonus only if no row matches in the window: 'day' → DATE(created_at) = today 'week' → ISO week match 'forever' → never twice Wired events (Phase 4 subset of the plan): • Daily login — 10 coins, once/day. Hooked in updateStreak so the bonus rides on the existing 'daily_activity' XP trigger. • Daily goal completion — 15/25/40 coins (easy/medium/hard), once/day. Sits next to the existing tier XP bonus in updateDailyGoal. • Variant clear — 30 coins, once per (user, variant) forever. Fires from the exam-prep attempts endpoint when the user's final correct answer fills out a math9 variant. Deferred (need invasive trigger hooks): weekly goal, paragraph close, boss defeated, referral. Verified end-to-end: awardCoinsOnce returns true→false on repeated calls, coin_log records the first, coins balance moves once. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/gamification/service.js | 48 +++++++++++++++++-- backend/src/db/migrations/032_coin_log.sql | 26 ++++++++++ backend/src/routes/exam-prep.js | 24 ++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 backend/src/db/migrations/032_coin_log.sql diff --git a/backend/src/controllers/gamification/service.js b/backend/src/controllers/gamification/service.js index 4b18372..11f07c4 100644 --- a/backend/src/controllers/gamification/service.js +++ b/backend/src/controllers/gamification/service.js @@ -14,10 +14,45 @@ const { ═══════════════════════════════════════════════════════════════════════ */ /* ── Coins ─────────────────────────────────────────────────────────── */ -function awardCoins(userId, amount /*, reason */) { +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 ────────────────────────────────────────────────────────────── */ @@ -76,6 +111,8 @@ function updateStreak(userId) { 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; } @@ -501,8 +538,13 @@ function updateDailyGoal(userId, addTests, addXp) { const already = stmts.checkGoalBonus.get(userId, today); if (!already) { const pref = stmts.getUserGoalTier.get(userId); - const tier = GOAL_TIERS[(pref && pref.goal_tier) || 'medium'] || GOAL_TIERS.medium; + 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); } @@ -605,7 +647,7 @@ function updateChallenges(userId, score, total, subjectSlug, topicId) { module.exports = { // Core - awardXP, awardCoins, getXPInfo, updateStreak, + awardXP, awardCoins, awardCoinsOnce, getXPInfo, updateStreak, // Achievements seedAchievements, unlockAchievement, checkAchievements, checkRedBookAchievements, pushAchievementNotif, // Hooks (for other controllers) diff --git a/backend/src/db/migrations/032_coin_log.sql b/backend/src/db/migrations/032_coin_log.sql new file mode 100644 index 0000000..8c73014 --- /dev/null +++ b/backend/src/db/migrations/032_coin_log.sql @@ -0,0 +1,26 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 032: coin_log — audit + dedup table for coin-only events +-- +-- Until now coins were always a side-effect of XP (1 coin per 10 XP +-- in awardXP), and we had no per-event log for them. Phase 4 adds +-- standalone coin events (daily login, daily-goal completion, variant +-- clear, weekly challenge bonus) that need their own dedup window so +-- a user can't farm the same bonus twice in one day / week. +-- +-- The log is also useful for the upcoming /api/shop/coin-history view +-- on the profile page so users can see *where* their coins came from. +-- +-- Lookup pattern is (user_id, reason, created_at) — index supports +-- the "did this event already fire today?" query. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE coin_log ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount INTEGER NOT NULL, + reason TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_coin_log_user_reason_date ON coin_log(user_id, reason, created_at); +CREATE INDEX idx_coin_log_user_date ON coin_log(user_id, created_at); diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js index 1a4e0f6..792d4ce 100644 --- a/backend/src/routes/exam-prep.js +++ b/backend/src/routes/exam-prep.js @@ -694,6 +694,30 @@ router.post('/attempts', (req, res) => { mode, sessionId, hintUsed, solutionViewed, Date.now() ); + // Variant-clear coin bonus: when the user finishes a variant in + // 'variant' mode with every task correctly answered, give them a + // one-shot 30-coin tip. Dedup key includes the variant number, so a + // user who later re-solves a different variant gets paid again. + if (mode === 'variant' && isCorrect === 1) { + try { + const taskMeta = db.prepare('SELECT variant FROM exam_tasks WHERE id = ?').get(taskId); + if (taskMeta?.variant) { + const stats = db.prepare(` + SELECT + (SELECT COUNT(*) FROM exam_tasks WHERE exam_key='math9' AND variant=?) AS total, + (SELECT COUNT(DISTINCT t.id) + FROM exam_attempts a + JOIN exam_tasks t ON t.id = a.exam_task_id + WHERE a.user_id = ? AND t.variant = ? AND a.is_correct = 1) AS solved + `).get(taskMeta.variant, req.user.id, taskMeta.variant); + if (stats.total > 0 && stats.solved >= stats.total) { + const { awardCoinsOnce } = require('../controllers/gamification/service'); + awardCoinsOnce(req.user.id, 30, `variant_clear:${taskMeta.variant}`, 'forever'); + } + } + } catch (e) { /* don't break the response on bonus failure */ } + } + res.json({ id: Number(result.lastInsertRowid) }); });