split queue and fix imports
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
201
src/lib/server/match/queue.ts
Normal file
201
src/lib/server/match/queue.ts
Normal 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;
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user