feat: implement DM command handler for player interactions and queue management

This commit is contained in:
2026-02-28 23:23:26 +00:00
parent 593797c751
commit 81a51dca8c
6 changed files with 516 additions and 160 deletions

View File

@@ -1,3 +1,4 @@
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { building } from '$app/environment'; import { building } from '$app/environment';
import { auth } from '$lib/server/auth'; 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 { eq } from 'drizzle-orm';
import { ensureRootAdminRole } from '$lib/server/permissions'; 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 handleBetterAuth: Handle = async ({ event, resolve }) => {
const session = await auth.api.getSession({ headers: event.request.headers }); const session = await auth.api.getSession({ headers: event.request.headers });

View File

@@ -11,7 +11,14 @@ let connecting: Promise<void> | null = null;
let reconnecting = false; let reconnecting = false;
let keepaliveTimer: ReturnType<typeof setInterval> | null = null; let keepaliveTimer: ReturnType<typeof setInterval> | 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 (client?.isConnected?.()) return client;
if (connecting) { if (connecting) {
await connecting; await connecting;
@@ -43,6 +50,15 @@ async function getClient() {
console.error('[Bancho] IRC error:', err?.message ?? err); 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 ── // ── Reconnect: re-join all active lobby channels ──
let initialConnect = true; let initialConnect = true;
client.on('connected', async () => { client.on('connected', async () => {
@@ -77,27 +93,28 @@ async function getClient() {
return client; 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() { function startKeepalive() {
stopKeepalive(); stopKeepalive();
keepaliveTimer = setInterval(async () => { keepaliveTimer = setInterval(async () => {
try { try {
if (client?.isConnected?.()) { 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 { } 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() { function stopKeepalive() {
@@ -107,16 +124,10 @@ function stopKeepalive() {
} }
} }
/**
* Check if the IRC client is currently connected.
*/
export function isBanchoConnected(): boolean { export function isBanchoConnected(): boolean {
return client?.isConnected?.() ?? false; return client?.isConnected?.() ?? false;
} }
/**
* Check if we're in the middle of a reconnect.
*/
export function isBanchoReconnecting(): boolean { export function isBanchoReconnecting(): boolean {
return reconnecting; return reconnecting;
} }
@@ -152,6 +163,8 @@ export class TournamentLobby {
// Event callbacks (set by orchestrator) // Event callbacks (set by orchestrator)
public onRollResult: ((username: string, value: number) => void) | null = null; public onRollResult: ((username: string, value: number) => void) | null = null;
public onPickCommand: ((username: string, slotLabel: string) => 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. * Create a new multiplayer lobby via !mp make.
@@ -194,9 +207,6 @@ export class TournamentLobby {
this.attachMessageHandler(); this.attachMessageHandler();
} }
/**
* Re-join the channel after a reconnect.
*/
async _rejoinChannel(c: any) { async _rejoinChannel(c: any) {
if (!this.channelName) return; if (!this.channelName) return;
@@ -238,6 +248,7 @@ export class TournamentLobby {
// ── Message Parsing ───────────────────────────────────────────────── // ── Message Parsing ─────────────────────────────────────────────────
private parseBanchoMessage(text: string) { private parseBanchoMessage(text: string) {
// Player score: "<user> finished playing (Score: 1,234,567, ... PASSED)"
const scoreMatch = text.match( const scoreMatch = text.match(
/^(.+?) finished playing \(Score: ([\d,]+).*?(PASSED|FAILED)\)/i /^(.+?) finished playing \(Score: ([\d,]+).*?(PASSED|FAILED)\)/i
); );
@@ -251,6 +262,7 @@ export class TournamentLobby {
return; return;
} }
// Game finished
if (text.includes('The match has finished!')) { if (text.includes('The match has finished!')) {
console.log('[Bancho] Game finished. Scores:', this.collectedScores); console.log('[Bancho] Game finished. Scores:', this.collectedScores);
if (this.matchFinishedResolve) { if (this.matchFinishedResolve) {
@@ -261,6 +273,7 @@ export class TournamentLobby {
return; return;
} }
// Roll result: "Stan rolls 42 point(s)"
const rollMatch = text.match(/^(.+?) rolls (\d+) point\(s\)/); const rollMatch = text.match(/^(.+?) rolls (\d+) point\(s\)/);
if (rollMatch) { if (rollMatch) {
const username = rollMatch[1].trim(); const username = rollMatch[1].trim();
@@ -272,6 +285,18 @@ export class TournamentLobby {
return; 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')) { if (text.includes('All players are ready')) {
console.log('[Bancho] All players ready'); console.log('[Bancho] All players ready');
if (this.allReadyResolve) { if (this.allReadyResolve) {
@@ -281,6 +306,7 @@ export class TournamentLobby {
return; return;
} }
// Room closed
if (text.includes('Closed the match')) { if (text.includes('Closed the match')) {
console.log(`[Bancho] Room ${this.channelName} was closed`); console.log(`[Bancho] Room ${this.channelName} was closed`);
this._dead = true; this._dead = true;
@@ -289,6 +315,7 @@ export class TournamentLobby {
} }
private parsePlayerMessage(sender: string, text: string) { private parsePlayerMessage(sender: string, text: string) {
// !pick NM1, !pick HD2, etc.
const pickMatch = text.match(/^!pick\s+([A-Z]{2})(\d*)/i); const pickMatch = text.match(/^!pick\s+([A-Z]{2})(\d*)/i);
if (pickMatch) { if (pickMatch) {
const category = pickMatch[1].toUpperCase(); const category = pickMatch[1].toUpperCase();

View File

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

View File

@@ -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); setupChatHandlers(matchId, lobby);
// ── Send welcome AFTER invites with a delay so players have time to join ── // LOBBY → ROLLING (we'll greet players individually when they join)
await sleep(5000);
await lobby.chat(
`Welcome! BO${config.bestOf}. Type !roll to decide pick order, or use the web UI.`
);
// LOBBY → ROLLING
await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId)); await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId));
return { osuMatchId: osuId, channel }; 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) { 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 <slot> (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 ─────────────────────────────────────────── // ── Handle !roll from IRC ───────────────────────────────────────────
lobby.onRollResult = async (username: string, value: number) => { lobby.onRollResult = async (username: string, value: number) => {
try { try {
@@ -107,7 +147,7 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
return; 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 p of m.participants) {
for (const pl of p.players) { for (const pl of p.players) {
@@ -133,13 +173,24 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
await submitRoll(matchId, targetParticipant.id, value); await submitRoll(matchId, targetParticipant.id, value);
console.log(`[Orchestrator] IRC roll: ${username} = ${value}`); console.log(`[Orchestrator] IRC roll: ${username} = ${value}`);
// Check if all rolled → announce pick order
const updated = await getMatchFull(matchId); const updated = await getMatchFull(matchId);
const remaining = updated.participants.filter((p) => p.rollValue === null);
if (updated.state === MATCH_STATES.PICKING) { if (updated.state === MATCH_STATES.PICKING) {
const sorted = [...updated.participants].sort( const sorted = [...updated.participants].sort(
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
); );
await sleep(500);
await lobby.chat( await lobby.chat(
`Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick <slot> (e.g. !pick NM1) or pick in web UI.` `Rolls complete! ${sorted[0]?.team.name} picks first. ` +
`Use !pick <slot> (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) { } catch (err: any) {
@@ -187,7 +238,7 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
} }
if (!isPickerTurn) { 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; return;
} }
@@ -253,17 +304,24 @@ export async function playPickedMap(matchId: string, matchGameId: string) {
if (!game) throw new Error('Game not found'); if (!game) throw new Error('Game not found');
const slot = game.slot; 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 lobby.setMap(slot.beatmapId);
await sleep(1000); await sleep(1000);
// Set mods
await lobby.setMods(slot.mods); await lobby.setMods(slot.mods);
await sleep(500); await sleep(500);
// Announce with context: what map, current score, what to do
await lobby.chat( 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 { try {
await lobby.waitForReady(); await lobby.waitForReady();
await lobby.chat('All ready — starting in 5s!'); await lobby.chat('All ready — starting in 5s!');
@@ -271,11 +329,12 @@ export async function playPickedMap(matchId: string, matchGameId: string) {
} catch (err: any) { } catch (err: any) {
console.warn('[Orchestrator] Ready timeout:', err.message); console.warn('[Orchestrator] Ready timeout:', err.message);
try { 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 */ } } catch { /* lobby may be dead */ }
return; return;
} }
// Non-blocking: wait for scores then process
collectScores(matchId, matchGameId, lobby).catch((err) => collectScores(matchId, matchGameId, lobby).catch((err) =>
console.error('[Orchestrator] Score collection failed:', 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. * 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) { async function collectScores(matchId: string, matchGameId: string, lobby: TournamentLobby) {
const ircScores = await lobby.waitForScores(); const ircScores = await lobby.waitForScores();
console.log(`[Orchestrator] Scores for game ${matchGameId}:`, ircScores); console.log(`[Orchestrator] Scores for game ${matchGameId}:`, ircScores);
// Map IRC usernames → matchParticipantPlayer IDs
const m = await getMatchFull(matchId); const m = await getMatchFull(matchId);
const scores: { playerId: string; score: number; passed: boolean }[] = []; 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); await submitGameScores(matchGameId, scores);
// ── Announce results clearly ──
const updated = await getMatchFull(matchId); 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) { 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 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 sleep(5000);
await lobby.close(); await lobby.close();
} catch { /* lobby may already be closed */ } } catch { /* lobby may already be closed */ }
removeLobby(matchId); removeLobby(matchId);
} else { } else {
// Announce next picker with current score
const sorted = [...updated.participants].sort( const sorted = [...updated.participants].sort(
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
); );
const nextIdx = updated.games.length % sorted.length; const nextIdx = updated.games.length % sorted.length;
const nextPicker = sorted[nextIdx]; const nextPicker = sorted[nextIdx];
try { try {
await sleep(1000);
await lobby.chat( await lobby.chat(
`${nextPicker?.team.name}'s turn to pick. Use !pick <slot> (e.g. !pick HD1) or pick in web UI.` `Score: ${matchScore} (first to ${winsNeeded}). ` +
`${nextPicker?.team.name}'s turn to pick. Use !pick <slot>.`
); );
} catch { /* lobby may be dead */ } } catch { /* lobby may be dead */ }
} }

View File

@@ -131,12 +131,11 @@ export const actions: Actions = {
if (!expectedPicker) return { error: 'Cannot determine picker' }; if (!expectedPicker) return { error: 'Cannot determine picker' };
// ── Verify the logged-in user is on the expected picker's team ── // ── 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( const isOnPickerTeam = expectedPicker.players.some(
(pl) => pl.userId === locals.user!.id (pl) => pl.userId === locals.user!.id
); );
// Allow staff to pick on behalf of anyone if (!isOnPickerTeam) {
const isStaff = hasRole(locals.user!.role, 'referee');
if (!isOnPickerTeam && !isStaff) {
return { error: "It's not your turn to pick" }; return { error: "It's not your turn to pick" };
} }

View File

@@ -20,7 +20,7 @@
const expectedPickerIdx = $derived(m.games.length % sortedByPick.length); const expectedPickerIdx = $derived(m.games.length % sortedByPick.length);
const expectedPicker = $derived(sortedByPick[expectedPickerIdx]); 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( const isMyTurnToPick = $derived(
expectedPicker?.players?.some((pl: any) => pl.userId === data.userId) ?? false 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) 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<string, string> = { const catColors: Record<string, string> = {
NM: 'bg-blue-500/20 text-blue-400 border-blue-500/30', NM: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
HD: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', HD: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
@@ -61,6 +69,8 @@
let picking = $state(false); let picking = $state(false);
let pickError = $state(''); let pickError = $state('');
let forceStarting = $state(false); let forceStarting = $state(false);
let reinviting = $state(false);
let mappoolCollapsed = $state(false);
// Poll while match is active (every 3s) // Poll while match is active (every 3s)
let pollInterval: ReturnType<typeof setInterval> | null = null; let pollInterval: ReturnType<typeof setInterval> | null = null;
@@ -89,6 +99,22 @@
} }
</script> </script>
<!-- Shared reinvite snippet -->
{#snippet reinviteButton()}
<form method="post" action="?/reinvite" use:enhance={() => {
reinviting = true;
return async ({ update }) => { reinviting = false; await update(); };
}}>
<button
type="submit"
disabled={reinviting}
class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:opacity-50"
>
{reinviting ? 'Sending...' : '📨 Re-invite me to lobby'}
</button>
</form>
{/snippet}
<div class="mx-auto max-w-5xl"> <div class="mx-auto max-w-5xl">
<!-- Header --> <!-- Header -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -114,7 +140,6 @@
<!-- Scoreboard --> <!-- Scoreboard -->
{#if p1 && p2} {#if p1 && p2}
<div class="mt-6 flex items-center gap-4"> <div class="mt-6 flex items-center gap-4">
<!-- Team 1 -->
<div class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p1.teamId ? 'border-green-500/40 bg-green-500/5' : 'border-border bg-surface-800'}"> <div class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p1.teamId ? 'border-green-500/40 bg-green-500/5' : 'border-border bg-surface-800'}">
{#if p1.team.avatarUrl} {#if p1.team.avatarUrl}
<img src={p1.team.avatarUrl} alt="" class="h-10 w-10 rounded-full" /> <img src={p1.team.avatarUrl} alt="" class="h-10 w-10 rounded-full" />
@@ -130,7 +155,6 @@
<span class="text-lg font-700 text-text-secondary">vs</span> <span class="text-lg font-700 text-text-secondary">vs</span>
<!-- Team 2 -->
<div class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p2.teamId ? 'border-green-500/40 bg-green-500/5' : 'border-border bg-surface-800'}"> <div class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p2.teamId ? 'border-green-500/40 bg-green-500/5' : 'border-border bg-surface-800'}">
<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">
@@ -146,27 +170,17 @@
</div> </div>
{/if} {/if}
<!-- Re-invite (self only) — visible during LOBBY, ROLLING, PICKING, PLAYING --> <!-- Action bar: reinvite + lobby link — always visible during active match -->
{#if ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'].includes(m.state)} {#if ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'].includes(m.state)}
<div class="mt-3 flex justify-center"> <div class="mt-3 flex items-center justify-center gap-3">
<form method="post" action="?/reinvite" use:enhance={() => { {@render reinviteButton()}
return async ({ result, update }) => { {#if m.osuLobbyId}
if (result.type === 'success' && (result.data as any)?.reinvited) { <a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-xs text-text-secondary hover:text-accent">Open lobby →</a>
await update(); {/if}
}
};
}}>
<button
type="submit"
class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-accent"
>
📨 Re-invite me to lobby
</button>
</form>
</div> </div>
{/if} {/if}
<!-- ROLLING MODAL --> <!-- ROLLING MODAL (stays as overlay — it's a brief phase) -->
{#if m.state === 'ROLLING'} {#if m.state === 'ROLLING'}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"> <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="w-full max-w-md rounded-xl border border-yellow-500/30 bg-surface-800 p-8 text-center shadow-2xl"> <div class="w-full max-w-md rounded-xl border border-yellow-500/30 bg-surface-800 p-8 text-center shadow-2xl">
@@ -199,92 +213,12 @@
{:else} {:else}
<p class="mt-6 text-sm text-text-secondary animate-pulse">Waiting for all rolls...</p> <p class="mt-6 text-sm text-text-secondary animate-pulse">Waiting for all rolls...</p>
{/if} {/if}
</div>
</div>
{/if}
<!-- PICK MODAL --> <div class="mt-6 flex flex-col items-center gap-2 border-t border-border pt-4">
{#if m.state === 'PICKING'} {@render reinviteButton()}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"> {#if m.osuLobbyId}
<div class="w-full max-w-3xl max-h-[80vh] overflow-y-auto rounded-xl border border-blue-500/30 bg-surface-800 p-6 shadow-2xl"> <a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-xs text-text-secondary hover:text-accent">Open lobby in browser →</a>
<div class="text-center">
<h2 class="text-lg font-700">🎯 Pick a Map</h2>
<p class="mt-1 text-sm">
<span class="font-600 text-accent">{expectedPicker?.team.name}</span>'s turn to pick
{#if !isMyTurnToPick && !data.isStaff}
<span class="text-text-secondary">(waiting...)</span>
{/if} {/if}
</p>
<p class="mt-1 text-xs text-text-secondary">
or type <code class="rounded bg-surface-700 px-1.5 py-0.5 font-mono text-accent">!pick NM1</code> in osu! chat
</p>
</div>
{#if pickError}
<p class="mt-3 text-center text-sm text-red-400">{pickError}</p>
{/if}
<div class="mt-6 flex flex-col gap-4">
{#each Object.entries(groupedSlots()) as [category, slots]}
<div>
<span class="mb-2 inline-block rounded border px-2 py-0.5 text-xs font-600 {catColors[category] ?? 'border-border'}">
{category}
</span>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
{#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}
<form method="post" action="?/pick" use:enhance={() => {
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();
}
};
}}>
<input type="hidden" name="slotId" value={slot.id} />
<button
type="submit"
disabled={isPlayed || picking || tbLocked || cantPick}
class="flex w-full items-center gap-3 rounded-lg border p-2 text-left transition-all {isPlayed || tbLocked
? 'border-border/50 bg-surface-900 opacity-30 cursor-not-allowed'
: cantPick
? 'border-border bg-surface-700 opacity-60 cursor-not-allowed'
: 'border-border bg-surface-700 hover:border-accent hover:bg-surface-600 cursor-pointer'}"
>
{#if bm?.listCoverUrl}
<img src={bm.listCoverUrl} alt="" class="h-10 w-20 rounded object-cover" />
{:else}
<div class="flex h-10 w-20 items-center justify-center rounded bg-surface-600 text-xs text-text-secondary">?</div>
{/if}
<div class="min-w-0 flex-1">
{#if bm}
<p class="truncate text-xs font-500">{bm.artist} - {bm.title}</p>
<p class="text-xs text-text-secondary">[{bm.version}] &middot; ★{bm.starRating.toFixed(1)} &middot; {bm.bpm}bpm</p>
{:else}
<p class="text-xs text-text-secondary">#{slot.beatmapId}</p>
{/if}
</div>
<span class="text-xs font-600 text-text-secondary">{category}{slot.orderInCategory}</span>
{#if isPlayed}
<span class="text-xs text-text-secondary">✓</span>
{:else if tbLocked}
<span class="text-[10px] text-pink-400/60">🔒</span>
{/if}
</button>
</form>
{/each}
</div>
</div>
{/each}
</div> </div>
</div> </div>
</div> </div>
@@ -310,8 +244,8 @@
Waiting for players to ready up &amp; play in osu!... Waiting for players to ready up &amp; play in osu!...
</p> </p>
<!-- Force Start button — staff only -->
{#if data.isStaff} {#if data.isStaff}
<div class="mt-3">
<form method="post" action="?/forceStart" use:enhance={() => { <form method="post" action="?/forceStart" use:enhance={() => {
forceStarting = true; forceStarting = true;
return async ({ update }) => { forceStarting = false; await update(); }; return async ({ update }) => { forceStarting = false; await update(); };
@@ -319,11 +253,120 @@
<button <button
type="submit" type="submit"
disabled={forceStarting} disabled={forceStarting}
class="mt-4 rounded-md border border-yellow-500/30 px-4 py-2 text-xs font-600 text-yellow-400 transition-colors hover:bg-yellow-500/10 disabled:opacity-50" class="rounded-md border border-yellow-500/30 px-4 py-1.5 text-xs font-600 text-yellow-400 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
> >
{forceStarting ? 'Starting...' : 'Force Start (skip ready)'} {forceStarting ? 'Starting...' : 'Force Start (skip ready)'}
</button> </button>
</form> </form>
</div>
{/if}
</div>
{/if}
<!-- INLINE MAPPOOL — visible during PICKING and PLAYING (not a blocking modal) -->
{#if showMappool && Object.keys(groupedSlots()).length > 0}
<div class="mt-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h2 class="text-sm font-600">Mappool</h2>
{#if pickingPhase}
<span class="rounded bg-blue-500/20 px-2 py-0.5 text-[10px] font-600 text-blue-400">
{isMyTurnToPick ? 'Your pick!' : `${expectedPicker?.team.name}'s pick`}
</span>
{:else if m.state === 'PLAYING'}
<span class="rounded bg-green-500/20 px-2 py-0.5 text-[10px] font-600 text-green-400">In progress</span>
{:else if m.state === 'ROLLING'}
<span class="rounded bg-yellow-500/20 px-2 py-0.5 text-[10px] font-600 text-yellow-400">Rolling...</span>
{/if}
</div>
<button
onclick={() => mappoolCollapsed = !mappoolCollapsed}
class="text-xs text-text-secondary hover:text-text-primary"
>
{mappoolCollapsed ? 'Show ▼' : 'Hide ▲'}
</button>
</div>
{#if pickingPhase && !isMyTurnToPick}
<p class="mt-1 text-xs text-text-secondary">
Waiting for <span class="text-accent">{expectedPicker?.team.name}</span> to pick...
You can also type <code class="rounded bg-surface-700 px-1 py-0.5 font-mono text-[10px] text-accent">!pick NM1</code> in osu! chat when it's your turn.
</p>
{:else if pickingPhase && isMyTurnToPick}
<p class="mt-1 text-xs text-text-secondary">
Pick a map below, or type <code class="rounded bg-surface-700 px-1 py-0.5 font-mono text-[10px] text-accent">!pick NM1</code> in osu! chat.
</p>
{/if}
{#if pickError}
<p class="mt-2 text-center text-sm text-red-400">{pickError}</p>
{/if}
{#if !mappoolCollapsed}
<div class="mt-3 flex flex-col gap-3">
{#each Object.entries(groupedSlots()) as [category, slots]}
<div>
<span class="mb-1.5 inline-block rounded border px-2 py-0.5 text-xs font-600 {catColors[category] ?? 'border-border'}">
{category}
</span>
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{#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}
<form method="post" action="?/pick" use:enhance={() => {
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();
}
};
}}>
<input type="hidden" name="slotId" value={slot.id} />
<button
type="submit"
disabled={isPlayed || picking || tbLocked || cantPick}
class="flex w-full items-center gap-3 rounded-lg border p-2 text-left transition-all {isPlayed
? 'border-border/50 bg-surface-900 opacity-30 cursor-not-allowed'
: tbLocked
? 'border-border/50 bg-surface-900 opacity-30 cursor-not-allowed'
: cantPick
? 'border-border bg-surface-800 opacity-60 cursor-default'
: 'border-border bg-surface-700 hover:border-accent hover:bg-surface-600 cursor-pointer'}"
>
{#if bm?.listCoverUrl}
<img src={bm.listCoverUrl} alt="" class="h-10 w-20 rounded object-cover" />
{:else}
<div class="flex h-10 w-20 items-center justify-center rounded bg-surface-600 text-xs text-text-secondary">?</div>
{/if}
<div class="min-w-0 flex-1">
{#if bm}
<p class="truncate text-xs font-500">{bm.artist} - {bm.title}</p>
<p class="text-xs text-text-secondary">[{bm.version}] &middot; ★{bm.starRating.toFixed(1)} &middot; {bm.bpm}bpm</p>
{:else}
<p class="text-xs text-text-secondary">#{slot.beatmapId}</p>
{/if}
</div>
<span class="text-xs font-600 text-text-secondary">{category}{slot.orderInCategory}</span>
{#if isPlayed}
<span class="text-xs text-text-secondary">✓</span>
{:else if tbLocked}
<span class="text-[10px] text-pink-400/60" title="Match point only">🔒</span>
{/if}
</button>
</form>
{/each}
</div>
</div>
{/each}
</div>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -341,7 +384,6 @@
{p1?.score} – {p2?.score} {p1?.score} – {p2?.score}
</p> </p>
<!-- Game summary -->
<div class="mt-6 flex flex-col gap-1"> <div class="mt-6 flex flex-col gap-1">
{#each m.games as game} {#each m.games as game}
{@const bm = data.beatmapCache[game.slot?.beatmapId]} {@const bm = data.beatmapCache[game.slot?.beatmapId]}
@@ -422,6 +464,9 @@
<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>
</p> </p>
{/if} {/if}
<div class="mt-3">
{@render reinviteButton()}
</div>
</div> </div>
{/if} {/if}