diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 814ee7e..c257085 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,3 +1,4 @@ +// src/hooks.server.ts import type { Handle } from '@sveltejs/kit'; import { building } from '$app/environment'; import { auth } from '$lib/server/auth'; @@ -7,6 +8,15 @@ import { user as userTable } from '$lib/server/db/auth.schema'; import { eq } from 'drizzle-orm'; import { ensureRootAdminRole } from '$lib/server/permissions'; +// ── Initialize the IRC DM handler once on server startup ── +// This runs when the module is first imported (server boot). +// The dynamic import prevents build-time issues if bancho.js isn't available. +if (!building) { + import('$lib/server/bancho/dm-handler') + .then(({ initDMHandler }) => initDMHandler()) + .catch((err) => console.warn('[Hooks] DM handler init skipped:', err.message)); +} + const handleBetterAuth: Handle = async ({ event, resolve }) => { const session = await auth.api.getSession({ headers: event.request.headers }); diff --git a/src/lib/server/bancho/client.ts b/src/lib/server/bancho/client.ts index 36bb22c..b5b9605 100644 --- a/src/lib/server/bancho/client.ts +++ b/src/lib/server/bancho/client.ts @@ -11,7 +11,14 @@ let connecting: Promise | null = null; let reconnecting = false; let keepaliveTimer: ReturnType | null = null; -async function getClient() { +/** Global DM handler — set by dm-handler.ts on startup */ +let dmHandler: ((username: string, message: string) => void) | null = null; + +export function setDMHandler(handler: (username: string, message: string) => void) { + dmHandler = handler; +} + +export async function getClient() { if (client?.isConnected?.()) return client; if (connecting) { await connecting; @@ -43,6 +50,15 @@ async function getClient() { console.error('[Bancho] IRC error:', err?.message ?? err); }); + // ── DM listener: route private messages to dmHandler ── + client.on('PM', (msg: any) => { + const sender: string = msg.user?.ircUsername ?? ''; + const text: string = msg.message ?? String(msg); + if (sender && sender !== 'BanchoBot' && dmHandler) { + dmHandler(sender, text); + } + }); + // ── Reconnect: re-join all active lobby channels ── let initialConnect = true; client.on('connected', async () => { @@ -77,27 +93,28 @@ async function getClient() { return client; } -// ── Keepalive: prevent IRC timeout by pinging BanchoBot periodically ──── +/** + * Send a DM to a user via IRC. + */ +export async function sendDM(username: string, message: string) { + const c = await getClient(); + const user = c.getUser(username.replace(/ /g, '_')); + await user.sendMessage(message); +} + +// ── Keepalive: prevent IRC timeout ────────────────────────────────────── function startKeepalive() { stopKeepalive(); keepaliveTimer = setInterval(async () => { try { if (client?.isConnected?.()) { - // Send a silent PING via raw IRC to keep the connection alive. - // bancho.js exposes the underlying IRC socket for raw writes, - // but the simplest approach is just sending a stats request to BanchoBot. - // This generates minimal traffic but keeps TCP alive. - const banchoBot = client.getUser('BanchoBot'); - if (banchoBot) { - // WHOIS is lightweight and doesn't produce visible output in channels - client.send?.(`PING :keepalive-${Date.now()}`); - } + client.send?.(`PING :keepalive-${Date.now()}`); } } catch { - // Ignore keepalive errors — if connection is dead, the reconnect handler will fire + // If connection is dead, the reconnect handler will fire } - }, 30_000); // every 30 seconds + }, 30_000); } function stopKeepalive() { @@ -107,16 +124,10 @@ function stopKeepalive() { } } -/** - * Check if the IRC client is currently connected. - */ export function isBanchoConnected(): boolean { return client?.isConnected?.() ?? false; } -/** - * Check if we're in the middle of a reconnect. - */ export function isBanchoReconnecting(): boolean { return reconnecting; } @@ -152,6 +163,8 @@ export class TournamentLobby { // Event callbacks (set by orchestrator) public onRollResult: ((username: string, value: number) => void) | null = null; public onPickCommand: ((username: string, slotLabel: string) => void) | null = null; + /** Called when BanchoBot reports a player joined the lobby slot */ + public onPlayerJoined: ((username: string) => void) | null = null; /** * Create a new multiplayer lobby via !mp make. @@ -194,9 +207,6 @@ export class TournamentLobby { this.attachMessageHandler(); } - /** - * Re-join the channel after a reconnect. - */ async _rejoinChannel(c: any) { if (!this.channelName) return; @@ -238,6 +248,7 @@ export class TournamentLobby { // ── Message Parsing ───────────────────────────────────────────────── private parseBanchoMessage(text: string) { + // Player score: " finished playing (Score: 1,234,567, ... PASSED)" const scoreMatch = text.match( /^(.+?) finished playing \(Score: ([\d,]+).*?(PASSED|FAILED)\)/i ); @@ -251,6 +262,7 @@ export class TournamentLobby { return; } + // Game finished if (text.includes('The match has finished!')) { console.log('[Bancho] Game finished. Scores:', this.collectedScores); if (this.matchFinishedResolve) { @@ -261,6 +273,7 @@ export class TournamentLobby { return; } + // Roll result: "Stan rolls 42 point(s)" const rollMatch = text.match(/^(.+?) rolls (\d+) point\(s\)/); if (rollMatch) { const username = rollMatch[1].trim(); @@ -272,6 +285,18 @@ export class TournamentLobby { return; } + // Player joined: "Stan joined in slot 1." + const joinMatch = text.match(/^(.+?) joined in slot \d+/); + if (joinMatch) { + const username = joinMatch[1].trim(); + console.log(`[Bancho] Player joined: ${username}`); + if (this.onPlayerJoined) { + this.onPlayerJoined(username); + } + return; + } + + // All players ready if (text.includes('All players are ready')) { console.log('[Bancho] All players ready'); if (this.allReadyResolve) { @@ -281,6 +306,7 @@ export class TournamentLobby { return; } + // Room closed if (text.includes('Closed the match')) { console.log(`[Bancho] Room ${this.channelName} was closed`); this._dead = true; @@ -289,6 +315,7 @@ export class TournamentLobby { } private parsePlayerMessage(sender: string, text: string) { + // !pick NM1, !pick HD2, etc. const pickMatch = text.match(/^!pick\s+([A-Z]{2})(\d*)/i); if (pickMatch) { const category = pickMatch[1].toUpperCase(); diff --git a/src/lib/server/bancho/dm-handler.ts b/src/lib/server/bancho/dm-handler.ts new file mode 100644 index 0000000..8f385eb --- /dev/null +++ b/src/lib/server/bancho/dm-handler.ts @@ -0,0 +1,185 @@ +/** + * Persistent DM command handler. + * Players can DM the bot in osu! to interact with the system: + * !queue — Join the ranked queue + * !leave — Leave the queue + * !elo — Check your ELO rating + * !help — List available commands + * + * Initialized once on server startup via hooks.server.ts. + */ + +import { db } from '$lib/server/db'; +import { user, playerRating, teamMember, team } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { setDMHandler, sendDM, getClient } from './client'; +import { joinQueue, leaveQueue, getQueueStatus } from '../match/engine'; + +let initialized = false; + +/** + * Call once on server startup to register the DM listener. + * Safe to call multiple times — only initializes once. + */ +export async function initDMHandler() { + if (initialized) return; + initialized = true; + + // Ensure IRC connection is established (this also triggers getClient) + try { + await getClient(); + } catch (err: any) { + console.error('[DM] Failed to connect IRC for DM handler:', err.message); + return; + } + + setDMHandler(async (ircUsername: string, message: string) => { + const text = message.trim(); + if (!text.startsWith('!')) return; // Ignore non-commands + + const [cmd, ...args] = text.split(/\s+/); + const command = cmd.toLowerCase(); + + try { + switch (command) { + case '!help': + await handleHelp(ircUsername); + break; + case '!queue': + case '!q': + await handleQueue(ircUsername); + break; + case '!leave': + await handleLeave(ircUsername); + break; + case '!elo': + case '!rating': + case '!rank': + await handleElo(ircUsername); + break; + case '!status': + await handleStatus(ircUsername); + break; + default: + await sendDM(ircUsername, `Unknown command: ${command}. Type !help for available commands.`); + } + } catch (err: any) { + console.error(`[DM] Error handling "${text}" from ${ircUsername}:`, err.message); + await sendDM(ircUsername, `Error: ${err.message}`).catch(() => {}); + } + }); + + console.log('[DM] DM command handler initialized'); +} + +// ── Resolve IRC username → DB user ────────────────────────────────────── + +async function resolveUser(ircUsername: string) { + // IRC uses underscores for spaces: "Cool Player" → "Cool_Player" + const normalized = ircUsername.replace(/_/g, ' ').toLowerCase(); + + const users = await db.query.user.findMany(); + const match = users.find((u) => u.name?.toLowerCase() === normalized); + + if (!match) { + throw new Error( + 'Your osu! account is not linked. Please sign in at the web UI first.' + ); + } + + return match; +} + +// ── Command Handlers ──────────────────────────────────────────────────── + +async function handleHelp(ircUsername: string) { + await sendDM( + ircUsername, + 'VASH Bot Commands: ' + + '!queue — Join ranked queue | ' + + '!leave — Leave queue | ' + + '!elo — Check your rating | ' + + '!status — Queue status | ' + + '!help — This message' + ); +} + +async function handleQueue(ircUsername: string) { + const u = await resolveUser(ircUsername); + + // Check if already in queue + const status = await getQueueStatus(u.id); + if (status.inQueue) { + await sendDM(ircUsername, `You're already in queue (${status.queueSize} searching). Type !leave to exit.`); + return; + } + + // Find the user's solo/personal team + const membership = await db.query.teamMember.findFirst({ + where: eq(teamMember.userId, u.id), + with: { team: true } + }); + + if (!membership) { + await sendDM(ircUsername, 'You need a team to queue. Please create one in the web UI first.'); + return; + } + + const result = await joinQueue(u.id, membership.teamId); + + if (result) { + // Matched immediately + await sendDM( + ircUsername, + `Match found! You've been matched. Check osu! for the lobby invite or visit the web UI.` + ); + } else { + const updated = await getQueueStatus(u.id); + await sendDM( + ircUsername, + `You're in the queue! (${updated.queueSize} searching). You'll get an invite when matched. Type !leave to cancel.` + ); + } +} + +async function handleLeave(ircUsername: string) { + const u = await resolveUser(ircUsername); + + const status = await getQueueStatus(u.id); + if (!status.inQueue) { + await sendDM(ircUsername, "You're not in the queue."); + return; + } + + await leaveQueue(u.id); + await sendDM(ircUsername, 'Left the queue.'); +} + +async function handleElo(ircUsername: string) { + const u = await resolveUser(ircUsername); + + const rating = await db.query.playerRating.findFirst({ + where: eq(playerRating.userId, u.id) + }); + + if (!rating) { + await sendDM(ircUsername, 'No rating yet. Play a ranked match to get placed!'); + return; + } + + const record = `${rating.wins}W-${rating.losses}L`; + await sendDM(ircUsername, `${u.name}: ${rating.elo} ELO (${record})`); +} + +async function handleStatus(ircUsername: string) { + const u = await resolveUser(ircUsername); + const status = await getQueueStatus(u.id); + + if (status.inQueue) { + await sendDM(ircUsername, `In queue (${status.queueSize} searching). Type !leave to cancel.`); + } else if (status.matchedMatchId) { + await sendDM(ircUsername, `You have an active match! Check osu! for the lobby invite.`); + } else { + await sendDM(ircUsername, `Not in queue. Type !queue to find a match.`); + } +} diff --git a/src/lib/server/match/orchestrator.ts b/src/lib/server/match/orchestrator.ts index 871147a..10854d1 100644 --- a/src/lib/server/match/orchestrator.ts +++ b/src/lib/server/match/orchestrator.ts @@ -79,25 +79,65 @@ export async function initMatchLobby(matchId: string) { } } - // Wire up IRC chat handlers for rolls and picks + // Wire up IRC chat handlers for rolls, picks, and player joins setupChatHandlers(matchId, lobby); - // ── Send welcome AFTER invites with a delay so players have time to join ── - await sleep(5000); - await lobby.chat( - `Welcome! BO${config.bestOf}. Type !roll to decide pick order, or use the web UI.` - ); - - // LOBBY → ROLLING + // LOBBY → ROLLING (we'll greet players individually when they join) await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId)); return { osuMatchId: osuId, channel }; } /** - * Wire up IRC event callbacks so players can !roll and !pick from osu! chat. + * Wire up IRC event callbacks for the full match lifecycle. + * + * Design principle: players may join late and have NO chat history. + * Every phase transition message should give enough context to know what to do. + * We also greet each player individually when they join, telling them the current state. */ 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 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 + 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 lobby.chat( + `Welcome ${username}! This is a 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 nextIdx = m.games.length % sorted.length; + const picker = sorted[nextIdx]; + const score = m.participants.map((p) => `${p.team.name}: ${p.score}`).join(' | '); + await sleep(1000); + await lobby.chat( + `Welcome back ${username}! Score: ${score}. ` + + `${picker?.team.name}'s turn to pick. Use !pick (e.g. !pick NM1).` + ); + } else if (m.state === MATCH_STATES.PLAYING) { + 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); + } + }; + // ── Handle !roll from IRC ─────────────────────────────────────────── lobby.onRollResult = async (username: string, value: number) => { try { @@ -107,7 +147,7 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) { return; } - let targetParticipant: { id: string; rollValue: number | null } | null = null; + let targetParticipant: { id: string; rollValue: number | null; team: any } | null = null; for (const p of m.participants) { for (const pl of p.players) { @@ -133,13 +173,24 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) { await submitRoll(matchId, targetParticipant.id, value); console.log(`[Orchestrator] IRC roll: ${username} = ${value}`); + // Check if all rolled → announce pick order const updated = await getMatchFull(matchId); + const remaining = updated.participants.filter((p) => p.rollValue === null); + if (updated.state === MATCH_STATES.PICKING) { const sorted = [...updated.participants].sort( (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) ); + await sleep(500); await lobby.chat( - `Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick (e.g. !pick NM1) or pick in web UI.` + `Rolls complete! ${sorted[0]?.team.name} picks first. ` + + `Use !pick (e.g. !pick NM1).` + ); + } else if (remaining.length > 0) { + // Tell the other player they still need to roll + await lobby.chat( + `${targetParticipant.team?.name ?? username} rolled ${value}. ` + + `Waiting for ${remaining.map((p) => p.team.name).join(', ')} to !roll.` ); } } catch (err: any) { @@ -187,7 +238,7 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) { } if (!isPickerTurn) { - await lobby.chat(`${username}: It's not your turn to pick.`); + await lobby.chat(`${username}: It's ${expectedPicker?.team.name}'s turn to pick.`); return; } @@ -253,17 +304,24 @@ export async function playPickedMap(matchId: string, matchGameId: string) { if (!game) throw new Error('Game not found'); 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 await lobby.setMap(slot.beatmapId); await sleep(1000); + // Set mods await lobby.setMods(slot.mods); await sleep(500); + // Announce with context: what map, current score, what to do await lobby.chat( - `Playing ${slot.category}${slot.orderInCategory}. Ready up! (Game starts when all players are ready)` + `[${score}] Now playing ${slot.category}${slot.orderInCategory}. Please ready up!` ); + // Wait for all players to ready up in osu!, then start try { await lobby.waitForReady(); await lobby.chat('All ready — starting in 5s!'); @@ -271,11 +329,12 @@ export async function playPickedMap(matchId: string, matchGameId: string) { } catch (err: any) { console.warn('[Orchestrator] Ready timeout:', err.message); try { - await lobby.chat('Ready timed out. Use the web UI to force start, or ready up!'); + 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 */ } return; } + // Non-blocking: wait for scores then process collectScores(matchId, matchGameId, lobby).catch((err) => console.error('[Orchestrator] Score collection failed:', err) ); @@ -294,11 +353,13 @@ export async function forceStartGame(matchId: string) { /** * Background task: waits for IRC scores, writes them to DB, advances match state. + * Announces results clearly in chat. */ async function collectScores(matchId: string, matchGameId: string, lobby: TournamentLobby) { const ircScores = await lobby.waitForScores(); console.log(`[Orchestrator] Scores for game ${matchGameId}:`, ircScores); + // Map IRC usernames → matchParticipantPlayer IDs const m = await getMatchFull(matchId); const scores: { playerId: string; score: number; passed: boolean }[] = []; @@ -322,24 +383,53 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna await submitGameScores(matchGameId, scores); + // ── Announce results clearly ── const updated = await getMatchFull(matchId); + 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!" + const game = updated.games.find((g) => g.id === matchGameId); + if (game && game.winnerParticipantId) { + const winner = updated.participants.find((p) => p.id === game.winnerParticipantId); + + // Individual player scores + const scoreLines = ircScores + .map((s) => `${s.username}: ${s.score.toLocaleString()}`) + .join(' vs '); + + await sleep(500); + try { + await lobby.chat(`${scoreLines} — ${winner?.team.name ?? '?'} wins the point!`); + } catch { /* ignore */ } + } + + // Match score line + const matchScore = updated.participants + .map((p) => `${p.team.name} ${p.score}`) + .join(' - '); + if (updated.state === MATCH_STATES.FINISHED) { const winner = updated.participants.find((p) => p.teamId === updated.winnerId); try { - await lobby.chat(`GG! ${winner?.team.name ?? '?'} wins the match!`); + await sleep(1000); + await lobby.chat(`GG! ${winner?.team.name ?? '?'} wins the match! Final: ${matchScore}`); await sleep(5000); await lobby.close(); } catch { /* lobby may already be closed */ } removeLobby(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]; try { + await sleep(1000); await lobby.chat( - `${nextPicker?.team.name}'s turn to pick. Use !pick (e.g. !pick HD1) or pick in web UI.` + `Score: ${matchScore} (first to ${winsNeeded}). ` + + `${nextPicker?.team.name}'s turn to pick. Use !pick .` ); } catch { /* lobby may be dead */ } } diff --git a/src/routes/(app)/matches/[id]/+page.server.ts b/src/routes/(app)/matches/[id]/+page.server.ts index 77ef0d8..86dc0cd 100644 --- a/src/routes/(app)/matches/[id]/+page.server.ts +++ b/src/routes/(app)/matches/[id]/+page.server.ts @@ -131,12 +131,11 @@ export const actions: Actions = { if (!expectedPicker) return { error: 'Cannot determine picker' }; // ── Verify the logged-in user is on the expected picker's team ── + // No staff bypass here — even admins can't pick for another team const isOnPickerTeam = expectedPicker.players.some( (pl) => pl.userId === locals.user!.id ); - // Allow staff to pick on behalf of anyone - const isStaff = hasRole(locals.user!.role, 'referee'); - if (!isOnPickerTeam && !isStaff) { + if (!isOnPickerTeam) { return { error: "It's not your turn to pick" }; } diff --git a/src/routes/(app)/matches/[id]/+page.svelte b/src/routes/(app)/matches/[id]/+page.svelte index 56c7217..3a55311 100644 --- a/src/routes/(app)/matches/[id]/+page.svelte +++ b/src/routes/(app)/matches/[id]/+page.svelte @@ -20,7 +20,7 @@ const expectedPickerIdx = $derived(m.games.length % sortedByPick.length); const expectedPicker = $derived(sortedByPick[expectedPickerIdx]); - // Is the logged-in user on the expected picker's team? + // Is the logged-in user on the expected picker's team? No staff bypass. const isMyTurnToPick = $derived( expectedPicker?.players?.some((pl: any) => pl.userId === data.userId) ?? false ); @@ -34,6 +34,14 @@ m.state === 'ROLLING' && m.participants.some((p: any) => p.rollValue === null) ); + // Should the mappool be visible? + const showMappool = $derived( + ['PICKING', 'PLAYING', 'ROLLING'].includes(m.state) + ); + + // Can picks actually be made right now? + const pickingPhase = $derived(m.state === 'PICKING'); + const catColors: Record = { NM: 'bg-blue-500/20 text-blue-400 border-blue-500/30', HD: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', @@ -61,6 +69,8 @@ let picking = $state(false); let pickError = $state(''); let forceStarting = $state(false); + let reinviting = $state(false); + let mappoolCollapsed = $state(false); // Poll while match is active (every 3s) let pollInterval: ReturnType | null = null; @@ -89,6 +99,22 @@ } + +{#snippet reinviteButton()} +
{ + reinviting = true; + return async ({ update }) => { reinviting = false; await update(); }; + }}> + +
+{/snippet} +
@@ -114,7 +140,6 @@ {#if p1 && p2}
-
{#if p1.team.avatarUrl} @@ -130,7 +155,6 @@ vs -
{p2.score}
@@ -146,27 +170,17 @@
{/if} - + {#if ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'].includes(m.state)} -
-
{ - return async ({ result, update }) => { - if (result.type === 'success' && (result.data as any)?.reinvited) { - await update(); - } - }; - }}> - -
+
+ {@render reinviteButton()} + {#if m.osuLobbyId} + Open lobby → + {/if}
{/if} - + {#if m.state === 'ROLLING'}
@@ -199,92 +213,12 @@ {:else}

Waiting for all rolls...

{/if} -
-
- {/if} - - {#if m.state === 'PICKING'} -
-
-
-

🎯 Pick a Map

-

- {expectedPicker?.team.name}'s turn to pick - {#if !isMyTurnToPick && !data.isStaff} - (waiting...) - {/if} -

-

- or type !pick NM1 in osu! chat -

-
- - {#if pickError} -

{pickError}

- {/if} - -
- {#each Object.entries(groupedSlots()) as [category, slots]} -
- - {category} - -
- {#each slots as slot, i} - {@const bm = data.beatmapCache[slot.beatmapId]} - {@const isPlayed = playedSlotIds.has(slot.id)} - {@const isTB = slot.category === 'TB'} - {@const tbLocked = isTB && !isTiebreakerAllowed} - {@const cantPick = !isMyTurnToPick && !data.isStaff} - -
{ - picking = true; - pickError = ''; - return async ({ result, update }) => { - picking = false; - if (result.type === 'failure' || (result.type === 'success' && (result.data as any)?.error)) { - pickError = (result.data as any)?.error ?? 'Pick failed'; - } else { - await update(); - } - }; - }}> - - -
- {/each} -
-
- {/each} +
+ {@render reinviteButton()} + {#if m.osuLobbyId} + Open lobby in browser → + {/if}
@@ -310,20 +244,129 @@ Waiting for players to ready up & play in osu!...

- {#if data.isStaff} -
{ - forceStarting = true; - return async ({ update }) => { forceStarting = false; await update(); }; - }}> - -
+
+
{ + forceStarting = true; + return async ({ update }) => { forceStarting = false; await update(); }; + }}> + +
+
+ {/if} +
+ {/if} + + + {#if showMappool && Object.keys(groupedSlots()).length > 0} +
+
+
+

Mappool

+ {#if pickingPhase} + + {isMyTurnToPick ? 'Your pick!' : `${expectedPicker?.team.name}'s pick`} + + {:else if m.state === 'PLAYING'} + In progress + {:else if m.state === 'ROLLING'} + Rolling... + {/if} +
+ +
+ + {#if pickingPhase && !isMyTurnToPick} +

+ Waiting for {expectedPicker?.team.name} to pick... + You can also type !pick NM1 in osu! chat when it's your turn. +

+ {:else if pickingPhase && isMyTurnToPick} +

+ Pick a map below, or type !pick NM1 in osu! chat. +

+ {/if} + + {#if pickError} +

{pickError}

+ {/if} + + {#if !mappoolCollapsed} +
+ {#each Object.entries(groupedSlots()) as [category, slots]} +
+ + {category} + +
+ {#each slots as slot} + {@const bm = data.beatmapCache[slot.beatmapId]} + {@const isPlayed = playedSlotIds.has(slot.id)} + {@const isTB = slot.category === 'TB'} + {@const tbLocked = isTB && !isTiebreakerAllowed} + {@const cantPick = !isMyTurnToPick || !pickingPhase} + +
{ + picking = true; + pickError = ''; + return async ({ result, update }) => { + picking = false; + if (result.type === 'failure' || (result.type === 'success' && (result.data as any)?.error)) { + pickError = (result.data as any)?.error ?? 'Pick failed'; + } else { + await update(); + } + }; + }}> + + +
+ {/each} +
+
+ {/each} +
{/if}
{/if} @@ -341,7 +384,6 @@ {p1?.score} – {p2?.score}

-
{#each m.games as game} {@const bm = data.beatmapCache[game.slot?.beatmapId]} @@ -422,6 +464,9 @@ osu! mp/{m.osuLobbyId}

{/if} +
+ {@render reinviteButton()} +
{/if}