From 593797c7510bee61d5e3e11372918e254d981a03 Mon Sep 17 00:00:00 2001
From: Stan Runge
Date: Sat, 28 Feb 2026 21:17:05 +0000
Subject: [PATCH] feat: implement keepalive mechanism for IRC connection and
enhance match actions with role-based permissions
---
src/lib/server/bancho/client.ts | 89 +++++++++----------
src/lib/server/match/orchestrator.ts | 57 ++++--------
src/routes/(app)/matches/[id]/+page.server.ts | 54 ++++++++---
src/routes/(app)/matches/[id]/+page.svelte | 65 +++++++++-----
src/routes/api/health/+server.ts | 1 +
5 files changed, 146 insertions(+), 120 deletions(-)
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)}
@@ -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}
{/each}
@@ -289,19 +310,21 @@
Waiting for players to ready up & play in osu!...
-
-
+
+ {#if data.isStaff}
+
+ {/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';