diff --git a/src/lib/server/bancho/client.ts b/src/lib/server/bancho/client.ts index dfd0a42..36bb22c 100644 --- a/src/lib/server/bancho/client.ts +++ b/src/lib/server/bancho/client.ts @@ -9,6 +9,7 @@ function sleep(ms: number) { let client: any = null; let connecting: Promise | null = null; let reconnecting = false; +let keepaliveTimer: ReturnType | null = null; async function getClient() { if (client?.isConnected?.()) return client; @@ -29,18 +30,13 @@ async function getClient() { client = new BanchoClient({ username: env.OSU_IRC_USERNAME, - password: env.OSU_IRC_PASSWORD, - // bancho.js has built-in reconnect but we need to handle channel rejoin - apiKey: undefined + password: env.OSU_IRC_PASSWORD }); // ── Lifecycle logging ── - client.on('connected', () => { - console.log('[Bancho] Connected as', env.OSU_IRC_USERNAME); - }); - client.on('disconnected', () => { console.warn('[Bancho] Disconnected from IRC'); + stopKeepalive(); }); client.on('error', (err: any) => { @@ -48,16 +44,18 @@ async function getClient() { }); // ── Reconnect: re-join all active lobby channels ── - // bancho.js fires 'connected' again after an auto-reconnect let initialConnect = true; client.on('connected', async () => { if (initialConnect) { initialConnect = false; - return; // skip the first connect, lobbies haven't been created yet + console.log('[Bancho] Connected as', env.OSU_IRC_USERNAME); + startKeepalive(); + return; } reconnecting = true; console.log('[Bancho] Reconnected — re-joining', lobbies.size, 'active lobby channels'); + startKeepalive(); for (const [matchId, lobby] of lobbies.entries()) { try { @@ -65,8 +63,6 @@ async function getClient() { console.log(`[Bancho] Re-joined channel for match ${matchId}`); } catch (err: any) { console.error(`[Bancho] Failed to re-join channel for match ${matchId}:`, err.message); - // Channel may have been closed by Bancho while we were offline - // Mark it as dead so orchestrator knows lobby._dead = true; } } @@ -81,6 +77,36 @@ async function getClient() { return client; } +// ── Keepalive: prevent IRC timeout by pinging BanchoBot periodically ──── + +function startKeepalive() { + stopKeepalive(); + keepaliveTimer = setInterval(async () => { + try { + if (client?.isConnected?.()) { + // Send a silent PING via raw IRC to keep the connection alive. + // bancho.js exposes the underlying IRC socket for raw writes, + // but the simplest approach is just sending a stats request to BanchoBot. + // This generates minimal traffic but keeps TCP alive. + const banchoBot = client.getUser('BanchoBot'); + if (banchoBot) { + // WHOIS is lightweight and doesn't produce visible output in channels + client.send?.(`PING :keepalive-${Date.now()}`); + } + } + } catch { + // Ignore keepalive errors — if connection is dead, the reconnect handler will fire + } + }, 30_000); // every 30 seconds +} + +function stopKeepalive() { + if (keepaliveTimer) { + clearInterval(keepaliveTimer); + keepaliveTimer = null; + } +} + /** * Check if the IRC client is currently connected. */ @@ -89,7 +115,7 @@ export function isBanchoConnected(): boolean { } /** - * Check if we're in the middle of a reconnect (channels may be temporarily unavailable). + * Check if we're in the middle of a reconnect. */ export function isBanchoReconnecting(): boolean { return reconnecting; @@ -113,7 +139,7 @@ export class TournamentLobby { /** Set to true if the channel couldn't be re-joined after reconnect */ public _dead = false; - // The message handler function (stored so we can re-attach after reconnect) + // Stored so we can re-attach after reconnect private messageHandler: ((msg: any) => void) | null = null; // Score collection @@ -170,18 +196,14 @@ export class TournamentLobby { /** * Re-join the channel after a reconnect. - * Called internally by the reconnect handler. */ async _rejoinChannel(c: any) { if (!this.channelName) return; - // Remove old listener if any if (this.channel && this.messageHandler) { try { this.channel.removeListener('message', this.messageHandler); - } catch { - /* old channel may be completely dead */ - } + } catch { /* old channel may be dead */ } } this.channel = c.getChannel(this.channelName); @@ -193,13 +215,10 @@ export class TournamentLobby { private attachMessageHandler() { if (!this.channel) return; - // Remove previous handler if exists (prevent duplicates) if (this.messageHandler) { try { this.channel.removeListener('message', this.messageHandler); - } catch { - /* ignore */ - } + } catch { /* ignore */ } } this.messageHandler = (msg: any) => { @@ -219,7 +238,6 @@ export class TournamentLobby { // ── Message Parsing ───────────────────────────────────────────────── private parseBanchoMessage(text: string) { - // Player score: " finished playing (Score: 1,234,567, ... PASSED)" const scoreMatch = text.match( /^(.+?) finished playing \(Score: ([\d,]+).*?(PASSED|FAILED)\)/i ); @@ -233,7 +251,6 @@ export class TournamentLobby { return; } - // Game finished if (text.includes('The match has finished!')) { console.log('[Bancho] Game finished. Scores:', this.collectedScores); if (this.matchFinishedResolve) { @@ -244,7 +261,6 @@ export class TournamentLobby { return; } - // Roll result: "Stan rolls 42 point(s)" const rollMatch = text.match(/^(.+?) rolls (\d+) point\(s\)/); if (rollMatch) { const username = rollMatch[1].trim(); @@ -256,7 +272,6 @@ export class TournamentLobby { return; } - // All players ready if (text.includes('All players are ready')) { console.log('[Bancho] All players ready'); if (this.allReadyResolve) { @@ -266,7 +281,6 @@ export class TournamentLobby { return; } - // Room closed (by Bancho or timeout) if (text.includes('Closed the match')) { console.log(`[Bancho] Room ${this.channelName} was closed`); this._dead = true; @@ -275,7 +289,6 @@ export class TournamentLobby { } private parsePlayerMessage(sender: string, text: string) { - // !pick NM1, !pick HD2, !pick TB, etc. const pickMatch = text.match(/^!pick\s+([A-Z]{2})(\d*)/i); if (pickMatch) { const category = pickMatch[1].toUpperCase(); @@ -303,7 +316,6 @@ export class TournamentLobby { async send(cmd: string) { this.ensureAlive(); - // If we're in the middle of reconnecting, wait a bit if (reconnecting) { console.log(`[Bancho] Waiting for reconnect before sending to ${this.channelName}...`); const start = Date.now(); @@ -362,19 +374,12 @@ export class TournamentLobby { await this.send(message); } - /** - * Check if this lobby is still usable. - */ get isAlive(): boolean { return !this._dead && this.channel != null; } // ── Async Waiters ─────────────────────────────────────────────────── - /** - * Wait for all players to click "Ready" in osu! client. - * BanchoBot sends "All players are ready" when they do. - */ waitForReady(timeoutMs = 180_000): Promise { return new Promise((resolve, reject) => { const t = setTimeout(() => { @@ -388,9 +393,6 @@ export class TournamentLobby { }); } - /** - * Wait for "The match has finished!" and return all player scores. - */ waitForScores(timeoutMs = 600_000): Promise { this.collectedScores = []; return new Promise((resolve, reject) => { @@ -406,7 +408,7 @@ export class TournamentLobby { } } -// ── Active lobby registry (in-memory, per server instance) ────────────── +// ── Active lobby registry ─────────────────────────────────────────────── const lobbies = new Map(); @@ -428,10 +430,6 @@ export function removeLobby(matchId: string) { lobbies.delete(matchId); } -/** - * Get count of active (non-dead) lobbies. - * Useful for checking against the 4-lobby limit for non-bot accounts. - */ export function getActiveLobbyCount(): number { let count = 0; for (const [id, lobby] of lobbies.entries()) { @@ -444,9 +442,6 @@ export function getActiveLobbyCount(): number { return count; } -/** - * Close all active lobbies. Useful for graceful shutdown. - */ export async function closeAllLobbies() { console.log(`[Bancho] Closing all ${lobbies.size} active lobbies...`); for (const [matchId, lobby] of lobbies.entries()) { diff --git a/src/lib/server/match/orchestrator.ts b/src/lib/server/match/orchestrator.ts index 7342684..871147a 100644 --- a/src/lib/server/match/orchestrator.ts +++ b/src/lib/server/match/orchestrator.ts @@ -8,9 +8,7 @@ import { setLobby, getLobby, removeLobby, - getActiveLobbyCount, - isBanchoConnected, - isBanchoReconnecting + getActiveLobbyCount } from '../bancho/client'; function sleep(ms: number) { @@ -84,6 +82,8 @@ export async function initMatchLobby(matchId: string) { // Wire up IRC chat handlers for rolls and picks setupChatHandlers(matchId, lobby); + // ── Send welcome AFTER invites with a delay so players have time to join ── + await sleep(5000); await lobby.chat( `Welcome! BO${config.bestOf}. Type !roll to decide pick order, or use the web UI.` ); @@ -107,19 +107,16 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) { return; } - // Find participant linked to this username let targetParticipant: { id: string; rollValue: number | null } | null = null; 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?.toLowerCase() === username.toLowerCase().replace(/_/g, ' ')) { - // If this participant already rolled, try finding another one (same-team testing) if (p.rollValue === null) { targetParticipant = p; break; } else if (!targetParticipant) { - // Keep looking for an unrolled one const unrolled = m.participants.find((pp) => pp.rollValue === null); if (unrolled) targetParticipant = unrolled; } @@ -136,7 +133,6 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) { await submitRoll(matchId, targetParticipant.id, value); console.log(`[Orchestrator] IRC roll: ${username} = ${value}`); - // Check if all rolled → announce pick order const updated = await getMatchFull(matchId); if (updated.state === MATCH_STATES.PICKING) { const sorted = [...updated.participants].sort( @@ -160,7 +156,6 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) { return; } - // Parse slot label: "NM1" → category=NM, orderInCategory=1 const labelMatch = slotLabel.match(/^([A-Z]{2})(\d+)$/); if (!labelMatch) { await lobby.chat(`${username}: Invalid slot. Use format like NM1, HD2, DT1.`); @@ -169,14 +164,12 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) { const category = labelMatch[1]; const order = parseInt(labelMatch[2]); - // Find the expected picker const sorted = [...m.participants].sort( (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) ); const expectedIdx = m.games.length % sorted.length; const expectedPicker = sorted[expectedIdx]; - // Verify it's this player's turn to pick let isPickerTurn = false; if (expectedPicker) { for (const pl of expectedPicker.players) { @@ -198,7 +191,6 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) { return; } - // Find the mappool slot const slot = m.mappool?.slots?.find( (s) => s.category === category && s.orderInCategory === order ); @@ -207,18 +199,26 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) { return; } - // Check if already played const alreadyPlayed = m.games.some((g) => g.mappoolSlotId === slot.id); if (alreadyPlayed) { await lobby.chat(`${username}: ${slotLabel} has already been played.`); return; } - // Execute the pick + // ── Tiebreaker restriction ── + if (slot.category === 'TB') { + const config = m.config as MatchConfig; + const winsNeeded = Math.ceil(config.bestOf / 2); + const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1); + if (!allAtMatchPoint) { + await lobby.chat(`${username}: Tiebreaker can only be picked at match point!`); + return; + } + } + const game = await pickMap(matchId, expectedPicker!.id, slot.id); console.log(`[Orchestrator] IRC pick: ${username} picked ${slotLabel}`); - // Set map in lobby and wait for ready playPickedMap(matchId, game.id).catch((err) => console.error('[Orchestrator] IRC play failed:', err.message) ); @@ -254,11 +254,9 @@ export async function playPickedMap(matchId: string, matchGameId: string) { const slot = game.slot; - // Set beatmap await lobby.setMap(slot.beatmapId); await sleep(1000); - // Set mods (always NF + slot mods) await lobby.setMods(slot.mods); await sleep(500); @@ -266,23 +264,18 @@ export async function playPickedMap(matchId: string, matchGameId: string) { `Playing ${slot.category}${slot.orderInCategory}. Ready up! (Game starts when all players are ready)` ); - // Wait for all players to ready up in osu!, then start try { await lobby.waitForReady(); await lobby.chat('All ready — starting in 5s!'); await lobby.startGame(5); } catch (err: any) { - // If ready times out, inform and don't start console.warn('[Orchestrator] Ready timeout:', err.message); try { await lobby.chat('Ready timed out. Use the web UI to force start, or ready up!'); - } catch { - // Lobby may be dead after timeout - } + } catch { /* lobby may be dead */ } return; } - // Non-blocking: wait for scores then process collectScores(matchId, matchGameId, lobby).catch((err) => console.error('[Orchestrator] Score collection failed:', err) ); @@ -290,16 +283,10 @@ export async function playPickedMap(matchId: string, matchGameId: string) { /** * Force start the current game without waiting for ready. - * Called from the web UI "Force Start" button. */ export async function forceStartGame(matchId: string) { const lobby = getLobby(matchId); - if (!lobby) return; - - if (!lobby.isAlive) { - console.warn('[Orchestrator] Cannot force start — lobby is dead'); - return; - } + if (!lobby || !lobby.isAlive) return; await lobby.chat('Force starting in 10s!'); await lobby.startGame(10); @@ -312,7 +299,6 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna const ircScores = await lobby.waitForScores(); console.log(`[Orchestrator] Scores for game ${matchGameId}:`, ircScores); - // Map IRC usernames → matchParticipantPlayer IDs const m = await getMatchFull(matchId); const scores: { playerId: string; score: number; passed: boolean }[] = []; @@ -320,7 +306,6 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna for (const participant of m.participants) { for (const player of participant.players) { const u = await db.query.user.findFirst({ where: eq(user.id, player.userId) }); - // IRC uses underscores for spaces in usernames const ircName = irc.username.toLowerCase().replace(/_/g, ' '); const dbName = u?.name?.toLowerCase(); if (dbName && (dbName === ircName || dbName === irc.username.toLowerCase())) { @@ -337,7 +322,6 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna await submitGameScores(matchGameId, scores); - // Check if match finished or needs next pick const updated = await getMatchFull(matchId); if (updated.state === MATCH_STATES.FINISHED) { const winner = updated.participants.find((p) => p.teamId === updated.winnerId); @@ -345,12 +329,9 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna await lobby.chat(`GG! ${winner?.team.name ?? '?'} wins the match!`); await sleep(5000); await lobby.close(); - } catch { - /* lobby may already be closed */ - } + } catch { /* lobby may already be closed */ } removeLobby(matchId); } else { - // Announce next picker const sorted = [...updated.participants].sort( (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) ); @@ -360,9 +341,7 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna await lobby.chat( `${nextPicker?.team.name}'s turn to pick. Use !pick (e.g. !pick HD1) or pick in web UI.` ); - } catch { - /* lobby may be dead */ - } + } catch { /* lobby may be dead */ } } } diff --git a/src/routes/(app)/matches/[id]/+page.server.ts b/src/routes/(app)/matches/[id]/+page.server.ts index 6abbb7f..77ef0d8 100644 --- a/src/routes/(app)/matches/[id]/+page.server.ts +++ b/src/routes/(app)/matches/[id]/+page.server.ts @@ -5,8 +5,10 @@ import { playPickedMap, closeLobby, forceStartGame } from '$lib/server/match/orc import { getLobby } 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: PageServerLoad = async ({ params, locals }) => { @@ -41,7 +43,9 @@ export const load: PageServerLoad = async ({ params, locals }) => { ); } - return { match: m, beatmapCache, userId: locals.user.id }; + const isStaff = hasRole(locals.user.role, 'referee'); + + return { match: m, beatmapCache, userId: locals.user.id, isStaff }; }; export const actions: Actions = { @@ -51,20 +55,14 @@ export const actions: 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 }) => { @@ -132,6 +130,28 @@ export const actions: Actions = { if (!expectedPicker) return { error: 'Cannot determine picker' }; + // ── Verify the logged-in user is on the expected picker's team ── + const isOnPickerTeam = expectedPicker.players.some( + (pl) => pl.userId === locals.user!.id + ); + // Allow staff to pick on behalf of anyone + const isStaff = hasRole(locals.user!.role, 'referee'); + if (!isOnPickerTeam && !isStaff) { + 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); @@ -148,12 +168,20 @@ export const actions: Actions = { forceStart: async ({ params, locals }) => { 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 }) => { 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/src/routes/(app)/matches/[id]/+page.svelte b/src/routes/(app)/matches/[id]/+page.svelte index 28057a6..56c7217 100644 --- a/src/routes/(app)/matches/[id]/+page.svelte +++ b/src/routes/(app)/matches/[id]/+page.svelte @@ -20,6 +20,16 @@ const expectedPickerIdx = $derived(m.games.length % sortedByPick.length); const expectedPicker = $derived(sortedByPick[expectedPickerIdx]); + // Is the logged-in user on the expected picker's team? + const isMyTurnToPick = $derived( + expectedPicker?.players?.some((pl: any) => pl.userId === data.userId) ?? false + ); + + // TB restriction: only pickable at match point (e.g. 2-2 in BO5) + const isTiebreakerAllowed = $derived( + m.participants.every((p: any) => p.score === winsNeeded - 1) + ); + const canRoll = $derived( m.state === 'ROLLING' && m.participants.some((p: any) => p.rollValue === null) ); @@ -94,7 +104,7 @@ {stateLabel(m.state)} - {#if !['FINISHED', 'CANCELLED'].includes(m.state)} + {#if !['FINISHED', 'CANCELLED'].includes(m.state) && data.isStaff}
@@ -136,13 +146,12 @@ {/if} - + {#if ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'].includes(m.state)}
{ return async ({ result, update }) => { if (result.type === 'success' && (result.data as any)?.reinvited) { - // Brief visual feedback — just reload await update(); } }; @@ -151,7 +160,7 @@ type="submit" class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-accent" > - 📨 Re-invite players to lobby + 📨 Re-invite me to lobby
@@ -202,6 +211,9 @@

🎯 Pick a Map

{expectedPicker?.team.name}'s turn to pick + {#if !isMyTurnToPick && !data.isStaff} + (waiting...) + {/if}

or type !pick NM1 in osu! chat @@ -222,6 +234,9 @@ {#each slots as slot, i} {@const bm = data.beatmapCache[slot.beatmapId]} {@const isPlayed = playedSlotIds.has(slot.id)} + {@const isTB = slot.category === 'TB'} + {@const tbLocked = isTB && !isTiebreakerAllowed} + {@const cantPick = !isMyTurnToPick && !data.isStaff}

{ picking = true; @@ -238,10 +253,12 @@
{/each} @@ -289,19 +310,21 @@ Waiting for players to ready up & play in osu!...

- -
{ - forceStarting = true; - return async ({ update }) => { forceStarting = false; await update(); }; - }}> - -
+ + {#if data.isStaff} +
{ + forceStarting = true; + return async ({ update }) => { forceStarting = false; await update(); }; + }}> + +
+ {/if} {/if} diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts index aa5a60f..ad81bda 100644 --- a/src/routes/api/health/+server.ts +++ b/src/routes/api/health/+server.ts @@ -1,3 +1,4 @@ +// src/routes/api/health/+server.ts import { json } from '@sveltejs/kit'; import { db } from '$lib/server/db'; import { sql } from 'drizzle-orm';