494 lines
14 KiB
TypeScript
494 lines
14 KiB
TypeScript
import { db } from '$lib/server/db';
|
|
import {
|
|
match,
|
|
matchParticipant,
|
|
matchParticipantPlayer,
|
|
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 { 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 };
|
|
}
|
|
}
|
|
|
|
// ── Create Match ────────────────────────────────────────────────────────
|
|
|
|
export async function createMatch(opts: {
|
|
name: string;
|
|
config: MatchConfig;
|
|
mappoolId: string;
|
|
teams: string[];
|
|
createdBy: string;
|
|
}) {
|
|
const { name, config, mappoolId, teams, createdBy } = opts;
|
|
|
|
const [created] = await db
|
|
.insert(match)
|
|
.values({
|
|
name,
|
|
config,
|
|
mappoolId,
|
|
createdBy,
|
|
state: MATCH_STATES.CREATED
|
|
})
|
|
.returning();
|
|
|
|
for (let i = 0; i < teams.length; i++) {
|
|
const [participant] = await db
|
|
.insert(matchParticipant)
|
|
.values({
|
|
matchId: created.id,
|
|
teamId: teams[i],
|
|
slot: i + 1
|
|
})
|
|
.returning();
|
|
|
|
const members = await db.query.teamMember.findMany({
|
|
where: eq(teamMember.teamId, teams[i])
|
|
});
|
|
|
|
for (const member of members) {
|
|
await db.insert(matchParticipantPlayer).values({
|
|
participantId: participant.id,
|
|
userId: member.userId
|
|
});
|
|
}
|
|
}
|
|
|
|
const full = await getMatchFull(created.id);
|
|
notifyMatchCreated(full).catch(() => {});
|
|
return full;
|
|
}
|
|
|
|
// ── State Transitions ───────────────────────────────────────────────────
|
|
|
|
export async function moveToLobby(matchId: string, osuLobbyId?: number) {
|
|
const m = await getMatchOrThrow(matchId);
|
|
assertState(m.state, MATCH_STATES.CREATED);
|
|
|
|
await db
|
|
.update(match)
|
|
.set({
|
|
state: MATCH_STATES.LOBBY,
|
|
osuLobbyId: osuLobbyId ?? null,
|
|
startedAt: new Date()
|
|
})
|
|
.where(eq(match.id, matchId));
|
|
|
|
return getMatchFull(matchId);
|
|
}
|
|
|
|
export async function moveToRolling(matchId: string) {
|
|
const m = await getMatchOrThrow(matchId);
|
|
assertState(m.state, MATCH_STATES.LOBBY);
|
|
|
|
await db
|
|
.update(match)
|
|
.set({ state: MATCH_STATES.ROLLING })
|
|
.where(eq(match.id, matchId));
|
|
|
|
return getMatchFull(matchId);
|
|
}
|
|
|
|
// ── Roll ────────────────────────────────────────────────────────────────
|
|
|
|
export async function submitRoll(matchId: string, participantId: string, value: number) {
|
|
const m = await getMatchOrThrow(matchId);
|
|
assertState(m.state, MATCH_STATES.ROLLING);
|
|
|
|
await db
|
|
.update(matchParticipant)
|
|
.set({ rollValue: value })
|
|
.where(eq(matchParticipant.id, participantId));
|
|
|
|
const participants = await db.query.matchParticipant.findMany({
|
|
where: eq(matchParticipant.matchId, matchId)
|
|
});
|
|
|
|
const allRolled = participants.every(
|
|
(p) => (p.id === participantId ? true : p.rollValue !== null)
|
|
);
|
|
|
|
if (allRolled) {
|
|
const finalRolls = participants.map((p) => ({
|
|
...p,
|
|
rollValue: p.id === participantId ? value : p.rollValue!
|
|
}));
|
|
|
|
const rollValues = finalRolls.map((p) => p.rollValue);
|
|
const hasTie = new Set(rollValues).size < rollValues.length;
|
|
|
|
if (hasTie) {
|
|
// Reset all rolls so everyone must roll again
|
|
for (const p of participants) {
|
|
await db
|
|
.update(matchParticipant)
|
|
.set({ rollValue: null })
|
|
.where(eq(matchParticipant.id, p.id));
|
|
}
|
|
// Stay in ROLLING state
|
|
} else {
|
|
const sorted = [...finalRolls].sort((a, b) => b.rollValue - a.rollValue);
|
|
|
|
for (let i = 0; i < sorted.length; i++) {
|
|
await db
|
|
.update(matchParticipant)
|
|
.set({ pickOrder: i + 1 })
|
|
.where(eq(matchParticipant.id, sorted[i].id));
|
|
}
|
|
|
|
await db
|
|
.update(match)
|
|
.set({ state: MATCH_STATES.PICKING })
|
|
.where(eq(match.id, matchId));
|
|
}
|
|
}
|
|
|
|
return getMatchFull(matchId);
|
|
}
|
|
|
|
// ── Pick Map ────────────────────────────────────────────────────────────
|
|
|
|
export async function pickMap(matchId: string, participantId: string, mappoolSlotId: string) {
|
|
const m = await getMatchOrThrow(matchId);
|
|
assertState(m.state, MATCH_STATES.PICKING);
|
|
|
|
const participants = await db.query.matchParticipant.findMany({
|
|
where: eq(matchParticipant.matchId, matchId)
|
|
});
|
|
|
|
const totalGames = await db.query.matchGame.findMany({
|
|
where: eq(matchGame.matchId, matchId)
|
|
});
|
|
|
|
const expectedPicker = getExpectedPicker(participants, totalGames.length);
|
|
if (expectedPicker.id !== participantId) {
|
|
throw new Error('Not your turn to pick');
|
|
}
|
|
|
|
const alreadyPlayed = totalGames.some((g) => g.mappoolSlotId === mappoolSlotId);
|
|
if (alreadyPlayed) {
|
|
throw new Error('This map has already been played');
|
|
}
|
|
|
|
// ── Tiebreaker restrictions ──
|
|
const config = m.config as MatchConfig;
|
|
const winsNeeded = Math.ceil(config.bestOf / 2);
|
|
const allAtMatchPoint = participants.every((p) => p.score === winsNeeded - 1);
|
|
|
|
const slot = await db.query.mappoolSlot.findFirst({
|
|
where: eq(mappoolSlot.id, mappoolSlotId)
|
|
});
|
|
|
|
if (slot) {
|
|
if (slot.category === 'TB' && !allAtMatchPoint) {
|
|
throw new Error('Tiebreaker can only be picked at match point');
|
|
}
|
|
if (allAtMatchPoint && slot.category !== 'TB') {
|
|
throw new Error('Only tiebreaker maps can be picked at match point');
|
|
}
|
|
}
|
|
|
|
const [game] = await db
|
|
.insert(matchGame)
|
|
.values({
|
|
matchId,
|
|
gameNumber: totalGames.length + 1,
|
|
mappoolSlotId,
|
|
pickedByParticipantId: participantId,
|
|
state: GAME_STATES.PLAYING,
|
|
startedAt: new Date()
|
|
})
|
|
.returning();
|
|
|
|
await db
|
|
.update(match)
|
|
.set({ state: MATCH_STATES.PLAYING })
|
|
.where(eq(match.id, matchId));
|
|
|
|
return game;
|
|
}
|
|
|
|
// ── Submit Scores ───────────────────────────────────────────────────────
|
|
|
|
export async function submitGameScores(
|
|
matchGameId: string,
|
|
scores: {
|
|
playerId: string;
|
|
score: number;
|
|
accuracy?: number;
|
|
maxCombo?: number;
|
|
count300?: number;
|
|
count100?: number;
|
|
count50?: number;
|
|
countMiss?: number;
|
|
mods?: string[];
|
|
passed?: boolean;
|
|
pp?: number | null;
|
|
}[]
|
|
) {
|
|
const game = await db.query.matchGame.findFirst({
|
|
where: eq(matchGame.id, matchGameId)
|
|
});
|
|
|
|
if (!game) throw new Error('Game not found');
|
|
if (game.state !== GAME_STATES.PLAYING) throw new Error(`Game is ${game.state}, not PLAYING`);
|
|
|
|
for (const s of scores) {
|
|
await db.insert(matchGameScore).values({
|
|
matchGameId,
|
|
playerId: s.playerId,
|
|
score: s.score,
|
|
accuracy: s.accuracy ?? 0,
|
|
maxCombo: s.maxCombo ?? 0,
|
|
count300: s.count300 ?? 0,
|
|
count100: s.count100 ?? 0,
|
|
count50: s.count50 ?? 0,
|
|
countMiss: s.countMiss ?? 0,
|
|
mods: s.mods ?? [],
|
|
passed: s.passed ?? true,
|
|
pp: s.pp ?? null
|
|
});
|
|
}
|
|
|
|
const allScores = await db.query.matchGameScore.findMany({
|
|
where: eq(matchGameScore.matchGameId, matchGameId),
|
|
with: { player: true }
|
|
});
|
|
|
|
const participantScores = new Map<string, number>();
|
|
for (const s of allScores) {
|
|
const pid = s.player.participantId;
|
|
participantScores.set(pid, (participantScores.get(pid) ?? 0) + s.score);
|
|
}
|
|
|
|
let winnerId: string | null = null;
|
|
let highScore = -1;
|
|
for (const [pid, total] of participantScores) {
|
|
if (total > highScore) {
|
|
highScore = total;
|
|
winnerId = pid;
|
|
}
|
|
}
|
|
|
|
await db
|
|
.update(matchGame)
|
|
.set({
|
|
state: GAME_STATES.FINISHED,
|
|
winnerParticipantId: winnerId,
|
|
finishedAt: new Date()
|
|
})
|
|
.where(eq(matchGame.id, matchGameId));
|
|
|
|
if (winnerId) {
|
|
const participant = await db.query.matchParticipant.findFirst({
|
|
where: eq(matchParticipant.id, winnerId)
|
|
});
|
|
if (participant) {
|
|
await db
|
|
.update(matchParticipant)
|
|
.set({ score: participant.score + 1 })
|
|
.where(eq(matchParticipant.id, winnerId));
|
|
}
|
|
}
|
|
|
|
const m = await getMatchOrThrow(game.matchId);
|
|
const config = m.config as MatchConfig;
|
|
const winsNeeded = Math.ceil(config.bestOf / 2);
|
|
|
|
const participants = await db.query.matchParticipant.findMany({
|
|
where: eq(matchParticipant.matchId, game.matchId)
|
|
});
|
|
|
|
const matchWinner = participants.find((p) => p.score >= winsNeeded);
|
|
|
|
if (matchWinner) {
|
|
await db
|
|
.update(match)
|
|
.set({
|
|
state: MATCH_STATES.FINISHED,
|
|
winnerId: matchWinner.teamId,
|
|
finishedAt: new Date()
|
|
})
|
|
.where(eq(match.id, game.matchId));
|
|
|
|
if (config.allowEloChange !== false) {
|
|
await updateElo(participants, matchWinner.id);
|
|
}
|
|
|
|
const finishedMatch = await getMatchFull(game.matchId);
|
|
notifyMatchFinished(finishedMatch).catch(() => {});
|
|
} else {
|
|
await db
|
|
.update(match)
|
|
.set({ state: MATCH_STATES.PICKING })
|
|
.where(eq(match.id, 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 ────────────────────────────────────────────────────────
|
|
|
|
export async function cancelMatch(matchId: string) {
|
|
await db
|
|
.update(match)
|
|
.set({
|
|
state: MATCH_STATES.CANCELLED,
|
|
finishedAt: new Date()
|
|
})
|
|
.where(eq(match.id, 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}`);
|
|
}
|
|
}
|