Files
esports/src/lib/server/tournament/scheduler.ts

246 lines
7.5 KiB
TypeScript

import { db } from '$lib/server/db';
import { tournament, tournamentMatch, tournamentRound } from '$lib/server/db/schema';
import { eq, and, lte, asc, inArray } from 'drizzle-orm';
import { log } from '$lib/server/logger';
import { TOURNAMENT_MATCH_STATES, TOURNAMENT_STATES, type TournamentConfig } from './types';
import { createMatch } from '$lib/server/match/engine';
import { initMatchLobby } from '$lib/server/match/orchestrator';
import { getActiveLobbyCount } from '$lib/server/bancho/client';
import type { MatchConfig } from '$lib/server/match/types';
const MAX_TOTAL_LOBBIES = 4;
const SCHEDULER_INTERVAL_MS = 30_000; // 30 seconds
let schedulerTimer: ReturnType<typeof setInterval> | null = null;
// ── Start/Stop Scheduler ───────────────────────────────────────────────
export function startTournamentScheduler() {
if (schedulerTimer) return;
schedulerTimer = setInterval(() => {
processTournamentMatchQueue().catch((err) =>
log.tournament.error({ err }, 'scheduler tick failed')
);
}, SCHEDULER_INTERVAL_MS);
log.tournament.info('tournament scheduler started');
}
export function stopTournamentScheduler() {
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
log.tournament.info('tournament scheduler stopped');
}
}
// ── Process Tournament Match Queue ─────────────────────────────────────
export async function processTournamentMatchQueue() {
// check how many lobby slots are available
const activeLobbies = getActiveLobbyCount();
const available = MAX_TOTAL_LOBBIES - activeLobbies;
if (available <= 0) {
log.tournament.debug({ activeLobbies }, 'no lobby slots available');
return;
}
// find tournaments in BRACKET state
const activeTournaments = await db.query.tournament.findMany({
where: eq(tournament.state, TOURNAMENT_STATES.BRACKET)
});
if (activeTournaments.length === 0) return;
// find READY tournament matches across all active tournaments
// that are either past their scheduled time or have no scheduled time
const readyMatches = await db.query.tournamentMatch.findMany({
where: and(
inArray(
tournamentMatch.tournamentId,
activeTournaments.map((t) => t.id)
),
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.READY)
),
orderBy: [asc(tournamentMatch.scheduledAt)]
});
// filter to matches that are ready to start (scheduled time passed or no schedule)
const now = new Date();
const eligible = readyMatches.filter((m) => !m.scheduledAt || m.scheduledAt <= now);
if (eligible.length === 0) return;
// respect per-tournament concurrency limits
const tournamentConfigs = new Map<string, TournamentConfig>();
for (const t of activeTournaments) {
tournamentConfigs.set(t.id, t.config as TournamentConfig);
}
// count currently live matches per tournament
const liveMatches = await db.query.tournamentMatch.findMany({
where: and(
inArray(
tournamentMatch.tournamentId,
activeTournaments.map((t) => t.id)
),
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.LIVE)
)
});
const livePerTournament = new Map<string, number>();
for (const m of liveMatches) {
livePerTournament.set(m.tournamentId, (livePerTournament.get(m.tournamentId) ?? 0) + 1);
}
let slotsUsed = 0;
for (const tm of eligible) {
if (slotsUsed >= available) break;
const config = tournamentConfigs.get(tm.tournamentId);
const maxConcurrent = config?.scheduling?.maxConcurrentLobbies ?? 3;
const currentLive = livePerTournament.get(tm.tournamentId) ?? 0;
if (currentLive >= maxConcurrent) continue;
try {
await startTournamentMatch(tm);
slotsUsed++;
livePerTournament.set(tm.tournamentId, currentLive + 1);
} catch (err) {
log.tournament.error(
{ err, tournamentMatchId: tm.id, tournamentId: tm.tournamentId },
'failed to start tournament match'
);
}
}
if (slotsUsed > 0) {
log.tournament.info({ started: slotsUsed }, 'started tournament matches');
}
}
// ── Start a Single Tournament Match ────────────────────────────────────
async function startTournamentMatch(tm: typeof tournamentMatch.$inferSelect) {
if (!tm.team1Id || !tm.team2Id) {
throw new Error('Cannot start match without both teams');
}
// get the round for config (bestOf, mappool)
const round = await db.query.tournamentRound.findFirst({
where: eq(tournamentRound.id, tm.roundId)
});
if (!round) throw new Error('Round not found');
// get tournament for config
const t = await db.query.tournament.findFirst({
where: eq(tournament.id, tm.tournamentId)
});
if (!t) throw new Error('Tournament not found');
const tournamentConfig = t.config as TournamentConfig;
if (!round.mappoolId) {
throw new Error(`Round ${round.name} has no mappool assigned`);
}
// build match config from tournament + round settings
const matchConfig: MatchConfig = {
bestOf: round.bestOf,
teamSize: tournamentConfig.teamSize,
scoringType: tournamentConfig.scoringType,
freemod: tournamentConfig.freemod,
forceNoFail: tournamentConfig.forceNoFail,
allowEloChange: false // tournament matches don't affect elo
};
// create the actual match using the existing engine
const matchName = `${t.name} - ${round.name} - Match ${tm.bracketPosition + 1}`;
const createdMatch = await createMatch({
name: matchName,
config: matchConfig,
mappoolId: round.mappoolId,
teams: [tm.team1Id, tm.team2Id],
createdBy: t.createdBy
});
// link tournament match to the real match
await db
.update(tournamentMatch)
.set({
matchId: createdMatch.id,
state: TOURNAMENT_MATCH_STATES.LIVE as string
})
.where(eq(tournamentMatch.id, tm.id));
// start the IRC lobby
await initMatchLobby(createdMatch.id);
log.tournament.info(
{
tournamentId: tm.tournamentId,
tournamentMatchId: tm.id,
matchId: createdMatch.id,
round: round.name
},
'tournament match started'
);
}
// ── Schedule Matches for a Round ───────────────────────────────────────
export async function scheduleTournamentMatches(tournamentId: string, roundId?: string) {
const t = await db.query.tournament.findFirst({
where: eq(tournament.id, tournamentId)
});
if (!t) throw new Error('Tournament not found');
const config = t.config as TournamentConfig;
const intervalMs = (config.scheduling?.matchIntervalMinutes ?? 30) * 60 * 1000;
// get matches to schedule
const where = roundId
? and(
eq(tournamentMatch.tournamentId, tournamentId),
eq(tournamentMatch.roundId, roundId),
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.READY)
)
: and(
eq(tournamentMatch.tournamentId, tournamentId),
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.READY)
);
const matches = await db.query.tournamentMatch.findMany({
where,
orderBy: [asc(tournamentMatch.bracketPosition)]
});
// get the round's scheduled start time
const round = roundId
? await db.query.tournamentRound.findFirst({
where: eq(tournamentRound.id, roundId)
})
: null;
const baseTime = round?.scheduledAt ?? t.startAt ?? new Date();
// assign scheduled times with intervals
for (let i = 0; i < matches.length; i++) {
const scheduledAt = new Date(baseTime.getTime() + i * intervalMs);
await db
.update(tournamentMatch)
.set({ scheduledAt })
.where(eq(tournamentMatch.id, matches[i].id));
}
log.tournament.info(
{ tournamentId, roundId, count: matches.length },
'scheduled tournament matches'
);
}