set rating based on user score
This commit is contained in:
131
scripts/reseed-ratings.ts
Normal file
131
scripts/reseed-ratings.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -195,6 +195,8 @@ export const playerRating = pgTable('player_rating', {
|
|||||||
elo: integer('elo').default(1000).notNull(),
|
elo: integer('elo').default(1000).notNull(),
|
||||||
wins: integer('wins').default(0).notNull(),
|
wins: integer('wins').default(0).notNull(),
|
||||||
losses: integer('losses').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()
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,42 @@ import {
|
|||||||
playerRating,
|
playerRating,
|
||||||
teamMember
|
teamMember
|
||||||
} from '$lib/server/db/schema';
|
} 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 { MATCH_STATES, GAME_STATES, type MatchConfig } from './types';
|
||||||
import { notifyMatchCreated, notifyMatchFinished } from '$lib/server/discord/client';
|
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 ───────────────────────────────────────────────────────────────
|
// ── Queue ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -36,9 +69,10 @@ export async function joinQueue(userId: string, teamId: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!rating) {
|
if (!rating) {
|
||||||
|
const { elo: initialElo, osuRank } = await calculateInitialElo(userId);
|
||||||
[rating] = await db
|
[rating] = await db
|
||||||
.insert(playerRating)
|
.insert(playerRating)
|
||||||
.values({ userId, elo: 1000 })
|
.values({ userId, elo: initialElo, initialElo, osuRankAtSeed: osuRank })
|
||||||
.returning();
|
.returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,8 +226,8 @@ async function selectMappoolForRating(avgElo: number) {
|
|||||||
|
|
||||||
if (pools.length === 0) return null;
|
if (pools.length === 0) return null;
|
||||||
|
|
||||||
// Map ELO range (e.g. 800-1200) to star rating range (e.g. 3-7)
|
// Map ELO range (0-3500) to star rating range (2-8)
|
||||||
const targetStars = 3 + ((avgElo - 800) / 400) * 4;
|
const targetStars = 2 + avgElo / 700;
|
||||||
const clampedTarget = Math.max(2, Math.min(8, targetStars));
|
const clampedTarget = Math.max(2, Math.min(8, targetStars));
|
||||||
|
|
||||||
// Score each pool by distance from target
|
// Score each pool by distance from target
|
||||||
@@ -513,29 +547,52 @@ export async function submitGameScores(
|
|||||||
// ── ELO ─────────────────────────────────────────────────────────────────
|
// ── ELO ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function updateElo(participants: { id: string; teamId: string }[], winnerId: string) {
|
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) {
|
for (const p of participants) {
|
||||||
const players = await db.query.matchParticipantPlayer.findMany({
|
const players = await db.query.matchParticipantPlayer.findMany({
|
||||||
where: eq(matchParticipantPlayer.participantId, p.id)
|
where: eq(matchParticipantPlayer.participantId, p.id)
|
||||||
});
|
});
|
||||||
|
|
||||||
const isWinner = p.id === winnerId;
|
const playersWithRatings = [];
|
||||||
|
|
||||||
for (const player of players) {
|
for (const player of players) {
|
||||||
let rating = await db.query.playerRating.findFirst({
|
let rating = await db.query.playerRating.findFirst({
|
||||||
where: eq(playerRating.userId, player.userId)
|
where: eq(playerRating.userId, player.userId)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!rating) {
|
if (!rating) {
|
||||||
|
const { elo: initialElo, osuRank } = await calculateInitialElo(player.userId);
|
||||||
[rating] = await db
|
[rating] = await db
|
||||||
.insert(playerRating)
|
.insert(playerRating)
|
||||||
.values({ userId: player.userId, elo: 1000 })
|
.values({ userId: player.userId, elo: initialElo, initialElo, osuRankAtSeed: osuRank })
|
||||||
.returning();
|
.returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
const K = 32;
|
playersWithRatings.push({ userId: player.userId, rating });
|
||||||
const expected = 0.5;
|
}
|
||||||
|
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 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
|
await db
|
||||||
.update(playerRating)
|
.update(playerRating)
|
||||||
@@ -545,7 +602,7 @@ async function updateElo(participants: { id: string; teamId: string }[], winnerI
|
|||||||
losses: isWinner ? rating.losses : rating.losses + 1,
|
losses: isWinner ? rating.losses : rating.losses + 1,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
})
|
})
|
||||||
.where(eq(playerRating.userId, player.userId));
|
.where(eq(playerRating.userId, userId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
function eloColor(elo: number): string {
|
function eloColor(elo: number): string {
|
||||||
if (elo >= 1400) return 'text-yellow-400';
|
if (elo >= 2500) return 'text-yellow-400';
|
||||||
if (elo >= 1200) return 'text-purple-400';
|
if (elo >= 2000) return 'text-purple-400';
|
||||||
if (elo >= 1000) return 'text-blue-400';
|
if (elo >= 1500) return 'text-blue-400';
|
||||||
|
if (elo >= 1000) return 'text-green-400';
|
||||||
return 'text-text-secondary';
|
return 'text-text-secondary';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,10 +236,11 @@
|
|||||||
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
|
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
|
||||||
<h2 class="text-sm font-600">How ELO Works</h2>
|
<h2 class="text-sm font-600">How ELO Works</h2>
|
||||||
<p class="mt-2 text-xs text-text-secondary leading-relaxed">
|
<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.
|
Your initial rating is seeded from your osu! global rank. Winning gains ELO and
|
||||||
The system uses K=32, so new players' ratings shift quickly. As you play more,
|
losing drops it, but the amount depends on your opponent's rating — beating a
|
||||||
your rating stabilizes around your true skill level. Queue matchmaking pairs players
|
stronger player earns more points. New players' ratings shift faster to help
|
||||||
with similar ELO for fair matches.
|
find your true skill level quickly. Queue matchmaking pairs players with similar
|
||||||
|
ELO for fair matches.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
},
|
},
|
||||||
rating: rating
|
rating: rating
|
||||||
? { elo: rating.elo, wins: rating.wins, losses: rating.losses }
|
? { elo: rating.elo, wins: rating.wins, losses: rating.losses }
|
||||||
: { elo: 1000, wins: 0, losses: 0 },
|
: null,
|
||||||
hasOsuLinked: !!osuAccount
|
hasOsuLinked: !!osuAccount
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
const badge = $derived(roleBadge[data.profile.role] ?? roleBadge.player);
|
const badge = $derived(roleBadge[data.profile.role] ?? roleBadge.player);
|
||||||
|
|
||||||
const winRate = $derived(
|
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)
|
? ((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>
|
<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 class="mt-4 grid grid-cols-4 gap-4">
|
||||||
<div>
|
<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>
|
<p class="mt-1 text-xs text-text-secondary">ELO Rating</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p class="mt-1 text-xs text-text-secondary">Wins</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p class="mt-1 text-xs text-text-secondary">Losses</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user