From 60956f2643f8c4c29ed6d33d961f270170df4e49 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sun, 1 Mar 2026 00:40:47 +0100 Subject: [PATCH] feat: enhance match and lobby management with player status tracking and osu! API integration --- src/lib/server/bancho/client.ts | 55 +++++++++ src/lib/server/db/schema.ts | 3 +- src/lib/server/match/engine.ts | 4 +- src/lib/server/match/orchestrator.ts | 88 +++++++++++++- src/lib/server/osu/api.ts | 8 ++ src/routes/(app)/+layout.server.ts | 25 +++- src/routes/(app)/+layout.svelte | 29 +++++ src/routes/(app)/+page.svelte | 14 ++- src/routes/(app)/leaderboard/+page.server.ts | 46 ++++--- src/routes/(app)/leaderboard/+page.svelte | 13 +- src/routes/(app)/matches/[id]/+page.server.ts | 15 ++- src/routes/(app)/matches/[id]/+page.svelte | 115 +++++++++++++++--- 12 files changed, 366 insertions(+), 49 deletions(-) diff --git a/src/lib/server/bancho/client.ts b/src/lib/server/bancho/client.ts index b5b9605..696f5f8 100644 --- a/src/lib/server/bancho/client.ts +++ b/src/lib/server/bancho/client.ts @@ -160,6 +160,11 @@ export class TournamentLobby { // Ready tracking private allReadyResolve: (() => void) | null = null; + // Lobby status tracking + public inLobby: Set = new Set(); + public readyPlayers: Set = new Set(); + public gameInProgress = false; + // Event callbacks (set by orchestrator) public onRollResult: ((username: string, value: number) => void) | null = null; public onPickCommand: ((username: string, slotLabel: string) => void) | null = null; @@ -265,6 +270,8 @@ export class TournamentLobby { // Game finished if (text.includes('The match has finished!')) { console.log('[Bancho] Game finished. Scores:', this.collectedScores); + this.gameInProgress = false; + this.readyPlayers.clear(); if (this.matchFinishedResolve) { this.matchFinishedResolve([...this.collectedScores]); this.matchFinishedResolve = null; @@ -290,15 +297,42 @@ export class TournamentLobby { if (joinMatch) { const username = joinMatch[1].trim(); console.log(`[Bancho] Player joined: ${username}`); + this.inLobby.add(username); + this.readyPlayers.delete(username); if (this.onPlayerJoined) { this.onPlayerJoined(username); } return; } + // Player left: "Stan left the game." + const leftMatch = text.match(/^(.+?) left the game\./); + if (leftMatch) { + const username = leftMatch[1].trim(); + console.log(`[Bancho] Player left: ${username}`); + this.inLobby.delete(username); + this.readyPlayers.delete(username); + return; + } + + // Player ready: "Stan is Ready." + const readyMatch = text.match(/^(.+?) is Ready\.?$/i); + if (readyMatch) { + this.readyPlayers.add(readyMatch[1].trim()); + return; + } + + // Player not ready: "Stan is not Ready." + const notReadyMatch = text.match(/^(.+?) is not Ready\.?$/i); + if (notReadyMatch) { + this.readyPlayers.delete(notReadyMatch[1].trim()); + return; + } + // All players ready if (text.includes('All players are ready')) { console.log('[Bancho] All players ready'); + for (const u of this.inLobby) this.readyPlayers.add(u); if (this.allReadyResolve) { this.allReadyResolve(); this.allReadyResolve = null; @@ -306,6 +340,13 @@ export class TournamentLobby { return; } + // Game started + if (text.includes('The match has started!')) { + this.gameInProgress = true; + this.readyPlayers.clear(); + return; + } + // Room closed if (text.includes('Closed the match')) { console.log(`[Bancho] Room ${this.channelName} was closed`); @@ -457,6 +498,20 @@ export function removeLobby(matchId: string) { lobbies.delete(matchId); } +export function getLobbyStatus(matchId: string): { + inLobby: string[]; + readyPlayers: string[]; + gameInProgress: boolean; +} | null { + const lobby = lobbies.get(matchId); + if (!lobby || lobby._dead) return null; + return { + inLobby: [...lobby.inLobby], + readyPlayers: [...lobby.readyPlayers], + gameInProgress: lobby.gameInProgress + }; +} + export function getActiveLobbyCount(): number { let count = 0; for (const [id, lobby] of lobbies.entries()) { diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 9fd37a1..5db2b77 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -160,7 +160,8 @@ export const matchGameScore = pgTable( count50: integer('count_50').default(0).notNull(), countMiss: integer('count_miss').default(0).notNull(), mods: text('mods').array().default([]).notNull(), - passed: boolean('passed').default(false).notNull() + passed: boolean('passed').default(false).notNull(), + pp: real('pp') }, (t) => [index('idx_mgs_game').on(t.matchGameId)] ); diff --git a/src/lib/server/match/engine.ts b/src/lib/server/match/engine.ts index 820c870..f8a0973 100644 --- a/src/lib/server/match/engine.ts +++ b/src/lib/server/match/engine.ts @@ -366,6 +366,7 @@ export async function submitGameScores( countMiss?: number; mods?: string[]; passed?: boolean; + pp?: number | null; }[] ) { const game = await db.query.matchGame.findFirst({ @@ -387,7 +388,8 @@ export async function submitGameScores( count50: s.count50 ?? 0, countMiss: s.countMiss ?? 0, mods: s.mods ?? [], - passed: s.passed ?? true + passed: s.passed ?? true, + pp: s.pp ?? null }); } diff --git a/src/lib/server/match/orchestrator.ts b/src/lib/server/match/orchestrator.ts index 10854d1..04709fe 100644 --- a/src/lib/server/match/orchestrator.ts +++ b/src/lib/server/match/orchestrator.ts @@ -1,5 +1,5 @@ import { db } from '$lib/server/db'; -import { match, matchGame, mappoolSlot, user } from '$lib/server/db/schema'; +import { match, matchGame, user } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; import { MATCH_STATES, type MatchConfig } from './types'; import { getMatchFull, submitRoll, pickMap, submitGameScores } from './engine'; @@ -10,6 +10,8 @@ import { removeLobby, getActiveLobbyCount } from '../bancho/client'; +import { getOsuMatch } from '../osu/api'; +import { account } from '../db/auth.schema'; function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); @@ -305,7 +307,6 @@ export async function playPickedMap(matchId: string, matchGameId: string) { const slot = game.slot; const m = await getMatchFull(matchId); - const config = m.config as MatchConfig; const score = m.participants.map((p) => `${p.team.name} ${p.score}`).join(' - '); // Set beatmap @@ -331,6 +332,10 @@ export async function playPickedMap(matchId: string, matchGameId: string) { try { await lobby.chat('Timed out waiting for ready. Ready up and the game will start, or an admin can force start from the web UI.'); } catch { /* lobby may be dead */ } + // Still set up score collection — force start will trigger the game + collectScores(matchId, matchGameId, lobby).catch((err) => + console.error('[Orchestrator] Score collection failed:', err) + ); return; } @@ -361,7 +366,22 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna // Map IRC usernames → matchParticipantPlayer IDs const m = await getMatchFull(matchId); - const scores: { playerId: string; score: number; passed: boolean }[] = []; + const scores: { + playerId: string; + score: number; + passed: boolean; + accuracy?: number; + maxCombo?: number; + count300?: number; + count100?: number; + count50?: number; + countMiss?: number; + mods?: string[]; + pp?: number | null; + }[] = []; + + // Build userId → osu accountId map for API enrichment + const userOsuIdMap = new Map(); // userId → osu numeric ID for (const irc of ircScores) { for (const participant of m.participants) { @@ -371,6 +391,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)) { + 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)); + } + } } } } @@ -381,6 +410,59 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna return; } + // Enrich scores with osu! API data (300/100/50/miss/accuracy/maxCombo/pp) + if (m.osuLobbyId) { + try { + const osuMatchData = await getOsuMatch(m.osuLobbyId); + // Find the most recent game event matching our beatmap + const gameRecord = await db.query.matchGame.findFirst({ + where: eq(matchGame.id, matchGameId), + with: { slot: true } + }); + const beatmapId = gameRecord?.slot?.beatmapId ? parseInt(gameRecord.slot.beatmapId) : null; + + if (beatmapId && osuMatchData?.events) { + // Find game events in reverse (most recent first) matching our beatmap + const gameEvents = [...osuMatchData.events] + .reverse() + .filter((e: any) => e.game?.beatmap_id === beatmapId); + + const osuGame = gameEvents[0]?.game; + if (osuGame?.scores) { + // Build osu userID → score stats map + const osuScoreMap = new Map(); + for (const os of osuGame.scores) { + 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; + + s.accuracy = osuScore.accuracy ?? undefined; + s.maxCombo = osuScore.max_combo ?? undefined; + s.count300 = osuScore.statistics?.count_300 ?? undefined; + s.count100 = osuScore.statistics?.count_100 ?? undefined; + s.count50 = osuScore.statistics?.count_50 ?? undefined; + s.countMiss = osuScore.statistics?.count_miss ?? undefined; + s.pp = osuScore.pp ?? null; + if (osuScore.mods?.length) { + s.mods = osuScore.mods.map((mod: any) => + typeof mod === 'string' ? mod : mod.acronym ?? mod + ); + } + } + console.log(`[Orchestrator] Enriched scores from osu! API for game ${matchGameId}`); + } + } + } catch (err: any) { + console.warn('[Orchestrator] Failed to enrich scores from osu! API:', err.message); + } + } + await submitGameScores(matchGameId, scores); // ── Announce results clearly ── diff --git a/src/lib/server/osu/api.ts b/src/lib/server/osu/api.ts index 3dfa4ac..5092bfb 100644 --- a/src/lib/server/osu/api.ts +++ b/src/lib/server/osu/api.ts @@ -133,4 +133,12 @@ export async function getUserScores( return osuFetch(`/users/${userId}/scores/${type}?limit=${limit}`); } +/** + * Fetch osu! match data (events + scores) for a multiplayer lobby. + * Returns events array which includes game events with per-player scores and statistics. + */ +export async function getOsuMatch(osuMatchId: number) { + return osuFetch(`/matches/${osuMatchId}`); +} + export { getUserToken, getClientToken }; diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts index e8a23ab..a655760 100644 --- a/src/routes/(app)/+layout.server.ts +++ b/src/routes/(app)/+layout.server.ts @@ -1,8 +1,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: LayoutServerLoad = async ({ locals }) => { + 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 ? { @@ -13,6 +35,7 @@ export const load: LayoutServerLoad = async ({ locals }) => { role: locals.user.role ?? 'player', isRootAdmin: isRootAdmin(locals.user.email) } - : null + : null, + activeMatch }; }; diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 68f820d..62c758f 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -7,6 +7,15 @@ let { children } = $props(); const user = $derived(page.data?.user); + const activeMatch = $derived(page.data?.activeMatch); + + const stateLabel: Record = { + CREATED: 'Starting', + LOBBY: 'In Lobby', + ROLLING: 'Rolling', + PICKING: 'Picking', + PLAYING: 'Playing' + }; {#if user} @@ -15,6 +24,26 @@
+ {#if activeMatch && !page.url.pathname.startsWith('/matches/' + activeMatch.id)} +
+
+
+
+
+
+ Active match in progress + + {activeMatch.name ?? 'Match'} · {stateLabel[activeMatch.state] ?? activeMatch.state} + +
+ + Go to match → + +
+ {/if}
{@render children()}
diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 35081f1..284393f 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -1,4 +1,6 @@