diff --git a/src/lib/server/match/engine.ts b/src/lib/server/match/engine.ts index c77dce2..820c870 100644 --- a/src/lib/server/match/engine.ts +++ b/src/lib/server/match/engine.ts @@ -7,9 +7,9 @@ import { matchGameScore, matchQueue, playerRating, - teamMember, + teamMember } from '$lib/server/db/schema'; -import { eq, asc, sql } from 'drizzle-orm'; +import { eq, asc, sql, desc, and, inArray } from 'drizzle-orm'; import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types'; // ── Queue ─────────────────────────────────────────────────────────────── @@ -54,13 +54,55 @@ export async function getQueueStatus(userId: string) { .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 + 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; +} + async function tryMatchFromQueue() { const queue = await db.query.matchQueue.findMany({ orderBy: asc(matchQueue.elo) @@ -69,7 +111,7 @@ async function tryMatchFromQueue() { if (queue.length < 2) return null; // Find closest ELO pair - let bestPair: [typeof queue[0], typeof queue[0]] | null = null; + let bestPair: [(typeof queue)[0], (typeof queue)[0]] | null = null; let smallestGap = Infinity; for (let i = 0; i < queue.length - 1; i++) { @@ -100,6 +142,17 @@ async function tryMatchFromQueue() { 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; } @@ -112,7 +165,6 @@ async function selectMappoolForRating(avgElo: number) { if (pools.length === 0) return null; // Map ELO range (e.g. 800-1200) to star rating range (e.g. 3-7) - // Simple linear: ELO 800 → 3★, ELO 1200 → 7★ const targetStars = 3 + ((avgElo - 800) / 400) * 4; const clampedTarget = Math.max(2, Math.min(8, targetStars)); @@ -413,10 +465,7 @@ export async function submitGameScores( // ── ELO ───────────────────────────────────────────────────────────────── -async function updateElo( - participants: { id: string; teamId: string }[], - winnerId: string -) { +async function updateElo(participants: { id: string; teamId: string }[], winnerId: string) { for (const p of participants) { const players = await db.query.matchParticipantPlayer.findMany({ where: eq(matchParticipantPlayer.participantId, p.id) diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 12fc307..35081f1 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -14,13 +14,25 @@ }; // Queue state - let queueStatus = $state<{ inQueue: boolean; queueSize: number } | null>(null); + let queueStatus = $state<{ + inQueue: boolean; + queueSize: number; + matchedMatchId?: string | null; + } | null>(null); let queueLoading = $state(false); async function fetchQueueStatus() { try { const res = await fetch('/api/queue'); - if (res.ok) queueStatus = await res.json(); + if (res.ok) { + const status = await res.json(); + queueStatus = status; + + // If we were in queue and now got matched, redirect to the match + if (!status.inQueue && status.matchedMatchId) { + window.location.href = `/matches/${status.matchedMatchId}`; + } + } } catch {} } @@ -28,9 +40,9 @@ queueLoading = true; try { const res = await fetch('/api/queue', { method: 'POST' }); - const data = await res.json(); - if (data.matched && data.match) { - window.location.href = `/matches/${data.match.id}`; + const result = await res.json(); + if (result.matched && result.match) { + window.location.href = `/matches/${result.match.id}`; return; } await fetchQueueStatus(); @@ -47,11 +59,11 @@ queueLoading = false; } - // Poll queue status while on dashboard + // Poll queue status while on dashboard (faster when in queue) $effect(() => { if (data.user) { fetchQueueStatus(); - const interval = setInterval(fetchQueueStatus, 5000); + const interval = setInterval(fetchQueueStatus, queueStatus?.inQueue ? 2000 : 5000); return () => clearInterval(interval); } }); @@ -81,8 +93,8 @@ Welcome to Vash Esports

- Automated tournament match management for osu! — lobbies, mappools, picks, bans, and - ELO tracking all in one place. + Automated tournament match management for osu! — lobbies, mappools, picks, bans, and ELO + tracking all in one place.

- + {:else} @@ -301,7 +327,9 @@ {:else}

- No matches yet. Create one + No matches yet. Create one

{/each}