feat(gamification): pay the documented streak and badge XP rewards (#12522)

`XP_REWARDS` documented `streak_bonus` and `badge_unlock` and the pipeline paid neither — closing that gap is right. The idempotency design carries it: `advanceStreak()` reporting `extended` only on the call that moves the record to today is what keeps a same-day repeat from paying twice.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded with the rest of this batch — zero conflicts between the 19 PRs.

- `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, all within the frozen baseline); `check:changelog-integrity` OK
- complexity 2802 / baseline 3218 and cognitive-complexity 1267 / baseline 1437 — both under baseline
- 226 of 228 focused assertions green across the batch's 23 test files. The 2 remaining belong to #12551, which is held separately.

Two batch-owned defects were found and fixed in flight, both pure base drift: `173_xp_action_counts.sql` collided with `173_call_logs_video_content_removed.sql` (renumbered to 176 on #12651 — it aborted every DB open, which is what 53 of the first run's failures were), and the feature-flag catalog was missing the `SERVER_OWNED_TOOL_LOOP_ENABLED` row the base gained after #12552 was written.

⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` reproduce on the pure tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098, which this batch does not touch).

Thanks @pacocartones — the `file:line` citations and the explicit out-of-scope notes on every one of these made a 19-PR batch reviewable in one pass.
This commit is contained in:
Paco Cartones
2026-09-11 22:42:32 +02:00
committed by GitHub
parent 6caf836092
commit 16c68bad49
6 changed files with 353 additions and 32 deletions

View File

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

View File

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

View File

@@ -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<void> {
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<string, unknown>
): Promise<void> {
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<void> {
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<void> {
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");

View File

@@ -157,7 +157,39 @@ export async function getAggregateStreak(): Promise<
* console.log(count); // 8
*/
export async function updateStreak(apiKeyId: string): Promise<number> {
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<StreakAdvance> {
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<number> {
// 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<number> {
JSON.stringify(newData)
);
return newStreak;
return { currentStreak: newStreak, extended };
}

View File

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

View File

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