diff --git a/src/lib/server/bancho/dm-handler.ts b/src/lib/server/bancho/dm-handler.ts index 1fb2ad1..21146e4 100644 --- a/src/lib/server/bancho/dm-handler.ts +++ b/src/lib/server/bancho/dm-handler.ts @@ -13,7 +13,7 @@ import { db } from '$lib/server/db'; import { user, playerRating, teamMember, team, matchInvite } from '$lib/server/db/schema'; import { eq, and, inArray } from 'drizzle-orm'; import { setDMHandler, sendDM, getClient } from './client'; -import { joinQueue, leaveQueue, getQueueStatus } from '../match/engine'; +import { joinQueue, leaveQueue, getQueueStatus } from '../match/queue'; import { acceptInvite, declineInvite, getInvitesForUser } from '../match/invites'; let initialized = false; diff --git a/src/lib/server/match/engine.ts b/src/lib/server/match/engine.ts index 8b9a991..e24c255 100644 --- a/src/lib/server/match/engine.ts +++ b/src/lib/server/match/engine.ts @@ -5,13 +5,12 @@ import { matchParticipantPlayer, matchGame, matchGameScore, - matchQueue, mappoolSlot, playerRating, teamMember } from '$lib/server/db/schema'; import { account } from '$lib/server/db/auth.schema'; -import { eq, and, asc, sql, inArray, lt } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types'; import { notifyMatchCreated, notifyMatchFinished } from '$lib/server/discord/client'; import { getUser } from '$lib/server/osu/api'; @@ -47,204 +46,6 @@ export async function calculateInitialElo(userId: string): Promise<{ elo: number } } -// ── Queue ─────────────────────────────────────────────────────────────── - -const QUEUE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes - -async function purgeExpiredQueueEntries() { - const cutoff = new Date(Date.now() - QUEUE_TIMEOUT_MS); - const deleted = await db.delete(matchQueue).where(lt(matchQueue.joinedAt, cutoff)).returning(); - - if (deleted.length > 0) { - console.log(`[Queue] Purged ${deleted.length} expired queue entr${deleted.length === 1 ? 'y' : 'ies'}`); - } -} - -export async function joinQueue(userId: string, teamId: string) { - await purgeExpiredQueueEntries(); - const activeMatchId = await findRecentActiveMatch(userId); - if (activeMatchId) throw new Error('You are already in an active match'); - - const rating = await db.query.playerRating.findFirst({ - where: eq(playerRating.userId, userId) - }); - - if (!rating) throw new Error('No rating found — please re-register'); - - const existing = await db.query.matchQueue.findFirst({ - where: eq(matchQueue.userId, userId) - }); - - if (existing) throw new Error('Already in queue'); - - await db.insert(matchQueue).values({ - userId, - teamId, - elo: rating.elo - }); - - return tryMatchFromQueue(); -} - -export async function leaveQueue(userId: string) { - await db.delete(matchQueue).where(eq(matchQueue.userId, userId)); -} - -export async function getQueueStatus(userId: string) { - await purgeExpiredQueueEntries(); - const entry = await db.query.matchQueue.findFirst({ - where: eq(matchQueue.userId, userId) - }); - - const count = await db - .select({ count: sql`count(*)` }) - .from(matchQueue); - - // If NOT in queue, check if there's a recent active match for this user - // (they may have been matched while polling) - let matchedMatchId: string | null = null; - if (!entry) { - matchedMatchId = await findRecentActiveMatch(userId); - } - - return { - inQueue: !!entry, - queueSize: Number(count[0].count), - joinedAt: entry?.joinedAt ?? null, - matchedMatchId - }; -} - -/** - * Find a recent active (non-finished) match for this user. - * Used to redirect players who were matched via queue while polling. - */ -async function findRecentActiveMatch(userId: string): Promise { - // Find matchParticipantPlayer records for this user - const playerEntries = await db.query.matchParticipantPlayer.findMany({ - where: eq(matchParticipantPlayer.userId, userId), - with: { - participant: { - with: { - match: true - } - } - } - }); - - // Find the most recent match that's still active (not finished/cancelled) - const activeStates = [ - MATCH_STATES.CREATED, - MATCH_STATES.LOBBY, - MATCH_STATES.ROLLING, - MATCH_STATES.PICKING, - MATCH_STATES.PLAYING - ]; - - const activeMatches = playerEntries - .filter((pe) => activeStates.includes(pe.participant.match.state as any)) - .map((pe) => pe.participant.match) - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - - return activeMatches[0]?.id ?? null; -} - -const MAX_CONCURRENT_MATCHES = 4; - -async function tryMatchFromQueue() { - // Hard cap: osu! non-bot accounts can only host a limited number of lobbies - const [{ activeCount }] = await db - .select({ activeCount: sql`count(*)` }) - .from(match) - .where(inArray(match.state, [MATCH_STATES.LOBBY, MATCH_STATES.ROLLING, MATCH_STATES.PICKING, MATCH_STATES.PLAYING])); - - if (Number(activeCount) >= MAX_CONCURRENT_MATCHES) return null; - - const queue = await db.query.matchQueue.findMany({ - orderBy: asc(matchQueue.elo), - with: { - team: true - } - }); - - if (queue.length < 2) return null; - - // Find closest ELO pair - let bestPair: [(typeof queue)[0], (typeof queue)[0]] | null = null; - let smallestGap = Infinity; - - for (let i = 0; i < queue.length - 1; i++) { - const gap = Math.abs(queue[i].elo - queue[i + 1].elo); - if (gap < smallestGap) { - smallestGap = gap; - bestPair = [queue[i], queue[i + 1]]; - } - } - - if (!bestPair) return null; - - // Pick mappool based on average player rating - const avgElo = (bestPair[0].elo + bestPair[1].elo) / 2; - const selectedPool = await selectMappoolForRating(avgElo); - - if (!selectedPool) throw new Error('No mappools available'); - - // Remove both from queue - await db.delete(matchQueue).where(eq(matchQueue.userId, bestPair[0].userId)); - await db.delete(matchQueue).where(eq(matchQueue.userId, bestPair[1].userId)); - - const created = await createMatch({ - name: `VASH: ${bestPair[0].team.name} vs ${bestPair[1].team.name}`, - config: { bestOf: 5, teamSize: 1, scoringType: 'score_v2' }, - mappoolId: selectedPool.id, - teams: [bestPair[0].teamId, bestPair[1].teamId], - createdBy: 'system' - }); - - // Start the IRC lobby in the background - // Import dynamically to avoid circular dependency - try { - const { initMatchLobby } = await import('./orchestrator'); - initMatchLobby(created.id).catch((err) => { - console.error('[Queue] IRC lobby creation failed:', err.message); - }); - } catch (err: any) { - console.error('[Queue] Failed to import orchestrator:', err.message); - } - - return created; -} - -async function selectMappoolForRating(avgElo: number) { - // Only use verified mappools - const pools = await db.query.mappool.findMany({ - where: (m, { isNotNull }) => isNotNull(m.verifiedAt), - with: { slots: true } - }); - - if (pools.length === 0) return null; - - // Map ELO range (0-3500) to star rating range (2-8) - const targetStars = 2 + avgElo / 700; - const clampedTarget = Math.max(2, Math.min(8, targetStars)); - - // Score each pool by distance from target - const scored = pools - .filter((p) => p.slots.length > 0) - .map((p) => { - const avgStars = p.slots.reduce((sum, s) => sum + (s.starRating ?? 0), 0) / p.slots.length; - return { pool: p, diff: Math.abs(avgStars - clampedTarget) }; - }) - .sort((a, b) => a.diff - b.diff); - - if (scored.length === 0) return null; - - // Allow any pool within 1 star of the closest match, then pick randomly - const threshold = scored[0].diff + 1.0; - const candidates = scored.filter((s) => s.diff <= threshold); - return candidates[Math.floor(Math.random() * candidates.length)].pool; -} - // ── Create Match ──────────────────────────────────────────────────────── export async function createMatch(opts: { diff --git a/src/lib/server/match/queue.ts b/src/lib/server/match/queue.ts new file mode 100644 index 0000000..e2b51ec --- /dev/null +++ b/src/lib/server/match/queue.ts @@ -0,0 +1,201 @@ +import { db } from '$lib/server/db'; +import { match, matchQueue, matchParticipantPlayer, playerRating } from '$lib/server/db/schema'; +import { eq, asc, sql, inArray, lt } from 'drizzle-orm'; +import { MATCH_STATES } from './types'; +import { createMatch } from './engine'; + +const QUEUE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes + +async function purgeExpiredQueueEntries() { + const cutoff = new Date(Date.now() - QUEUE_TIMEOUT_MS); + const deleted = await db.delete(matchQueue).where(lt(matchQueue.joinedAt, cutoff)).returning(); + + if (deleted.length > 0) { + console.log(`[Queue] Purged ${deleted.length} expired queue entr${deleted.length === 1 ? 'y' : 'ies'}`); + } +} + +export async function joinQueue(userId: string, teamId: string) { + await purgeExpiredQueueEntries(); + const activeMatchId = await findRecentActiveMatch(userId); + if (activeMatchId) throw new Error('You are already in an active match'); + + const rating = await db.query.playerRating.findFirst({ + where: eq(playerRating.userId, userId) + }); + + if (!rating) throw new Error('No rating found — please re-register'); + + const existing = await db.query.matchQueue.findFirst({ + where: eq(matchQueue.userId, userId) + }); + + if (existing) throw new Error('Already in queue'); + + await db.insert(matchQueue).values({ + userId, + teamId, + elo: rating.elo + }); + + return tryMatchFromQueue(); +} + +export async function leaveQueue(userId: string) { + await db.delete(matchQueue).where(eq(matchQueue.userId, userId)); +} + +export async function getQueueStatus(userId: string) { + await purgeExpiredQueueEntries(); + const entry = await db.query.matchQueue.findFirst({ + where: eq(matchQueue.userId, userId) + }); + + const count = await db + .select({ count: sql`count(*)` }) + .from(matchQueue); + + // If NOT in queue, check if there's a recent active match for this user + // (they may have been matched while polling) + let matchedMatchId: string | null = null; + if (!entry) { + matchedMatchId = await findRecentActiveMatch(userId); + } + + return { + inQueue: !!entry, + queueSize: Number(count[0].count), + joinedAt: entry?.joinedAt ?? null, + matchedMatchId + }; +} + +/** + * Find a recent active (non-finished) match for this user. + * Used to redirect players who were matched via queue while polling. + */ +async function findRecentActiveMatch(userId: string): Promise { + // Find matchParticipantPlayer records for this user + const playerEntries = await db.query.matchParticipantPlayer.findMany({ + where: eq(matchParticipantPlayer.userId, userId), + with: { + participant: { + with: { + match: true + } + } + } + }); + + // Find the most recent match that's still active (not finished/cancelled) + const activeStates = [ + MATCH_STATES.CREATED, + MATCH_STATES.LOBBY, + MATCH_STATES.ROLLING, + MATCH_STATES.PICKING, + MATCH_STATES.PLAYING + ]; + + const activeMatches = playerEntries + .filter((pe) => activeStates.includes(pe.participant.match.state as any)) + .map((pe) => pe.participant.match) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return activeMatches[0]?.id ?? null; +} + +const MAX_CONCURRENT_MATCHES = 4; + +async function tryMatchFromQueue() { + // Hard cap: osu! non-bot accounts can only host a limited number of lobbies + const [{ activeCount }] = await db + .select({ activeCount: sql`count(*)` }) + .from(match) + .where(inArray(match.state, [MATCH_STATES.LOBBY, MATCH_STATES.ROLLING, MATCH_STATES.PICKING, MATCH_STATES.PLAYING])); + + if (Number(activeCount) >= MAX_CONCURRENT_MATCHES) return null; + + const queue = await db.query.matchQueue.findMany({ + orderBy: asc(matchQueue.elo), + with: { + team: true + } + }); + + if (queue.length < 2) return null; + + // Find closest ELO pair + let bestPair: [(typeof queue)[0], (typeof queue)[0]] | null = null; + let smallestGap = Infinity; + + for (let i = 0; i < queue.length - 1; i++) { + const gap = Math.abs(queue[i].elo - queue[i + 1].elo); + if (gap < smallestGap) { + smallestGap = gap; + bestPair = [queue[i], queue[i + 1]]; + } + } + + if (!bestPair) return null; + + // Pick mappool based on average player rating + const avgElo = (bestPair[0].elo + bestPair[1].elo) / 2; + const selectedPool = await selectMappoolForRating(avgElo); + + if (!selectedPool) throw new Error('No mappools available'); + + // Remove both from queue + await db.delete(matchQueue).where(eq(matchQueue.userId, bestPair[0].userId)); + await db.delete(matchQueue).where(eq(matchQueue.userId, bestPair[1].userId)); + + const created = await createMatch({ + name: `VASH: ${bestPair[0].team.name} vs ${bestPair[1].team.name}`, + config: { bestOf: 5, teamSize: 1, scoringType: 'score_v2' }, + mappoolId: selectedPool.id, + teams: [bestPair[0].teamId, bestPair[1].teamId], + createdBy: 'system' + }); + + // Start the IRC lobby in the background + // Import dynamically to avoid circular dependency + try { + const { initMatchLobby } = await import('./orchestrator'); + initMatchLobby(created.id).catch((err) => { + console.error('[Queue] IRC lobby creation failed:', err.message); + }); + } catch (err: any) { + console.error('[Queue] Failed to import orchestrator:', err.message); + } + + return created; +} + +async function selectMappoolForRating(avgElo: number) { + // Only use verified mappools + const pools = await db.query.mappool.findMany({ + where: (m, { isNotNull }) => isNotNull(m.verifiedAt), + with: { slots: true } + }); + + if (pools.length === 0) return null; + + // Map ELO range (0-3500) to star rating range (2-8) + const targetStars = 2 + avgElo / 700; + const clampedTarget = Math.max(2, Math.min(8, targetStars)); + + // Score each pool by distance from target + const scored = pools + .filter((p) => p.slots.length > 0) + .map((p) => { + const avgStars = p.slots.reduce((sum, s) => sum + (s.starRating ?? 0), 0) / p.slots.length; + return { pool: p, diff: Math.abs(avgStars - clampedTarget) }; + }) + .sort((a, b) => a.diff - b.diff); + + if (scored.length === 0) return null; + + // Allow any pool within 1 star of the closest match, then pick randomly + const threshold = scored[0].diff + 1.0; + const candidates = scored.filter((s) => s.diff <= threshold); + return candidates[Math.floor(Math.random() * candidates.length)].pool; +} diff --git a/src/routes/(app)/+page.server.ts b/src/routes/(app)/+page.server.ts index 7d6af03..9e9c005 100644 --- a/src/routes/(app)/+page.server.ts +++ b/src/routes/(app)/+page.server.ts @@ -2,7 +2,7 @@ import { db } from '$lib/server/db'; import { match, team, mappool } from '$lib/server/db/schema'; import { eq, desc, count, inArray } from 'drizzle-orm'; -import { getQueueStatus } from '$lib/server/match/engine'; +import { getQueueStatus } from '$lib/server/match/queue'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals }) => { diff --git a/src/routes/api/queue/+server.ts b/src/routes/api/queue/+server.ts index a4d65c6..e483078 100644 --- a/src/routes/api/queue/+server.ts +++ b/src/routes/api/queue/+server.ts @@ -1,6 +1,6 @@ // src/routes/api/queue/+server.ts import { json, error } from '@sveltejs/kit'; -import { joinQueue, leaveQueue, getQueueStatus } from '$lib/server/match/engine'; +import { joinQueue, leaveQueue, getQueueStatus } from '$lib/server/match/queue'; import { db } from '$lib/server/db'; import { teamMember } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm';