feat: add match duration and mappool average star rating display; enhance player rating info in match view
This commit is contained in:
@@ -16,6 +16,9 @@ import { notifyMatchCreated, notifyMatchFinished } from '$lib/server/discord/cli
|
|||||||
// ── Queue ───────────────────────────────────────────────────────────────
|
// ── Queue ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function joinQueue(userId: string, teamId: string) {
|
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({
|
let rating = await db.query.playerRating.findFirst({
|
||||||
where: eq(playerRating.userId, userId)
|
where: eq(playerRating.userId, userId)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { match, matchGame, 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, GAME_STATES, type MatchConfig } from './types';
|
||||||
import { getMatchFull, submitRoll, pickMap, submitGameScores } from './engine';
|
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 {
|
import {
|
||||||
TournamentLobby,
|
TournamentLobby,
|
||||||
setLobby,
|
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
|
// Wire up IRC chat handlers for rolls, picks, and player joins
|
||||||
setupChatHandlers(matchId, lobby);
|
setupChatHandlers(matchId, lobby);
|
||||||
|
|
||||||
// LOBBY → ROLLING (we'll greet players individually when they join)
|
// State stays LOBBY — transitions to ROLLING only when all players have joined
|
||||||
await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId));
|
|
||||||
|
|
||||||
return { osuMatchId: osuId, channel };
|
return { osuMatchId: osuId, channel };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,40 +108,60 @@ export async function initMatchLobby(matchId: string) {
|
|||||||
function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
|
function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
|
||||||
|
|
||||||
// ── Welcome players when they JOIN the channel ──────────────────────
|
// ── 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) => {
|
lobby.onPlayerJoined = async (username: string) => {
|
||||||
try {
|
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 m = await getMatchFull(matchId);
|
||||||
const config = m.config as MatchConfig;
|
const config = m.config as MatchConfig;
|
||||||
|
|
||||||
if (m.state === MATCH_STATES.ROLLING) {
|
if (m.state === MATCH_STATES.LOBBY) {
|
||||||
// Tell them what's happening and what they need to do
|
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);
|
const unrolled = m.participants.filter((p) => p.rollValue === null);
|
||||||
if (unrolled.length > 0) {
|
if (unrolled.length > 0) {
|
||||||
await sleep(1000); // small delay so they see it after join message
|
await sleep(1000);
|
||||||
await lobby.chat(
|
await lobby.chat(
|
||||||
`Welcome ${username}! This is a BO${config.bestOf} match. ` +
|
`Welcome ${username}! BO${config.bestOf} match — type !roll to decide pick order.`
|
||||||
`Type !roll to decide pick order.`
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await lobby.chat(`Welcome ${username}! Waiting for rolls to finish...`);
|
await lobby.chat(`Welcome ${username}! Waiting for rolls to finish...`);
|
||||||
}
|
}
|
||||||
} else if (m.state === MATCH_STATES.PICKING) {
|
} else if (m.state === MATCH_STATES.PICKING) {
|
||||||
const sorted = [...m.participants].sort(
|
const sorted = [...m.participants].sort((a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99));
|
||||||
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
|
||||||
);
|
|
||||||
const nextIdx = m.games.length % sorted.length;
|
const nextIdx = m.games.length % sorted.length;
|
||||||
const picker = sorted[nextIdx];
|
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 sleep(1000);
|
||||||
await lobby.chat(
|
await lobby.chat(
|
||||||
`Welcome back ${username}! Score: ${score}. ` +
|
`Welcome back ${username}! ${scoreStr} — ${picker?.team.name}'s turn to pick. Use !pick <slot> (e.g. !pick NM1).`
|
||||||
`${picker?.team.name}'s turn to pick. Use !pick <slot> (e.g. !pick NM1).`
|
|
||||||
);
|
);
|
||||||
} else if (m.state === MATCH_STATES.PLAYING) {
|
} else if (m.state === MATCH_STATES.PLAYING) {
|
||||||
await sleep(1000);
|
const activeGame = m.games.find((g: any) => g.state === GAME_STATES.PLAYING);
|
||||||
await lobby.chat(`Welcome back ${username}! A game is in progress — ready up when it finishes.`);
|
if (activeGame) {
|
||||||
|
await sleep(1000);
|
||||||
|
await lobby.chat(`Welcome back ${username}! A game is in progress — ready up when it finishes.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Orchestrator] Player join handler error:', err.message);
|
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 config = updated.config as MatchConfig;
|
||||||
const winsNeeded = Math.ceil(config.bestOf / 2);
|
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);
|
const game = updated.games.find((g) => g.id === matchGameId);
|
||||||
if (game && game.winnerParticipantId) {
|
if (game && game.winnerParticipantId) {
|
||||||
const winner = updated.participants.find((p) => p.id === game.winnerParticipantId);
|
const winner = updated.participants.find((p) => p.id === game.winnerParticipantId);
|
||||||
|
const slotLabel = `${game.slot?.category}${game.slot?.orderInCategory}`;
|
||||||
|
|
||||||
// Individual player scores
|
// Per-team score totals from game scores
|
||||||
const scoreLines = ircScores
|
const teamTotals = new Map<string, number>();
|
||||||
.map((s) => `${s.username}: ${s.score.toLocaleString()}`)
|
for (const s of game.scores ?? []) {
|
||||||
.join(' vs ');
|
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);
|
await sleep(500);
|
||||||
try {
|
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 */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match score line
|
// Match score string: "(Team1) 2 - 1 (Team2)"
|
||||||
const matchScore = updated.participants
|
const matchScoreStr = `(${ps1?.team.name}) ${ps1?.score} - ${ps2?.score} (${ps2?.team.name})`;
|
||||||
.map((p) => `${p.team.name} ${p.score}`)
|
|
||||||
.join(' - ');
|
|
||||||
|
|
||||||
if (updated.state === MATCH_STATES.FINISHED) {
|
if (updated.state === MATCH_STATES.FINISHED) {
|
||||||
const winner = updated.participants.find((p) => p.teamId === updated.winnerId);
|
const winner = updated.participants.find((p) => p.teamId === updated.winnerId);
|
||||||
try {
|
try {
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
await lobby.chat(`GG! ${winner?.team.name ?? '?'} wins the match! Final: ${matchScore}`);
|
await lobby.chat(`GG! ${winner?.team.name ?? '?'} wins the match! ${matchScoreStr}`);
|
||||||
await sleep(5000);
|
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();
|
await lobby.close();
|
||||||
} catch { /* lobby may already be closed */ }
|
} catch { /* lobby may already be closed */ }
|
||||||
removeLobby(matchId);
|
removeLobby(matchId);
|
||||||
|
expectedPlayers.delete(matchId);
|
||||||
|
joinedPlayers.delete(matchId);
|
||||||
} else {
|
} else {
|
||||||
// Announce next picker with current score
|
const nextIdx = updated.games.length % sortedPs.length;
|
||||||
const sorted = [...updated.participants].sort(
|
const nextPicker = sortedPs[nextIdx];
|
||||||
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
|
||||||
);
|
|
||||||
const nextIdx = updated.games.length % sorted.length;
|
|
||||||
const nextPicker = sorted[nextIdx];
|
|
||||||
try {
|
try {
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
await lobby.chat(
|
await lobby.chat(
|
||||||
`Score: ${matchScore} (first to ${winsNeeded}). ` +
|
`${matchScoreStr} — first to ${winsNeeded} — ${nextPicker?.team.name}'s turn to pick. Use !pick <slot>`
|
||||||
`${nextPicker?.team.name}'s turn to pick. Use !pick <slot>.`
|
|
||||||
);
|
);
|
||||||
} catch { /* lobby may be dead */ }
|
} catch { /* lobby may be dead */ }
|
||||||
}
|
}
|
||||||
@@ -547,4 +586,6 @@ export async function closeLobby(matchId: string) {
|
|||||||
await lobby.close();
|
await lobby.close();
|
||||||
removeLobby(matchId);
|
removeLobby(matchId);
|
||||||
}
|
}
|
||||||
|
expectedPlayers.delete(matchId);
|
||||||
|
joinedPlayers.delete(matchId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { hasRole } from '$lib/server/permissions';
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import type { MatchConfig } from '$lib/server/match/types';
|
import type { MatchConfig } from '$lib/server/match/types';
|
||||||
|
import { playerRating } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
@@ -92,7 +93,19 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
|
|
||||||
const lobbyStatus = getLobbyStatus(params.id);
|
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 = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -68,6 +68,36 @@
|
|||||||
return groups;
|
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 rolling = $state(false);
|
||||||
let picking = $state(false);
|
let picking = $state(false);
|
||||||
let pickError = $state('');
|
let pickError = $state('');
|
||||||
@@ -142,6 +172,12 @@
|
|||||||
<h1 class="text-xl font-700 tracking-tight">{m.name || 'Match'}</h1>
|
<h1 class="text-xl font-700 tracking-tight">{m.name || 'Match'}</h1>
|
||||||
<p class="mt-0.5 text-xs text-text-secondary">
|
<p class="mt-0.5 text-xs text-text-secondary">
|
||||||
Best of {config.bestOf} · First to {winsNeeded}
|
Best of {config.bestOf} · First to {winsNeeded}
|
||||||
|
{#if m.mappool}
|
||||||
|
· {m.mappool.name}{mappoolAvgSR() ? ` · avg ★${mappoolAvgSR()}` : ''}
|
||||||
|
{/if}
|
||||||
|
{#if duration}
|
||||||
|
· {duration}
|
||||||
|
{/if}
|
||||||
{#if m.osuLobbyId}
|
{#if m.osuLobbyId}
|
||||||
· <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>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -165,6 +201,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="text-sm font-600">{p1.team.name}</p>
|
<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}
|
{#if p1.rollValue != null}
|
||||||
<p class="text-xs text-text-secondary">Roll: {p1.rollValue}{p1.pickOrder === 1 ? ' ★' : ''}</p>
|
<p class="text-xs text-text-secondary">Roll: {p1.rollValue}{p1.pickOrder === 1 ? ' ★' : ''}</p>
|
||||||
{/if}
|
{/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>
|
<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">
|
<div class="flex-1 text-right">
|
||||||
<p class="text-sm font-600">{p2.team.name}</p>
|
<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}
|
{#if p2.rollValue != null}
|
||||||
<p class="text-xs text-text-secondary">{p2.pickOrder === 1 ? '★ ' : ''}Roll: {p2.rollValue}</p>
|
<p class="text-xs text-text-secondary">{p2.pickOrder === 1 ? '★ ' : ''}Roll: {p2.rollValue}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user