feat: enhance queue management with recent match redirection and leaderboard link

This commit is contained in:
2026-02-28 20:10:31 +01:00
parent a50bc4c57c
commit 307c41e4ea
2 changed files with 100 additions and 23 deletions

View File

@@ -7,9 +7,9 @@ import {
matchGameScore, matchGameScore,
matchQueue, matchQueue,
playerRating, playerRating,
teamMember, teamMember
} from '$lib/server/db/schema'; } 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'; import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types';
// ── Queue ─────────────────────────────────────────────────────────────── // ── Queue ───────────────────────────────────────────────────────────────
@@ -54,13 +54,55 @@ export async function getQueueStatus(userId: string) {
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(matchQueue); .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 { return {
inQueue: !!entry, inQueue: !!entry,
queueSize: Number(count[0].count), 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<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;
}
async function tryMatchFromQueue() { async function tryMatchFromQueue() {
const queue = await db.query.matchQueue.findMany({ const queue = await db.query.matchQueue.findMany({
orderBy: asc(matchQueue.elo) orderBy: asc(matchQueue.elo)
@@ -69,7 +111,7 @@ async function tryMatchFromQueue() {
if (queue.length < 2) return null; if (queue.length < 2) return null;
// Find closest ELO pair // 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; let smallestGap = Infinity;
for (let i = 0; i < queue.length - 1; i++) { for (let i = 0; i < queue.length - 1; i++) {
@@ -100,6 +142,17 @@ async function tryMatchFromQueue() {
createdBy: 'system' 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; return created;
} }
@@ -112,7 +165,6 @@ async function selectMappoolForRating(avgElo: number) {
if (pools.length === 0) return null; if (pools.length === 0) return null;
// Map ELO range (e.g. 800-1200) to star rating range (e.g. 3-7) // 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 targetStars = 3 + ((avgElo - 800) / 400) * 4;
const clampedTarget = Math.max(2, Math.min(8, targetStars)); const clampedTarget = Math.max(2, Math.min(8, targetStars));
@@ -413,10 +465,7 @@ export async function submitGameScores(
// ── ELO ───────────────────────────────────────────────────────────────── // ── ELO ─────────────────────────────────────────────────────────────────
async function updateElo( async function updateElo(participants: { id: string; teamId: string }[], winnerId: string) {
participants: { id: string; teamId: string }[],
winnerId: string
) {
for (const p of participants) { for (const p of participants) {
const players = await db.query.matchParticipantPlayer.findMany({ const players = await db.query.matchParticipantPlayer.findMany({
where: eq(matchParticipantPlayer.participantId, p.id) where: eq(matchParticipantPlayer.participantId, p.id)

View File

@@ -14,13 +14,25 @@
}; };
// Queue state // 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); let queueLoading = $state(false);
async function fetchQueueStatus() { async function fetchQueueStatus() {
try { try {
const res = await fetch('/api/queue'); 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 {} } catch {}
} }
@@ -28,9 +40,9 @@
queueLoading = true; queueLoading = true;
try { try {
const res = await fetch('/api/queue', { method: 'POST' }); const res = await fetch('/api/queue', { method: 'POST' });
const data = await res.json(); const result = await res.json();
if (data.matched && data.match) { if (result.matched && result.match) {
window.location.href = `/matches/${data.match.id}`; window.location.href = `/matches/${result.match.id}`;
return; return;
} }
await fetchQueueStatus(); await fetchQueueStatus();
@@ -47,11 +59,11 @@
queueLoading = false; queueLoading = false;
} }
// Poll queue status while on dashboard // Poll queue status while on dashboard (faster when in queue)
$effect(() => { $effect(() => {
if (data.user) { if (data.user) {
fetchQueueStatus(); fetchQueueStatus();
const interval = setInterval(fetchQueueStatus, 5000); const interval = setInterval(fetchQueueStatus, queueStatus?.inQueue ? 2000 : 5000);
return () => clearInterval(interval); return () => clearInterval(interval);
} }
}); });
@@ -81,8 +93,8 @@
Welcome to <span class="text-accent">Vash Esports</span> Welcome to <span class="text-accent">Vash Esports</span>
</h1> </h1>
<p class="mt-3 max-w-md text-text-secondary"> <p class="mt-3 max-w-md text-text-secondary">
Automated tournament match management for osu! — lobbies, mappools, picks, bans, and Automated tournament match management for osu! — lobbies, mappools, picks, bans, and ELO
ELO tracking all in one place. tracking all in one place.
</p> </p>
<a <a
href="/login" href="/login"
@@ -215,12 +227,13 @@
href="/matches" href="/matches"
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700" class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
> >
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-accent/10 text-accent" <span
class="flex h-8 w-8 items-center justify-center rounded-md bg-accent/10 text-accent"
>⚔</span >⚔</span
> >
<div> <div>
<p class="text-sm font-600">New Match</p> <p class="text-sm font-600">Matches</p>
<p class="text-xs text-text-secondary">Start a 1v1</p> <p class="text-xs text-text-secondary">View all matches</p>
</div> </div>
</a> </a>
<a <a
@@ -249,6 +262,19 @@
<p class="text-xs text-text-secondary">Manage rosters</p> <p class="text-xs text-text-secondary">Manage rosters</p>
</div> </div>
</a> </a>
<a
href="/leaderboard"
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<span
class="flex h-8 w-8 items-center justify-center rounded-md bg-yellow-500/10 text-yellow-400"
>🏆</span
>
<div>
<p class="text-sm font-600">Leaderboard</p>
<p class="text-xs text-text-secondary">Rankings & stats</p>
</div>
</a>
</div> </div>
</div> </div>
@@ -301,7 +327,9 @@
{:else} {:else}
<div class="rounded-lg border border-dashed border-border py-8 text-center"> <div class="rounded-lg border border-dashed border-border py-8 text-center">
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
No matches yet. <a href="/matches" class="text-accent hover:underline">Create one</a> No matches yet. <a href="/matches" class="text-accent hover:underline"
>Create one</a
>
</p> </p>
</div> </div>
{/each} {/each}