/** * 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 { SQL } from 'bun'; 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 = new SQL(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`; let rank: number | null = null; if (!acc) { console.log(` [WARN] user ${r.user_id} — no osu! account linked, treating as unranked`); } else { try { const osuUser = await getOsuUser(acc.account_id); rank = osuUser?.statistics?.global_rank ?? null; } catch (err: any) { console.error(` [ERR] user ${r.user_id} (osu! ${acc.account_id}):`, err.message); skipped++; continue; } } // Unranked → rank 10,000,000 (yields 0 ELO) const effectiveRank = rank && rank > 0 ? rank : 10_000_000; const seedElo = rankToElo(effectiveRank); // 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 ? rank.toLocaleString() : 'unranked (10M)'} → seed ${seedElo}, adj ${adjustment > 0 ? '+' : ''}${adjustment} → final ${finalElo} (was ${r.elo})` ); updated++; // Rate limit: 500ms between API calls await new Promise((resolve) => setTimeout(resolve, 500)); } console.log(`\nDone: ${updated} updated, ${skipped} skipped`); await sql.close(); } main().catch((err) => { console.error(err); process.exit(1); });