diff --git a/src/lib/server/match/engine.ts b/src/lib/server/match/engine.ts index 2a6f3fc..af50c52 100644 --- a/src/lib/server/match/engine.ts +++ b/src/lib/server/match/engine.ts @@ -16,6 +16,9 @@ import { notifyMatchCreated, notifyMatchFinished } from '$lib/server/discord/cli // ── Queue ─────────────────────────────────────────────────────────────── export async function joinQueue(userId: string, teamId: string) { + const activeMatchId = await findRecentActiveMatch(userId); + if (activeMatchId) throw new Error('You are already in an active match'); + let rating = await db.query.playerRating.findFirst({ where: eq(playerRating.userId, userId) }); diff --git a/src/lib/server/match/orchestrator.ts b/src/lib/server/match/orchestrator.ts index 01408cd..6b13616 100644 --- a/src/lib/server/match/orchestrator.ts +++ b/src/lib/server/match/orchestrator.ts @@ -1,8 +1,14 @@ import { db } from '$lib/server/db'; import { match, matchGame, user } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; -import { MATCH_STATES, type MatchConfig } from './types'; +import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types'; import { getMatchFull, submitRoll, pickMap, submitGameScores } from './engine'; +import { env } from '$env/dynamic/private'; + +// matchId → lowercase usernames of all expected players +const expectedPlayers = new Map>(); +// matchId → lowercase usernames who have joined the IRC lobby so far +const joinedPlayers = new Map>(); import { TournamentLobby, setLobby, @@ -81,12 +87,14 @@ export async function initMatchLobby(matchId: string) { } } + // Store expected players for the rolling gate (reuse the invited set) + expectedPlayers.set(matchId, new Set(invited)); + joinedPlayers.set(matchId, new Set()); + // Wire up IRC chat handlers for rolls, picks, and player joins setupChatHandlers(matchId, lobby); - // LOBBY → ROLLING (we'll greet players individually when they join) - await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId)); - + // State stays LOBBY — transitions to ROLLING only when all players have joined return { osuMatchId: osuId, channel }; } @@ -100,40 +108,60 @@ export async function initMatchLobby(matchId: string) { function setupChatHandlers(matchId: string, lobby: TournamentLobby) { // ── Welcome players when they JOIN the channel ────────────────────── - // This is the key fix: osu! doesn't show chat history, so players - // only see messages sent AFTER they join. We greet each one individually. lobby.onPlayerJoined = async (username: string) => { try { + const normalizedName = username.toLowerCase().replace(/_/g, ' '); + + // Track who has joined for the rolling gate + const joined = joinedPlayers.get(matchId) ?? new Set(); + joined.add(normalizedName); + joinedPlayers.set(matchId, joined); + const m = await getMatchFull(matchId); const config = m.config as MatchConfig; - if (m.state === MATCH_STATES.ROLLING) { - // Tell them what's happening and what they need to do + if (m.state === MATCH_STATES.LOBBY) { + const expected = expectedPlayers.get(matchId) ?? new Set(); + const allJoined = expected.size > 0 && [...expected].every((p) => joined.has(p)); + + if (allJoined) { + await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId)); + await sleep(500); + await lobby.chat( + `All players present! BO${config.bestOf} match — type !roll to decide pick order.` + ); + } else { + const missing = [...expected].filter((p) => !joined.has(p)); + await sleep(1000); + await lobby.chat( + `Welcome ${username}! Waiting for ${missing.length} more player(s) to join before we start.` + ); + } + } else if (m.state === MATCH_STATES.ROLLING) { const unrolled = m.participants.filter((p) => p.rollValue === null); if (unrolled.length > 0) { - await sleep(1000); // small delay so they see it after join message + await sleep(1000); await lobby.chat( - `Welcome ${username}! This is a BO${config.bestOf} match. ` + - `Type !roll to decide pick order.` + `Welcome ${username}! BO${config.bestOf} match — type !roll to decide pick order.` ); } else { await lobby.chat(`Welcome ${username}! Waiting for rolls to finish...`); } } else if (m.state === MATCH_STATES.PICKING) { - const sorted = [...m.participants].sort( - (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) - ); + const sorted = [...m.participants].sort((a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)); const nextIdx = m.games.length % sorted.length; const picker = sorted[nextIdx]; - const score = m.participants.map((p) => `${p.team.name}: ${p.score}`).join(' | '); + const scoreStr = `(${sorted[0]?.team.name}) ${sorted[0]?.score} - ${sorted[1]?.score} (${sorted[1]?.team.name})`; await sleep(1000); await lobby.chat( - `Welcome back ${username}! Score: ${score}. ` + - `${picker?.team.name}'s turn to pick. Use !pick (e.g. !pick NM1).` + `Welcome back ${username}! ${scoreStr} — ${picker?.team.name}'s turn to pick. Use !pick (e.g. !pick NM1).` ); } else if (m.state === MATCH_STATES.PLAYING) { - await sleep(1000); - await lobby.chat(`Welcome back ${username}! A game is in progress — ready up when it finishes.`); + const activeGame = m.games.find((g: any) => g.state === GAME_STATES.PLAYING); + if (activeGame) { + await sleep(1000); + await lobby.chat(`Welcome back ${username}! A game is in progress — ready up when it finishes.`); + } } } catch (err: any) { console.error('[Orchestrator] Player join handler error:', err.message); @@ -491,48 +519,59 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna const config = updated.config as MatchConfig; const winsNeeded = Math.ceil(config.bestOf / 2); - // Build score announcement: "Zigzy 808,050 vs Stan 3,418 — Zigzy wins!" + // Sort participants by pick order for consistent display + const sortedPs = [...updated.participants].sort( + (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) + ); + const ps1 = sortedPs[0]; + const ps2 = sortedPs[1]; + const game = updated.games.find((g) => g.id === matchGameId); if (game && game.winnerParticipantId) { const winner = updated.participants.find((p) => p.id === game.winnerParticipantId); + const slotLabel = `${game.slot?.category}${game.slot?.orderInCategory}`; - // Individual player scores - const scoreLines = ircScores - .map((s) => `${s.username}: ${s.score.toLocaleString()}`) - .join(' vs '); + // Per-team score totals from game scores + const teamTotals = new Map(); + for (const s of game.scores ?? []) { + const pid = s.player?.participantId; + if (pid) teamTotals.set(pid, (teamTotals.get(pid) ?? 0) + s.score); + } + const score1 = (teamTotals.get(ps1?.id ?? '') ?? 0).toLocaleString(); + const score2 = (teamTotals.get(ps2?.id ?? '') ?? 0).toLocaleString(); await sleep(500); try { - await lobby.chat(`${scoreLines} — ${winner?.team.name ?? '?'} wins the point!`); + await lobby.chat( + `(${ps1?.team.name}) ${score1} - ${score2} (${ps2?.team.name}) — ${winner?.team.name ?? '?'} wins ${slotLabel}!` + ); } catch { /* ignore */ } } - // Match score line - const matchScore = updated.participants - .map((p) => `${p.team.name} ${p.score}`) - .join(' - '); + // Match score string: "(Team1) 2 - 1 (Team2)" + const matchScoreStr = `(${ps1?.team.name}) ${ps1?.score} - ${ps2?.score} (${ps2?.team.name})`; if (updated.state === MATCH_STATES.FINISHED) { const winner = updated.participants.find((p) => p.teamId === updated.winnerId); try { await sleep(1000); - await lobby.chat(`GG! ${winner?.team.name ?? '?'} wins the match! Final: ${matchScore}`); - await sleep(5000); + await lobby.chat(`GG! ${winner?.team.name ?? '?'} wins the match! ${matchScoreStr}`); + await sleep(2000); + await lobby.chat(`Results: ${env.ORIGIN}/matches/${matchId}`); + await lobby.chat(`This lobby will close in 3 minutes.`); + await sleep(3 * 60 * 1000); await lobby.close(); } catch { /* lobby may already be closed */ } removeLobby(matchId); + expectedPlayers.delete(matchId); + joinedPlayers.delete(matchId); } else { - // Announce next picker with current score - const sorted = [...updated.participants].sort( - (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) - ); - const nextIdx = updated.games.length % sorted.length; - const nextPicker = sorted[nextIdx]; + const nextIdx = updated.games.length % sortedPs.length; + const nextPicker = sortedPs[nextIdx]; try { await sleep(1000); await lobby.chat( - `Score: ${matchScore} (first to ${winsNeeded}). ` + - `${nextPicker?.team.name}'s turn to pick. Use !pick .` + `${matchScoreStr} — first to ${winsNeeded} — ${nextPicker?.team.name}'s turn to pick. Use !pick ` ); } catch { /* lobby may be dead */ } } @@ -547,4 +586,6 @@ export async function closeLobby(matchId: string) { await lobby.close(); removeLobby(matchId); } + expectedPlayers.delete(matchId); + joinedPlayers.delete(matchId); } diff --git a/src/routes/(app)/matches/[id]/+page.server.ts b/src/routes/(app)/matches/[id]/+page.server.ts index ce0d6ee..6c6eef1 100644 --- a/src/routes/(app)/matches/[id]/+page.server.ts +++ b/src/routes/(app)/matches/[id]/+page.server.ts @@ -11,6 +11,7 @@ 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'; +import { playerRating } from '$lib/server/db/schema'; export const load: PageServerLoad = async ({ params, locals }) => { @@ -92,7 +93,19 @@ export const load: PageServerLoad = async ({ params, locals }) => { const lobbyStatus = getLobbyStatus(params.id); - return { match: m, beatmapCache, userId: locals.user.id, isStaff, lobbyStatus, playerNames }; + const playerRatings: Record = {}; + for (const p of m.participants) { + for (const pl of p.players) { + const rating = await db.query.playerRating.findFirst({ + where: eq(playerRating.userId, pl.userId) + }); + if (rating) { + playerRatings[pl.userId] = { elo: rating.elo, wins: rating.wins, losses: rating.losses }; + } + } + } + + return { match: m, beatmapCache, userId: locals.user.id, isStaff, lobbyStatus, playerNames, playerRatings }; }; export const actions: Actions = { diff --git a/src/routes/(app)/matches/[id]/+page.svelte b/src/routes/(app)/matches/[id]/+page.svelte index 35601d6..79b59af 100644 --- a/src/routes/(app)/matches/[id]/+page.svelte +++ b/src/routes/(app)/matches/[id]/+page.svelte @@ -68,6 +68,36 @@ return groups; }); + // Match duration + let now = $state(Date.now()); + $effect(() => { + if (m.startedAt && !['FINISHED', 'CANCELLED'].includes(m.state)) { + const interval = setInterval(() => { now = Date.now(); }, 1000); + return () => clearInterval(interval); + } + }); + function formatDuration(ms: number): string { + const totalSecs = Math.floor(ms / 1000); + const mins = Math.floor(totalSecs / 60); + const secs = totalSecs % 60; + return `${mins}m ${secs.toString().padStart(2, '0')}s`; + } + const duration = $derived( + m.startedAt + ? m.finishedAt + ? formatDuration(new Date(m.finishedAt).getTime() - new Date(m.startedAt).getTime()) + : formatDuration(now - new Date(m.startedAt).getTime()) + : null + ); + + // Mappool avg SR + const mappoolAvgSR = $derived(() => { + const slots = m.mappool?.slots; + if (!slots?.length) return null; + const avg = slots.reduce((sum: number, s: any) => sum + (s.starRating ?? 0), 0) / slots.length; + return avg.toFixed(2); + }); + let rolling = $state(false); let picking = $state(false); let pickError = $state(''); @@ -142,6 +172,12 @@

