feat: enhance queue management with recent match redirection and leaderboard link
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -95,7 +107,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── DASHBOARD (logged in) ── -->
|
<!-- ── DASHBOARD (logged in) ── -->
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mx-auto max-w-5xl">
|
<div class="mx-auto max-w-5xl">
|
||||||
<div>
|
<div>
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user