From 0c6d91ae61f3a0b149107a7a9dd68a4cc54db9f2 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sun, 1 Mar 2026 01:11:11 +0100 Subject: [PATCH] feat: enhance osu! API integration with stale data resilience and improve match participant handling --- .svelte-kit/ambient.d.ts | 16 +++++ .../(app)/matches/[id]/proxy+page.server.ts | 66 +++++++++++++++---- .../src/routes/(app)/proxy+layout.server.ts | 25 ++++++- src/lib/server/match/orchestrator.ts | 45 +++++++++---- src/lib/server/osu/api.ts | 35 ++++++++-- src/lib/server/osu/cache.ts | 20 ++++-- 6 files changed, 168 insertions(+), 39 deletions(-) diff --git a/.svelte-kit/ambient.d.ts b/.svelte-kit/ambient.d.ts index a02c43d..a5cad36 100644 --- a/.svelte-kit/ambient.d.ts +++ b/.svelte-kit/ambient.d.ts @@ -38,6 +38,14 @@ * The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values. */ declare module '$env/static/private' { + export const ROOT_ADMIN_EMAIL: string; + export const DATABASE_URL: string; + export const ORIGIN: string; + export const BETTER_AUTH_SECRET: string; + export const OSU_CLIENT_ID: string; + export const OSU_CLIENT_SECRET: string; + export const OSU_IRC_USERNAME: string; + export const OSU_IRC_PASSWORD: string; export const NVM_INC: string; export const TERM_PROGRAM: string; export const NODE: string; @@ -191,6 +199,14 @@ declare module '$env/static/public' { */ declare module '$env/dynamic/private' { export const env: { + ROOT_ADMIN_EMAIL: string; + DATABASE_URL: string; + ORIGIN: string; + BETTER_AUTH_SECRET: string; + OSU_CLIENT_ID: string; + OSU_CLIENT_SECRET: string; + OSU_IRC_USERNAME: string; + OSU_IRC_PASSWORD: string; NVM_INC: string; TERM_PROGRAM: string; NODE: string; diff --git a/.svelte-kit/types/src/routes/(app)/matches/[id]/proxy+page.server.ts b/.svelte-kit/types/src/routes/(app)/matches/[id]/proxy+page.server.ts index d6088ea..7b5b145 100644 --- a/.svelte-kit/types/src/routes/(app)/matches/[id]/proxy+page.server.ts +++ b/.svelte-kit/types/src/routes/(app)/matches/[id]/proxy+page.server.ts @@ -3,11 +3,13 @@ import { user } from '$lib/server/db/schema'; import { db } from '$lib/server/db'; import { getMatchFull, submitRoll, pickMap, cancelMatch } from '$lib/server/match/engine'; import { playPickedMap, closeLobby, forceStartGame } from '$lib/server/match/orchestrator'; -import { getLobby } from '$lib/server/bancho/client'; +import { getLobby, getLobbyStatus } from '$lib/server/bancho/client'; import { error, redirect } from '@sveltejs/kit'; import { getBeatmap } from '$lib/server/osu/api'; +import { hasRole } from '$lib/server/permissions'; import type { PageServerLoad, Actions } from './$types'; import { eq } from 'drizzle-orm'; +import type { MatchConfig } from '$lib/server/match/types'; export const load = async ({ params, locals }: Parameters[0]) => { @@ -42,7 +44,20 @@ export const load = async ({ params, locals }: Parameters[0]) => ); } - return { match: m, beatmapCache, userId: locals.user.id }; + const isStaff = hasRole(locals.user.role, 'referee'); + + // Fetch player usernames for lobby status display + const playerNames: Record = {}; + for (const p of m.participants) { + for (const pl of p.players) { + const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) }); + if (u?.name) playerNames[pl.userId] = u.name; + } + } + + const lobbyStatus = getLobbyStatus(params.id); + + return { match: m, beatmapCache, userId: locals.user.id, isStaff, lobbyStatus, playerNames }; }; export const actions = { @@ -52,20 +67,14 @@ export const actions = { const lobby = getLobby(params.id); if (!lobby) return { error: 'No active IRC lobby for this match' }; - const m = await getMatchFull(params.id); - - const invited: string[] = []; - for (const p of m.participants) { - for (const pl of p.players) { - const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) }); - if (u?.name) { - await lobby.invite(u.name); - invited.push(u.name); - } - } + // Only invite the currently logged-in user + const u = await db.query.user.findFirst({ where: eq(user.id, locals.user.id) }); + if (u?.name) { + await lobby.invite(u.name); + return { reinvited: [u.name] }; } - return { reinvited: invited }; + return { error: 'Could not find your username' }; }, roll: async ({ params, locals }: import('./$types').RequestEvent) => { @@ -133,6 +142,27 @@ export const actions = { if (!expectedPicker) return { error: 'Cannot determine picker' }; + // ── Verify the logged-in user is on the expected picker's team ── + // No staff bypass here — even admins can't pick for another team + const isOnPickerTeam = expectedPicker.players.some( + (pl) => pl.userId === locals.user!.id + ); + if (!isOnPickerTeam) { + return { error: "It's not your turn to pick" }; + } + + // ── Tiebreaker restriction ── + // TB maps can only be picked when both teams are at match point (e.g. 2-2 in BO5) + const config = m.config as MatchConfig; + const winsNeeded = Math.ceil(config.bestOf / 2); + const slot = m.mappool?.slots?.find((s) => s.id === slotId); + if (slot?.category === 'TB') { + const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1); + if (!allAtMatchPoint) { + return { error: 'Tiebreaker can only be picked when both teams are at match point' }; + } + } + try { const game = await pickMap(params.id, expectedPicker.id, slotId); @@ -149,12 +179,20 @@ export const actions = { forceStart: async ({ params, locals }: import('./$types').RequestEvent) => { if (!locals.user) redirect(302, '/'); + // Only staff can force start + if (!hasRole(locals.user.role, 'referee')) { + return { error: 'Only referees and admins can force start' }; + } await forceStartGame(params.id); return { started: true }; }, cancel: async ({ params, locals }: import('./$types').RequestEvent) => { if (!locals.user) redirect(302, '/'); + // Only staff can cancel matches + if (!hasRole(locals.user.role, 'referee')) { + return { error: 'Only referees and admins can cancel matches' }; + } await cancelMatch(params.id); await closeLobby(params.id); diff --git a/.svelte-kit/types/src/routes/(app)/proxy+layout.server.ts b/.svelte-kit/types/src/routes/(app)/proxy+layout.server.ts index fab6214..55d5e7d 100644 --- a/.svelte-kit/types/src/routes/(app)/proxy+layout.server.ts +++ b/.svelte-kit/types/src/routes/(app)/proxy+layout.server.ts @@ -2,8 +2,30 @@ // src/routes/(app)/+layout.server.ts import type { LayoutServerLoad } from './$types'; import { isRootAdmin } from '$lib/server/permissions'; +import { db } from '$lib/server/db'; +import { match, matchParticipant, matchParticipantPlayer } from '$lib/server/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; export const load = async ({ locals }: Parameters[0]) => { + let activeMatch: { id: string; name: string | null; state: string } | null = null; + + if (locals.user) { + const rows = await db + .select({ id: match.id, name: match.name, state: match.state }) + .from(matchParticipantPlayer) + .innerJoin(matchParticipant, eq(matchParticipantPlayer.participantId, matchParticipant.id)) + .innerJoin(match, eq(matchParticipant.matchId, match.id)) + .where( + and( + eq(matchParticipantPlayer.userId, locals.user.id), + inArray(match.state, ['CREATED', 'LOBBY', 'ROLLING', 'PICKING', 'PLAYING']) + ) + ) + .limit(1); + + activeMatch = rows[0] ?? null; + } + return { user: locals.user ? { @@ -14,6 +36,7 @@ export const load = async ({ locals }: Parameters[0]) => { role: locals.user.role ?? 'player', isRootAdmin: isRootAdmin(locals.user.email) } - : null + : null, + activeMatch }; }; diff --git a/src/lib/server/match/orchestrator.ts b/src/lib/server/match/orchestrator.ts index 04709fe..01408cd 100644 --- a/src/lib/server/match/orchestrator.ts +++ b/src/lib/server/match/orchestrator.ts @@ -380,8 +380,10 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna pp?: number | null; }[] = []; - // Build userId → osu accountId map for API enrichment - const userOsuIdMap = new Map(); // userId → osu numeric ID + // ── FIX: Build matchParticipantPlayer.id → osu numeric account ID map ── + // Previously this was keyed by userId but looked up by playerId, which broke enrichment. + const playerOsuIdMap = new Map(); // matchParticipantPlayer.id → osu numeric ID + const playerUserIdMap = new Map(); // matchParticipantPlayer.id → userId for (const irc of ircScores) { for (const participant of m.participants) { @@ -391,13 +393,15 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna const dbName = u?.name?.toLowerCase(); if (dbName && (dbName === ircName || dbName === irc.username.toLowerCase())) { scores.push({ playerId: player.id, score: irc.score, passed: irc.passed }); - // Cache the osu account ID for enrichment - if (u && !userOsuIdMap.has(player.userId)) { + playerUserIdMap.set(player.id, player.userId); + + // Cache the osu account ID for enrichment, keyed by matchParticipantPlayer.id + if (!playerOsuIdMap.has(player.id)) { const acc = await db.query.account.findFirst({ where: and(eq(account.userId, player.userId), eq(account.providerId, 'osu')) }); if (acc?.accountId) { - userOsuIdMap.set(player.userId, parseInt(acc.accountId)); + playerOsuIdMap.set(player.id, parseInt(acc.accountId)); } } } @@ -410,9 +414,12 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna return; } - // Enrich scores with osu! API data (300/100/50/miss/accuracy/maxCombo/pp) + // ── Enrich scores with osu! API data (300/100/50/miss/accuracy/maxCombo/pp) ── if (m.osuLobbyId) { try { + // Small delay to let osu! API update with the latest game data + await sleep(2000); + const osuMatchData = await getOsuMatch(m.osuLobbyId); // Find the most recent game event matching our beatmap const gameRecord = await db.query.matchGame.findFirst({ @@ -435,13 +442,25 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna osuScoreMap.set(os.user_id, os); } - // Enrich each score - for (const s of scores) { - const playerOsuId = userOsuIdMap.get(s.playerId); - if (playerOsuId === undefined) continue; - const osuScore = osuScoreMap.get(playerOsuId); - if (!osuScore) continue; + console.log( + `[Orchestrator] osu! API returned ${osuGame.scores.length} scores for beatmap ${beatmapId}. ` + + `Player map has ${playerOsuIdMap.size} entries.` + ); + // Enrich each score using the FIXED mapping (matchParticipantPlayer.id → osu ID) + for (const s of scores) { + const playerOsuId = playerOsuIdMap.get(s.playerId); + if (playerOsuId === undefined) { + console.warn(`[Orchestrator] No osu account ID for player ${s.playerId}`); + continue; + } + const osuScore = osuScoreMap.get(playerOsuId); + if (!osuScore) { + console.warn(`[Orchestrator] No osu! API score for osu user ${playerOsuId}`); + continue; + } + + // v2 API format: accuracy is a float (0-1), statistics has count_300 etc. s.accuracy = osuScore.accuracy ?? undefined; s.maxCombo = osuScore.max_combo ?? undefined; s.count300 = osuScore.statistics?.count_300 ?? undefined; @@ -456,6 +475,8 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna } } console.log(`[Orchestrator] Enriched scores from osu! API for game ${matchGameId}`); + } else { + console.warn(`[Orchestrator] No scores in osu! API game event for beatmap ${beatmapId}`); } } } catch (err: any) { diff --git a/src/lib/server/osu/api.ts b/src/lib/server/osu/api.ts index 5092bfb..4879c7e 100644 --- a/src/lib/server/osu/api.ts +++ b/src/lib/server/osu/api.ts @@ -3,7 +3,7 @@ import { env } from '$env/dynamic/private'; import { db } from '$lib/server/db'; import { and, eq } from 'drizzle-orm'; import { account } from '$lib/server/db/auth.schema'; -import { getCached, setCache } from './cache'; +import { getCached, setCache, getStale } from './cache'; let clientToken: string | null = null; let clientTokenExpiresAt = 0; @@ -95,14 +95,29 @@ async function osuFetch(path: string, token?: string) { // ── Public API (with caching) ─────────────────────────────────────────── +/** + * Fetch beatmap data with stale-on-error resilience. + * If the osu! API is down or rate-limited, we return the last known data + * so the UI doesn't degrade to "?" placeholders mid-match. + */ export async function getBeatmap(beatmapId: string | number) { const cacheKey = `beatmap:${beatmapId}`; const cached = getCached(cacheKey); if (cached) return cached; - const data = await osuFetch(`/beatmaps/${beatmapId}`); - setCache(cacheKey, data); - return data; + try { + const data = await osuFetch(`/beatmaps/${beatmapId}`); + setCache(cacheKey, data); + return data; + } catch (err) { + // If fetch fails, try returning stale (expired) cached data + const stale = getStale(cacheKey); + if (stale) { + console.warn(`[osu! API] Failed to fetch beatmap ${beatmapId}, returning stale cache`); + return stale; + } + throw err; // No stale data either — propagate the error + } } export async function getBeatmapset(beatmapsetId: string | number) { @@ -110,9 +125,15 @@ export async function getBeatmapset(beatmapsetId: string | number) { const cached = getCached(cacheKey); if (cached) return cached; - const data = await osuFetch(`/beatmapsets/${beatmapsetId}`); - setCache(cacheKey, data); - return data; + try { + const data = await osuFetch(`/beatmapsets/${beatmapsetId}`); + setCache(cacheKey, data); + return data; + } catch (err) { + const stale = getStale(cacheKey); + if (stale) return stale; + throw err; + } } export async function getUser(userId: string | number) { diff --git a/src/lib/server/osu/cache.ts b/src/lib/server/osu/cache.ts index cc0ed1d..949267c 100644 --- a/src/lib/server/osu/cache.ts +++ b/src/lib/server/osu/cache.ts @@ -1,15 +1,15 @@ // src/lib/server/osu/cache.ts // -// Simple in-memory cache for osu! beatmap metadata. -// TTL: 10 minutes. Max entries: 500. -// Avoids hammering the osu! API on every page load. +// In-memory cache for osu! beatmap metadata. +// Beatmap data is essentially immutable, so we use a long TTL (2 hours) +// and return stale entries on fetch failure to prevent "?" in the UI. interface CacheEntry { data: T; expiresAt: number; } -const CACHE_TTL = 10 * 60 * 1000; // 10 minutes +const CACHE_TTL = 2 * 60 * 60 * 1000; // 2 hours (beatmap data rarely changes) const MAX_ENTRIES = 500; const cache = new Map>(); @@ -18,12 +18,22 @@ export function getCached(key: string): T | null { const entry = cache.get(key); if (!entry) return null; if (Date.now() > entry.expiresAt) { - cache.delete(key); + // Expired — don't delete yet, keep as stale fallback return null; } return entry.data as T; } +/** + * Return stale (expired) data for a key if it exists. + * Used as a fallback when the osu! API fails, so the UI doesn't lose data. + */ +export function getStale(key: string): T | null { + const entry = cache.get(key); + if (!entry) return null; + return entry.data as T; +} + export function setCache(key: string, data: T, ttl = CACHE_TTL): void { // Evict oldest entries if at capacity if (cache.size >= MAX_ENTRIES) {