feat: enhance match and lobby management with player status tracking and osu! API integration

This commit is contained in:
2026-03-01 00:40:47 +01:00
parent 81a51dca8c
commit 60956f2643
12 changed files with 366 additions and 49 deletions

View File

@@ -1,8 +1,30 @@
// src/routes/(app)/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import { isRootAdmin } from '$lib/server/permissions';
import { db } from '$lib/server/db';
import { match, matchParticipant, matchParticipantPlayer } from '$lib/server/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
export const load: LayoutServerLoad = async ({ locals }) => {
let activeMatch: { id: string; name: string | null; state: string } | null = null;
if (locals.user) {
const rows = await db
.select({ id: match.id, name: match.name, state: match.state })
.from(matchParticipantPlayer)
.innerJoin(matchParticipant, eq(matchParticipantPlayer.participantId, matchParticipant.id))
.innerJoin(match, eq(matchParticipant.matchId, match.id))
.where(
and(
eq(matchParticipantPlayer.userId, locals.user.id),
inArray(match.state, ['CREATED', 'LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])
)
)
.limit(1);
activeMatch = rows[0] ?? null;
}
return {
user: locals.user
? {
@@ -13,6 +35,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
role: locals.user.role ?? 'player',
isRootAdmin: isRootAdmin(locals.user.email)
}
: null
: null,
activeMatch
};
};

View File

@@ -7,6 +7,15 @@
let { children } = $props();
const user = $derived(page.data?.user);
const activeMatch = $derived(page.data?.activeMatch);
const stateLabel: Record<string, string> = {
CREATED: 'Starting',
LOBBY: 'In Lobby',
ROLLING: 'Rolling',
PICKING: 'Picking',
PLAYING: 'Playing'
};
</script>
{#if user}
@@ -15,6 +24,26 @@
<Sidebar />
<div class="ml-56 flex flex-1 flex-col">
<Header />
{#if activeMatch && !page.url.pathname.startsWith('/matches/' + activeMatch.id)}
<div class="flex items-center justify-between border-b border-yellow-500/30 bg-yellow-500/10 px-6 py-2.5">
<div class="flex items-center gap-2.5 text-sm">
<div class="relative h-2 w-2">
<div class="absolute inset-0 animate-ping rounded-full bg-yellow-400 opacity-75"></div>
<div class="h-2 w-2 rounded-full bg-yellow-400"></div>
</div>
<span class="font-600 text-yellow-300">Active match in progress</span>
<span class="text-xs text-yellow-400/70">
{activeMatch.name ?? 'Match'} &middot; {stateLabel[activeMatch.state] ?? activeMatch.state}
</span>
</div>
<a
href="/matches/{activeMatch.id}"
class="rounded-md border border-yellow-500/40 px-3 py-1 text-xs font-600 text-yellow-300 transition-colors hover:bg-yellow-500/20"
>
Go to match &rarr;
</a>
</div>
{/if}
<main class="flex-1 p-6">
{@render children()}
</main>

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
let { data } = $props();
const liveStates = ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'];
@@ -26,11 +28,12 @@
const res = await fetch('/api/queue');
if (res.ok) {
const status = await res.json();
const wasInQueue = queueStatus?.inQueue;
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}`;
// If we were in queue and now got matched, refresh the layout to show the active match banner
if (wasInQueue && !status.inQueue && status.matchedMatchId) {
await invalidateAll();
}
}
} catch {}
@@ -42,7 +45,10 @@
const res = await fetch('/api/queue', { method: 'POST' });
const result = await res.json();
if (result.matched && result.match) {
window.location.href = `/matches/${result.match.id}`;
// Refresh layout to show banner instead of hard redirecting
await invalidateAll();
queueStatus = { inQueue: false, queueSize: 0, matchedMatchId: result.match.id };
queueLoading = false;
return;
}
await fetchQueueStatus();

View File

@@ -87,23 +87,34 @@ export const load: PageServerLoad = async () => {
const topTeams = teamsWithDetails.filter(Boolean);
// ── Recent high scores (individual map scores) ──
const recentHighScores = await db.query.matchGameScore.findMany({
orderBy: desc(matchGameScore.score),
limit: 15,
with: {
player: true,
game: {
with: {
slot: true,
match: true
}
}
}
});
// ── High scores — sorted by pp (nulls last) then score ──
const recentHighScores = await db
.select()
.from(matchGameScore)
.orderBy(
sql`${matchGameScore.pp} DESC NULLS LAST`,
desc(matchGameScore.score)
)
.limit(15)
.then((rows) =>
Promise.all(
rows.map((s) =>
db.query.matchGameScore.findFirst({
where: eq(matchGameScore.id, s.id),
with: {
player: true,
game: {
with: { slot: true, match: true }
}
}
})
)
)
);
const highScoresWithNames = await Promise.all(
recentHighScores.map(async (s, i) => {
if (!s) return null;
const u = await db.query.user.findFirst({
where: eq(user.id, s.player.userId)
});
@@ -114,6 +125,11 @@ export const load: PageServerLoad = async () => {
score: s.score,
accuracy: s.accuracy,
maxCombo: s.maxCombo,
count300: s.count300,
count100: s.count100,
count50: s.count50,
countMiss: s.countMiss,
pp: s.pp,
mods: s.mods,
passed: s.passed,
beatmapId: s.game.slot?.beatmapId ?? null,
@@ -123,7 +139,7 @@ export const load: PageServerLoad = async () => {
matchId: s.game.matchId
};
})
);
).then((rows) => rows.filter(Boolean));
return {
topPlayers: playersWithUsers,

View File

@@ -187,13 +187,17 @@
<p class="text-sm text-text-secondary">No scores recorded yet.</p>
</div>
{:else}
<div class="px-4 py-2.5 border-b border-border">
<p class="text-xs text-text-secondary">Ranked by pp (performance points) when available, then score</p>
</div>
<div
class="grid grid-cols-[auto_auto_1fr_auto_auto_auto] gap-4 border-b border-border px-4 py-3"
class="grid grid-cols-[auto_auto_1fr_auto_auto_auto_auto] gap-3 border-b border-border px-4 py-3"
>
<span class="text-xs font-600 text-text-secondary">#</span>
<span class="text-xs font-600 text-text-secondary"></span>
<span class="text-xs font-600 text-text-secondary">Player</span>
<span class="text-xs font-600 text-text-secondary">Map</span>
<span class="text-xs font-600 text-accent">PP</span>
<span class="text-xs font-600 text-text-secondary">Score</span>
<span class="text-xs font-600 text-text-secondary">Acc</span>
</div>
@@ -201,7 +205,7 @@
{#each data.highScores as s}
{@const medal = rankBadge(s.rank)}
<div
class="grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 border-b border-border/50 px-4 py-3 last:border-b-0 {s.rank <=
class="grid grid-cols-[auto_auto_1fr_auto_auto_auto_auto] items-center gap-3 border-b border-border/50 px-4 py-3 last:border-b-0 {s.rank <=
3
? 'bg-accent/[0.02]'
: ''}"
@@ -235,6 +239,11 @@
{s.mapCategory}{s.mapOrder}
</span>
<!-- PP -->
<span class="text-sm font-700 tabular-nums {s.pp != null ? 'text-accent' : 'text-text-secondary/40'}">
{s.pp != null ? `${Math.round(s.pp)}pp` : '—'}
</span>
<!-- Score -->
<span class="text-sm font-700 tabular-nums text-text-primary">
{s.score.toLocaleString()}

View File

@@ -2,7 +2,7 @@ import { user } from '$lib/server/db/schema';
import { db } from '$lib/server/db';
import { getMatchFull, submitRoll, pickMap, cancelMatch } from '$lib/server/match/engine';
import { playPickedMap, closeLobby, forceStartGame } from '$lib/server/match/orchestrator';
import { getLobby } from '$lib/server/bancho/client';
import { getLobby, getLobbyStatus } from '$lib/server/bancho/client';
import { error, redirect } from '@sveltejs/kit';
import { getBeatmap } from '$lib/server/osu/api';
import { hasRole } from '$lib/server/permissions';
@@ -45,7 +45,18 @@ export const load: PageServerLoad = async ({ params, locals }) => {
const isStaff = hasRole(locals.user.role, 'referee');
return { match: m, beatmapCache, userId: locals.user.id, isStaff };
// Fetch player usernames for lobby status display
const playerNames: Record<string, string> = {};
for (const p of m.participants) {
for (const pl of p.players) {
const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) });
if (u?.name) playerNames[pl.userId] = u.name;
}
}
const lobbyStatus = getLobbyStatus(params.id);
return { match: m, beatmapCache, userId: locals.user.id, isStaff, lobbyStatus, playerNames };
};
export const actions: Actions = {

View File

@@ -5,6 +5,8 @@
let { data } = $props();
let m = $derived(data.match);
let lobbyStatus = $derived(data.lobbyStatus);
let playerNames = $derived(data.playerNames);
let p1 = $derived(m.participants[0]);
let p2 = $derived(m.participants[1]);
@@ -228,24 +230,54 @@
{#if m.state === 'PLAYING'}
{@const currentGame = m.games[m.games.length - 1]}
{@const bm = currentGame ? data.beatmapCache[currentGame.slot?.beatmapId] : null}
<div class="mt-6 rounded-lg border border-green-500/30 bg-green-500/5 p-5 text-center">
<div class="mt-6 rounded-lg border border-green-500/30 bg-green-500/5 p-5">
<div class="flex items-center justify-center gap-2">
<div class="relative h-3 w-3">
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
<div class="relative h-3 w-3 rounded-full bg-green-400"></div>
</div>
<span class="text-sm font-600 text-green-400">Now Playing</span>
<span class="text-sm font-600 text-green-400">
{lobbyStatus?.gameInProgress ? 'Playing' : 'Now Playing — Waiting for ready'}
</span>
</div>
{#if bm}
<p class="mt-2 text-sm">{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span></p>
<p class="mt-1 text-xs text-text-secondary">★{bm.starRating.toFixed(1)} &middot; {bm.bpm}bpm &middot; {formatLength(bm.totalLength)}</p>
<p class="mt-2 text-center text-sm">{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span></p>
<p class="mt-1 text-center text-xs text-text-secondary">★{bm.starRating.toFixed(1)} &middot; {bm.bpm}bpm &middot; {formatLength(bm.totalLength)}</p>
{/if}
<!-- Per-player lobby status -->
{#if lobbyStatus}
<div class="mt-4 flex flex-col gap-2">
{#each m.participants as participant}
{#each participant.players as player}
{@const username = playerNames[player.userId]}
{@const inLobby = username && lobbyStatus.inLobby.includes(username)}
{@const isReady = username && lobbyStatus.readyPlayers.includes(username)}
<div class="flex items-center gap-2 rounded border border-border bg-surface-800/50 px-3 py-1.5 text-xs">
<span class="font-500">{username ?? player.userId}</span>
<span class="ml-auto flex items-center gap-1.5">
{#if lobbyStatus.gameInProgress}
<span class="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] font-600 text-green-400">Playing</span>
{:else if isReady}
<span class="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] font-600 text-green-400">Ready</span>
{:else if inLobby}
<span class="rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] font-600 text-yellow-400">In Lobby</span>
{:else}
<span class="rounded bg-surface-700 px-1.5 py-0.5 text-[10px] font-600 text-text-secondary">Not in lobby</span>
{/if}
</span>
</div>
{/each}
{/each}
</div>
{:else}
<p class="mt-3 text-center text-xs text-text-secondary animate-pulse">
Waiting for players to ready up &amp; play in osu!...
</p>
{/if}
<p class="mt-3 text-xs text-text-secondary animate-pulse">
Waiting for players to ready up &amp; play in osu!...
</p>
{#if data.isStaff}
<div class="mt-3">
<div class="mt-3 text-center">
<form method="post" action="?/forceStart" use:enhance={() => {
forceStarting = true;
return async ({ update }) => { forceStarting = false; await update(); };
@@ -428,22 +460,45 @@
</div>
</div>
{#if game.state === 'FINISHED' && p1 && p2}
{@const p1Score = game.scores.filter((s: any) => s.player?.participantId === p1.id).reduce((sum: number, s: any) => sum + s.score, 0)}
{@const p2Score = game.scores.filter((s: any) => s.player?.participantId === p2.id).reduce((sum: number, s: any) => sum + s.score, 0)}
<div class="flex items-center gap-2 font-mono text-sm tabular-nums">
<span class="{p1Score > p2Score ? 'font-700 text-green-400' : 'text-text-secondary'}">{p1Score.toLocaleString()}</span>
<span class="text-text-secondary">-</span>
<span class="{p2Score > p1Score ? 'font-700 text-green-400' : 'text-text-secondary'}">{p2Score.toLocaleString()}</span>
</div>
{:else if game.state === 'PLAYING'}
{#if game.state === 'PLAYING'}
<span class="text-xs text-green-400 animate-pulse">Live</span>
{/if}
{#if winnerTeam}
{:else if winnerTeam}
<span class="text-xs text-green-400">✓ {winnerTeam.name}</span>
{/if}
</div>
<!-- Per-player score breakdown -->
{#if game.state === 'FINISHED' && game.scores?.length > 0}
<div class="mt-1.5 flex flex-col gap-1">
{#each game.scores as s}
{@const username = playerNames[s.player?.userId]}
<div class="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded bg-surface-900 px-3 py-1.5 text-xs">
<span class="w-24 truncate font-500">{username ?? s.player?.userId ?? '?'}</span>
<span class="font-mono tabular-nums text-text-primary">{s.score.toLocaleString()}</span>
{#if s.accuracy > 0}
<span class="text-text-secondary">{(s.accuracy * 100).toFixed(2)}%</span>
{/if}
{#if s.maxCombo > 0}
<span class="text-text-secondary">{s.maxCombo}x</span>
{/if}
{#if s.count300 > 0 || s.count100 > 0 || s.countMiss >= 0}
<span class="text-text-secondary">
<span class="text-blue-400">{s.count300}</span>
/<span class="text-green-400">{s.count100}</span>
/<span class="text-yellow-400">{s.count50}</span>
/<span class="text-red-400">{s.countMiss}</span>
</span>
{/if}
{#if s.pp != null}
<span class="font-600 text-accent">{Math.round(s.pp)}pp</span>
{/if}
{#if !s.passed}
<span class="rounded bg-red-500/20 px-1 text-[10px] text-red-400">FAILED</span>
{/if}
</div>
{/each}
</div>
{/if}
{/each}
</div>
</div>
@@ -464,6 +519,26 @@
<a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a>
</p>
{/if}
{#if lobbyStatus}
<div class="mt-3 flex flex-col gap-1.5">
{#each m.participants as participant}
{#each participant.players as player}
{@const username = playerNames[player.userId]}
{@const inLobby = username && lobbyStatus.inLobby.includes(username)}
<div class="flex items-center gap-2 rounded border border-border bg-surface-800/50 px-3 py-1.5 text-xs">
<span class="font-500">{username ?? player.userId}</span>
<span class="ml-auto">
{#if inLobby}
<span class="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] font-600 text-green-400">In Lobby</span>
{:else}
<span class="rounded bg-surface-700 px-1.5 py-0.5 text-[10px] font-600 text-text-secondary">Not in lobby</span>
{/if}
</span>
</div>
{/each}
{/each}
</div>
{/if}
<div class="mt-3">
{@render reinviteButton()}
</div>