diff --git a/src/lib/server/match/engine.ts b/src/lib/server/match/engine.ts index e24c255..57e0f79 100644 --- a/src/lib/server/match/engine.ts +++ b/src/lib/server/match/engine.ts @@ -6,45 +6,13 @@ import { matchGame, matchGameScore, mappoolSlot, - playerRating, teamMember } from '$lib/server/db/schema'; -import { account } from '$lib/server/db/auth.schema'; -import { eq, and } from 'drizzle-orm'; +import { eq } 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 ────────────────────────────────────────────────────── - -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 }; - } -} +import { updateElo } from './rating'; +import { getExpectedPicker, getMatchOrThrow, assertState, getMatchFull } from './helpers'; // ── Create Match ──────────────────────────────────────────────────────── @@ -363,66 +331,6 @@ export async function submitGameScores( 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(); - - 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(); - 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 ──────────────────────────────────────────────────────── export async function cancelMatch(matchId: string) { @@ -436,58 +344,3 @@ export async function cancelMatch(matchId: string) { 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}`); - } -} diff --git a/src/lib/server/match/helpers.ts b/src/lib/server/match/helpers.ts new file mode 100644 index 0000000..0444351 --- /dev/null +++ b/src/lib/server/match/helpers.ts @@ -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}`); + } +} diff --git a/src/lib/server/match/orchestrator.ts b/src/lib/server/match/orchestrator.ts index 0962680..376cbbc 100644 --- a/src/lib/server/match/orchestrator.ts +++ b/src/lib/server/match/orchestrator.ts @@ -2,7 +2,8 @@ import { db } from '$lib/server/db'; import { match, matchGame, matchParticipant, user } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; 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'; // matchId → lowercase usernames of all expected players diff --git a/src/lib/server/match/queue.ts b/src/lib/server/match/queue.ts index e2b51ec..00ab640 100644 --- a/src/lib/server/match/queue.ts +++ b/src/lib/server/match/queue.ts @@ -3,6 +3,7 @@ import { match, matchQueue, matchParticipantPlayer, playerRating } from '$lib/se import { eq, asc, sql, inArray, lt } from 'drizzle-orm'; import { MATCH_STATES } from './types'; import { createMatch } from './engine'; +import { selectMappoolForRating } from './rating'; const QUEUE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes @@ -169,33 +170,3 @@ async function tryMatchFromQueue() { 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; -} diff --git a/src/lib/server/match/rating.ts b/src/lib/server/match/rating.ts new file mode 100644 index 0000000..02baf01 --- /dev/null +++ b/src/lib/server/match/rating.ts @@ -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(); + + 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(); + 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; +} diff --git a/test/match/engine.test.ts b/test/match/engine.test.ts index e2a7e16..65437d3 100644 --- a/test/match/engine.test.ts +++ b/test/match/engine.test.ts @@ -24,7 +24,8 @@ mock.module('drizzle-orm', () => ({ asc: mock(() => ({})), lt: mock(() => ({})), inArray: mock(() => ({})), - sql: mock(() => ({})) + sql: mock(() => ({})), + isNotNull: mock(() => ({})) })); mock.module('$lib/server/db/schema', () => ({ diff --git a/test/match/queue.test.ts b/test/match/queue.test.ts index bfde58b..88fe532 100644 --- a/test/match/queue.test.ts +++ b/test/match/queue.test.ts @@ -16,10 +16,12 @@ mock.module('$lib/server/db', () => ({ db: mocks.db })); mock.module('drizzle-orm', () => ({ eq: mock(() => ({})), + and: mock(() => ({})), asc: mock(() => ({})), lt: mock(() => ({})), inArray: mock(() => ({})), - sql: mock(() => ({})) + sql: mock(() => ({})), + isNotNull: mock(() => ({})) })); mock.module('$lib/server/db/schema', () => ({ @@ -29,6 +31,12 @@ mock.module('$lib/server/db/schema', () => ({ playerRating: {} })); +mock.module('$lib/server/db/auth.schema', () => ({ account: {} })); + +mock.module('$lib/server/osu/api', () => ({ + getUser: mock() +})); + mock.module('$lib/server/match/engine', () => ({ createMatch: mock() }));