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
@@ -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)