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
|
||||
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)
|
||||
public onRollResult: ((username: string, value: number) => void) | null = null;
|
||||
public onPickCommand: ((username: string, slotLabel: string) => void) | null = null;
|
||||
@@ -265,6 +270,8 @@ export class TournamentLobby {
|
||||
// Game finished
|
||||
if (text.includes('The match has finished!')) {
|
||||
console.log('[Bancho] Game finished. Scores:', this.collectedScores);
|
||||
this.gameInProgress = false;
|
||||
this.readyPlayers.clear();
|
||||
if (this.matchFinishedResolve) {
|
||||
this.matchFinishedResolve([...this.collectedScores]);
|
||||
this.matchFinishedResolve = null;
|
||||
@@ -290,15 +297,42 @@ export class TournamentLobby {
|
||||
if (joinMatch) {
|
||||
const username = joinMatch[1].trim();
|
||||
console.log(`[Bancho] Player joined: ${username}`);
|
||||
this.inLobby.add(username);
|
||||
this.readyPlayers.delete(username);
|
||||
if (this.onPlayerJoined) {
|
||||
this.onPlayerJoined(username);
|
||||
}
|
||||
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
|
||||
if (text.includes('All players are ready')) {
|
||||
console.log('[Bancho] All players ready');
|
||||
for (const u of this.inLobby) this.readyPlayers.add(u);
|
||||
if (this.allReadyResolve) {
|
||||
this.allReadyResolve();
|
||||
this.allReadyResolve = null;
|
||||
@@ -306,6 +340,13 @@ export class TournamentLobby {
|
||||
return;
|
||||
}
|
||||
|
||||
// Game started
|
||||
if (text.includes('The match has started!')) {
|
||||
this.gameInProgress = true;
|
||||
this.readyPlayers.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Room closed
|
||||
if (text.includes('Closed the match')) {
|
||||
console.log(`[Bancho] Room ${this.channelName} was closed`);
|
||||
@@ -457,6 +498,20 @@ export function removeLobby(matchId: string) {
|
||||
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 {
|
||||
let count = 0;
|
||||
for (const [id, lobby] of lobbies.entries()) {
|
||||
|
||||
@@ -160,7 +160,8 @@ export const matchGameScore = pgTable(
|
||||
count50: integer('count_50').default(0).notNull(),
|
||||
countMiss: integer('count_miss').default(0).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)]
|
||||
);
|
||||
|
||||
@@ -366,6 +366,7 @@ export async function submitGameScores(
|
||||
countMiss?: number;
|
||||
mods?: string[];
|
||||
passed?: boolean;
|
||||
pp?: number | null;
|
||||
}[]
|
||||
) {
|
||||
const game = await db.query.matchGame.findFirst({
|
||||
@@ -387,7 +388,8 @@ export async function submitGameScores(
|
||||
count50: s.count50 ?? 0,
|
||||
countMiss: s.countMiss ?? 0,
|
||||
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 { 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 { MATCH_STATES, type MatchConfig } from './types';
|
||||
import { getMatchFull, submitRoll, pickMap, submitGameScores } from './engine';
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
removeLobby,
|
||||
getActiveLobbyCount
|
||||
} from '../bancho/client';
|
||||
import { getOsuMatch } from '../osu/api';
|
||||
import { account } from '../db/auth.schema';
|
||||
|
||||
function sleep(ms: number) {
|
||||
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 m = await getMatchFull(matchId);
|
||||
const config = m.config as MatchConfig;
|
||||
const score = m.participants.map((p) => `${p.team.name} ${p.score}`).join(' - ');
|
||||
|
||||
// Set beatmap
|
||||
@@ -331,6 +332,10 @@ export async function playPickedMap(matchId: string, matchGameId: string) {
|
||||
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.');
|
||||
} 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;
|
||||
}
|
||||
|
||||
@@ -361,7 +366,22 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
||||
|
||||
// Map IRC usernames → matchParticipantPlayer IDs
|
||||
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 participant of m.participants) {
|
||||
@@ -371,6 +391,15 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
||||
const dbName = u?.name?.toLowerCase();
|
||||
if (dbName && (dbName === ircName || dbName === irc.username.toLowerCase())) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// ── Announce results clearly ──
|
||||
|
||||
@@ -133,4 +133,12 @@ export async function getUserScores(
|
||||
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 };
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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'} · {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">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
// ── 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
|
||||
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,
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)} · {bm.bpm}bpm · {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)} · {bm.bpm}bpm · {formatLength(bm.totalLength)}</p>
|
||||
{/if}
|
||||
<p class="mt-3 text-xs text-text-secondary animate-pulse">
|
||||
|
||||
<!-- 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 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>
|
||||
|
||||
Reference in New Issue
Block a user