feat: implement DM command handler for player interactions and queue management
This commit is contained in:
@@ -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 });
|
||||
|
||||
|
||||
@@ -11,7 +11,14 @@ let connecting: Promise<void> | null = null;
|
||||
let reconnecting = false;
|
||||
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 (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: "<user> 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();
|
||||
|
||||
185
src/lib/server/bancho/dm-handler.ts
Normal file
185
src/lib/server/bancho/dm-handler.ts
Normal 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.`);
|
||||
}
|
||||
}
|
||||
@@ -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 <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 ───────────────────────────────────────────
|
||||
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 <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) {
|
||||
@@ -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 <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 */ }
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<typeof setInterval> | null = null;
|
||||
@@ -89,6 +99,22 @@
|
||||
}
|
||||
</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">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -114,7 +140,6 @@
|
||||
<!-- Scoreboard -->
|
||||
{#if p1 && p2}
|
||||
<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'}">
|
||||
{#if p1.team.avatarUrl}
|
||||
<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>
|
||||
|
||||
<!-- 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'}">
|
||||
<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">
|
||||
@@ -146,27 +170,17 @@
|
||||
</div>
|
||||
{/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)}
|
||||
<div class="mt-3 flex justify-center">
|
||||
<form method="post" action="?/reinvite" use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success' && (result.data as any)?.reinvited) {
|
||||
await update();
|
||||
}
|
||||
};
|
||||
}}>
|
||||
<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 class="mt-3 flex items-center justify-center gap-3">
|
||||
{@render reinviteButton()}
|
||||
{#if m.osuLobbyId}
|
||||
<a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-xs text-text-secondary hover:text-accent">Open lobby →</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ROLLING MODAL -->
|
||||
<!-- ROLLING MODAL (stays as overlay — it's a brief phase) -->
|
||||
{#if m.state === 'ROLLING'}
|
||||
<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">
|
||||
@@ -199,92 +213,12 @@
|
||||
{:else}
|
||||
<p class="mt-6 text-sm text-text-secondary animate-pulse">Waiting for all rolls...</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- PICK MODAL -->
|
||||
{#if m.state === 'PICKING'}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<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">
|
||||
<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}
|
||||
</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}] · ★{bm.starRating.toFixed(1)} · {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 class="mt-6 flex flex-col items-center gap-2 border-t border-border pt-4">
|
||||
{@render reinviteButton()}
|
||||
{#if m.osuLobbyId}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,20 +244,129 @@
|
||||
Waiting for players to ready up & play in osu!...
|
||||
</p>
|
||||
|
||||
<!-- Force Start button — staff only -->
|
||||
{#if data.isStaff}
|
||||
<form method="post" action="?/forceStart" use:enhance={() => {
|
||||
forceStarting = true;
|
||||
return async ({ update }) => { forceStarting = false; await update(); };
|
||||
}}>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{forceStarting ? 'Starting...' : 'Force Start (skip ready)'}
|
||||
</button>
|
||||
</form>
|
||||
<div class="mt-3">
|
||||
<form method="post" action="?/forceStart" use:enhance={() => {
|
||||
forceStarting = true;
|
||||
return async ({ update }) => { forceStarting = false; await update(); };
|
||||
}}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={forceStarting}
|
||||
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)'}
|
||||
</button>
|
||||
</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}] · ★{bm.starRating.toFixed(1)} · {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}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -341,7 +384,6 @@
|
||||
{p1?.score} – {p2?.score}
|
||||
</p>
|
||||
|
||||
<!-- Game summary -->
|
||||
<div class="mt-6 flex flex-col gap-1">
|
||||
{#each m.games as game}
|
||||
{@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>
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-3">
|
||||
{@render reinviteButton()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user