1083 lines
43 KiB
Markdown
1083 lines
43 KiB
Markdown
---
|
||
title: "Gamification & Leaderboard System"
|
||
version: 3.8.40
|
||
lastUpdated: 2026-06-28
|
||
---
|
||
|
||
# Gamification & Leaderboard System
|
||
|
||
> **Source of truth:** `src/lib/gamification/`, `src/lib/db/gamification.ts`, `src/app/api/gamification/`
|
||
> **Last updated:** 2026-06-28 — v3.8.40
|
||
|
||
OmniRoute includes a local-first gamification layer that rewards users for
|
||
engaging with the platform — making requests, switching providers, creating
|
||
combos, sharing tokens, and contributing to the community. All state lives in
|
||
SQLite; federation with community servers is opt-in and push-based.
|
||
|
||
The system is designed to be **zero-latency on the hot path** — gamification
|
||
events are dispatched fire-and-forget from the request pipeline and never block
|
||
an LLM response.
|
||
|
||
---
|
||
|
||
## Overview
|
||
|
||
### Purpose
|
||
|
||
Increase user engagement and retention by providing visible progress (XP,
|
||
levels, badges), social proof (leaderboards), and economic incentives (token
|
||
sharing, invite rewards).
|
||
|
||
### Scope
|
||
|
||
| Feature | Description |
|
||
| ----------------- | --------------------------------------------------------------- |
|
||
| XP & Levels | Earn XP per action; level up along a polynomial curve |
|
||
| Badges | 20+ achievements across 5 categories with 4 rarity tiers |
|
||
| Streaks | Daily active usage tracking with current/longest streak |
|
||
| Leaderboards | Global, weekly, monthly, token-sharing, and contribution scopes |
|
||
| Token Sharing | Transfer credits between users via double-entry ledger |
|
||
| Invite & Redeem | Referral codes with SHA-256 hashed storage |
|
||
| Community Servers | Federate with external OmniRoute instances |
|
||
| Anti-Cheat | Server-side scoring, rate limiting, z-score anomaly detection |
|
||
|
||
### Design Principles
|
||
|
||
1. **Local-first** — all state in SQLite, no external services required.
|
||
2. **Non-blocking** — events are fire-and-forget; the LLM response path is
|
||
never delayed by gamification logic.
|
||
3. **Server-authoritative** — XP is computed server-side only; clients cannot
|
||
inflate scores.
|
||
4. **Privacy-respecting** — leaderboard participation is opt-in; users can
|
||
hide their profile.
|
||
5. **Federation-ready** — community servers can push scores via signed API;
|
||
sync is overwrite, not additive.
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
### High-Level Flow
|
||
|
||
```
|
||
Client Request
|
||
→ /v1/chat/completions
|
||
→ handleChatCore() [open-sse/handlers/chatCore.ts]
|
||
→ ... (existing pipeline) ...
|
||
→ upstream response sent to client
|
||
→ setImmediate (fire-and-forget):
|
||
→ emitGamificationEvent() [src/lib/gamification/events.ts]
|
||
→ awardXp() [src/lib/gamification/xp.ts]
|
||
→ updateStreak() [src/lib/gamification/streaks.ts]
|
||
→ evaluateBadges() [src/lib/gamification/badges.ts]
|
||
→ updateLeaderboard() [src/lib/gamification/leaderboard.ts]
|
||
→ checkAnomalies() [src/lib/gamification/antiCheat.ts]
|
||
```
|
||
|
||
The event emitter is the single integration point. `chatCore.ts` calls
|
||
`emitGamificationEvent()` after the response is sent; the event module fans
|
||
out to XP, streak, badge, leaderboard, and anti-cheat subsystems.
|
||
|
||
### Module Dependency Graph
|
||
|
||
```
|
||
src/lib/gamification/
|
||
events.ts ← entry point (called from chatCore.ts)
|
||
├── xp.ts ← XP calculation & level resolution
|
||
├── streaks.ts ← daily active streak tracking
|
||
├── badges.ts ← badge criteria evaluation
|
||
├── leaderboard.ts ← rank computation & SSE broadcasting
|
||
├── antiCheat.ts ← rate limiting & anomaly detection
|
||
├── sharing.ts ← token transfer ledger
|
||
├── invites.ts ← invite/redeem code management
|
||
├── servers.ts ← community server federation
|
||
└── notifications.ts ← SSE notification stream
|
||
|
||
src/lib/db/
|
||
gamification.ts ← all CRUD operations (8 tables)
|
||
|
||
src/app/api/gamification/
|
||
leaderboard/ ← GET rankings, POST manual refresh
|
||
leaderboard/stream ← SSE real-time updates
|
||
transfer/ ← GET history, POST send tokens
|
||
invite/ ← GET/POST codes, DELETE revoke
|
||
invite/redeem/ ← POST redeem a code
|
||
servers/ ← GET/POST/DELETE community servers
|
||
federation/score/ ← POST push score to server
|
||
federation/leaderboard/ ← GET pull leaderboard from server
|
||
notifications/ ← SSE badge/level-up notifications
|
||
anomalies/ ← GET anomaly reports (admin)
|
||
rotate/ ← POST rotate invite token secrets
|
||
```
|
||
|
||
---
|
||
|
||
## Data Layer
|
||
|
||
### Database Tables
|
||
|
||
All tables live in the main OmniRoute SQLite database, created by migration
|
||
`060_create_gamification.sql`. WAL journaling is inherited from the singleton
|
||
`getDbInstance()` in `src/lib/db/core.ts`.
|
||
|
||
```
|
||
┌─────────────────────────┐ ┌──────────────────────────┐
|
||
│ leaderboard │ │ user_levels │
|
||
├─────────────────────────┤ ├──────────────────────────┤
|
||
│ id TEXT PK │ │ api_key_id TEXT PK │
|
||
│ api_key_id TEXT │ │ xp INTEGER │
|
||
│ scope TEXT │ │ level INTEGER │
|
||
│ score INTEGER │ │ title TEXT │
|
||
│ period TEXT │ │ updated_at TEXT │
|
||
│ updated_at TEXT │ └──────────────────────────┘
|
||
└─────────────────────────┘
|
||
│
|
||
│ 1:N
|
||
▼
|
||
┌─────────────────────────┐ ┌──────────────────────────┐
|
||
│ user_badges │ │ badge_definitions │
|
||
├─────────────────────────┤ ├──────────────────────────┤
|
||
│ id TEXT PK │ │ id TEXT PK │
|
||
│ api_key_id TEXT │ │ name TEXT │
|
||
│ badge_id TEXT FK │ │ category TEXT │
|
||
│ earned_at TEXT │ │ rarity TEXT │
|
||
│ notified INTEGER │ │ criteria_type TEXT │
|
||
└─────────────────────────┘ │ criteria TEXT(JSON) │
|
||
│ description TEXT │
|
||
│ icon TEXT │
|
||
│ hidden INTEGER │
|
||
└──────────────────────────┘
|
||
|
||
┌─────────────────────────┐ ┌──────────────────────────┐
|
||
│ xp_audit_log │ │ token_ledger │
|
||
├─────────────────────────┤ ├──────────────────────────┤
|
||
│ id TEXT PK │ │ id TEXT PK │
|
||
│ api_key_id TEXT │ │ from_key_id TEXT │
|
||
│ action TEXT │ │ to_key_id TEXT │
|
||
│ xp_awarded INTEGER │ │ amount INTEGER │
|
||
│ metadata TEXT(JSON)│ │ idempotency_key TEXT UQ │
|
||
│ created_at TEXT │ │ created_at TEXT │
|
||
└─────────────────────────┘ └──────────────────────────┘
|
||
|
||
┌─────────────────────────┐ ┌──────────────────────────┐
|
||
│ invite_tokens │ │ community_servers │
|
||
├─────────────────────────┤ ├──────────────────────────┤
|
||
│ id TEXT PK │ │ id TEXT PK │
|
||
│ api_key_id TEXT │ │ name TEXT │
|
||
│ code TEXT UQ │ │ url TEXT │
|
||
│ token_hash TEXT │ │ token_hash TEXT │
|
||
│ uses INTEGER │ │ status TEXT │
|
||
│ max_uses INTEGER │ │ last_sync TEXT │
|
||
│ created_at TEXT │ │ created_at TEXT │
|
||
│ expires_at TEXT │ └──────────────────────────┘
|
||
└─────────────────────────┘
|
||
```
|
||
|
||
### Domain Module: `src/lib/db/gamification.ts`
|
||
|
||
Follows the standard OmniRoute pattern — imports `getDbInstance()` from
|
||
`core.ts`, exports typed CRUD functions. No raw SQL in route handlers.
|
||
|
||
Key functions:
|
||
|
||
| Function | Description |
|
||
| -------------------------- | ------------------------------------------------------ |
|
||
| `upsertLeaderboardEntry()` | Insert or update score for (api_key_id, scope, period) |
|
||
| `getLeaderboard()` | Paginated rankings for a given scope/period |
|
||
| `getUserLevel()` | Get or create user level record |
|
||
| `updateUserLevel()` | Set XP, level, and title atomically |
|
||
| `getBadgeDefinitions()` | All badge definitions (optionally filtered) |
|
||
| `getUserBadges()` | Badges earned by a user |
|
||
| `awardBadge()` | Insert badge earn (idempotent on badge_id) |
|
||
| `logXpAction()` | Append to xp_audit_log |
|
||
| `getXpAuditLog()` | Paginated audit history for a user |
|
||
| `insertLedgerEntry()` | Double-entry transfer (in transaction) |
|
||
| `getBalance()` | Sum of received minus sent for a user |
|
||
| `getTransferHistory()` | Paginated transfer log |
|
||
| `createInviteToken()` | Insert invite code + hashed token |
|
||
| `redeemInviteToken()` | Look up by code, validate, increment uses |
|
||
| `upsertCommunityServer()` | Register or update a federation server |
|
||
| `getCommunityServers()` | List servers for a user |
|
||
| `deleteCommunityServer()` | Remove a server registration |
|
||
|
||
---
|
||
|
||
## XP / Level System
|
||
|
||
**File:** `src/lib/gamification/xp.ts`
|
||
|
||
### Level Curve
|
||
|
||
The XP required to reach level `n` follows a polynomial curve:
|
||
|
||
```
|
||
xp_for_level(n) = floor(100 * n^1.5)
|
||
```
|
||
|
||
| Level | XP to Next | Cumulative XP | Title |
|
||
| ----- | ---------- | ------------- | -------- |
|
||
| 1 | 100 | 100 | Beginner |
|
||
| 5 | 1,118 | 2,415 | Beginner |
|
||
| 10 | 3,162 | 10,523 | Explorer |
|
||
| 25 | 12,500 | 86,024 | Explorer |
|
||
| 50 | 35,355 | 345,529 | Expert |
|
||
| 75 | 64,952 | 948,683 | Master |
|
||
| 100 | 100,000 | 2,050,000 | Legend |
|
||
|
||
### Titles
|
||
|
||
| Level Range | Title |
|
||
| ----------- | -------- |
|
||
| 1 – 9 | Beginner |
|
||
| 10 – 24 | Explorer |
|
||
| 25 – 49 | Expert |
|
||
| 50 – 74 | Master |
|
||
| 75 – 100 | Legend |
|
||
|
||
### XP Rewards
|
||
|
||
| Action | XP | Description |
|
||
| ------------------ | --- | --------------------------------------------------------- |
|
||
| `request` | 1 | Per successful LLM request |
|
||
| `provider_switch` | 5 | Switching to a different provider |
|
||
| `combo_create` | 10 | Creating a new combo configuration |
|
||
| `combo_use` | 2 | Using a combo (per target hit) |
|
||
| `badge_earned` | 25 | Earning any badge |
|
||
| `streak_milestone` | 15 | Reaching a streak milestone (7, 14, 30, 60, 90, 180, 365) |
|
||
| `referral` | 50 | Successfully referring a new user |
|
||
| `token_share` | 5 | Sharing tokens with another user |
|
||
| `daily_login` | 3 | First request of the day |
|
||
| `model_diversity` | 3 | Using a model not used in the past 7 days |
|
||
| `compression_use` | 2 | Using prompt compression |
|
||
| `skill_use` | 2 | Executing a skill via MCP |
|
||
|
||
### Award Flow
|
||
|
||
```typescript
|
||
export async function awardXp(
|
||
apiKeyId: string,
|
||
action: XpAction,
|
||
metadata?: Record<string, unknown>
|
||
): Promise<{ xp: number; level: number; title: string; levelUp: boolean }>;
|
||
```
|
||
|
||
1. Look up `XP_REWARDS[action]` to get the XP amount.
|
||
2. Pass through `checkRateLimit()` (anti-cheat: max 1000 XP/min per key).
|
||
3. Open a transaction:
|
||
- Read current `user_levels` row.
|
||
- Add XP; recompute level via `levelFromXp(totalXp)`.
|
||
- If level changed, set `levelUp = true`.
|
||
- Update `user_levels` row.
|
||
- Insert into `xp_audit_log`.
|
||
4. Return the result. Caller handles notifications.
|
||
|
||
### Helper: `levelFromXp(totalXp)`
|
||
|
||
Iterates level 1..100, summing `xp_for_level(n)` until the cumulative XP
|
||
exceeds `totalXp`. Returns the highest level whose threshold is met.
|
||
This is O(100) — acceptable since levels cap at 100.
|
||
|
||
---
|
||
|
||
## Badge System
|
||
|
||
**File:** `src/lib/gamification/badges.ts`
|
||
|
||
### Categories
|
||
|
||
| Category | Description | Example Badges |
|
||
| -------------- | ---------------------------------- | --------------------------------- |
|
||
| `usage` | Volume-based milestones | First Request, 1K Requests, 100K |
|
||
| `sharing` | Token sharing and referrals | First Share, Generous (10 shares) |
|
||
| `contribution` | Community engagement | Combo Creator, Provider Explorer |
|
||
| `streak` | Consistency over time | Week Warrior, Monthly Devoted |
|
||
| `rare` | Hard-to-get or hidden achievements | Early Adopter, Bug Reporter |
|
||
|
||
### Rarities
|
||
|
||
| Rarity | Color | Probability Hint |
|
||
| ----------- | ----- | ---------------- |
|
||
| `common` | Gray | Most users |
|
||
| `uncommon` | Green | Active users |
|
||
| `rare` | Blue | Dedicated users |
|
||
| `legendary` | Gold | Top 1% |
|
||
|
||
### Criteria Types
|
||
|
||
| Type | Field | Description |
|
||
| -------------- | ------------ | ----------------------------------------------- |
|
||
| `action_count` | `count` | Perform action N times (e.g., 1000 requests) |
|
||
| `streak` | `days` | Maintain streak for N consecutive days |
|
||
| `unique_count` | `field`, `n` | Use N unique values (e.g., 10 different models) |
|
||
| `rank` | `scope`, `n` | Reach rank N on a leaderboard scope |
|
||
| `first` | — | Be the first to perform an action |
|
||
| `hidden` | (varies) | Criteria not shown until earned |
|
||
|
||
Badge definitions are stored in `badge_definitions` as JSON `criteria`:
|
||
|
||
```json
|
||
{
|
||
"type": "action_count",
|
||
"action": "request",
|
||
"count": 1000
|
||
}
|
||
```
|
||
|
||
### Evaluation Flow
|
||
|
||
```
|
||
emitGamificationEvent(event)
|
||
→ evaluateBadges(apiKeyId, event)
|
||
→ getBadgeDefinitions() # all definitions
|
||
→ getUserBadges(apiKeyId) # already earned (skip)
|
||
→ for each unearned badge:
|
||
→ matchesCriteria(badge, event, userState)
|
||
→ if match: awardBadge(apiKeyId, badgeId)
|
||
→ return notification payload
|
||
```
|
||
|
||
Evaluation is **event-driven** — it runs after every gamification event, but
|
||
only checks badges whose `criteria.type` aligns with the event action. This
|
||
keeps evaluation fast (< 5ms for most events).
|
||
|
||
### `matchesCriteria(badge, event, userState)`
|
||
|
||
| Criteria Type | Check |
|
||
| -------------- | -------------------------------------------------- |
|
||
| `action_count` | `getActionCount(apiKeyId, action) >= count` |
|
||
| `streak` | `getCurrentStreak(apiKeyId) >= days` |
|
||
| `unique_count` | `getUniqueCount(apiKeyId, field) >= n` |
|
||
| `rank` | `getRank(apiKeyId, scope) <= n` |
|
||
| `first` | No prior `xp_audit_log` entry for this action type |
|
||
| `hidden` | Delegates to the appropriate sub-check |
|
||
|
||
### Built-in Badges (20+)
|
||
|
||
<details>
|
||
<summary>Full badge list</summary>
|
||
|
||
| Badge | Category | Rarity | Criteria |
|
||
| ------------------- | ------------ | --------- | ---------------------------- |
|
||
| First Steps | usage | common | 1 request |
|
||
| Getting Warmed Up | usage | common | 100 requests |
|
||
| Power User | usage | uncommon | 1,000 requests |
|
||
| Centurion | usage | rare | 10,000 requests |
|
||
| OmniPower | usage | legendary | 100,000 requests |
|
||
| Provider Hopper | contribution | common | Use 5 different providers |
|
||
| Provider Master | contribution | uncommon | Use 20 different providers |
|
||
| Combo Architect | contribution | uncommon | Create 5 combos |
|
||
| Combo Grandmaster | contribution | rare | Create 25 combos |
|
||
| First Share | sharing | common | 1 token transfer |
|
||
| Generous | sharing | uncommon | 10 token transfers |
|
||
| Philanthropist | sharing | rare | Transfer 10,000 tokens total |
|
||
| Referrer | sharing | common | 1 successful referral |
|
||
| Network Builder | sharing | uncommon | 10 successful referrals |
|
||
| Week Warrior | streak | uncommon | 7-day streak |
|
||
| Monthly Devoted | streak | rare | 30-day streak |
|
||
| Unstoppable | streak | legendary | 365-day streak |
|
||
| Early Adopter | rare | legendary | Join during beta period |
|
||
| Compression Pioneer | rare | uncommon | Use compression 100 times |
|
||
| Skill Collector | rare | rare | Use 10 different skills |
|
||
| Model Explorer | contribution | uncommon | Use 15 different models |
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
## Streak Tracker
|
||
|
||
**File:** `src/lib/gamification/streaks.ts`
|
||
|
||
### Data Model
|
||
|
||
Streaks are stored in the `key_value` table (shared utility table) under
|
||
namespaced keys:
|
||
|
||
| Key | Value | Description |
|
||
| ----------------------------- | -------------------------------- | ------------------ |
|
||
| `gamification:streak:{keyId}` | `{current},{longest},{lastDate}` | Active streak data |
|
||
|
||
### Logic
|
||
|
||
```typescript
|
||
export async function updateStreak(
|
||
apiKeyId: string
|
||
): Promise<{ current: number; longest: number; milestone: boolean }>;
|
||
```
|
||
|
||
1. Read streak record from `key_value`.
|
||
2. Parse `{current}`, `{longest}`, `{lastDate}` (ISO date string).
|
||
3. If `lastDate === today` — no change (already counted today).
|
||
4. If `lastDate === yesterday` — increment `current`; update `longest` if needed.
|
||
5. If `lastDate < yesterday` — reset `current = 1` (streak broken).
|
||
6. Write updated record.
|
||
7. Check milestones: 7, 14, 30, 60, 90, 180, 365 days. If crossed, set
|
||
`milestone = true` (caller awards XP and checks badges).
|
||
|
||
### Edge Cases
|
||
|
||
- **Timezone**: streaks use UTC dates (`new Date().toISOString().slice(0, 10)`).
|
||
This is intentional — a single canonical timezone prevents gaming via
|
||
timezone hopping.
|
||
- **New users**: no streak record exists; first request creates it with
|
||
`current=1, longest=1, lastDate=today`.
|
||
- **Multiple requests per day**: only the first request of the UTC day
|
||
increments the streak.
|
||
|
||
---
|
||
|
||
## Leaderboard
|
||
|
||
**File:** `src/lib/gamification/leaderboard.ts`
|
||
|
||
### Scopes
|
||
|
||
| Scope | Period | Description |
|
||
| --------------- | ------- | --------------------------------------------- |
|
||
| `global` | `all` | All-time cumulative XP |
|
||
| `weekly` | `week` | XP earned in current UTC week (Mon-Sun) |
|
||
| `monthly` | `month` | XP earned in current UTC month |
|
||
| `tokens_shared` | `all` | Total tokens transferred to others |
|
||
| `contributions` | `all` | Combos created + providers used + skills used |
|
||
|
||
### Rank Computation
|
||
|
||
Ranks are **computed at read time**, not stored. This avoids stale rank data
|
||
and eliminates the need for periodic rank recalculation jobs.
|
||
|
||
```typescript
|
||
export async function getLeaderboard(
|
||
scope: LeaderboardScope,
|
||
period: string,
|
||
limit: number,
|
||
offset: number
|
||
): Promise<{ entries: LeaderboardEntry[]; total: number }>;
|
||
```
|
||
|
||
Query pattern:
|
||
|
||
```sql
|
||
SELECT api_key_id, score,
|
||
RANK() OVER (ORDER BY score DESC) as rank
|
||
FROM leaderboard
|
||
WHERE scope = ? AND period = ?
|
||
ORDER BY score DESC
|
||
LIMIT ? OFFSET ?
|
||
```
|
||
|
||
### Period Rotation
|
||
|
||
Weekly and monthly leaderboards rotate automatically:
|
||
|
||
1. **Archive**: at period boundary, copy current entries to
|
||
`leaderboard_archive` with the period label.
|
||
2. **Reset**: delete entries for the expired period.
|
||
3. **Trigger**: checked on every `updateLeaderboard()` call; the first request
|
||
of a new period triggers the rotation.
|
||
|
||
This ensures weekly boards reset every Monday 00:00 UTC and monthly boards
|
||
reset on the 1st of each month.
|
||
|
||
### SSE Real-Time Updates
|
||
|
||
**Endpoint:** `GET /api/gamification/stream`
|
||
|
||
```
|
||
Client → GET /api/gamification/stream
|
||
→ SSE connection established
|
||
→ Server sends top-10 leaderboard snapshot immediately
|
||
→ Every 5 seconds: push updated top-10 if changed
|
||
→ Every 15 seconds: heartbeat comment (": heartbeat\n\n")
|
||
→ Client disconnects → cleanup (remove listener)
|
||
```
|
||
|
||
Event format:
|
||
|
||
```
|
||
event: leaderboard
|
||
data: {"scope":"global","entries":[...]}
|
||
|
||
event: leaderboard
|
||
data: {"scope":"weekly","entries":[...]}
|
||
|
||
: heartbeat
|
||
```
|
||
|
||
The SSE manager tracks connected clients per scope and only sends updates
|
||
when the leaderboard data has actually changed since the last push.
|
||
|
||
---
|
||
|
||
## Token Sharing
|
||
|
||
**File:** `src/lib/gamification/sharing.ts`
|
||
|
||
### Double-Entry Ledger
|
||
|
||
Every transfer creates two rows in `token_ledger`:
|
||
|
||
| Row | `from_key_id` | `to_key_id` | `amount` |
|
||
| ------ | ------------- | ----------- | -------- |
|
||
| Debit | sender | receiver | +amount |
|
||
| Credit | receiver | sender | -amount |
|
||
|
||
Wait — the convention is:
|
||
|
||
| Row | `from_key_id` | `to_key_id` | `amount` | Meaning |
|
||
| ------- | ------------- | ----------- | -------- | ------------------- |
|
||
| Send | sender | receiver | +amount | Outflow from sender |
|
||
| Receive | receiver | sender | +amount | Inflow to receiver |
|
||
|
||
Balance is computed as:
|
||
|
||
```sql
|
||
SELECT
|
||
COALESCE(SUM(CASE WHEN to_key_id = ? THEN amount ELSE 0 END), 0)
|
||
- COALESCE(SUM(CASE WHEN from_key_id = ? THEN amount ELSE 0 END), 0)
|
||
AS balance
|
||
FROM token_ledger
|
||
WHERE from_key_id = ? OR to_key_id = ?
|
||
```
|
||
|
||
### Transfer Flow
|
||
|
||
```typescript
|
||
export async function transferTokens(
|
||
fromKeyId: string,
|
||
toKeyId: string,
|
||
amount: number,
|
||
idempotencyKey: string
|
||
): Promise<{ success: boolean; balance: number }>;
|
||
```
|
||
|
||
1. **Validate**: `amount > 0`, `fromKeyId !== toKeyId`.
|
||
2. **Idempotency**: check if `idempotency_key` already exists in ledger.
|
||
If yes, return cached result.
|
||
3. **Transaction** (single SQLite transaction):
|
||
a. Compute sender balance.
|
||
b. If `balance < amount`, abort (insufficient funds).
|
||
c. Insert send row (`from=sender, to=receiver, amount`).
|
||
d. Insert receive row (`from=receiver, to=sender, amount`).
|
||
4. **Rate limit**: check transfer rate for sender (max 10 transfers/min).
|
||
5. **Event**: emit `token_share` gamification event for XP + badge evaluation.
|
||
6. Return `{ success: true, balance: newBalance }`.
|
||
|
||
### Rate Limiting
|
||
|
||
- Max 10 transfers per minute per API key.
|
||
- Max 10,000 tokens per single transfer.
|
||
- Max 100,000 tokens transferred per day per API key.
|
||
|
||
---
|
||
|
||
## Invite & Redeem Tokens
|
||
|
||
**File:** `src/lib/gamification/invites.ts`
|
||
|
||
### Code Format
|
||
|
||
- **Code**: 8-character alphanumeric (e.g., `A3K9-X7M2`), human-readable,
|
||
displayed to the user.
|
||
- **Token**: 32-byte random token, stored as SHA-256 hash. Used for
|
||
programmatic redemption (e.g., URL links).
|
||
|
||
### Storage
|
||
|
||
| Column | Value |
|
||
| ------------ | ---------------------------- |
|
||
| `code` | `A3K9X7M2` (unique, indexed) |
|
||
| `token_hash` | SHA-256(raw_token) |
|
||
|
||
The raw token is returned to the user exactly once at creation time. OmniRoute
|
||
never stores or displays it again — only the hash persists.
|
||
|
||
### Self-Referral Prevention
|
||
|
||
When a user redeems a code, the system checks:
|
||
|
||
1. The code belongs to a different `api_key_id`.
|
||
2. The redeeming user has not previously redeemed any code from the same
|
||
referrer (joins on `invite_tokens` + redemption log).
|
||
|
||
If either check fails, the redemption is rejected with a clear error message.
|
||
|
||
### Expiry & Limits
|
||
|
||
- Default `max_uses`: 10 (configurable at creation).
|
||
- Default `expires_at`: 30 days from creation.
|
||
- Expired or exhausted codes return HTTP 410 Gone.
|
||
|
||
---
|
||
|
||
## Community Server Federation
|
||
|
||
**File:** `src/lib/gamification/servers.ts`
|
||
|
||
### Connect
|
||
|
||
A community server is registered via an invite token issued by the remote
|
||
server. The local instance:
|
||
|
||
1. Receives the invite token (e.g., pasted into dashboard).
|
||
2. Calls `POST /api/gamification/federation/leaderboard` on the remote server
|
||
to validate the token and fetch the current leaderboard.
|
||
3. Stores the server record with `status: connected`.
|
||
|
||
### Sync Model
|
||
|
||
Federation uses **overwrite sync**, not additive:
|
||
|
||
```
|
||
Local Instance Community Server
|
||
│ │
|
||
├── push score ───────────────►│ POST /federation/score
|
||
│ { api_key_id, score } │ (server validates token hash)
|
||
│ │
|
||
├── pull leaderboard ─────────►│ GET /federation/leaderboard
|
||
│◄── top-N entries ────────────┤ (overwrites local cache)
|
||
│ │
|
||
└── health check ─────────────►│ GET /federation/health
|
||
(every 60s, timeout 5s) │
|
||
```
|
||
|
||
### Auth
|
||
|
||
Federation requests include:
|
||
|
||
```
|
||
Authorization: Bearer <raw_token>
|
||
X-Federation-Version: 1
|
||
```
|
||
|
||
The remote server hashes the token and looks up the matching
|
||
`community_servers` row. This avoids transmitting the stored hash.
|
||
|
||
### Health Monitoring
|
||
|
||
Each server record tracks:
|
||
|
||
| Field | Description |
|
||
| ----------- | -------------------------------------- |
|
||
| `status` | `connected`, `degraded`, `unreachable` |
|
||
| `last_sync` | ISO timestamp of last successful sync |
|
||
| `failures` | Consecutive health check failures |
|
||
|
||
After 5 consecutive failures, status changes to `unreachable` and sync is
|
||
paused until a manual health check succeeds.
|
||
|
||
---
|
||
|
||
## Anti-Cheat
|
||
|
||
**File:** `src/lib/gamification/antiCheat.ts`
|
||
|
||
### Server-Side Scoring
|
||
|
||
All XP calculations happen in `src/lib/gamification/xp.ts`. Clients never
|
||
submit a score — they submit actions, and the server computes XP. The
|
||
`leaderboard.score` column is only writable by server-side code.
|
||
|
||
### Rate Limiting
|
||
|
||
| Limit | Value | Scope |
|
||
| --------------------- | ------- | ------------ |
|
||
| Max XP per minute | 1,000 | Per API key |
|
||
| Max transfers per min | 10 | Per API key |
|
||
| Max transfer amount | 10,000 | Per transfer |
|
||
| Max daily transfers | 100,000 | Per API key |
|
||
|
||
Rate limits use an in-memory sliding window (same pattern as
|
||
`RateLimitManager` in `open-sse/services/`). Falls back to SQLite-backed
|
||
counters if the process restarts.
|
||
|
||
### Z-Score Anomaly Detection
|
||
|
||
For each API key, the system maintains a rolling 7-day window of XP earned per
|
||
hour. On each XP award:
|
||
|
||
1. Compute the user's current hourly XP rate.
|
||
2. Compute the population mean and standard deviation.
|
||
3. Calculate `z = (user_rate - mean) / stddev`.
|
||
4. If `z > 3.0` (3 standard deviations), flag as anomaly.
|
||
|
||
Anomalies are logged to `xp_audit_log` with `action = 'anomaly_detected'`
|
||
and surfaced on the admin dashboard.
|
||
|
||
### Audit Trail
|
||
|
||
Every XP award, transfer, badge earn, and anomaly detection is logged to
|
||
`xp_audit_log` with:
|
||
|
||
| Field | Description |
|
||
| ------------ | ---------------------------------------------- |
|
||
| `api_key_id` | Who |
|
||
| `action` | What happened (xp_award, transfer, anomaly, …) |
|
||
| `xp_awarded` | Amount (0 for non-XP events) |
|
||
| `metadata` | JSON with context (action type, target, …) |
|
||
| `created_at` | When (ISO 8601) |
|
||
|
||
Admins can query the full audit trail via `GET /api/gamification/anomalies`.
|
||
|
||
---
|
||
|
||
## API Routes
|
||
|
||
All routes follow the standard OmniRoute pattern:
|
||
|
||
```
|
||
Route → CORS preflight → Body validation (Zod) → Auth (extractApiKey)
|
||
→ Handler
|
||
```
|
||
|
||
### Endpoints
|
||
|
||
| Method | Path | Description | Auth |
|
||
| ------ | ------------------------------------------ | ------------------------------------------- | ---------- |
|
||
| GET | `/api/gamification/leaderboard` | Get leaderboard (scope, period, pagination) | Optional |
|
||
| POST | `/api/gamification/leaderboard` | Force refresh leaderboard cache | Required |
|
||
| GET | `/api/gamification/stream` | SSE real-time leaderboard updates | Optional |
|
||
| GET | `/api/gamification/transfer` | Get transfer history (pagination) | Required |
|
||
| POST | `/api/gamification/transfer` | Send tokens to another user | Required |
|
||
| GET | `/api/gamification/invite` | List my invite codes | Required |
|
||
| POST | `/api/gamification/invite` | Generate a new invite code | Required |
|
||
| DELETE | `/api/gamification/invite` | Revoke an invite code | Required |
|
||
| POST | `/api/gamification/invite/redeem` | Redeem an invite code | Required |
|
||
| GET | `/api/gamification/servers` | List community servers | Required |
|
||
| POST | `/api/gamification/servers` | Connect to a community server | Required |
|
||
| DELETE | `/api/gamification/servers` | Disconnect from a community server | Required |
|
||
| POST | `/api/gamification/federation/score` | Push score to remote server | Federation |
|
||
| GET | `/api/gamification/federation/leaderboard` | Pull leaderboard from remote | Federation |
|
||
| GET | `/api/gamification/notifications` | SSE badge/level-up notifications | Required |
|
||
| GET | `/api/gamification/anomalies` | View anomaly reports (admin) | Admin |
|
||
| POST | `/api/gamification/rotate` | Rotate invite token secrets | Required |
|
||
|
||
### Request/Response Examples
|
||
|
||
**POST /api/gamification/transfer**
|
||
|
||
```json
|
||
// Request
|
||
{
|
||
"to": "recipient-api-key-id",
|
||
"amount": 500,
|
||
"idempotencyKey": "uuid-v4"
|
||
}
|
||
|
||
// Response 200
|
||
{
|
||
"success": true,
|
||
"transfer": {
|
||
"id": "txn-uuid",
|
||
"from": "sender-api-key-id",
|
||
"to": "recipient-api-key-id",
|
||
"amount": 500,
|
||
"createdAt": "2026-05-19T12:00:00.000Z"
|
||
},
|
||
"balance": 2500
|
||
}
|
||
|
||
// Response 400 (insufficient funds)
|
||
{
|
||
"error": "Insufficient balance",
|
||
"balance": 200,
|
||
"requested": 500
|
||
}
|
||
```
|
||
|
||
**GET /api/gamification/leaderboard?scope=weekly&limit=10**
|
||
|
||
```json
|
||
{
|
||
"scope": "weekly",
|
||
"period": "2026-W20",
|
||
"entries": [
|
||
{
|
||
"rank": 1,
|
||
"apiKeyId": "key-uuid",
|
||
"displayName": "User***1234",
|
||
"score": 15230,
|
||
"level": 42,
|
||
"title": "Expert"
|
||
}
|
||
],
|
||
"total": 847,
|
||
"updatedAt": "2026-05-19T12:00:00.000Z"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## MCP Tools (8)
|
||
|
||
Registered in `open-sse/mcp-server/` alongside existing tools. Scoped under
|
||
the `gamification` permission scope.
|
||
|
||
| Tool | Description | Input Schema |
|
||
| -------------------------- | ------------------------------------- | ---------------------------- | --------- |
|
||
| `gamification_leaderboard` | Get leaderboard for a scope/period | `{ scope, period?, limit? }` |
|
||
| `gamification_rank` | Get caller's rank and neighbors | `{ scope }` |
|
||
| `gamification_profile` | Get XP, level, title, streak summary | `{}` |
|
||
| `gamification_badges` | List earned badges or all definitions | `{ earned?: boolean }` |
|
||
| `gamification_transfer` | Send tokens to another user | `{ to, amount }` |
|
||
| `gamification_invite` | Generate or list invite codes | `{ action: "create" | "list" }` |
|
||
| `gamification_servers` | List or connect community servers | `{ action, token? }` |
|
||
| `gamification_anomalies` | View anomaly reports (admin scope) | `{ limit?, since? }` |
|
||
|
||
---
|
||
|
||
## Dashboard Pages
|
||
|
||
### `/dashboard/leaderboard`
|
||
|
||
- Podium display (top 3 with avatars and XP).
|
||
- Scope selector: Global / Weekly / Monthly / Tokens Shared / Contributions.
|
||
- Paginated table (25 per page) with rank, name, score, level, title.
|
||
- SSE real-time updates — rank changes animate in.
|
||
- Current user highlighted in the table with a "Your Rank" sticky row.
|
||
|
||
### `/dashboard/profile`
|
||
|
||
- XP progress bar with current level and next-level threshold.
|
||
- Title badge displayed prominently.
|
||
- Badge gallery — earned badges with earn date, unearned badges grayed out
|
||
(hidden badges show "???" until earned).
|
||
- Streak counter with flame icon; streak calendar (last 30 days).
|
||
- XP history chart (daily XP over last 30 days).
|
||
|
||
### `/dashboard/tokens`
|
||
|
||
- Token balance (prominent, top of page).
|
||
- Transfer form: recipient, amount, confirm dialog.
|
||
- Transfer history table with filters (sent/received/all).
|
||
- Invite section: active codes, generate new, share link.
|
||
- Community servers: list with health status, connect/disconnect.
|
||
|
||
### `/dashboard/gamification/admin`
|
||
|
||
- Anomaly list with severity, user, timestamp, z-score.
|
||
- Audit log viewer with filters (action type, user, date range).
|
||
- System stats: total XP awarded, active users, badge earn rates.
|
||
- Federation server health overview.
|
||
|
||
---
|
||
|
||
## Pipeline Integration
|
||
|
||
### Integration Point
|
||
|
||
Gamification hooks into the request pipeline at a single point in
|
||
`open-sse/handlers/chatCore.ts`:
|
||
|
||
```typescript
|
||
// After response is sent to client:
|
||
setImmediate(() => {
|
||
emitGamificationEvent({
|
||
type: "request.completed",
|
||
apiKeyId,
|
||
metadata: {
|
||
provider: selectedProvider,
|
||
model: selectedModel,
|
||
comboId: resolvedCombo?.id,
|
||
compressionUsed: compressionStats?.applied,
|
||
skillUsed: skillExecution?.name,
|
||
},
|
||
}).catch(() => {
|
||
// Fire-and-forget: log but never propagate to client
|
||
});
|
||
});
|
||
```
|
||
|
||
### Event Types
|
||
|
||
| Event Type | When Emitted |
|
||
| ------------------- | ---------------------------------------- |
|
||
| `request.completed` | Successful LLM response sent |
|
||
| `provider.switch` | Provider changed (combo fallback counts) |
|
||
| `combo.created` | New combo configuration saved |
|
||
| `combo.used` | Combo target successfully hit |
|
||
| `badge.earned` | Badge evaluation found a match |
|
||
| `streak.milestone` | Streak threshold crossed |
|
||
| `transfer.sent` | Token transfer completed |
|
||
| `referral.redeemed` | Invite code successfully redeemed |
|
||
| `compression.used` | Prompt compression applied |
|
||
| `skill.executed` | Skill execution completed |
|
||
| `model.first_use` | Model not used in past 7 days |
|
||
|
||
### Non-Blocking Guarantee
|
||
|
||
The `setImmediate` + `.catch(() => {})` pattern ensures:
|
||
|
||
1. The response is fully sent before gamification runs.
|
||
2. Gamification errors never surface to the client.
|
||
3. The event processing runs in the next microtask, not inline.
|
||
|
||
---
|
||
|
||
## Security
|
||
|
||
### Threat Model
|
||
|
||
| Threat | Mitigation |
|
||
| ------------------------ | ------------------------------------------------------------------- |
|
||
| Score inflation | Server-side XP computation only; clients submit actions, not scores |
|
||
| Replay attacks | Idempotency keys on transfers; audit log dedup |
|
||
| Transfer fraud | Double-entry ledger; atomic transactions; rate limits |
|
||
| Self-referral | Cross-check `api_key_id` on redemption |
|
||
| Leaderboard manipulation | Z-score anomaly detection; admin anomaly dashboard |
|
||
| Federation token theft | SHA-256 hashed storage; raw token shown once only |
|
||
| Brute force invite codes | Rate limiting on redemption endpoint; 8-char entropy |
|
||
| XSS in display names | Display names sanitized; leaderboard entries escaped |
|
||
| Timing attacks on hashes | `crypto.timingSafeEqual` for token hash comparison |
|
||
|
||
### Auth Requirements
|
||
|
||
- **Public** (no auth): `GET /leaderboard`, `GET /stream` (read-only
|
||
leaderboards).
|
||
- **API key required**: all write operations, profile, transfers, invites.
|
||
- **Admin only**: anomaly dashboard, audit log viewer.
|
||
- **Federation**: separate auth path using raw token in `Authorization`
|
||
header, validated against stored SHA-256 hash.
|
||
|
||
---
|
||
|
||
## Testing
|
||
|
||
### Test Files
|
||
|
||
All tests use the Node.js native test runner (`node --import tsx/esm --test`).
|
||
|
||
| Test File | Covers | Tests |
|
||
| --------------------------------------------- | --------------------------------------- | ----- |
|
||
| `tests/unit/gamification/xp.test.ts` | XP calculation, level curve, titles | 8 |
|
||
| `tests/unit/gamification/badges.test.ts` | Badge criteria matching, awarding | 10 |
|
||
| `tests/unit/gamification/streaks.test.ts` | Streak logic, milestones, edge cases | 7 |
|
||
| `tests/unit/gamification/leaderboard.test.ts` | Rank computation, pagination, rotation | 8 |
|
||
| `tests/unit/gamification/sharing.test.ts` | Transfers, balance, idempotency | 9 |
|
||
| `tests/unit/gamification/invites.test.ts` | Create, redeem, expiry, self-referral | 7 |
|
||
| `tests/unit/gamification/antiCheat.test.ts` | Rate limits, z-score, audit logging | 6 |
|
||
| `tests/unit/gamification/events.test.ts` | Event emission, fan-out, error handling | 5 |
|
||
|
||
### Running Tests
|
||
|
||
```bash
|
||
# All gamification tests
|
||
node --import tsx/esm --test tests/unit/gamification/*.test.ts
|
||
|
||
# Single test file
|
||
node --import tsx/esm --test tests/unit/gamification/xp.test.ts
|
||
```
|
||
|
||
### Coverage Requirements
|
||
|
||
Per `CONTRIBUTING.md` — all new modules must have:
|
||
|
||
- Branch coverage >= 80%.
|
||
- Every public function tested at least once.
|
||
- Error paths tested (insufficient balance, expired codes, rate limits).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
src/
|
||
lib/
|
||
db/
|
||
migrations/
|
||
060_create_gamification.sql # All 8 tables + indexes
|
||
gamification.ts # Domain CRUD module
|
||
gamification/
|
||
xp.ts # XP calculation, level curve, titles
|
||
badges.ts # Badge definitions, criteria, evaluation
|
||
streaks.ts # Daily streak tracking
|
||
leaderboard.ts # Rank computation, SSE, rotation
|
||
antiCheat.ts # Rate limiting, z-score, audit
|
||
sharing.ts # Token transfer ledger
|
||
invites.ts # Invite/redeem codes
|
||
servers.ts # Community server federation
|
||
events.ts # Event emitter (integration point)
|
||
notifications.ts # SSE notification stream
|
||
app/
|
||
api/
|
||
gamification/
|
||
leaderboard/route.ts # GET/POST leaderboard
|
||
leaderboard/stream/route.ts # SSE real-time updates
|
||
transfer/route.ts # GET/POST transfers
|
||
invite/route.ts # GET/POST/DELETE invite codes
|
||
invite/redeem/route.ts # POST redeem code
|
||
servers/route.ts # GET/POST/DELETE servers
|
||
federation/score/route.ts # POST push score
|
||
federation/leaderboard/route.ts # GET pull leaderboard
|
||
notifications/route.ts # SSE notifications
|
||
anomalies/route.ts # GET anomaly reports
|
||
rotate/route.ts # POST rotate secrets
|
||
(dashboard)/
|
||
dashboard/
|
||
leaderboard/page.tsx # Rankings page
|
||
profile/page.tsx # XP/badges/streaks page
|
||
tokens/page.tsx # Balance/transfers/invites page
|
||
gamification/admin/page.tsx # Admin anomaly monitoring
|
||
shared/
|
||
constants/
|
||
gamification.ts # XP_REWARDS, TITLES, BADGE_DEFS, LIMITS
|
||
|
||
tests/
|
||
unit/
|
||
gamification/
|
||
xp.test.ts
|
||
badges.test.ts
|
||
streaks.test.ts
|
||
leaderboard.test.ts
|
||
sharing.test.ts
|
||
invites.test.ts
|
||
antiCheat.test.ts
|
||
events.test.ts
|
||
|
||
docs/
|
||
frameworks/
|
||
GAMIFICATION.md # This document
|
||
```
|
||
|
||
---
|
||
|
||
## Migration Strategy
|
||
|
||
### Phase 1: Backend Core (PR 1)
|
||
|
||
- Migration `060_create_gamification.sql` (8 tables).
|
||
- `src/lib/db/gamification.ts` (domain module).
|
||
- `src/lib/gamification/xp.ts`, `streaks.ts`, `events.ts`.
|
||
- Integration point in `chatCore.ts`.
|
||
- Unit tests for XP, streaks, events.
|
||
|
||
### Phase 2: Badges & Leaderboard (PR 2)
|
||
|
||
- `src/lib/gamification/badges.ts`, `leaderboard.ts`.
|
||
- Badge definitions in constants.
|
||
- Leaderboard API routes + SSE stream.
|
||
- Unit tests for badges, leaderboard.
|
||
|
||
### Phase 3: Sharing & Invites (PR 3)
|
||
|
||
- `src/lib/gamification/sharing.ts`, `invites.ts`, `antiCheat.ts`.
|
||
- Transfer + invite API routes.
|
||
- Unit tests for sharing, invites, anti-cheat.
|
||
|
||
### Phase 4: Federation & Dashboard (PR 4)
|
||
|
||
- `src/lib/gamification/servers.ts`, `notifications.ts`.
|
||
- Federation API routes.
|
||
- Dashboard pages (leaderboard, profile, tokens, admin).
|
||
- MCP tools registration.
|
||
|
||
---
|
||
|
||
## Future Considerations
|
||
|
||
- **Seasonal events**: time-limited badge sets and leaderboard seasons.
|
||
- **Team leaderboards**: group users by organization or combo.
|
||
- **XP multipliers**: boost XP during promotional periods.
|
||
- **Achievement sharing**: generate shareable badge cards (OpenGraph images).
|
||
- **Mobile push**: webhook-based notifications for badge/level events.
|
||
- **Leaderboard API**: public API for third-party integrations.
|