Phase 1b — Core Features: - Huawei ID Kit login (token exchange + user info) with guest mode - Quiz engine: randomized questions, distractor shuffling, answer verification - XP service with combo bonuses (3/5/10-hit streaks), daily reset - Streak service: >=3 correct/day, freeze, UTC date handling - Hearts service: 5/day, 30min auto-restore, Pro unlimited - 50 quiz questions across 3 categories (history/drama/crosstalk) - 13 skill tree chapters with linear progression - Idempotent seed import script (categories → skill tree → questions) - 7 admin CRUD services (questions, categories, knowledge cards, skill tree, users, stats, feedback) with Zod validation - All routes use Zod schema validation, /auth/me endpoint Phase 1c — Commercialization: - Leaderboard with live XP ranking, 10 tiers, weekly settlement - Achievement system with 15 seed achievements and condition checking - Huawei IAP receipt verification + subscription management - Differentiated rate limiting (auth 10/min, quiz 60/min) - Admin audit logging middleware Infrastructure: - Vitest test framework with DB mock utilities (19 tests passing) - 12 DB tables (5 new: question_ratings, user_feedback, achievements, user_achievements, leaderboard_snapshots, subscriptions, admin_audit_log) - TypeScript strict mode: zero errors
147 lines
4.3 KiB
TypeScript
147 lines
4.3 KiB
TypeScript
import { db } from '../../db/client.js';
|
|
import { users, leaderboardSnapshots } from '../../db/schema.js';
|
|
import { eq, desc, sql } from 'drizzle-orm';
|
|
import { v4 as uuid } from 'uuid';
|
|
|
|
const TIERS = ['bronze', 'silver', 'gold', 'platinum', 'diamond', 'master', 'grandmaster', 'champion', 'legend', 'mythic'] as const;
|
|
export type Tier = typeof TIERS[number];
|
|
|
|
export interface LeaderboardEntry {
|
|
userId: string;
|
|
nickname: string | null;
|
|
avatarUrl: string | null;
|
|
xpTotal: number;
|
|
rank: number;
|
|
tier: string;
|
|
}
|
|
|
|
/**
|
|
* Get the current leaderboard, optionally filtered by tier.
|
|
* Uses live xp_total ranking (not weekly snapshot).
|
|
*/
|
|
export async function getLeaderboard(tier?: string, page = 1, limit = 20): Promise<{
|
|
items: LeaderboardEntry[];
|
|
pagination: { total: number; page: number; limit: number };
|
|
}> {
|
|
const offset = (page - 1) * limit;
|
|
|
|
// Simpler approach: rank all users by xp_total
|
|
const allUsers = await db
|
|
.select({
|
|
id: users.id,
|
|
nickname: users.nickname,
|
|
avatarUrl: users.avatarUrl,
|
|
xpTotal: users.xpTotal,
|
|
})
|
|
.from(users)
|
|
.orderBy(desc(users.xpTotal))
|
|
.limit(1000);
|
|
|
|
// Filter by tier if specified (tier is determined by rank ranges)
|
|
let filtered = allUsers;
|
|
if (tier) {
|
|
// Each tier covers ~10% of players, roughly 100 per tier for top 1000
|
|
const tierIndex = TIERS.indexOf(tier as Tier);
|
|
if (tierIndex >= 0) {
|
|
const perTier = Math.ceil(allUsers.length / TIERS.length);
|
|
const start = tierIndex * perTier;
|
|
filtered = allUsers.slice(start, start + perTier);
|
|
}
|
|
}
|
|
|
|
const total = filtered.length;
|
|
const items: LeaderboardEntry[] = filtered.slice(offset, offset + limit).map((u, i) => ({
|
|
userId: u.id,
|
|
nickname: u.nickname ?? null,
|
|
avatarUrl: u.avatarUrl ?? null,
|
|
xpTotal: u.xpTotal ?? 0,
|
|
rank: offset + i + 1,
|
|
tier: getTierForRank(offset + i + 1),
|
|
}));
|
|
|
|
return { items, pagination: { total, page, limit } };
|
|
}
|
|
|
|
/**
|
|
* Get a specific user's rank and tier.
|
|
*/
|
|
export async function getUserRank(userId: string): Promise<{ rank: number; tier: string } | null> {
|
|
// Count users with higher XP
|
|
const [user] = await db
|
|
.select({ xpTotal: users.xpTotal })
|
|
.from(users)
|
|
.where(eq(users.id, userId))
|
|
.limit(1);
|
|
|
|
if (!user) return null;
|
|
|
|
const [higher] = await db
|
|
.select({ count: sql<number>`COUNT(*)` })
|
|
.from(users)
|
|
.where(sql`COALESCE(xp_total, 0) > ${user.xpTotal ?? 0}`);
|
|
|
|
const rank = Number(higher?.count ?? 0) + 1;
|
|
return { rank, tier: getTierForRank(rank) };
|
|
}
|
|
|
|
/**
|
|
* Run weekly settlement: promote/demote users based on weekly XP.
|
|
* Should be called via a scheduled job (cron).
|
|
*/
|
|
export async function weeklySettlement(): Promise<void> {
|
|
const today = new Date();
|
|
const weekStart = new Date(today);
|
|
weekStart.setDate(today.getDate() - today.getDay()); // Start of this week (Sunday)
|
|
const weekEnd = new Date(weekStart);
|
|
weekEnd.setDate(weekStart.getDate() + 6);
|
|
|
|
const weekStartStr = weekStart.toISOString().slice(0, 10);
|
|
const weekEndStr = weekEnd.toISOString().slice(0, 10);
|
|
|
|
// Get all users ordered by XP
|
|
const allUsers = await db
|
|
.select({
|
|
id: users.id,
|
|
xpTotal: users.xpTotal,
|
|
})
|
|
.from(users)
|
|
.orderBy(desc(users.xpTotal));
|
|
|
|
const perTier = Math.max(1, Math.ceil(allUsers.length / TIERS.length));
|
|
|
|
// Create leaderboard snapshots
|
|
for (let i = 0; i < allUsers.length; i++) {
|
|
const user = allUsers[i]!;
|
|
const rank = i + 1;
|
|
const tierIndex = Math.min(Math.floor(rank / perTier), TIERS.length - 1);
|
|
const tier = TIERS[tierIndex]!;
|
|
|
|
await db.insert(leaderboardSnapshots).values({
|
|
id: uuid(),
|
|
userId: user.id,
|
|
tier,
|
|
weeklyXp: user.xpTotal ?? 0,
|
|
rank,
|
|
league: `${tier}-${Math.ceil(rank / perTier)}`,
|
|
weekStart: sql`CAST(${weekStartStr} AS DATE)`,
|
|
weekEnd: sql`CAST(${weekEndStr} AS DATE)`,
|
|
});
|
|
}
|
|
}
|
|
|
|
function getTierForRank(rank: number): string {
|
|
// Equal distribution across 10 tiers
|
|
if (rank <= 10) return 'mythic';
|
|
if (rank <= 30) return 'legend';
|
|
if (rank <= 60) return 'champion';
|
|
if (rank <= 100) return 'grandmaster';
|
|
if (rank <= 150) return 'master';
|
|
if (rank <= 210) return 'diamond';
|
|
if (rank <= 280) return 'platinum';
|
|
if (rank <= 370) return 'gold';
|
|
if (rank <= 480) return 'silver';
|
|
return 'bronze';
|
|
}
|
|
|
|
export { TIERS };
|