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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
@@ -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) });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user