449 lines
16 KiB
JavaScript
449 lines
16 KiB
JavaScript
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 <slot> (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 <slot> (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
|
|
};
|