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`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 { 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 };