feat: add match duration and mappool average star rating display; enhance player rating info in match view

This commit is contained in:
2026-03-01 18:28:43 +01:00
parent 2949d251ee
commit 65468b277c
4 changed files with 145 additions and 40 deletions

View File

@@ -16,6 +16,9 @@ import { notifyMatchCreated, notifyMatchFinished } from '$lib/server/discord/cli
// ── Queue ───────────────────────────────────────────────────────────────
export async function joinQueue(userId: string, teamId: string) {
const activeMatchId = await findRecentActiveMatch(userId);
if (activeMatchId) throw new Error('You are already in an active match');
let rating = await db.query.playerRating.findFirst({
where: eq(playerRating.userId, userId)
});

View File

@@ -1,8 +1,14 @@
import { db } from '$lib/server/db';
import { match, matchGame, user } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { MATCH_STATES, type MatchConfig } from './types';
import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types';
import { getMatchFull, submitRoll, pickMap, submitGameScores } from './engine';
import { env } from '$env/dynamic/private';
// matchId → lowercase usernames of all expected players
const expectedPlayers = new Map<string, Set<string>>();
// matchId → lowercase usernames who have joined the IRC lobby so far
const joinedPlayers = new Map<string, Set<string>>();
import {
TournamentLobby,
setLobby,
@@ -81,12 +87,14 @@ export async function initMatchLobby(matchId: string) {
}
}
// Store expected players for the rolling gate (reuse the invited set)
expectedPlayers.set(matchId, new Set(invited));
joinedPlayers.set(matchId, new Set<string>());
// Wire up IRC chat handlers for rolls, picks, and player joins
setupChatHandlers(matchId, lobby);
// LOBBY → ROLLING (we'll greet players individually when they join)
await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId));
// State stays LOBBY — transitions to ROLLING only when all players have joined
return { osuMatchId: osuId, channel };
}
@@ -100,41 +108,61 @@ export async function initMatchLobby(matchId: string) {
function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
// ── Welcome players when they JOIN the channel ──────────────────────
// This is the key fix: osu! doesn't show chat history, so players
// only see messages sent AFTER they join. We greet each one individually.
lobby.onPlayerJoined = async (username: string) => {
try {
const normalizedName = username.toLowerCase().replace(/_/g, ' ');
// Track who has joined for the rolling gate
const joined = joinedPlayers.get(matchId) ?? new Set<string>();
joined.add(normalizedName);
joinedPlayers.set(matchId, joined);
const m = await getMatchFull(matchId);
const config = m.config as MatchConfig;
if (m.state === MATCH_STATES.ROLLING) {
// Tell them what's happening and what they need to do
if (m.state === MATCH_STATES.LOBBY) {
const expected = expectedPlayers.get(matchId) ?? new Set<string>();
const allJoined = expected.size > 0 && [...expected].every((p) => joined.has(p));
if (allJoined) {
await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId));
await sleep(500);
await lobby.chat(
`All players present! BO${config.bestOf} match — type !roll to decide pick order.`
);
} else {
const missing = [...expected].filter((p) => !joined.has(p));
await sleep(1000);
await lobby.chat(
`Welcome ${username}! Waiting for ${missing.length} more player(s) to join before we start.`
);
}
} else if (m.state === MATCH_STATES.ROLLING) {
const unrolled = m.participants.filter((p) => p.rollValue === null);
if (unrolled.length > 0) {
await sleep(1000); // small delay so they see it after join message
await sleep(1000);
await lobby.chat(
`Welcome ${username}! This is a BO${config.bestOf} match. ` +
`Type !roll to decide pick order.`
`Welcome ${username}! BO${config.bestOf} match — type !roll to decide pick order.`
);
} else {
await lobby.chat(`Welcome ${username}! Waiting for rolls to finish...`);
}
} else if (m.state === MATCH_STATES.PICKING) {
const sorted = [...m.participants].sort(
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
);
const sorted = [...m.participants].sort((a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99));
const nextIdx = m.games.length % sorted.length;
const picker = sorted[nextIdx];
const score = m.participants.map((p) => `${p.team.name}: ${p.score}`).join(' | ');
const scoreStr = `(${sorted[0]?.team.name}) ${sorted[0]?.score} - ${sorted[1]?.score} (${sorted[1]?.team.name})`;
await sleep(1000);
await lobby.chat(
`Welcome back ${username}! Score: ${score}. ` +
`${picker?.team.name}'s turn to pick. Use !pick <slot> (e.g. !pick NM1).`
`Welcome back ${username}! ${scoreStr} — ${picker?.team.name}'s turn to pick. Use !pick <slot> (e.g. !pick NM1).`
);
} else if (m.state === MATCH_STATES.PLAYING) {
const activeGame = m.games.find((g: any) => g.state === GAME_STATES.PLAYING);
if (activeGame) {
await sleep(1000);
await lobby.chat(`Welcome back ${username}! A game is in progress — ready up when it finishes.`);
}
}
} catch (err: any) {
console.error('[Orchestrator] Player join handler error:', err.message);
}
@@ -491,48 +519,59 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
const config = updated.config as MatchConfig;
const winsNeeded = Math.ceil(config.bestOf / 2);
// Build score announcement: "Zigzy 808,050 vs Stan 3,418 — Zigzy wins!"
// Sort participants by pick order for consistent display
const sortedPs = [...updated.participants].sort(
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
);
const ps1 = sortedPs[0];
const ps2 = sortedPs[1];
const game = updated.games.find((g) => g.id === matchGameId);
if (game && game.winnerParticipantId) {
const winner = updated.participants.find((p) => p.id === game.winnerParticipantId);
const slotLabel = `${game.slot?.category}${game.slot?.orderInCategory}`;
// Individual player scores
const scoreLines = ircScores
.map((s) => `${s.username}: ${s.score.toLocaleString()}`)
.join(' vs ');
// Per-team score totals from game scores
const teamTotals = new Map<string, number>();
for (const s of game.scores ?? []) {
const pid = s.player?.participantId;
if (pid) teamTotals.set(pid, (teamTotals.get(pid) ?? 0) + s.score);
}
const score1 = (teamTotals.get(ps1?.id ?? '') ?? 0).toLocaleString();
const score2 = (teamTotals.get(ps2?.id ?? '') ?? 0).toLocaleString();
await sleep(500);
try {
await lobby.chat(`${scoreLines} — ${winner?.team.name ?? '?'} wins the point!`);
await lobby.chat(
`(${ps1?.team.name}) ${score1} - ${score2} (${ps2?.team.name}) — ${winner?.team.name ?? '?'} wins ${slotLabel}!`
);
} catch { /* ignore */ }
}
// Match score line
const matchScore = updated.participants
.map((p) => `${p.team.name} ${p.score}`)
.join(' - ');
// Match score string: "(Team1) 2 - 1 (Team2)"
const matchScoreStr = `(${ps1?.team.name}) ${ps1?.score} - ${ps2?.score} (${ps2?.team.name})`;
if (updated.state === MATCH_STATES.FINISHED) {
const winner = updated.participants.find((p) => p.teamId === updated.winnerId);
try {
await sleep(1000);
await lobby.chat(`GG! ${winner?.team.name ?? '?'} wins the match! Final: ${matchScore}`);
await sleep(5000);
await lobby.chat(`GG! ${winner?.team.name ?? '?'} wins the match! ${matchScoreStr}`);
await sleep(2000);
await lobby.chat(`Results: ${env.ORIGIN}/matches/${matchId}`);
await lobby.chat(`This lobby will close in 3 minutes.`);
await sleep(3 * 60 * 1000);
await lobby.close();
} catch { /* lobby may already be closed */ }
removeLobby(matchId);
expectedPlayers.delete(matchId);
joinedPlayers.delete(matchId);
} else {
// Announce next picker with current score
const sorted = [...updated.participants].sort(
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
);
const nextIdx = updated.games.length % sorted.length;
const nextPicker = sorted[nextIdx];
const nextIdx = updated.games.length % sortedPs.length;
const nextPicker = sortedPs[nextIdx];
try {
await sleep(1000);
await lobby.chat(
`Score: ${matchScore} (first to ${winsNeeded}). ` +
`${nextPicker?.team.name}'s turn to pick. Use !pick <slot>.`
`${matchScoreStr} — first to ${winsNeeded} — ${nextPicker?.team.name}'s turn to pick. Use !pick <slot>`
);
} catch { /* lobby may be dead */ }
}
@@ -547,4 +586,6 @@ export async function closeLobby(matchId: string) {
await lobby.close();
removeLobby(matchId);
}
expectedPlayers.delete(matchId);
joinedPlayers.delete(matchId);
}

