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

@@ -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()) {

View File

@@ -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)]
);

View File

@@ -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
});
}

View File

@@ -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 ──

View File

@@ -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 };

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,
// ── 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,

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}
<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 &amp; 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>