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:
@@ -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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
56
src/lib/server/match/helpers.ts
Normal file
56
src/lib/server/match/helpers.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
128
src/lib/server/match/rating.ts
Normal file
128
src/lib/server/match/rating.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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', () => ({
|
||||||
|
|||||||
@@ -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()
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user