import { d as db, b as match, u as user, g as matchGame } from "./index2.js"; import { eq } from "drizzle-orm"; import { g as getMatchFull, M as MATCH_STATES, s as submitRoll, p as pickMap, d as submitGameScores } from "./engine.js"; import { b as private_env } from "./shared-server.js"; let client = null; let connecting = null; async function getClient() { if (client?.isConnected?.()) return client; if (connecting) { await connecting; return client; } if (!private_env.OSU_IRC_USERNAME || !private_env.OSU_IRC_PASSWORD) { throw new Error("OSU_IRC_USERNAME and OSU_IRC_PASSWORD must be set in .env"); } const banchoModule = await import("bancho.js"); const BanchoClient = banchoModule.BanchoClient ?? banchoModule.default?.BanchoClient ?? banchoModule.default; client = new BanchoClient({ username: private_env.OSU_IRC_USERNAME, password: private_env.OSU_IRC_PASSWORD }); connecting = client.connect().then(() => { console.log("[Bancho] Connected as", private_env.OSU_IRC_USERNAME); connecting = null; }); await connecting; return client; } class TournamentLobby { channel = null; channelName = ""; osuMatchId = 0; // Score collection collectedScores = []; matchFinishedResolve = null; // Ready tracking allReadyResolve = null; // Event callbacks (set by orchestrator) onRollResult = null; onPickCommand = null; /** * Create a new multiplayer lobby via !mp make. */ async create(name) { const c = await getClient(); return new Promise((resolve, reject) => { const timeout = setTimeout( () => reject(new Error("Lobby creation timed out")), 15e3 ); const banchoBot = c.getUser("BanchoBot"); const onPM = (msg) => { const text = msg.message ?? String(msg); const m = text.match(/https:\/\/osu\.ppy\.sh\/mp\/(\d+)/); if (m) { clearTimeout(timeout); banchoBot.removeListener("message", onPM); this.osuMatchId = parseInt(m[1]); this.channelName = `#mp_${this.osuMatchId}`; this.joinChannel(c).then( () => resolve({ matchId: this.osuMatchId, channel: this.channelName }) ); } }; banchoBot.on("message", onPM); banchoBot.sendMessage(`!mp make ${name}`); }); } async joinChannel(c) { this.channel = c.getChannel(this.channelName); await this.channel.join(); this.channel.on("message", (msg) => { const text = msg.message ?? String(msg); const sender = msg.user?.ircUsername ?? ""; if (sender === "BanchoBot") { this.parseBanchoMessage(text); } else if (sender) { this.parsePlayerMessage(sender, text); } }); } // ── Message Parsing ───────────────────────────────────────────────── parseBanchoMessage(text) { const scoreMatch = text.match( /^(.+?) finished playing \(Score: ([\d,]+).*?(PASSED|FAILED)\)/i ); if (scoreMatch) { this.collectedScores.push({ username: scoreMatch[1].trim(), score: parseInt(scoreMatch[2].replace(/,/g, "")), passed: scoreMatch[3] === "PASSED" }); console.log(`[Bancho] Score: ${scoreMatch[1].trim()} = ${scoreMatch[2]}`); return; } if (text.includes("The match has finished!")) { console.log("[Bancho] Game finished. Scores:", this.collectedScores); if (this.matchFinishedResolve) { this.matchFinishedResolve([...this.collectedScores]); this.matchFinishedResolve = null; } this.collectedScores = []; return; } const rollMatch = text.match(/^(.+?) rolls (\d+) point\(s\)/); if (rollMatch) { const username = rollMatch[1].trim(); const value = parseInt(rollMatch[2]); console.log(`[Bancho] Roll: ${username} = ${value}`); if (this.onRollResult) { this.onRollResult(username, value); } return; } if (text.includes("All players are ready")) { console.log("[Bancho] All players ready"); if (this.allReadyResolve) { this.allReadyResolve(); this.allReadyResolve = null; } return; } } parsePlayerMessage(sender, text) { const pickMatch = text.match(/^!pick\s+([A-Z]{2})(\d*)/i); if (pickMatch) { const category = pickMatch[1].toUpperCase(); const num = pickMatch[2] ? parseInt(pickMatch[2]) : 1; const label = `${category}${num}`; console.log(`[Bancho] Pick command from ${sender}: ${label}`); if (this.onPickCommand) { this.onPickCommand(sender, label); } return; } } // ── IRC Commands ──────────────────────────────────────────────────── async send(cmd) { if (!this.channel) throw new Error("Lobby not created"); console.log(`[Bancho] ${this.channelName} > ${cmd}`); await this.channel.sendMessage(cmd); } async invite(username) { await this.send(`!mp invite ${username.replace(/ /g, "_")}`); } async setProperties(teamMode, scoreMode, size) { await this.send(`!mp set ${teamMode} ${scoreMode} ${size}`); } async setMap(beatmapId, playmode = 0) { await this.send(`!mp map ${beatmapId} ${playmode}`); } async setMods(mods) { const filtered = mods.filter((m) => !["NM", "TB", "None", "NF"].includes(m)); if (mods.includes("FM") || mods.includes("Freemod")) { await this.send("!mp mods Freemod NF"); } else if (filtered.length > 0) { await this.send(`!mp mods NF ${filtered.join(" ")}`); } else { await this.send("!mp mods NF"); } } async startGame(delaySec = 5) { await this.send(`!mp start ${delaySec}`); } async close() { try { await this.send("!mp close"); } catch { } } async chat(message) { await this.send(message); } // ── Async Waiters ─────────────────────────────────────────────────── /** * Wait for all players to click "Ready" in osu! client. * BanchoBot sends "All players are ready" when they do. */ waitForReady(timeoutMs = 18e4) { return new Promise((resolve, reject) => { const t = setTimeout(() => { this.allReadyResolve = null; reject(new Error("Timed out waiting for players to ready")); }, timeoutMs); this.allReadyResolve = () => { clearTimeout(t); resolve(); }; }); } /** * Wait for "The match has finished!" and return all player scores. */ waitForScores(timeoutMs = 6e5) { this.collectedScores = []; return new Promise((resolve, reject) => { const t = setTimeout(() => { this.matchFinishedResolve = null; reject(new Error("Timed out waiting for match scores")); }, timeoutMs); this.matchFinishedResolve = (scores) => { clearTimeout(t); resolve(scores); }; }); } } const lobbies = /* @__PURE__ */ new Map(); function getLobby(matchId) { return lobbies.get(matchId); } function setLobby(matchId, lobby) { lobbies.set(matchId, lobby); } function removeLobby(matchId) { lobbies.delete(matchId); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } async function initMatchLobby(matchId) { const m = await getMatchFull(matchId); const config = m.config; const lobby = new TournamentLobby(); const lobbyName = `VASH: (${m.participants[0]?.team.name}) vs (${m.participants[1]?.team.name})`; console.log(`[Orchestrator] Creating lobby: ${lobbyName}`); const { matchId: osuId, channel } = await lobby.create(lobbyName); console.log(`[Orchestrator] Created ${channel} (osu mp/${osuId})`); setLobby(matchId, lobby); await db.update(match).set({ state: MATCH_STATES.LOBBY, osuLobbyId: osuId, startedAt: /* @__PURE__ */ new Date() }).where(eq(match.id, matchId)); const scoreMode = config.scoringType === "score_v2" ? 3 : config.scoringType === "accuracy" ? 1 : config.scoringType === "combo" ? 2 : 0; await lobby.setProperties(0, scoreMode, config.teamSize * 2); await lobby.send("!mp password"); await sleep(500); const invited = /* @__PURE__ */ new Set(); for (const p of m.participants) { for (const pl of p.players) { const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) }); if (u?.name && !invited.has(u.name.toLowerCase())) { await lobby.invite(u.name); invited.add(u.name.toLowerCase()); console.log(`[Orchestrator] Invited ${u.name}`); await sleep(300); } } } setupChatHandlers(matchId, lobby); await lobby.chat( `Welcome! BO${config.bestOf}. Type !roll to decide pick order, or use the web UI.` ); await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId)); return { osuMatchId: osuId, channel }; } function setupChatHandlers(matchId, lobby) { lobby.onRollResult = async (username, value) => { try { const m = await getMatchFull(matchId); if (m.state !== MATCH_STATES.ROLLING) { await lobby.chat(`Rolls are not active right now.`); return; } let targetParticipant = null; for (const p of m.participants) { for (const pl of p.players) { const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) }); if (u?.name?.toLowerCase() === username.toLowerCase().replace(/_/g, " ")) { if (p.rollValue === null) { targetParticipant = p; break; } else if (!targetParticipant) { const unrolled = m.participants.find((pp) => pp.rollValue === null); if (unrolled) targetParticipant = unrolled; } } } if (targetParticipant?.rollValue === null) break; } if (!targetParticipant || targetParticipant.rollValue !== null) { await lobby.chat(`${username}: already rolled or not in this match.`); return; } await submitRoll(matchId, targetParticipant.id, value); console.log(`[Orchestrator] IRC roll: ${username} = ${value}`); const updated = await getMatchFull(matchId); if (updated.state === MATCH_STATES.PICKING) { const sorted = [...updated.participants].sort( (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) ); await lobby.chat( `Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick (e.g. !pick NM1) or pick in web UI.` ); } } catch (err) { console.error("[Orchestrator] IRC roll error:", err.message); } }; lobby.onPickCommand = async (username, slotLabel) => { try { const m = await getMatchFull(matchId); if (m.state !== MATCH_STATES.PICKING) { await lobby.chat(`Picks are not active right now.`); return; } const labelMatch = slotLabel.match(/^([A-Z]{2})(\d+)$/); if (!labelMatch) { await lobby.chat(`${username}: Invalid slot. Use format like NM1, HD2, DT1.`); return; } const category = labelMatch[1]; const order = parseInt(labelMatch[2]); const sorted = [...m.participants].sort( (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) ); const expectedIdx = m.games.length % sorted.length; const expectedPicker = sorted[expectedIdx]; let isPickerTurn = false; if (expectedPicker) { for (const pl of expectedPicker.players) { const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) }); if (u?.name?.toLowerCase() === username.toLowerCase().replace(/_/g, " ")) { isPickerTurn = true; break; } } } if (!isPickerTurn && m.participants[0]?.teamId === m.participants[1]?.teamId) { isPickerTurn = true; } if (!isPickerTurn) { await lobby.chat(`${username}: It's not your turn to pick.`); return; } const slot = m.mappool?.slots?.find( (s) => s.category === category && s.orderInCategory === order ); if (!slot) { await lobby.chat(`${username}: Slot ${slotLabel} not found in this mappool.`); return; } const alreadyPlayed = m.games.some((g) => g.mappoolSlotId === slot.id); if (alreadyPlayed) { await lobby.chat(`${username}: ${slotLabel} has already been played.`); return; } const game = await pickMap(matchId, expectedPicker.id, slot.id); console.log(`[Orchestrator] IRC pick: ${username} picked ${slotLabel}`); playPickedMap(matchId, game.id).catch( (err) => console.error("[Orchestrator] IRC play failed:", err.message) ); } catch (err) { console.error("[Orchestrator] IRC pick error:", err.message); await lobby.chat(`Error: ${err.message}`).catch(() => { }); } }; } async function playPickedMap(matchId, matchGameId) { const lobby = getLobby(matchId); if (!lobby) { console.warn("[Orchestrator] No IRC lobby for", matchId, "— skipping IRC"); return; } const game = await db.query.matchGame.findFirst({ where: eq(matchGame.id, matchGameId), with: { slot: true } }); if (!game) throw new Error("Game not found"); const slot = game.slot; await lobby.setMap(slot.beatmapId); await sleep(1e3); await lobby.setMods(slot.mods); await sleep(500); await lobby.chat( `Playing ${slot.category}${slot.orderInCategory}. Ready up! (Game starts when all players are ready)` ); try { await lobby.waitForReady(); await lobby.chat("All ready — starting in 5s!"); await lobby.startGame(5); } catch (err) { console.warn("[Orchestrator] Ready timeout:", err.message); await lobby.chat("Ready timed out. Use the web UI to force start, or ready up!"); return; } collectScores(matchId, matchGameId, lobby).catch( (err) => console.error("[Orchestrator] Score collection failed:", err) ); } async function forceStartGame(matchId) { const lobby = getLobby(matchId); if (!lobby) return; await lobby.chat("Force starting in 10s!"); await lobby.startGame(10); } async function collectScores(matchId, matchGameId, lobby) { const ircScores = await lobby.waitForScores(); console.log(`[Orchestrator] Scores for game ${matchGameId}:`, ircScores); const m = await getMatchFull(matchId); const scores = []; for (const irc of ircScores) { for (const participant of m.participants) { for (const player of participant.players) { const u = await db.query.user.findFirst({ where: eq(user.id, player.userId) }); const ircName = irc.username.toLowerCase().replace(/_/g, " "); const dbName = u?.name?.toLowerCase(); if (dbName && (dbName === ircName || dbName === irc.username.toLowerCase())) { scores.push({ playerId: player.id, score: irc.score, passed: irc.passed }); } } } } if (scores.length === 0) { console.warn("[Orchestrator] No scores matched any IRC usernames"); return; } await submitGameScores(matchGameId, scores); const updated = await getMatchFull(matchId); if (updated.state === MATCH_STATES.FINISHED) { const winner = updated.participants.find((p) => p.teamId === updated.winnerId); await lobby.chat(`GG! ${winner?.team.name ?? "?"} wins the match!`); await sleep(5e3); await lobby.close(); removeLobby(matchId); } else { const sorted = [...updated.participants].sort( (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) ); const nextIdx = updated.games.length % sorted.length; const nextPicker = sorted[nextIdx]; await lobby.chat( `${nextPicker?.team.name}'s turn to pick. Use !pick (e.g. !pick HD1) or pick in web UI.` ); } } async function closeLobby(matchId) { const lobby = getLobby(matchId); if (lobby) { await lobby.close(); removeLobby(matchId); } } export { closeLobby as c, forceStartGame as f, getLobby as g, initMatchLobby as i, playPickedMap as p };