Merge pull request #111 from VashSoftware/rating_stuff

move rating stuff and helper stuff into rating.ts and helpers.ts
This commit is contained in:
nick
2026-03-13 08:44:00 -04:00
committed by GitHub
7 changed files with 201 additions and 183 deletions

View File

@@ -6,45 +6,13 @@ import {
matchGame, matchGame,
matchGameScore, matchGameScore,
mappoolSlot, mappoolSlot,
playerRating,
teamMember teamMember
} from '$lib/server/db/schema'; } from '$lib/server/db/schema';
import { account } from '$lib/server/db/auth.schema'; import { eq } from 'drizzle-orm';
import { eq, and } 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'; import { updateElo } from './rating';
import { getExpectedPicker, getMatchOrThrow, assertState, getMatchFull } from './helpers';
// ── Initial Rating ──────────────────────────────────────────────────────
export 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 };
}
}
// ── Create Match ──────────────────────────────────────────────────────── // ── Create Match ────────────────────────────────────────────────────────
@@ -363,66 +331,6 @@ export async function submitGameScores(
return getMatchFull(game.matchId); return getMatchFull(game.matchId);
} }
// ── 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 playersWithRatings = [];
for (const player of players) {
const rating = await db.query.playerRating.findFirst({
where: eq(playerRating.userId, player.userId)
});
if (!rating) {
console.warn(`[ELO] No rating for user ${player.userId}, skipping`);
continue;
}
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.max(0, Math.round(rating.elo + K * (actual - expected)));
await db
.update(playerRating)
.set({
elo: newElo,
wins: isWinner ? rating.wins + 1 : rating.wins,
losses: isWinner ? rating.losses : rating.losses + 1,
updatedAt: new Date()
})
.where(eq(playerRating.userId, userId));
}
}
}
// ── Cancel Match ──────────────────────────────────────────────────────── // ── Cancel Match ────────────────────────────────────────────────────────
export async function cancelMatch(matchId: string) { export async function cancelMatch(matchId: string) {
@@ -436,58 +344,3 @@ export async function cancelMatch(matchId: string) {
return getMatchFull(matchId); return getMatchFull(matchId);
} }
// ── Helpers ─────────────────────────────────────────────────────────────
function getExpectedPicker(
participants: { id: string; pickOrder: number | null; score: number }[],
gameCount: number
) {
const sorted = [...participants].sort((a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99));
const pickerIndex = gameCount % sorted.length;
return sorted[pickerIndex];
}
export async function getMatchFull(matchId: string) {
const m = await db.query.match.findFirst({
where: eq(match.id, matchId),
with: {
mappool: {
with: { slots: true }
},
participants: {
with: {
team: true,
players: true
}
},
games: {
with: {
slot: true,
scores: {
with: { player: true },
orderBy: (s, { desc }) => [desc(s.score)]
}
},
orderBy: (g, { asc }) => [asc(g.gameNumber)]
}
}
});
if (!m) throw new Error('Match not found');
return m;
}
async function getMatchOrThrow(matchId: string) {
const m = await db.query.match.findFirst({
where: eq(match.id, matchId)
});
if (!m) throw new Error('Match not found');
return m;
}
function assertState(current: string, expected: string) {
if (current !== expected) {
throw new Error(`Match is ${current}, expected ${expected}`);
}
}

View File

@@ -0,0 +1,56 @@
import { db } from '$lib/server/db';
import { match } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export function getExpectedPicker(
participants: { id: string; pickOrder: number | null; score: number }[],
gameCount: number
) {
const sorted = [...participants].sort((a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99));
const pickerIndex = gameCount % sorted.length;
return sorted[pickerIndex];
}
export async function getMatchFull(matchId: string) {
const m = await db.query.match.findFirst({
where: eq(match.id, matchId),
with: {
mappool: {
with: { slots: true }
},
participants: {
with: {
team: true,
players: true
}
},
games: {
with: {
slot: true,
scores: {
with: { player: true },
orderBy: (s, { desc }) => [desc(s.score)]
}
},
orderBy: (g, { asc }) => [asc(g.gameNumber)]
}
}
});
if (!m) throw new Error('Match not found');
return m;
}
export async function getMatchOrThrow(matchId: string) {
const m = await db.query.match.findFirst({
where: eq(match.id, matchId)
});
if (!m) throw new Error('Match not found');
return m;
}
export function assertState(current: string, expected: string) {
if (current !== expected) {
throw new Error(`Match is ${current}, expected ${expected}`);
}
}

View File

@@ -2,7 +2,8 @@ import { db } from '$lib/server/db';
import { match, matchGame, matchParticipant, user } from '$lib/server/db/schema'; import { match, matchGame, matchParticipant, user } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types'; import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types';
import { getMatchFull, submitRoll, pickMap, submitGameScores, cancelMatch } from './engine'; import { submitRoll, pickMap, submitGameScores, cancelMatch } from './engine';
import { getMatchFull } from './helpers';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
// matchId → lowercase usernames of all expected players // matchId → lowercase usernames of all expected players

View File

@@ -3,6 +3,7 @@ import { match, matchQueue, matchParticipantPlayer, playerRating } from '$lib/se
import { eq, asc, sql, inArray, lt } from 'drizzle-orm'; import { eq, asc, sql, inArray, lt } from 'drizzle-orm';
import { MATCH_STATES } from './types'; import { MATCH_STATES } from './types';
import { createMatch } from './engine'; import { createMatch } from './engine';
import { selectMappoolForRating } from './rating';
const QUEUE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes const QUEUE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
@@ -169,33 +170,3 @@ async function tryMatchFromQueue() {
return created; return created;
} }
async function selectMappoolForRating(avgElo: number) {
// Only use verified mappools
const pools = await db.query.mappool.findMany({
where: (m, { isNotNull }) => isNotNull(m.verifiedAt),
with: { slots: true }
});
if (pools.length === 0) return null;
// 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
const scored = pools
.filter((p) => p.slots.length > 0)
.map((p) => {
const avgStars = p.slots.reduce((sum, s) => sum + (s.starRating ?? 0), 0) / p.slots.length;
return { pool: p, diff: Math.abs(avgStars - clampedTarget) };
})
.sort((a, b) => a.diff - b.diff);
if (scored.length === 0) return null;
// Allow any pool within 1 star of the closest match, then pick randomly
const threshold = scored[0].diff + 1.0;
const candidates = scored.filter((s) => s.diff <= threshold);
return candidates[Math.floor(Math.random() * candidates.length)].pool;
}

