feat: implement match timeout management; auto-cancel matches if players do not join or ready up in time

This commit is contained in:
2026-03-01 19:28:17 +00:00
parent a30fd0bb66
commit 2386f1f00c

View File

@@ -2,7 +2,7 @@ import { db } from '$lib/server/db';
import { match, matchGame, user } from '$lib/server/db/schema'; import { match, matchGame, user } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types'; import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types';
import { getMatchFull, submitRoll, pickMap, submitGameScores } from './engine'; import { getMatchFull, submitRoll, pickMap, submitGameScores, cancelMatch } from './engine';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
// matchId → lowercase usernames of all expected players // matchId → lowercase usernames of all expected players
@@ -23,6 +23,118 @@ function sleep(ms: number) {
return new Promise<void>((r) => setTimeout(r, ms)); return new Promise<void>((r) => setTimeout(r, ms));
} }
// ── Match Timeouts ──────────────────────────────────────────────────────
// Hard caps so the platform doesn't wait forever for players.
// If a phase isn't completed in time the match is auto-cancelled.
const TIMEOUT_LOBBY_JOIN = 10 * 60 * 1000; // 10 min to join
const TIMEOUT_ROLLING = 5 * 60 * 1000; // 5 min to complete all rolls
const TIMEOUT_PICKING = 5 * 60 * 1000; // 5 min to pick a map
const TIMEOUT_READY = 5 * 60 * 1000; // 5 min to ready up after map is set
const matchTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
function setMatchTimeout(matchId: string, phase: string, ms: number, fn: () => void) {
const key = `${matchId}:${phase}`;
clearMatchTimeout(matchId, phase);
matchTimeouts.set(key, setTimeout(fn, ms));
const secs = Math.round(ms / 1000);
console.log(`[Timeout] Set ${phase} timeout for match ${matchId} (${secs}s)`);
}
function clearMatchTimeout(matchId: string, phase: string) {
const key = `${matchId}:${phase}`;
const t = matchTimeouts.get(key);
if (t) {
clearTimeout(t);
matchTimeouts.delete(key);
console.log(`[Timeout] Cleared ${phase} timeout for match ${matchId}`);
}
}
function clearAllMatchTimeouts(matchId: string) {
for (const [key, t] of matchTimeouts.entries()) {
if (key.startsWith(`${matchId}:`)) {
clearTimeout(t);
matchTimeouts.delete(key);
}
}
}
/**
* Cancel a match due to timeout. Notifies in IRC, closes the lobby, and
* marks the match CANCELLED in the database.
*/
async function timeoutMatch(matchId: string, reason: string) {
console.warn(`[Orchestrator] Match ${matchId} timed out: ${reason}`);
clearAllMatchTimeouts(matchId);
const lobby = getLobby(matchId);
if (lobby?.isAlive) {
try {
await lobby.chat(`⏰ Match cancelled — ${reason}`);
await sleep(3000);
await lobby.close();
} catch { /* lobby may already be dead */ }
}
removeLobby(matchId);
expectedPlayers.delete(matchId);
joinedPlayers.delete(matchId);
try {
await cancelMatch(matchId);
} catch (err: any) {
console.error(`[Orchestrator] Failed to cancel timed-out match ${matchId}:`, err.message);
}
}
/**
* Central timeout manager. Call after every state transition so the
* correct phase-timeout is armed. Safe to call from both IRC handlers
* and web form actions.
*/
export function onMatchStateChange(matchId: string, newState: string) {
// Always wipe previous phase timers first
clearAllMatchTimeouts(matchId);
switch (newState) {
case MATCH_STATES.LOBBY:
setMatchTimeout(matchId, 'lobby', TIMEOUT_LOBBY_JOIN, async () => {
const joined = joinedPlayers.get(matchId) ?? new Set();
const expected = expectedPlayers.get(matchId) ?? new Set();
const missing = [...expected].filter((p) => !joined.has(p));
const detail =
missing.length > 0
? `Missing: ${missing.join(', ')}.`
: '';
await timeoutMatch(
matchId,
`Not all players joined within 10 minutes. ${detail}`
);
});
break;
case MATCH_STATES.ROLLING:
setMatchTimeout(matchId, 'rolling', TIMEOUT_ROLLING, () => {
timeoutMatch(matchId, 'Not all players rolled within 5 minutes.');
});
break;
case MATCH_STATES.PICKING:
setMatchTimeout(matchId, 'picking', TIMEOUT_PICKING, () => {
timeoutMatch(matchId, 'No map was picked within 5 minutes.');
});
break;
// PLAYING ready-timeout is set by playPickedMap (needs gameInProgress guard)
case MATCH_STATES.FINISHED:
case MATCH_STATES.CANCELLED:
// Already cleared above — nothing else to arm
break;
}
}
/** Max concurrent lobbies for non-bot accounts */ /** Max concurrent lobbies for non-bot accounts */
const MAX_LOBBIES = 4; const MAX_LOBBIES = 4;
@@ -94,6 +206,9 @@ export async function initMatchLobby(matchId: string) {
// Wire up IRC chat handlers for rolls, picks, and player joins // Wire up IRC chat handlers for rolls, picks, and player joins
setupChatHandlers(matchId, lobby); setupChatHandlers(matchId, lobby);
// ── Arm the lobby-join timeout (10 min) ──
onMatchStateChange(matchId, MATCH_STATES.LOBBY);
// State stays LOBBY — transitions to ROLLING only when all players have joined // State stays LOBBY — transitions to ROLLING only when all players have joined
return { osuMatchId: osuId, channel }; return { osuMatchId: osuId, channel };
} }
@@ -126,9 +241,11 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
if (allJoined) { if (allJoined) {
await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId)); await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId));
// ── Arm rolling timeout (clears lobby timeout) ──
onMatchStateChange(matchId, MATCH_STATES.ROLLING);
await sleep(500); await sleep(500);
await lobby.chat( await lobby.chat(
`All players present! BO${config.bestOf} match — type !roll to decide pick order.` `All players present! BO${config.bestOf} match — type !roll to decide pick order. You have 5 minutes.`
); );
} else { } else {
const missing = [...expected].filter((p) => !joined.has(p)); const missing = [...expected].filter((p) => !joined.has(p));
@@ -157,10 +274,16 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
`Welcome back ${username}! ${scoreStr} — ${picker?.team.name}'s turn to pick. Use !pick <slot> (e.g. !pick NM1).` `Welcome back ${username}! ${scoreStr} — ${picker?.team.name}'s turn to pick. Use !pick <slot> (e.g. !pick NM1).`
); );
} else if (m.state === MATCH_STATES.PLAYING) { } else if (m.state === MATCH_STATES.PLAYING) {
const activeGame = m.games.find((g: any) => g.state === GAME_STATES.PLAYING); // ── FIX: check lobby.gameInProgress to give the right message ──
if (activeGame) {
await sleep(1000); await sleep(1000);
await lobby.chat(`Welcome back ${username}! A game is in progress — ready up when it finishes.`); if (lobby.gameInProgress) {
await lobby.chat(
`Welcome back ${username}! A game is in progress — ready up when it finishes.`
);
} else {
await lobby.chat(
`Welcome back ${username}! Please ready up so the game can start.`
);
} }
} }
} catch (err: any) { } catch (err: any) {
@@ -208,13 +331,15 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
const remaining = updated.participants.filter((p) => p.rollValue === null); const remaining = updated.participants.filter((p) => p.rollValue === null);
if (updated.state === MATCH_STATES.PICKING) { if (updated.state === MATCH_STATES.PICKING) {
// ── Arm picking timeout (clears rolling timeout) ──
onMatchStateChange(matchId, MATCH_STATES.PICKING);
const sorted = [...updated.participants].sort( const sorted = [...updated.participants].sort(
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
); );
await sleep(500); await sleep(500);
await lobby.chat( await lobby.chat(
`Rolls complete! ${sorted[0]?.team.name} picks first. ` + `Rolls complete! ${sorted[0]?.team.name} picks first. ` +
`Use !pick <slot> (e.g. !pick NM1).` `Use !pick <slot> (e.g. !pick NM1). You have 5 minutes.`
); );
} else if (remaining.length > 0) { } else if (remaining.length > 0) {
// Tell the other player they still need to roll // Tell the other player they still need to roll
@@ -300,6 +425,7 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
const game = await pickMap(matchId, expectedPicker!.id, slot.id); const game = await pickMap(matchId, expectedPicker!.id, slot.id);
console.log(`[Orchestrator] IRC pick: ${username} picked ${slotLabel}`); console.log(`[Orchestrator] IRC pick: ${username} picked ${slotLabel}`);
// Picking timeout is cleared inside playPickedMap
playPickedMap(matchId, game.id).catch((err) => playPickedMap(matchId, game.id).catch((err) =>
console.error('[Orchestrator] IRC play failed:', err.message) console.error('[Orchestrator] IRC play failed:', err.message)
); );
@@ -315,6 +441,9 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
* Sets map + mods in IRC, waits for ready, starts game, collects scores. * Sets map + mods in IRC, waits for ready, starts game, collects scores.
*/ */
export async function playPickedMap(matchId: string, matchGameId: string) { export async function playPickedMap(matchId: string, matchGameId: string) {
// ── Clear picking timeout (a pick was made) ──
clearMatchTimeout(matchId, 'picking');
const lobby = getLobby(matchId); const lobby = getLobby(matchId);
if (!lobby) { if (!lobby) {
console.warn('[Orchestrator] No IRC lobby for', matchId, '— skipping IRC'); console.warn('[Orchestrator] No IRC lobby for', matchId, '— skipping IRC');
@@ -347,18 +476,37 @@ export async function playPickedMap(matchId: string, matchGameId: string) {
// Announce with context: what map, current score, what to do // Announce with context: what map, current score, what to do
await lobby.chat( await lobby.chat(
`[${score}] Now playing ${slot.category}${slot.orderInCategory}. Please ready up!` `[${score}] Now playing ${slot.category}${slot.orderInCategory}. Please ready up! You have 5 minutes.`
); );
// ── Arm ready timeout: cancel if nobody readies within 5 min ──
// Guard: if the game actually starts (force start / normal start)
// the timeout checks gameInProgress before cancelling.
setMatchTimeout(matchId, 'ready', TIMEOUT_READY, async () => {
const currentLobby = getLobby(matchId);
if (currentLobby?.gameInProgress) {
// Game is running — don't cancel, scores will come in eventually
console.log(`[Timeout] Ready timeout fired but game is in progress for ${matchId}, ignoring`);
return;
}
await timeoutMatch(matchId, 'Players did not ready up within 5 minutes.');
});
// Wait for all players to ready up in osu!, then start // Wait for all players to ready up in osu!, then start
// Use a shorter soft-timeout so we can warn before the hard cancel fires
try { try {
await lobby.waitForReady(); await lobby.waitForReady(TIMEOUT_READY - 30_000);
clearMatchTimeout(matchId, 'ready'); // players readied — disarm
await lobby.chat('All ready — starting in 5s!'); await lobby.chat('All ready — starting in 5s!');
await lobby.startGame(5); await lobby.startGame(5);
} catch (err: any) { } catch (err: any) {
console.warn('[Orchestrator] Ready timeout:', err.message); console.warn('[Orchestrator] Ready timeout:', err.message);
try { 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.'); 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. ' +
'Match will auto-cancel if nobody readies soon.'
);
} catch { /* lobby may be dead */ } } catch { /* lobby may be dead */ }
// Still set up score collection — force start will trigger the game // Still set up score collection — force start will trigger the game
collectScores(matchId, matchGameId, lobby).catch((err) => collectScores(matchId, matchGameId, lobby).catch((err) =>
@@ -380,6 +528,9 @@ export async function forceStartGame(matchId: string) {
const lobby = getLobby(matchId); const lobby = getLobby(matchId);
if (!lobby || !lobby.isAlive) return; if (!lobby || !lobby.isAlive) return;
// Game is being force-started → disarm the ready timeout
clearMatchTimeout(matchId, 'ready');
await lobby.chat('Force starting in 10s!'); await lobby.chat('Force starting in 10s!');
await lobby.startGame(10); await lobby.startGame(10);
} }
@@ -390,6 +541,10 @@ export async function forceStartGame(matchId: string) {
*/ */
async function collectScores(matchId: string, matchGameId: string, lobby: TournamentLobby) { async function collectScores(matchId: string, matchGameId: string, lobby: TournamentLobby) {
const ircScores = await lobby.waitForScores(); const ircScores = await lobby.waitForScores();
// Game finished → disarm the ready timeout if it's still ticking
clearMatchTimeout(matchId, 'ready');
console.log(`[Orchestrator] Scores for game ${matchGameId}:`, ircScores); console.log(`[Orchestrator] Scores for game ${matchGameId}:`, ircScores);
// Map IRC usernames → matchParticipantPlayer IDs // Map IRC usernames → matchParticipantPlayer IDs
@@ -409,9 +564,8 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
}[] = []; }[] = [];
// ── FIX: Build matchParticipantPlayer.id → osu numeric account ID map ── // ── 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>();
const playerOsuIdMap = new Map<string, number>(); // matchParticipantPlayer.id → osu numeric ID const playerUserIdMap = new Map<string, string>();
const playerUserIdMap = new Map<string, string>(); // matchParticipantPlayer.id → userId
for (const irc of ircScores) { for (const irc of ircScores) {
for (const participant of m.participants) { for (const participant of m.participants) {
@@ -423,7 +577,6 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
scores.push({ playerId: player.id, score: irc.score, passed: irc.passed }); scores.push({ playerId: player.id, score: irc.score, passed: irc.passed });
playerUserIdMap.set(player.id, player.userId); playerUserIdMap.set(player.id, player.userId);
// Cache the osu account ID for enrichment, keyed by matchParticipantPlayer.id
if (!playerOsuIdMap.has(player.id)) { if (!playerOsuIdMap.has(player.id)) {
const acc = await db.query.account.findFirst({ const acc = await db.query.account.findFirst({
where: and(eq(account.userId, player.userId), eq(account.providerId, 'osu')) where: and(eq(account.userId, player.userId), eq(account.providerId, 'osu'))
@@ -442,14 +595,12 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
return; return;
} }
// ── Enrich scores with osu! API data (300/100/50/miss/accuracy/maxCombo/pp) ── // ── Enrich scores with osu! API data ──
if (m.osuLobbyId) { if (m.osuLobbyId) {
try { try {
// Small delay to let osu! API update with the latest game data
await sleep(2000); await sleep(2000);
const osuMatchData = await getOsuMatch(m.osuLobbyId); const osuMatchData = await getOsuMatch(m.osuLobbyId);
// Find the most recent game event matching our beatmap
const gameRecord = await db.query.matchGame.findFirst({ const gameRecord = await db.query.matchGame.findFirst({
where: eq(matchGame.id, matchGameId), where: eq(matchGame.id, matchGameId),
with: { slot: true } with: { slot: true }
@@ -457,14 +608,12 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
const beatmapId = gameRecord?.slot?.beatmapId ? parseInt(gameRecord.slot.beatmapId) : null; const beatmapId = gameRecord?.slot?.beatmapId ? parseInt(gameRecord.slot.beatmapId) : null;
if (beatmapId && osuMatchData?.events) { if (beatmapId && osuMatchData?.events) {
// Find game events in reverse (most recent first) matching our beatmap
const gameEvents = [...osuMatchData.events] const gameEvents = [...osuMatchData.events]
.reverse() .reverse()
.filter((e: any) => e.game?.beatmap_id === beatmapId); .filter((e: any) => e.game?.beatmap_id === beatmapId);
const osuGame = gameEvents[0]?.game; const osuGame = gameEvents[0]?.game;
if (osuGame?.scores) { if (osuGame?.scores) {
// Build osu userID → score stats map
const osuScoreMap = new Map<number, any>(); const osuScoreMap = new Map<number, any>();
for (const os of osuGame.scores) { for (const os of osuGame.scores) {
osuScoreMap.set(os.user_id, os); osuScoreMap.set(os.user_id, os);
@@ -475,7 +624,6 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
`Player map has ${playerOsuIdMap.size} entries.` `Player map has ${playerOsuIdMap.size} entries.`
); );
// Enrich each score using the FIXED mapping (matchParticipantPlayer.id → osu ID)
for (const s of scores) { for (const s of scores) {
const playerOsuId = playerOsuIdMap.get(s.playerId); const playerOsuId = playerOsuIdMap.get(s.playerId);
if (playerOsuId === undefined) { if (playerOsuId === undefined) {
@@ -488,7 +636,6 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
continue; continue;
} }
// v2 API format: accuracy is a float (0-1), statistics has count_300 etc.
s.accuracy = osuScore.accuracy ?? undefined; s.accuracy = osuScore.accuracy ?? undefined;
s.maxCombo = osuScore.max_combo ?? undefined; s.maxCombo = osuScore.max_combo ?? undefined;
s.count300 = osuScore.statistics?.count_300 ?? undefined; s.count300 = osuScore.statistics?.count_300 ?? undefined;
@@ -519,7 +666,6 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
const config = updated.config as MatchConfig; const config = updated.config as MatchConfig;
const winsNeeded = Math.ceil(config.bestOf / 2); const winsNeeded = Math.ceil(config.bestOf / 2);
// Sort participants by pick order for consistent display
const sortedPs = [...updated.participants].sort( const sortedPs = [...updated.participants].sort(
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
); );
@@ -531,7 +677,6 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
const winner = updated.participants.find((p) => p.id === game.winnerParticipantId); const winner = updated.participants.find((p) => p.id === game.winnerParticipantId);
const slotLabel = `${game.slot?.category}${game.slot?.orderInCategory}`; const slotLabel = `${game.slot?.category}${game.slot?.orderInCategory}`;
// Per-team score totals from game scores
const teamTotals = new Map<string, number>(); const teamTotals = new Map<string, number>();
for (const s of game.scores ?? []) { for (const s of game.scores ?? []) {
const pid = s.player?.participantId; const pid = s.player?.participantId;
@@ -548,10 +693,12 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
} catch { /* ignore */ } } catch { /* ignore */ }
} }
// Match score string: "(Team1) 2 - 1 (Team2)"
const matchScoreStr = `(${ps1?.team.name}) ${ps1?.score} - ${ps2?.score} (${ps2?.team.name})`; const matchScoreStr = `(${ps1?.team.name}) ${ps1?.score} - ${ps2?.score} (${ps2?.team.name})`;
if (updated.state === MATCH_STATES.FINISHED) { if (updated.state === MATCH_STATES.FINISHED) {
// ── Match over — clear everything ──
clearAllMatchTimeouts(matchId);
const winner = updated.participants.find((p) => p.teamId === updated.winnerId); const winner = updated.participants.find((p) => p.teamId === updated.winnerId);
try { try {
await sleep(1000); await sleep(1000);
@@ -566,12 +713,15 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
expectedPlayers.delete(matchId); expectedPlayers.delete(matchId);
joinedPlayers.delete(matchId); joinedPlayers.delete(matchId);
} else { } else {
// ── Back to PICKING — arm the pick timeout ──
onMatchStateChange(matchId, MATCH_STATES.PICKING);
const nextIdx = updated.games.length % sortedPs.length; const nextIdx = updated.games.length % sortedPs.length;
const nextPicker = sortedPs[nextIdx]; const nextPicker = sortedPs[nextIdx];
try { try {
await sleep(1000); await sleep(1000);
await lobby.chat( await lobby.chat(
`${matchScoreStr} — first to ${winsNeeded} — ${nextPicker?.team.name}'s turn to pick. Use !pick <slot>` `${matchScoreStr} — first to ${winsNeeded} — ${nextPicker?.team.name}'s turn to pick. Use !pick <slot> (5 min to pick)`
); );
} catch { /* lobby may be dead */ } } catch { /* lobby may be dead */ }
} }
@@ -581,6 +731,7 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
* Close the IRC lobby for a match. * Close the IRC lobby for a match.
*/ */
export async function closeLobby(matchId: string) { export async function closeLobby(matchId: string) {
clearAllMatchTimeouts(matchId);
const lobby = getLobby(matchId); const lobby = getLobby(matchId);
if (lobby) { if (lobby) {
await lobby.close(); await lobby.close();