diff --git a/changelog.d/features/12522-gamification-streak-badge-xp.md b/changelog.d/features/12522-gamification-streak-badge-xp.md new file mode 100644 index 0000000000..d13d0ad942 --- /dev/null +++ b/changelog.d/features/12522-gamification-streak-badge-xp.md @@ -0,0 +1 @@ +- **feat(gamification): pay the documented `streak_bonus` and `badge_unlock` XP rewards.** `XP_REWARDS` listed both rewards but the award pipeline never paid them: the private reward table in `events.ts` omitted them, `updateStreak()` did not report when a streak extended, and badge unlocks carried no XP. Every request that extends a daily streak now pays `streak_bonus × streak length` once per UTC day (guarded by a same-day `xp_audit_log` check), and every badge unlocked through the pipeline pays `badge_unlock` once per badge (guarded by the `user_badges` primary key; `unlockBadge()` now reports whether it inserted). Bonus XP flows through the same `addXp` + level sync + global/weekly/monthly leaderboard path as action XP, so level-ups and rankings include it. The Radar supporter recognition unlock stays XP-free. (#12522 — thanks @pacocartones) diff --git a/src/lib/db/gamification.ts b/src/lib/db/gamification.ts index df452271a6..12085a633a 100644 --- a/src/lib/db/gamification.ts +++ b/src/lib/db/gamification.ts @@ -222,10 +222,17 @@ export function updateLevel(apiKeyId: string, level: number): void { // ──────────────── Badges ──────────────── -export function unlockBadge(apiKeyId: string, badgeId: string): void { - db() +/** + * Award a badge to an API key. Idempotent on the `(api_key_id, badge_id)` primary key. + * + * @returns `true` when this call inserted the badge, `false` when it was already earned. + * Callers that pay the `badge_unlock` XP reward key off this so a badge is paid once. + */ +export function unlockBadge(apiKeyId: string, badgeId: string): boolean { + const result = db() .prepare(`INSERT OR IGNORE INTO user_badges (api_key_id, badge_id) VALUES (?, ?)`) .run(apiKeyId, badgeId); + return result.changes > 0; } /** @@ -243,6 +250,24 @@ export function hasBadge(apiKeyId: string, badgeId: string): boolean { return !!row; } +/** + * Whether `xp_audit_log` already holds an entry for this action on the current UTC day. + * + * `created_at` is written by the table default `datetime('now')` as + * `"YYYY-MM-DD HH:MM:SS"` (UTC), so a lexical compare against `date('now')` selects + * today's rows. Used as the once-per-day guard for daily rewards such as `streak_bonus`. + */ +export function hasXpActionToday(apiKeyId: string, action: string): boolean { + const row = db() + .prepare( + `SELECT 1 FROM xp_audit_log + WHERE api_key_id = ? AND action = ? AND created_at >= date('now') + LIMIT 1` + ) + .get(apiKeyId, action); + return !!row; +} + export function getBadges(apiKeyId: string): UserBadge[] { const rows = db() .prepare( diff --git a/src/lib/gamification/events.ts b/src/lib/gamification/events.ts index 8c2ad62369..3560037a26 100644 --- a/src/lib/gamification/events.ts +++ b/src/lib/gamification/events.ts @@ -5,6 +5,7 @@ */ import { logger } from "../../../open-sse/utils/logger.ts"; +import { calculateLevel, XP_REWARDS } from "./xp"; const log = logger("GAMIFICATION"); @@ -57,23 +58,19 @@ export async function emitGamificationEvent(params: { const { addXp } = await import("../db/gamification"); addXp(apiKeyId, action, xpAmount, metadata ? JSON.stringify(metadata) : undefined); - // Update level - const { getXp, updateLevel } = await import("../db/gamification"); - const xp = getXp(apiKeyId); - if (xp) { - const { calculateLevel } = await import("./xp"); - const newLevel = calculateLevel(xp.totalXp); - if (newLevel !== xp.currentLevel) { - updateLevel(apiKeyId, newLevel); - log.info("events.level_up", { apiKeyId, oldLevel: xp.currentLevel, newLevel }); - } - } + await syncLevel(apiKeyId); } // 2. Update streak if (action === "request") { - const { updateStreak } = await import("./streaks"); - const streak = await updateStreak(apiKeyId); + const { advanceStreak } = await import("./streaks"); + const { currentStreak: streak, extended } = await advanceStreak(apiKeyId); + + // Pay the documented streak_bonus (XP_REWARDS: per consecutive streak day, multiplied + // by streak length) on the one request per UTC day that extends the streak. + if (extended) { + await awardStreakBonus(apiKeyId, streak); + } // Check streak badges if (streak >= 365) { @@ -112,6 +109,54 @@ export async function emitGamificationEvent(params: { } } +/** + * Recompute the level from total XP and persist it when it changed. + * Runs after every award so bonus XP (streaks, badges) also counts toward level-ups. + */ +async function syncLevel(apiKeyId: string): Promise { + const { getXp, updateLevel } = await import("../db/gamification"); + const xp = getXp(apiKeyId); + if (!xp) return; + const newLevel = calculateLevel(xp.totalXp); + if (newLevel !== xp.currentLevel) { + updateLevel(apiKeyId, newLevel); + log.info("events.level_up", { apiKeyId, oldLevel: xp.currentLevel, newLevel }); + } +} + +/** + * Award a bonus reward (`streak_bonus`, `badge_unlock`) through the same path as action XP: + * `xp_audit_log` + `user_levels` via addXp, level sync, and the global/weekly/monthly + * leaderboard scopes. Idempotency is the caller's responsibility. + */ +async function awardBonusXp( + apiKeyId: string, + action: "streak_bonus" | "badge_unlock", + amount: number, + metadata: Record +): Promise { + const { addXp } = await import("../db/gamification"); + addXp(apiKeyId, action, amount, JSON.stringify(metadata)); + await syncLevel(apiKeyId); + + const { updateScore } = await import("./leaderboard"); + await updateScore(apiKeyId, "global", amount); + await updateScore(apiKeyId, "weekly", amount); + await updateScore(apiKeyId, "monthly", amount); + log.info("events.bonus_awarded", { apiKeyId, action, amount, ...metadata }); +} + +/** + * Pay `streak_bonus × streak` once per UTC day. The `xp_audit_log` same-day check and the + * insert run synchronously with no await in between, so two requests racing at the day + * boundary cannot both pay. + */ +async function awardStreakBonus(apiKeyId: string, streak: number): Promise { + const { hasXpActionToday } = await import("../db/gamification"); + if (hasXpActionToday(apiKeyId, "streak_bonus")) return; + await awardBonusXp(apiKeyId, "streak_bonus", XP_REWARDS.streak_bonus * streak, { streak }); +} + /** * Get XP amount for an action. */ @@ -130,20 +175,28 @@ function getXpForAction(action: string): number { } /** - * Check and unlock a specific badge. + * Check and unlock a specific badge, paying the documented `badge_unlock` XP once per badge. + * + * @param rewardable - `false` for recognition-only unlocks (Radar supporter): the caller + * supplies a one-way identity, so the unlock neither earns XP nor logs the identity. */ async function checkAndUnlockBadge( apiKeyId: string, badgeId: string, - logIdentity = true + rewardable = true ): Promise { const { unlockBadge, hasBadge } = await import("../db/gamification"); // #3472: dedup via user_badges directly. getBadges() INNER-JOINs badge_definitions, which is // empty until seeded, so it falsely reported "not earned" and re-emitted the unlock event on // every request. if (!hasBadge(apiKeyId, badgeId)) { - unlockBadge(apiKeyId, badgeId); - log.info("events.badge_unlocked", logIdentity ? { apiKeyId, badgeId } : { badgeId }); + // unlockBadge is INSERT OR IGNORE on the (api_key_id, badge_id) primary key; only the call + // that actually inserts the row pays, so concurrent unlocks cannot double-pay. + const inserted = unlockBadge(apiKeyId, badgeId); + log.info("events.badge_unlocked", rewardable ? { apiKeyId, badgeId } : { badgeId }); + if (inserted && rewardable) { + await awardBonusXp(apiKeyId, "badge_unlock", XP_REWARDS.badge_unlock, { badgeId }); + } // Look up badge details from badge_definitions const { getDbInstance } = await import("../db/core"); diff --git a/src/lib/gamification/streaks.ts b/src/lib/gamification/streaks.ts index 4406375ac1..9c9303375c 100644 --- a/src/lib/gamification/streaks.ts +++ b/src/lib/gamification/streaks.ts @@ -157,7 +157,39 @@ export async function getAggregateStreak(): Promise< * console.log(count); // 8 */ export async function updateStreak(apiKeyId: string): Promise { - if (isBuildPhase || isCloud) return 0; + const { currentStreak } = await advanceStreak(apiKeyId); + return currentStreak; +} + +/** + * Result of {@link advanceStreak}. + */ +export interface StreakAdvance { + /** Current consecutive active days after this call */ + currentStreak: number; + /** + * `true` only on the call that extended the streak onto a new consecutive day + * (yesterday was active, today was not yet counted). `false` when today was + * already counted, when a new streak starts at 1, or when streaks are disabled. + */ + extended: boolean; +} + +/** + * Same as {@link updateStreak}, but also reports whether this call extended the + * streak onto a new consecutive day. The award pipeline uses `extended` to pay + * the `streak_bonus` reward once per UTC day; repeated requests on the same day + * see `extended: false` because the record already carries today's date. + * + * @param apiKeyId - The API key identifier + * @returns The new streak count and whether it just extended + * + * @example + * const { currentStreak, extended } = await advanceStreak("key_abc123"); + * if (extended) console.log(`day ${currentStreak} of the streak`); + */ +export async function advanceStreak(apiKeyId: string): Promise { + if (isBuildPhase || isCloud) return { currentStreak: 0, extended: false }; const db = getDbInstance() as unknown as DbLike; const today = todayUtc(); @@ -165,19 +197,13 @@ export async function updateStreak(apiKeyId: string): Promise { // Already counted today if (streak.lastActiveDate === today) { - return streak.currentStreak; + return { currentStreak: streak.currentStreak, extended: false }; } const yesterday = yesterdayUtc(); - let newStreak: number; - - if (streak.lastActiveDate === yesterday) { - // Consecutive day — extend streak - newStreak = streak.currentStreak + 1; - } else { - // Streak broken or first activity — start fresh - newStreak = 1; - } + const extended = streak.lastActiveDate === yesterday; + // Consecutive day — extend streak; otherwise streak broken or first activity — start fresh + const newStreak = extended ? streak.currentStreak + 1 : 1; const newData: StreakData = { currentStreak: newStreak, @@ -192,5 +218,5 @@ export async function updateStreak(apiKeyId: string): Promise { JSON.stringify(newData) ); - return newStreak; + return { currentStreak: newStreak, extended }; } diff --git a/tests/unit/gamification/events.test.ts b/tests/unit/gamification/events.test.ts index 0e2a9b6ed3..41a21b474e 100644 --- a/tests/unit/gamification/events.test.ts +++ b/tests/unit/gamification/events.test.ts @@ -1,6 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { emitGamificationEvent } from "../../../src/lib/gamification/events"; +import { XP_REWARDS } from "../../../src/lib/gamification/xp"; import { getDbInstance } from "../../../src/lib/db/core"; describe("Gamification Events", () => { @@ -107,7 +108,10 @@ describe("Gamification Events", () => { await emitGamificationEvent({ apiKeyId: key, action: "request" }); assert.equal(countRequestRows(key), 1); - assert.equal(leaderboardScore(key), 1); + // The very first request also unlocks the "first-token" badge, and badge unlocks now + // pay XP_REWARDS.badge_unlock through the same leaderboard path. The gate only governs + // the action award, so the score is the 1 XP action plus the badge bonus. + assert.equal(leaderboardScore(key), 1 + XP_REWARDS.badge_unlock); cleanup(key); }); diff --git a/tests/unit/gamification/streak-badge-xp.test.ts b/tests/unit/gamification/streak-badge-xp.test.ts new file mode 100644 index 0000000000..21eea0815d --- /dev/null +++ b/tests/unit/gamification/streak-badge-xp.test.ts @@ -0,0 +1,212 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { emitGamificationEvent } from "../../../src/lib/gamification/events"; +import { advanceStreak, getStreak } from "../../../src/lib/gamification/streaks"; +import { XP_REWARDS } from "../../../src/lib/gamification/xp"; +import { addXp, getXp, unlockBadge } from "../../../src/lib/db/gamification"; +import { getDbInstance } from "../../../src/lib/db/core"; + +// `XP_REWARDS` documents `streak_bonus` ("per consecutive streak day, multiplied by streak +// length") and `badge_unlock`, but the award pipeline never paid either: events.ts kept a +// private reward table without them, updateStreak() did not report whether the streak had +// just extended, and checkAndUnlockBadge() unlocked badges without XP. These tests pin the +// documented rewards and their idempotency guards (once per UTC day, once per badge). + +const MS_PER_DAY = 86_400_000; +const STREAK_NS = "gamification:streaks"; + +function utcDate(offsetDays: number): string { + return new Date(Date.now() - offsetDays * MS_PER_DAY).toISOString().split("T")[0]; +} + +function seedStreak(apiKeyId: string, currentStreak: number, lastActiveDaysAgo: number): void { + const lastActiveDate = utcDate(lastActiveDaysAgo); + getDbInstance() + .prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)") + .run( + STREAK_NS, + apiKeyId, + JSON.stringify({ + currentStreak, + longestStreak: currentStreak, + lastActiveDate, + streakStartDate: utcDate(lastActiveDaysAgo + currentStreak - 1), + }) + ); +} + +function auditRows( + apiKeyId: string, + action: string +): Array<{ xp_earned: number; metadata: string | null }> { + return getDbInstance() + .prepare("SELECT xp_earned, metadata FROM xp_audit_log WHERE api_key_id = ? AND action = ?") + .all(apiKeyId, action) as Array<{ xp_earned: number; metadata: string | null }>; +} + +function auditTotal(apiKeyId: string): number { + const row = getDbInstance() + .prepare("SELECT COALESCE(SUM(xp_earned), 0) AS total FROM xp_audit_log WHERE api_key_id = ?") + .get(apiKeyId) as { total: number }; + return row.total; +} + +function leaderboardScore(apiKeyId: string, scope: string): number { + const row = getDbInstance() + .prepare("SELECT score FROM leaderboard WHERE api_key_id = ? AND scope = ?") + .get(apiKeyId, scope) as { score: number } | undefined; + return row?.score ?? 0; +} + +function cleanup(apiKeyId: string): void { + const db = getDbInstance(); + db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(apiKeyId); + db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(apiKeyId); + db.prepare("DELETE FROM user_badges WHERE api_key_id = ?").run(apiKeyId); + db.prepare("DELETE FROM leaderboard WHERE api_key_id = ?").run(apiKeyId); + db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(STREAK_NS, apiKeyId); +} + +describe("streak bonus XP", () => { + it("advanceStreak reports whether the streak extended today", async () => { + const key = `sb-advance-${Date.now()}`; + try { + seedStreak(key, 1, 1); + const first = await advanceStreak(key); + assert.deepEqual(first, { currentStreak: 2, extended: true }); + const second = await advanceStreak(key); + assert.deepEqual(second, { currentStreak: 2, extended: false }, "same day is a no-op"); + } finally { + cleanup(key); + } + }); + + it("pays streak_bonus x streak length on the day the streak extends", async () => { + const key = `sb-pay-${Date.now()}`; + try { + seedStreak(key, 1, 1); // active yesterday → today's request extends to 2 + await emitGamificationEvent({ apiKeyId: key, action: "request" }); + + const rows = auditRows(key, "streak_bonus"); + assert.equal(rows.length, 1, "exactly one streak_bonus audit row"); + assert.equal(rows[0].xp_earned, XP_REWARDS.streak_bonus * 2); + assert.deepEqual(JSON.parse(rows[0].metadata ?? "{}"), { streak: 2 }); + assert.equal((await getStreak(key)).currentStreak, 2); + + const total = auditTotal(key); + assert.equal(getXp(key)?.totalXp, total, "user_levels.total_xp matches the audit log"); + assert.equal(leaderboardScore(key, "global"), total, "global leaderboard credits the bonus"); + assert.equal(leaderboardScore(key, "weekly"), total); + assert.equal(leaderboardScore(key, "monthly"), total); + } finally { + cleanup(key); + } + }); + + it("pays the bonus once per UTC day even when requests repeat", async () => { + const key = `sb-once-${Date.now()}`; + try { + seedStreak(key, 4, 1); + await emitGamificationEvent({ apiKeyId: key, action: "request" }); + await emitGamificationEvent({ apiKeyId: key, action: "request" }); + await emitGamificationEvent({ apiKeyId: key, action: "request" }); + + const rows = auditRows(key, "streak_bonus"); + assert.equal(rows.length, 1); + assert.equal(rows[0].xp_earned, XP_REWARDS.streak_bonus * 5); + } finally { + cleanup(key); + } + }); + + it("does not pay on the first day of a streak or after a broken streak", async () => { + const fresh = `sb-fresh-${Date.now()}`; + const broken = `sb-broken-${Date.now()}`; + try { + await emitGamificationEvent({ apiKeyId: fresh, action: "request" }); + assert.equal(auditRows(fresh, "streak_bonus").length, 0, "day 1 is not a consecutive day"); + + seedStreak(broken, 6, 3); // last active three days ago → streak resets to 1 + await emitGamificationEvent({ apiKeyId: broken, action: "request" }); + assert.equal((await getStreak(broken)).currentStreak, 1); + assert.equal(auditRows(broken, "streak_bonus").length, 0); + } finally { + cleanup(fresh); + cleanup(broken); + } + }); +}); + +describe("badge unlock XP", () => { + it("unlockBadge reports whether a new row was inserted", () => { + const key = `bu-insert-${Date.now()}`; + try { + assert.equal(unlockBadge(key, "first-token"), true); + assert.equal(unlockBadge(key, "first-token"), false, "INSERT OR IGNORE → no new row"); + } finally { + cleanup(key); + } + }); + + it("pays badge_unlock once per badge when the pipeline unlocks it", async () => { + const key = `bu-pay-${Date.now()}`; + try { + await emitGamificationEvent({ apiKeyId: key, action: "request" }); // → first-token + await emitGamificationEvent({ apiKeyId: key, action: "request" }); // already earned + + const rows = auditRows(key, "badge_unlock"); + assert.equal(rows.length, 1, "exactly one badge_unlock audit row"); + assert.equal(rows[0].xp_earned, XP_REWARDS.badge_unlock); + assert.deepEqual(JSON.parse(rows[0].metadata ?? "{}"), { badgeId: "first-token" }); + + const total = auditTotal(key); + assert.equal(total, 2 * XP_REWARDS.request + XP_REWARDS.badge_unlock); + assert.equal(getXp(key)?.totalXp, total); + assert.equal(leaderboardScore(key, "global"), total); + } finally { + cleanup(key); + } + }); + + it("pays the streak badge and the streak bonus from the same request", async () => { + const key = `bu-streak-${Date.now()}`; + try { + seedStreak(key, 2, 1); // → 3 today: daily-user badge + bonus + await emitGamificationEvent({ apiKeyId: key, action: "request" }); + + const badgeRows = auditRows(key, "badge_unlock"); + const unlocked = badgeRows.map((r) => JSON.parse(r.metadata ?? "{}").badgeId).sort(); + assert.deepEqual(unlocked, ["daily-user", "first-token"]); + assert.equal(auditRows(key, "streak_bonus")[0]?.xp_earned, XP_REWARDS.streak_bonus * 3); + } finally { + cleanup(key); + } + }); + + it("recomputes the level after bonus XP, not only after the action XP", async () => { + const key = `bu-level-${Date.now()}`; + try { + // Level 2 needs 282 XP. 280 + 1 (request) = 281 stays level 1; the first-token + // badge_unlock XP crosses the threshold, so the level must be synced after it. + addXp(key, "request", 280); + assert.equal(getXp(key)?.currentLevel, 1); + await emitGamificationEvent({ apiKeyId: key, action: "request" }); + assert.equal(getXp(key)?.totalXp, 280 + XP_REWARDS.request + XP_REWARDS.badge_unlock); + assert.equal(getXp(key)?.currentLevel, 2); + } finally { + cleanup(key); + } + }); + + it("keeps the radar_supporter recognition path free of XP", async () => { + const identity = `bu-radar-${Date.now()}`; + try { + await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" }); + assert.equal(auditRows(identity, "badge_unlock").length, 0); + assert.equal(getXp(identity), null); + assert.equal(leaderboardScore(identity, "global"), 0); + } finally { + cleanup(identity); + } + }); +});