feat: enhance osu! API integration with stale data resilience and improve match participant handling
This commit is contained in:
@@ -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<string, number>(); // 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<string, number>(); // matchParticipantPlayer.id → osu numeric ID
|
||||
const playerUserIdMap = new Map<string, string>(); // 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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<T> {
|
||||
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<string, CacheEntry<any>>();
|
||||
@@ -18,12 +18,22 @@ export function getCached<T>(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<T>(key: string): T | null {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return null;
|
||||
return entry.data as T;
|
||||
}
|
||||
|
||||
export function setCache<T>(key: string, data: T, ttl = CACHE_TTL): void {
|
||||
// Evict oldest entries if at capacity
|
||||
if (cache.size >= MAX_ENTRIES) {
|
||||
|
||||
Reference in New Issue
Block a user