View File

@@ -0,0 +1,128 @@
import { db } from '$lib/server/db';
import { playerRating, matchParticipantPlayer } from '$lib/server/db/schema';
import { account } from '$lib/server/db/auth.schema';
import { eq, and, isNotNull } from 'drizzle-orm';
import { getUser } from '$lib/server/osu/api';
// ── Initial Rating ──────────────────────────────────────────────────────
export 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 };
}
}
// ── ELO ─────────────────────────────────────────────────────────────────
export 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 playersWithRatings = [];
for (const player of players) {
const rating = await db.query.playerRating.findFirst({
where: eq(playerRating.userId, player.userId)
});
if (!rating) {
console.warn(`[ELO] No rating for user ${player.userId}, skipping`);
continue;
}
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.max(0, Math.round(rating.elo + K * (actual - expected)));
await db
.update(playerRating)
.set({
elo: newElo,
wins: isWinner ? rating.wins + 1 : rating.wins,
losses: isWinner ? rating.losses : rating.losses + 1,
updatedAt: new Date()
})
.where(eq(playerRating.userId, userId));
}
}
}
// ── Mappool Selection ───────────────────────────────────────────────────
export async function selectMappoolForRating(avgElo: number) {
// Only use verified mappools
const pools = await db.query.mappool.findMany({
where: (m) => isNotNull(m.verifiedAt),
with: { slots: true }
});
if (pools.length === 0) return null;
// 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
const scored = pools
.filter((p) => p.slots.length > 0)
.map((p) => {
const avgStars = p.slots.reduce((sum, s) => sum + (s.starRating ?? 0), 0) / p.slots.length;
return { pool: p, diff: Math.abs(avgStars - clampedTarget) };
})
.sort((a, b) => a.diff - b.diff);
if (scored.length === 0) return null;
// Allow any pool within 1 star of the closest match, then pick randomly
const threshold = scored[0].diff + 1.0;
const candidates = scored.filter((s) => s.diff <= threshold);
return candidates[Math.floor(Math.random() * candidates.length)].pool;
}

View File

@@ -24,7 +24,8 @@ mock.module('drizzle-orm', () => ({
asc: mock(() => ({})), asc: mock(() => ({})),
lt: mock(() => ({})), lt: mock(() => ({})),
inArray: mock(() => ({})), inArray: mock(() => ({})),
sql: mock(() => ({})) sql: mock(() => ({})),
isNotNull: mock(() => ({}))
})); }));
mock.module('$lib/server/db/schema', () => ({ mock.module('$lib/server/db/schema', () => ({

View File

@@ -16,10 +16,12 @@ mock.module('$lib/server/db', () => ({ db: mocks.db }));
mock.module('drizzle-orm', () => ({ mock.module('drizzle-orm', () => ({
eq: mock(() => ({})), eq: mock(() => ({})),
and: mock(() => ({})),
asc: mock(() => ({})), asc: mock(() => ({})),
lt: mock(() => ({})), lt: mock(() => ({})),
inArray: mock(() => ({})), inArray: mock(() => ({})),
sql: mock(() => ({})) sql: mock(() => ({})),
isNotNull: mock(() => ({}))
})); }));
mock.module('$lib/server/db/schema', () => ({ mock.module('$lib/server/db/schema', () => ({
@@ -29,6 +31,12 @@ mock.module('$lib/server/db/schema', () => ({
playerRating: {} playerRating: {}
})); }));
mock.module('$lib/server/db/auth.schema', () => ({ account: {} }));
mock.module('$lib/server/osu/api', () => ({
getUser: mock()
}));
mock.module('$lib/server/match/engine', () => ({ mock.module('$lib/server/match/engine', () => ({
createMatch: mock() createMatch: mock()
})); }));