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

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>