diff --git a/scripts/reseed-ratings.ts b/scripts/reseed-ratings.ts new file mode 100644 index 0000000..b03210e --- /dev/null +++ b/scripts/reseed-ratings.ts @@ -0,0 +1,131 @@ +/** + * Re-seed all existing player ratings from osu! global rank. + * + * Formula: elo = 3500 - log10(rank) * 500, clamped to [0, 3500] + * Adjusted by win/loss differential: seedElo + (wins - losses) * 12 + * + * Run with: bun run scripts/reseed-ratings.ts + */ + +import postgres from 'postgres'; + +const DATABASE_URL = process.env.DATABASE_URL!; +const OSU_CLIENT_ID = process.env.OSU_CLIENT_ID!; +const OSU_CLIENT_SECRET = process.env.OSU_CLIENT_SECRET!; + +for (const [k, v] of Object.entries({ DATABASE_URL, OSU_CLIENT_ID, OSU_CLIENT_SECRET })) { + if (!v) { + console.error(`Missing env var: ${k}`); + process.exit(1); + } +} + +const sql = postgres(DATABASE_URL); + +// ── osu! API ─────────────────────────────────────────────────────────── + +let clientToken: string | null = null; +let clientTokenExpiresAt = 0; + +async function getClientToken(): Promise { + if (clientToken && Date.now() < clientTokenExpiresAt - 60_000) { + return clientToken; + } + + const res = await fetch('https://osu.ppy.sh/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: OSU_CLIENT_ID, + client_secret: OSU_CLIENT_SECRET, + grant_type: 'client_credentials', + scope: 'identify public' + }) + }); + + if (!res.ok) throw new Error(`osu! token: ${res.status}`); + const data = await res.json(); + clientToken = data.access_token; + clientTokenExpiresAt = Date.now() + data.expires_in * 1000; + return clientToken!; +} + +async function getOsuUser(osuId: string) { + const token = await getClientToken(); + const res = await fetch(`https://osu.ppy.sh/api/v2/users/${osuId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!res.ok) throw new Error(`osu! user ${osuId}: ${res.status}`); + return res.json(); +} + +function rankToElo(rank: number): number { + const raw = 3500 - Math.log10(rank) * 500; + return Math.round(Math.max(0, Math.min(3500, raw))); +} + +// ── Main ─────────────────────────────────────────────────────────────── + +async function main() { + const ratings = await sql`SELECT pr.id, pr.user_id, pr.elo, pr.wins, pr.losses + FROM player_rating pr`; + + console.log(`Found ${ratings.length} player(s) to re-seed\n`); + + let updated = 0; + let skipped = 0; + + for (const r of ratings) { + // Look up the osu! account ID for this user + const [acc] = await sql`SELECT account_id FROM account + WHERE user_id = ${r.user_id} AND provider_id = 'osu' LIMIT 1`; + + if (!acc) { + console.log(` [SKIP] user ${r.user_id} — no osu! account linked`); + skipped++; + continue; + } + + try { + const osuUser = await getOsuUser(acc.account_id); + const rank = osuUser?.statistics?.global_rank; + + if (!rank || rank <= 0) { + console.log(` [SKIP] user ${r.user_id} (osu! ${acc.account_id}) — unranked`); + skipped++; + continue; + } + + const seedElo = rankToElo(rank); + // Adjust by win/loss differential to preserve some earned progress + const adjustment = (r.wins - r.losses) * 12; + const finalElo = Math.max(0, Math.min(3500, seedElo + adjustment)); + + await sql`UPDATE player_rating SET + elo = ${finalElo}, + initial_elo = ${seedElo}, + osu_rank_at_seed = ${rank}, + updated_at = now() + WHERE id = ${r.id}`; + + console.log( + ` [OK] user ${r.user_id} — rank #${rank.toLocaleString()} → seed ${seedElo}, adj ${adjustment > 0 ? '+' : ''}${adjustment} → final ${finalElo} (was ${r.elo})` + ); + updated++; + } catch (err: any) { + console.error(` [ERR] user ${r.user_id} (osu! ${acc.account_id}):`, err.message); + skipped++; + } + + // Rate limit: 500ms between API calls + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + console.log(`\nDone: ${updated} updated, ${skipped} skipped`); + await sql.end(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 3244858..975e18e 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -195,6 +195,8 @@ export const playerRating = pgTable('player_rating', { elo: integer('elo').default(1000).notNull(), wins: integer('wins').default(0).notNull(), losses: integer('losses').default(0).notNull(), + initialElo: integer('initial_elo'), + osuRankAtSeed: integer('osu_rank_at_seed'), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() }); diff --git a/src/lib/server/match/engine.ts b/src/lib/server/match/engine.ts index a68aa8a..c90feeb 100644 --- a/src/lib/server/match/engine.ts +++ b/src/lib/server/match/engine.ts @@ -9,9 +9,42 @@ import { playerRating, teamMember } from '$lib/server/db/schema'; -import { eq, asc, sql, inArray, lt } from 'drizzle-orm'; +import { account } from '$lib/server/db/auth.schema'; +import { eq, and, asc, sql, inArray, lt } from 'drizzle-orm'; import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types'; import { notifyMatchCreated, notifyMatchFinished } from '$lib/server/discord/client'; +import { getUser } from '$lib/server/osu/api'; + +// ── Initial Rating ────────────────────────────────────────────────────── + +async function calculateInitialElo(userId: string): Promise<{ elo: number; osuRank: number | null }> { + const osuAccount = await db.query.account.findFirst({ + where: and(eq(account.userId, userId), eq(account.providerId, 'osu')) + }); + + if (!osuAccount?.accountId) { + return { elo: 1000, osuRank: null }; + } + + try { + const osuUser = await getUser(osuAccount.accountId); + const rank = osuUser?.statistics?.global_rank; + + if (!rank || rank <= 0) { + return { elo: 1000, osuRank: null }; + } + + // elo = 3500 - log10(rank) * 500 + // rank 1 → 3500, rank 1000 → 2000, rank 100k → 1000, rank 1M → 500 + const rawElo = 3500 - Math.log10(rank) * 500; + const elo = Math.round(Math.max(0, Math.min(3500, rawElo))); + + return { elo, osuRank: rank }; + } catch (err) { + console.warn(`[Rating] Failed to fetch osu! rank for user ${userId}, defaulting to 1000:`, err); + return { elo: 1000, osuRank: null }; + } +} // ── Queue ─────────────────────────────────────────────────────────────── @@ -36,9 +69,10 @@ export async function joinQueue(userId: string, teamId: string) { }); if (!rating) { + const { elo: initialElo, osuRank } = await calculateInitialElo(userId); [rating] = await db .insert(playerRating) - .values({ userId, elo: 1000 }) + .values({ userId, elo: initialElo, initialElo, osuRankAtSeed: osuRank }) .returning(); } @@ -192,8 +226,8 @@ async function selectMappoolForRating(avgElo: number) { if (pools.length === 0) return null; - // Map ELO range (e.g. 800-1200) to star rating range (e.g. 3-7) - const targetStars = 3 + ((avgElo - 800) / 400) * 4; + // Map ELO range (0-3500) to star rating range (2-8) + const targetStars = 2 + avgElo / 700; const clampedTarget = Math.max(2, Math.min(8, targetStars)); // Score each pool by distance from target @@ -513,29 +547,52 @@ export async function submitGameScores( // ── ELO ───────────────────────────────────────────────────────────────── async function updateElo(participants: { id: string; teamId: string }[], winnerId: string) { + // Gather all players and their ratings per participant (team side) + const sides = new Map(); + for (const p of participants) { const players = await db.query.matchParticipantPlayer.findMany({ where: eq(matchParticipantPlayer.participantId, p.id) }); - const isWinner = p.id === winnerId; - + const playersWithRatings = []; for (const player of players) { let rating = await db.query.playerRating.findFirst({ where: eq(playerRating.userId, player.userId) }); if (!rating) { + const { elo: initialElo, osuRank } = await calculateInitialElo(player.userId); [rating] = await db .insert(playerRating) - .values({ userId: player.userId, elo: 1000 }) + .values({ userId: player.userId, elo: initialElo, initialElo, osuRankAtSeed: osuRank }) .returning(); } - const K = 32; - const expected = 0.5; + playersWithRatings.push({ userId: player.userId, rating }); + } + sides.set(p.id, playersWithRatings); + } + + // Compute average ELO per side for expected-score calculation + const avgElo = new Map(); + for (const [pid, players] of sides) { + avgElo.set(pid, players.reduce((sum, p) => sum + p.rating.elo, 0) / players.length); + } + + // Update each player's ELO using proper expected-score formula + for (const p of participants) { + const isWinner = p.id === winnerId; + const opponent = participants.find((op) => op.id !== p.id)!; + const opponentAvg = avgElo.get(opponent.id)!; + + for (const { userId, rating } of sides.get(p.id)!) { + const gamesPlayed = rating.wins + rating.losses; + const K = gamesPlayed < 10 ? 40 : gamesPlayed < 30 ? 32 : 24; + + const expected = 1 / (1 + Math.pow(10, (opponentAvg - rating.elo) / 400)); const actual = isWinner ? 1 : 0; - const newElo = Math.round(rating.elo + K * (actual - expected)); + const newElo = Math.max(0, Math.round(rating.elo + K * (actual - expected))); await db .update(playerRating) @@ -545,7 +602,7 @@ async function updateElo(participants: { id: string; teamId: string }[], winnerI losses: isWinner ? rating.losses : rating.losses + 1, updatedAt: new Date() }) - .where(eq(playerRating.userId, player.userId)); + .where(eq(playerRating.userId, userId)); } } } diff --git a/src/routes/(app)/leaderboard/+page.svelte b/src/routes/(app)/leaderboard/+page.svelte index b7aa27a..86387e0 100644 --- a/src/routes/(app)/leaderboard/+page.svelte +++ b/src/routes/(app)/leaderboard/+page.svelte @@ -10,9 +10,10 @@ ]; function eloColor(elo: number): string { - if (elo >= 1400) return 'text-yellow-400'; - if (elo >= 1200) return 'text-purple-400'; - if (elo >= 1000) return 'text-blue-400'; + if (elo >= 2500) return 'text-yellow-400'; + if (elo >= 2000) return 'text-purple-400'; + if (elo >= 1500) return 'text-blue-400'; + if (elo >= 1000) return 'text-green-400'; return 'text-text-secondary'; } @@ -235,10 +236,11 @@

