feat(gamification): Phase 4 — standalone coin events + coin_log

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) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-29 20:30:14 +03:00
parent b005226e2c
commit 268ea31bb8
3 changed files with 95 additions and 3 deletions
+24
View File
@@ -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) });
});