View File

@@ -11,6 +11,7 @@ import { hasRole } from '$lib/server/permissions';
import type { PageServerLoad, Actions } from './$types';
import { eq } from 'drizzle-orm';
import type { MatchConfig } from '$lib/server/match/types';
import { playerRating } from '$lib/server/db/schema';
export const load: PageServerLoad = async ({ params, locals }) => {
@@ -92,7 +93,19 @@ export const load: PageServerLoad = async ({ params, locals }) => {
const lobbyStatus = getLobbyStatus(params.id);
return { match: m, beatmapCache, userId: locals.user.id, isStaff, lobbyStatus, playerNames };
const playerRatings: Record<string, { elo: number; wins: number; losses: number }> = {};
for (const p of m.participants) {
for (const pl of p.players) {
const rating = await db.query.playerRating.findFirst({
where: eq(playerRating.userId, pl.userId)
});
if (rating) {
playerRatings[pl.userId] = { elo: rating.elo, wins: rating.wins, losses: rating.losses };
}
}
}
return { match: m, beatmapCache, userId: locals.user.id, isStaff, lobbyStatus, playerNames, playerRatings };
};
export const actions: Actions = {

View File

@@ -68,6 +68,36 @@
return groups;
});
// Match duration
let now = $state(Date.now());
$effect(() => {
if (m.startedAt && !['FINISHED', 'CANCELLED'].includes(m.state)) {
const interval = setInterval(() => { now = Date.now(); }, 1000);
return () => clearInterval(interval);
}
});
function formatDuration(ms: number): string {
const totalSecs = Math.floor(ms / 1000);
const mins = Math.floor(totalSecs / 60);
const secs = totalSecs % 60;
return `${mins}m ${secs.toString().padStart(2, '0')}s`;
}
const duration = $derived(
m.startedAt
? m.finishedAt
? formatDuration(new Date(m.finishedAt).getTime() - new Date(m.startedAt).getTime())
: formatDuration(now - new Date(m.startedAt).getTime())
: null
);
// Mappool avg SR
const mappoolAvgSR = $derived(() => {
const slots = m.mappool?.slots;
if (!slots?.length) return null;
const avg = slots.reduce((sum: number, s: any) => sum + (s.starRating ?? 0), 0) / slots.length;
return avg.toFixed(2);
});
let rolling = $state(false);
let picking = $state(false);
let pickError = $state('');
@@ -142,6 +172,12 @@
<h1 class="text-xl font-700 tracking-tight">{m.name || 'Match'}</h1>
<p class="mt-0.5 text-xs text-text-secondary">
Best of {config.bestOf} &middot; First to {winsNeeded}
{#if m.mappool}
&middot; {m.mappool.name}{mappoolAvgSR() ? ` · avg ★${mappoolAvgSR()}` : ''}
{/if}
{#if duration}
&middot; {duration}
{/if}
{#if m.osuLobbyId}
&middot; <a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a>
{/if}
@@ -165,6 +201,12 @@
{/if}
<div class="flex-1">
<p class="text-sm font-600">{p1.team.name}</p>
{#if config.teamSize === 1}
{@const r1 = data.playerRatings?.[p1.players[0]?.userId]}
{#if r1}
<p class="text-xs text-text-secondary">{r1.elo} ELO · {r1.wins}W {r1.losses}L</p>
{/if}
{/if}
{#if p1.rollValue != null}
<p class="text-xs text-text-secondary">Roll: {p1.rollValue}{p1.pickOrder === 1 ? ' ★' : ''}</p>
{/if}
@@ -178,6 +220,12 @@
<span class="text-3xl font-800 tabular-nums {p2.score >= winsNeeded ? 'text-green-400' : 'text-text-primary'}">{p2.score}</span>
<div class="flex-1 text-right">
<p class="text-sm font-600">{p2.team.name}</p>
{#if config.teamSize === 1}
{@const r2 = data.playerRatings?.[p2.players[0]?.userId]}
{#if r2}
<p class="text-xs text-text-secondary">{r2.wins}W {r2.losses}L · {r2.elo} ELO</p>
{/if}
{/if}
{#if p2.rollValue != null}
<p class="text-xs text-text-secondary">{p2.pickOrder === 1 ? '★ ' : ''}Roll: {p2.rollValue}</p>
{/if}