How ELO Works

- Every player starts at 1000 ELO. Win a match to gain points, lose to drop. - The system uses K=32, so new players' ratings shift quickly. As you play more, - your rating stabilizes around your true skill level. Queue matchmaking pairs players - with similar ELO for fair matches. + Your initial rating is seeded from your osu! global rank. Winning gains ELO and + losing drops it, but the amount depends on your opponent's rating — beating a + stronger player earns more points. New players' ratings shift faster to help + find your true skill level quickly. Queue matchmaking pairs players with similar + ELO for fair matches.

diff --git a/src/routes/(app)/settings/+page.server.ts b/src/routes/(app)/settings/+page.server.ts index d3788ca..272605f 100644 --- a/src/routes/(app)/settings/+page.server.ts +++ b/src/routes/(app)/settings/+page.server.ts @@ -31,7 +31,7 @@ export const load: PageServerLoad = async ({ locals }) => { }, rating: rating ? { elo: rating.elo, wins: rating.wins, losses: rating.losses } - : { elo: 1000, wins: 0, losses: 0 }, + : null, hasOsuLinked: !!osuAccount }; }; diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index 4004f36..85a0a99 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -12,7 +12,7 @@ const badge = $derived(roleBadge[data.profile.role] ?? roleBadge.player); const winRate = $derived( - data.rating.wins + data.rating.losses > 0 + data.rating && data.rating.wins + data.rating.losses > 0 ? ((data.rating.wins / (data.rating.wins + data.rating.losses)) * 100).toFixed(1) : '—' ); @@ -79,15 +79,15 @@

Ranked Stats

-

{data.rating.elo}

+

{data.rating?.elo ?? '—'}

ELO Rating

-

{data.rating.wins}

+

{data.rating?.wins ?? 0}

Wins

-

{data.rating.losses}

+

{data.rating?.losses ?? 0}

Losses