split queue and fix imports

This commit is contained in:
nicksales72
2026-03-11 11:45:10 -04:00
parent bda1d796b0
commit eac073378a
5 changed files with 205 additions and 203 deletions

View File

@@ -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;

View File

@@ -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<number>`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<string | null> {
// 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<number>`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: {

View File

@@ -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<number>`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<string | null> {
// 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<number>`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;
}

View File

@@ -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 }) => {

View File

@@ -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';