{m.name || 'Match'}

Best of {config.bestOf} · First to {winsNeeded} + {#if m.mappool} + · {m.mappool.name}{mappoolAvgSR() ? ` · avg ★${mappoolAvgSR()}` : ''} + {/if} + {#if duration} + · {duration} + {/if} {#if m.osuLobbyId} · osu! mp/{m.osuLobbyId} {/if} @@ -165,6 +201,12 @@ {/if}

{p1.team.name}

+ {#if config.teamSize === 1} + {@const r1 = data.playerRatings?.[p1.players[0]?.userId]} + {#if r1} +

{r1.elo} ELO · {r1.wins}W {r1.losses}L

+ {/if} + {/if} {#if p1.rollValue != null}

Roll: {p1.rollValue}{p1.pickOrder === 1 ? ' ★' : ''}

{/if} @@ -178,6 +220,12 @@ {p2.score}

{p2.team.name}

+ {#if config.teamSize === 1} + {@const r2 = data.playerRatings?.[p2.players[0]?.userId]} + {#if r2} +

{r2.wins}W {r2.losses}L · {r2.elo} ELO

+ {/if} + {/if} {#if p2.rollValue != null}

{p2.pickOrder === 1 ? '★ ' : ''}Roll: {p2.rollValue}

{/if}