set rating based on user score

This commit is contained in:
2026-03-07 02:42:35 +01:00
parent c3b3534b6e
commit c6511c7f87
6 changed files with 215 additions and 23 deletions

131
scripts/reseed-ratings.ts Normal file
View File

@@ -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<string> {
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);
});

View File

@@ -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()
});

View File

@@ -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<string, { userId: string; rating: typeof playerRating.$inferSelect }[]>();
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<string, number>();
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));
}
}
}

View File

@@ -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 @@
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
<h2 class="text-sm font-600">How ELO Works</h2>
<p class="mt-2 text-xs text-text-secondary leading-relaxed">
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.
</p>
</div>
</div>

View File

@@ -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
};
};

View File

@@ -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 @@
<h3 class="text-xs font-600 text-text-secondary uppercase tracking-wider">Ranked Stats</h3>
<div class="mt-4 grid grid-cols-4 gap-4">
<div>
<p class="text-2xl font-800 tabular-nums text-accent">{data.rating.elo}</p>
<p class="text-2xl font-800 tabular-nums text-accent">{data.rating?.elo ?? '—'}</p>
<p class="mt-1 text-xs text-text-secondary">ELO Rating</p>
</div>
<div>
<p class="text-2xl font-800 tabular-nums text-green-400">{data.rating.wins}</p>
<p class="text-2xl font-800 tabular-nums text-green-400">{data.rating?.wins ?? 0}</p>
<p class="mt-1 text-xs text-text-secondary">Wins</p>
</div>
<div>
<p class="text-2xl font-800 tabular-nums text-red-400">{data.rating.losses}</p>
<p class="text-2xl font-800 tabular-nums text-red-400">{data.rating?.losses ?? 0}</p>
<p class="mt-1 text-xs text-text-secondary">Losses</p>
</div>
<div>