feat: enhance match and lobby management with player status tracking and osu! API integration
This commit is contained in:
@@ -160,6 +160,11 @@ export class TournamentLobby {
|
|||||||
// Ready tracking
|
// Ready tracking
|
||||||
private allReadyResolve: (() => void) | null = null;
|
private allReadyResolve: (() => void) | null = null;
|
||||||
|
|
||||||
|
// Lobby status tracking
|
||||||
|
public inLobby: Set<string> = new Set();
|
||||||
|
public readyPlayers: Set<string> = new Set();
|
||||||
|
public gameInProgress = false;
|
||||||
|
|
||||||
// Event callbacks (set by orchestrator)
|
// Event callbacks (set by orchestrator)
|
||||||
public onRollResult: ((username: string, value: number) => void) | null = null;
|
public onRollResult: ((username: string, value: number) => void) | null = null;
|
||||||
public onPickCommand: ((username: string, slotLabel: string) => void) | null = null;
|
public onPickCommand: ((username: string, slotLabel: string) => void) | null = null;
|
||||||
@@ -265,6 +270,8 @@ export class TournamentLobby {
|
|||||||
// Game finished
|
// Game finished
|
||||||
if (text.includes('The match has finished!')) {
|
if (text.includes('The match has finished!')) {
|
||||||
console.log('[Bancho] Game finished. Scores:', this.collectedScores);
|
console.log('[Bancho] Game finished. Scores:', this.collectedScores);
|
||||||
|
this.gameInProgress = false;
|
||||||
|
this.readyPlayers.clear();
|
||||||
if (this.matchFinishedResolve) {
|
if (this.matchFinishedResolve) {
|
||||||
this.matchFinishedResolve([...this.collectedScores]);
|
this.matchFinishedResolve([...this.collectedScores]);
|
||||||
this.matchFinishedResolve = null;
|
this.matchFinishedResolve = null;
|
||||||
@@ -290,15 +297,42 @@ export class TournamentLobby {
|
|||||||
if (joinMatch) {
|
if (joinMatch) {
|
||||||
const username = joinMatch[1].trim();
|
const username = joinMatch[1].trim();
|
||||||
console.log(`[Bancho] Player joined: ${username}`);
|
console.log(`[Bancho] Player joined: ${username}`);
|
||||||
|
this.inLobby.add(username);
|
||||||
|
this.readyPlayers.delete(username);
|
||||||
if (this.onPlayerJoined) {
|
if (this.onPlayerJoined) {
|
||||||
this.onPlayerJoined(username);
|
this.onPlayerJoined(username);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Player left: "Stan left the game."
|
||||||
|
const leftMatch = text.match(/^(.+?) left the game\./);
|
||||||
|
if (leftMatch) {
|
||||||
|
const username = leftMatch[1].trim();
|
||||||
|
console.log(`[Bancho] Player left: ${username}`);
|
||||||
|
this.inLobby.delete(username);
|
||||||
|
this.readyPlayers.delete(username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player ready: "Stan is Ready."
|
||||||
|
const readyMatch = text.match(/^(.+?) is Ready\.?$/i);
|
||||||
|
if (readyMatch) {
|
||||||
|
this.readyPlayers.add(readyMatch[1].trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player not ready: "Stan is not Ready."
|
||||||
|
const notReadyMatch = text.match(/^(.+?) is not Ready\.?$/i);
|
||||||
|
if (notReadyMatch) {
|
||||||
|
this.readyPlayers.delete(notReadyMatch[1].trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// All players ready
|
// All players ready
|
||||||
if (text.includes('All players are ready')) {
|
if (text.includes('All players are ready')) {
|
||||||
console.log('[Bancho] All players ready');
|
console.log('[Bancho] All players ready');
|
||||||
|
for (const u of this.inLobby) this.readyPlayers.add(u);
|
||||||
if (this.allReadyResolve) {
|
if (this.allReadyResolve) {
|
||||||
this.allReadyResolve();
|
this.allReadyResolve();
|
||||||
this.allReadyResolve = null;
|
this.allReadyResolve = null;
|
||||||
@@ -306,6 +340,13 @@ export class TournamentLobby {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Game started
|
||||||
|
if (text.includes('The match has started!')) {
|
||||||
|
this.gameInProgress = true;
|
||||||
|
this.readyPlayers.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Room closed
|
// Room closed
|
||||||
if (text.includes('Closed the match')) {
|
if (text.includes('Closed the match')) {
|
||||||
console.log(`[Bancho] Room ${this.channelName} was closed`);
|
console.log(`[Bancho] Room ${this.channelName} was closed`);
|
||||||
@@ -457,6 +498,20 @@ export function removeLobby(matchId: string) {
|
|||||||
lobbies.delete(matchId);
|
lobbies.delete(matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLobbyStatus(matchId: string): {
|
||||||
|
inLobby: string[];
|
||||||
|
readyPlayers: string[];
|
||||||
|
gameInProgress: boolean;
|
||||||
|
} | null {
|
||||||
|
const lobby = lobbies.get(matchId);
|
||||||
|
if (!lobby || lobby._dead) return null;
|
||||||
|
return {
|
||||||
|
inLobby: [...lobby.inLobby],
|
||||||
|
readyPlayers: [...lobby.readyPlayers],
|
||||||
|
gameInProgress: lobby.gameInProgress
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getActiveLobbyCount(): number {
|
export function getActiveLobbyCount(): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const [id, lobby] of lobbies.entries()) {
|
for (const [id, lobby] of lobbies.entries()) {
|
||||||
|
|||||||
@@ -160,7 +160,8 @@ export const matchGameScore = pgTable(
|
|||||||
count50: integer('count_50').default(0).notNull(),
|
count50: integer('count_50').default(0).notNull(),
|
||||||
countMiss: integer('count_miss').default(0).notNull(),
|
countMiss: integer('count_miss').default(0).notNull(),
|
||||||
mods: text('mods').array().default([]).notNull(),
|
mods: text('mods').array().default([]).notNull(),
|
||||||
passed: boolean('passed').default(false).notNull()
|
passed: boolean('passed').default(false).notNull(),
|
||||||
|
pp: real('pp')
|
||||||
},
|
},
|
||||||
(t) => [index('idx_mgs_game').on(t.matchGameId)]
|
(t) => [index('idx_mgs_game').on(t.matchGameId)]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ export async function submitGameScores(
|
|||||||
countMiss?: number;
|
countMiss?: number;
|
||||||
mods?: string[];
|
mods?: string[];
|
||||||
passed?: boolean;
|
passed?: boolean;
|
||||||
|
pp?: number | null;
|
||||||
}[]
|
}[]
|
||||||
) {
|
) {
|
||||||
const game = await db.query.matchGame.findFirst({
|
const game = await db.query.matchGame.findFirst({
|
||||||
@@ -387,7 +388,8 @@ export async function submitGameScores(
|
|||||||
count50: s.count50 ?? 0,
|
count50: s.count50 ?? 0,
|
||||||
countMiss: s.countMiss ?? 0,
|
countMiss: s.countMiss ?? 0,
|
||||||
mods: s.mods ?? [],
|
mods: s.mods ?? [],
|
||||||
passed: s.passed ?? true
|
passed: s.passed ?? true,
|
||||||
|
pp: s.pp ?? null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { match, matchGame, mappoolSlot, user } from '$lib/server/db/schema';
|
import { match, matchGame, user } from '$lib/server/db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { MATCH_STATES, type MatchConfig } from './types';
|
import { MATCH_STATES, type MatchConfig } from './types';
|
||||||
import { getMatchFull, submitRoll, pickMap, submitGameScores } from './engine';
|
import { getMatchFull, submitRoll, pickMap, submitGameScores } from './engine';
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
removeLobby,
|
removeLobby,
|
||||||
getActiveLobbyCount
|
getActiveLobbyCount
|
||||||
} from '../bancho/client';
|
} from '../bancho/client';
|
||||||
|
import { getOsuMatch } from '../osu/api';
|
||||||
|
import { account } from '../db/auth.schema';
|
||||||
|
|
||||||
function sleep(ms: number) {
|
function sleep(ms: number) {
|
||||||
return new Promise<void>((r) => setTimeout(r, ms));
|
return new Promise<void>((r) => setTimeout(r, ms));
|
||||||
@@ -305,7 +307,6 @@ export async function playPickedMap(matchId: string, matchGameId: string) {
|
|||||||
|
|
||||||
const slot = game.slot;
|
const slot = game.slot;
|
||||||
const m = await getMatchFull(matchId);
|
const m = await getMatchFull(matchId);
|
||||||
const config = m.config as MatchConfig;
|
|
||||||
const score = m.participants.map((p) => `${p.team.name} ${p.score}`).join(' - ');
|
const score = m.participants.map((p) => `${p.team.name} ${p.score}`).join(' - ');
|
||||||
|
|
||||||
// Set beatmap
|
// Set beatmap
|
||||||
@@ -331,6 +332,10 @@ export async function playPickedMap(matchId: string, matchGameId: string) {
|
|||||||
try {
|
try {
|
||||||
await lobby.chat('Timed out waiting for ready. Ready up and the game will start, or an admin can force start from the web UI.');
|
await lobby.chat('Timed out waiting for ready. Ready up and the game will start, or an admin can force start from the web UI.');
|
||||||
} catch { /* lobby may be dead */ }
|
} catch { /* lobby may be dead */ }
|
||||||
|
// Still set up score collection — force start will trigger the game
|
||||||
|
collectScores(matchId, matchGameId, lobby).catch((err) =>
|
||||||
|
console.error('[Orchestrator] Score collection failed:', err)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +366,22 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
|||||||
|
|
||||||
// Map IRC usernames → matchParticipantPlayer IDs
|
// Map IRC usernames → matchParticipantPlayer IDs
|
||||||
const m = await getMatchFull(matchId);
|
const m = await getMatchFull(matchId);
|
||||||
const scores: { playerId: string; score: number; passed: boolean }[] = [];
|
const scores: {
|
||||||
|
playerId: string;
|
||||||
|
score: number;
|
||||||
|
passed: boolean;
|
||||||
|
accuracy?: number;
|
||||||
|
maxCombo?: number;
|
||||||
|
count300?: number;
|
||||||
|
count100?: number;
|
||||||
|
count50?: number;
|
||||||
|
countMiss?: number;
|
||||||
|
mods?: string[];
|
||||||
|
pp?: number | null;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
// Build userId → osu accountId map for API enrichment
|
||||||
|
const userOsuIdMap = new Map<string, number>(); // userId → osu numeric ID
|
||||||
|
|
||||||
for (const irc of ircScores) {
|
for (const irc of ircScores) {
|
||||||
for (const participant of m.participants) {
|
for (const participant of m.participants) {
|
||||||
@@ -371,6 +391,15 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
|||||||
const dbName = u?.name?.toLowerCase();
|
const dbName = u?.name?.toLowerCase();
|
||||||
if (dbName && (dbName === ircName || dbName === irc.username.toLowerCase())) {
|
if (dbName && (dbName === ircName || dbName === irc.username.toLowerCase())) {
|
||||||
scores.push({ playerId: player.id, score: irc.score, passed: irc.passed });
|
scores.push({ playerId: player.id, score: irc.score, passed: irc.passed });
|
||||||
|
// Cache the osu account ID for enrichment
|
||||||
|
if (u && !userOsuIdMap.has(player.userId)) {
|
||||||
|
const acc = await db.query.account.findFirst({
|
||||||
|
where: and(eq(account.userId, player.userId), eq(account.providerId, 'osu'))
|
||||||
|
});
|
||||||
|
if (acc?.accountId) {
|
||||||
|
userOsuIdMap.set(player.userId, parseInt(acc.accountId));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,6 +410,59 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrich scores with osu! API data (300/100/50/miss/accuracy/maxCombo/pp)
|
||||||
|
if (m.osuLobbyId) {
|
||||||
|
try {
|
||||||
|
const osuMatchData = await getOsuMatch(m.osuLobbyId);
|
||||||
|
// Find the most recent game event matching our beatmap
|
||||||
|
const gameRecord = await db.query.matchGame.findFirst({
|
||||||
|
where: eq(matchGame.id, matchGameId),
|
||||||
|
with: { slot: true }
|
||||||
|
});
|
||||||
|
const beatmapId = gameRecord?.slot?.beatmapId ? parseInt(gameRecord.slot.beatmapId) : null;
|
||||||
|
|
||||||
|
if (beatmapId && osuMatchData?.events) {
|
||||||
|
// Find game events in reverse (most recent first) matching our beatmap
|
||||||
|
const gameEvents = [...osuMatchData.events]
|
||||||
|
.reverse()
|
||||||
|
.filter((e: any) => e.game?.beatmap_id === beatmapId);
|
||||||
|
|
||||||
|
const osuGame = gameEvents[0]?.game;
|
||||||
|
if (osuGame?.scores) {
|
||||||
|
// Build osu userID → score stats map
|
||||||
|
const osuScoreMap = new Map<number, any>();
|
||||||
|
for (const os of osuGame.scores) {
|
||||||
|
osuScoreMap.set(os.user_id, os);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich each score
|
||||||
|
for (const s of scores) {
|
||||||
|
const playerOsuId = userOsuIdMap.get(s.playerId);
|
||||||
|
if (playerOsuId === undefined) continue;
|
||||||
|
const osuScore = osuScoreMap.get(playerOsuId);
|
||||||
|
if (!osuScore) continue;
|
||||||
|
|
||||||
|
s.accuracy = osuScore.accuracy ?? undefined;
|
||||||
|
s.maxCombo = osuScore.max_combo ?? undefined;
|
||||||
|
s.count300 = osuScore.statistics?.count_300 ?? undefined;
|
||||||
|
s.count100 = osuScore.statistics?.count_100 ?? undefined;
|
||||||
|
s.count50 = osuScore.statistics?.count_50 ?? undefined;
|
||||||
|
s.countMiss = osuScore.statistics?.count_miss ?? undefined;
|
||||||
|
s.pp = osuScore.pp ?? null;
|
||||||
|
if (osuScore.mods?.length) {
|
||||||
|
s.mods = osuScore.mods.map((mod: any) =>
|
||||||
|
typeof mod === 'string' ? mod : mod.acronym ?? mod
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[Orchestrator] Enriched scores from osu! API for game ${matchGameId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn('[Orchestrator] Failed to enrich scores from osu! API:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await submitGameScores(matchGameId, scores);
|
await submitGameScores(matchGameId, scores);
|
||||||
|
|
||||||
// ── Announce results clearly ──
|
// ── Announce results clearly ──
|
||||||
|
|||||||
@@ -133,4 +133,12 @@ export async function getUserScores(
|
|||||||
return osuFetch(`/users/${userId}/scores/${type}?limit=${limit}`);
|
return osuFetch(`/users/${userId}/scores/${type}?limit=${limit}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch osu! match data (events + scores) for a multiplayer lobby.
|
||||||
|
* Returns events array which includes game events with per-player scores and statistics.
|
||||||
|
*/
|
||||||
|
export async function getOsuMatch(osuMatchId: number) {
|
||||||
|
return osuFetch(`/matches/${osuMatchId}`);
|
||||||
|
}
|
||||||
|
|
||||||
export { getUserToken, getClientToken };
|
export { getUserToken, getClientToken };
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
// src/routes/(app)/+layout.server.ts
|
// src/routes/(app)/+layout.server.ts
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
import { isRootAdmin } from '$lib/server/permissions';
|
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 }) => {
|
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 {
|
return {
|
||||||
user: locals.user
|
user: locals.user
|
||||||
? {
|
? {
|
||||||
@@ -13,6 +35,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
role: locals.user.role ?? 'player',
|
role: locals.user.role ?? 'player',
|
||||||
isRootAdmin: isRootAdmin(locals.user.email)
|
isRootAdmin: isRootAdmin(locals.user.email)
|
||||||
}
|
}
|
||||||
: null
|
: null,
|
||||||
|
activeMatch
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,15 @@
|
|||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
const user = $derived(page.data?.user);
|
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>
|
</script>
|
||||||
|
|
||||||
{#if user}
|
{#if user}
|
||||||
@@ -15,6 +24,26 @@
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div class="ml-56 flex flex-1 flex-col">
|
<div class="ml-56 flex flex-1 flex-col">
|
||||||
<Header />
|
<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'} · {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 →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<main class="flex-1 p-6">
|
<main class="flex-1 p-6">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const liveStates = ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'];
|
const liveStates = ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'];
|
||||||
@@ -26,11 +28,12 @@
|
|||||||
const res = await fetch('/api/queue');
|
const res = await fetch('/api/queue');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const status = await res.json();
|
const status = await res.json();
|
||||||
|
const wasInQueue = queueStatus?.inQueue;
|
||||||
queueStatus = status;
|
queueStatus = status;
|
||||||
|
|
||||||
// If we were in queue and now got matched, redirect to the match
|
// If we were in queue and now got matched, refresh the layout to show the active match banner
|
||||||
if (!status.inQueue && status.matchedMatchId) {
|
if (wasInQueue && !status.inQueue && status.matchedMatchId) {
|
||||||
window.location.href = `/matches/${status.matchedMatchId}`;
|
await invalidateAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -42,7 +45,10 @@
|
|||||||
const res = await fetch('/api/queue', { method: 'POST' });
|
const res = await fetch('/api/queue', { method: 'POST' });
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (result.matched && result.match) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
await fetchQueueStatus();
|
await fetchQueueStatus();
|
||||||
|
|||||||
@@ -87,23 +87,34 @@ export const load: PageServerLoad = async () => {
|
|||||||
|
|
||||||
const topTeams = teamsWithDetails.filter(Boolean);
|
const topTeams = teamsWithDetails.filter(Boolean);
|
||||||
|
|
||||||
// ── Recent high scores (individual map scores) ──
|
// ── High scores — sorted by pp (nulls last) then score ──
|
||||||
const recentHighScores = await db.query.matchGameScore.findMany({
|
const recentHighScores = await db
|
||||||
orderBy: desc(matchGameScore.score),
|
.select()
|
||||||
limit: 15,
|
.from(matchGameScore)
|
||||||
with: {
|
.orderBy(
|
||||||
player: true,
|
sql`${matchGameScore.pp} DESC NULLS LAST`,
|
||||||
game: {
|
desc(matchGameScore.score)
|
||||||
with: {
|
)
|
||||||
slot: true,
|
.limit(15)
|
||||||
match: true
|
.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(
|
const highScoresWithNames = await Promise.all(
|
||||||
recentHighScores.map(async (s, i) => {
|
recentHighScores.map(async (s, i) => {
|
||||||
|
if (!s) return null;
|
||||||
const u = await db.query.user.findFirst({
|
const u = await db.query.user.findFirst({
|
||||||
where: eq(user.id, s.player.userId)
|
where: eq(user.id, s.player.userId)
|
||||||
});
|
});
|
||||||
@@ -114,6 +125,11 @@ export const load: PageServerLoad = async () => {
|
|||||||
score: s.score,
|
score: s.score,
|
||||||
accuracy: s.accuracy,
|
accuracy: s.accuracy,
|
||||||
maxCombo: s.maxCombo,
|
maxCombo: s.maxCombo,
|
||||||
|
count300: s.count300,
|
||||||
|
count100: s.count100,
|
||||||
|
count50: s.count50,
|
||||||
|
countMiss: s.countMiss,
|
||||||
|
pp: s.pp,
|
||||||
mods: s.mods,
|
mods: s.mods,
|
||||||
passed: s.passed,
|
passed: s.passed,
|
||||||
beatmapId: s.game.slot?.beatmapId ?? null,
|
beatmapId: s.game.slot?.beatmapId ?? null,
|
||||||
@@ -123,7 +139,7 @@ export const load: PageServerLoad = async () => {
|
|||||||
matchId: s.game.matchId
|
matchId: s.game.matchId
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
).then((rows) => rows.filter(Boolean));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
topPlayers: playersWithUsers,
|
topPlayers: playersWithUsers,
|
||||||
|
|||||||
@@ -187,13 +187,17 @@
|
|||||||
<p class="text-sm text-text-secondary">No scores recorded yet.</p>
|
<p class="text-sm text-text-secondary">No scores recorded yet.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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
|
<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"></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">Player</span>
|
||||||
<span class="text-xs font-600 text-text-secondary">Map</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">Score</span>
|
||||||
<span class="text-xs font-600 text-text-secondary">Acc</span>
|
<span class="text-xs font-600 text-text-secondary">Acc</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +205,7 @@
|
|||||||
{#each data.highScores as s}
|
{#each data.highScores as s}
|
||||||
{@const medal = rankBadge(s.rank)}
|
{@const medal = rankBadge(s.rank)}
|
||||||
<div
|
<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
|
3
|
||||||
? 'bg-accent/[0.02]'
|
? 'bg-accent/[0.02]'
|
||||||
: ''}"
|
: ''}"
|
||||||
@@ -235,6 +239,11 @@
|
|||||||
{s.mapCategory}{s.mapOrder}
|
{s.mapCategory}{s.mapOrder}
|
||||||
</span>
|
</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 -->
|
<!-- Score -->
|
||||||
<span class="text-sm font-700 tabular-nums text-text-primary">
|
<span class="text-sm font-700 tabular-nums text-text-primary">
|
||||||
{s.score.toLocaleString()}
|
{s.score.toLocaleString()}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { user } from '$lib/server/db/schema';
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { getMatchFull, submitRoll, pickMap, cancelMatch } from '$lib/server/match/engine';
|
import { getMatchFull, submitRoll, pickMap, cancelMatch } from '$lib/server/match/engine';
|
||||||
import { playPickedMap, closeLobby, forceStartGame } from '$lib/server/match/orchestrator';
|
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 { error, redirect } from '@sveltejs/kit';
|
||||||
import { getBeatmap } from '$lib/server/osu/api';
|
import { getBeatmap } from '$lib/server/osu/api';
|
||||||
import { hasRole } from '$lib/server/permissions';
|
import { hasRole } from '$lib/server/permissions';
|
||||||
@@ -45,7 +45,18 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
|
|
||||||
const isStaff = hasRole(locals.user.role, 'referee');
|
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 = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let m = $derived(data.match);
|
let m = $derived(data.match);
|
||||||
|
let lobbyStatus = $derived(data.lobbyStatus);
|
||||||
|
let playerNames = $derived(data.playerNames);
|
||||||
let p1 = $derived(m.participants[0]);
|
let p1 = $derived(m.participants[0]);
|
||||||
let p2 = $derived(m.participants[1]);
|
let p2 = $derived(m.participants[1]);
|
||||||
|
|
||||||
@@ -228,24 +230,54 @@
|
|||||||
{#if m.state === 'PLAYING'}
|
{#if m.state === 'PLAYING'}
|
||||||
{@const currentGame = m.games[m.games.length - 1]}
|
{@const currentGame = m.games[m.games.length - 1]}
|
||||||
{@const bm = currentGame ? data.beatmapCache[currentGame.slot?.beatmapId] : null}
|
{@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="flex items-center justify-center gap-2">
|
||||||
<div class="relative h-3 w-3">
|
<div class="relative h-3 w-3">
|
||||||
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
|
<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 class="relative h-3 w-3 rounded-full bg-green-400"></div>
|
||||||
</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>
|
</div>
|
||||||
{#if bm}
|
{#if bm}
|
||||||
<p class="mt-2 text-sm">{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span></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-xs text-text-secondary">★{bm.starRating.toFixed(1)} · {bm.bpm}bpm · {formatLength(bm.totalLength)}</p>
|
<p class="mt-1 text-center text-xs text-text-secondary">★{bm.starRating.toFixed(1)} · {bm.bpm}bpm · {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 & play in osu!...
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="mt-3 text-xs text-text-secondary animate-pulse">
|
|
||||||
Waiting for players to ready up & play in osu!...
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if data.isStaff}
|
{#if data.isStaff}
|
||||||
<div class="mt-3">
|
<div class="mt-3 text-center">
|
||||||
<form method="post" action="?/forceStart" use:enhance={() => {
|
<form method="post" action="?/forceStart" use:enhance={() => {
|
||||||
forceStarting = true;
|
forceStarting = true;
|
||||||
return async ({ update }) => { forceStarting = false; await update(); };
|
return async ({ update }) => { forceStarting = false; await update(); };
|
||||||
@@ -428,22 +460,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if game.state === 'FINISHED' && p1 && p2}
|
{#if game.state === 'PLAYING'}
|
||||||
{@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'}
|
|
||||||
<span class="text-xs text-green-400 animate-pulse">Live</span>
|
<span class="text-xs text-green-400 animate-pulse">Live</span>
|
||||||
{/if}
|
{:else if winnerTeam}
|
||||||
|
|
||||||
{#if winnerTeam}
|
|
||||||
<span class="text-xs text-green-400">✓ {winnerTeam.name}</span>
|
<span class="text-xs text-green-400">✓ {winnerTeam.name}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/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">
|
<div class="mt-3">
|
||||||
{@render reinviteButton()}
|
{@render reinviteButton()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user