osu match playing

This commit is contained in:
2026-02-28 04:35:21 +01:00
parent 133b9e552b
commit e147ba4432
24 changed files with 2433 additions and 45 deletions

View File

@@ -0,0 +1,281 @@
import { env } from '$env/dynamic/private';
function sleep(ms: number) {
return new Promise<void>((r) => setTimeout(r, ms));
}
// ── Singleton bancho.js connection ──────────────────────────────────────
let client: any = null;
let connecting: Promise<void> | null = null;
async function getClient() {
if (client?.isConnected?.()) return client;
if (connecting) {
await connecting;
return client!;
}
if (!env.OSU_IRC_USERNAME || !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: env.OSU_IRC_USERNAME,
password: env.OSU_IRC_PASSWORD
});
connecting = client.connect().then(() => {
console.log('[Bancho] Connected as', env.OSU_IRC_USERNAME);
connecting = null;
});
await connecting;
return client;
}
// ── Types ───────────────────────────────────────────────────────────────
export interface PlayerScore {
username: string;
score: number;
passed: boolean;
}
// ── Tournament Lobby ────────────────────────────────────────────────────
export class TournamentLobby {
private channel: any = null;
private channelName = '';
public osuMatchId = 0;
// Score collection
private collectedScores: PlayerScore[] = [];
private matchFinishedResolve: ((scores: PlayerScore[]) => void) | null = null;
// Ready tracking
private allReadyResolve: (() => void) | null = null;
// Event callbacks (set by orchestrator)
public onRollResult: ((username: string, value: number) => void) | null = null;
public onPickCommand: ((username: string, slotLabel: string) => void) | null = null;
/**
* Create a new multiplayer lobby via !mp make.
*/
async create(name: string): Promise<{ matchId: number; channel: string }> {
const c = await getClient();
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error('Lobby creation timed out')),
15000
);
const banchoBot = c.getUser('BanchoBot');
const onPM = (msg: any) => {
const text: string = 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}`);
});
}
private async joinChannel(c: any) {
this.channel = c.getChannel(this.channelName);
await this.channel.join();
this.channel.on('message', (msg: any) => {
const text: string = msg.message ?? String(msg);
const sender: string = msg.user?.ircUsername ?? '';
if (sender === 'BanchoBot') {
this.parseBanchoMessage(text);
} else if (sender) {
this.parsePlayerMessage(sender, text);
}
});
}
// ── 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
);
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;
}
// Game finished
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;
}
// Roll result: "Stan rolls 42 point(s)"
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;
}
// All players ready
if (text.includes('All players are ready')) {
console.log('[Bancho] All players ready');
if (this.allReadyResolve) {
this.allReadyResolve();
this.allReadyResolve = null;
}
return;
}
}
private parsePlayerMessage(sender: string, text: string) {
// !pick NM1, !pick HD2, !pick TB, etc.
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: string) {
if (!this.channel) throw new Error('Lobby not created');
console.log(`[Bancho] ${this.channelName} > ${cmd}`);
await this.channel.sendMessage(cmd);
}
async invite(username: string) {
await this.send(`!mp invite ${username.replace(/ /g, '_')}`);
}
async setProperties(teamMode: number, scoreMode: number, size: number) {
await this.send(`!mp set ${teamMode} ${scoreMode} ${size}`);
}
async setMap(beatmapId: string | number, playmode = 0) {
await this.send(`!mp map ${beatmapId} ${playmode}`);
}
async setMods(mods: string[]) {
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 {
/* lobby may already be closed */
}
}
async chat(message: string) {
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 = 180_000): Promise<void> {
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 = 600_000): Promise<PlayerScore[]> {
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);
};
});
}
}
// ── Active lobby registry (in-memory, per server instance) ──────────────
const lobbies = new Map<string, TournamentLobby>();
export function getLobby(matchId: string) {
return lobbies.get(matchId);
}
export function setLobby(matchId: string, lobby: TournamentLobby) {
lobbies.set(matchId, lobby);
}
export function removeLobby(matchId: string) {
lobbies.delete(matchId);
}

View File

@@ -10,6 +10,7 @@ import {
uniqueIndex,
real
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export * from './auth.schema';
@@ -51,6 +52,7 @@ export const mappoolSlot = pgTable('mappool_slot', {
category: text('category').notNull(),
orderInCategory: integer('order_in_category').notNull(),
beatmapId: text('beatmap_id').notNull(),
starRating: real('star_rating'),
mods: text('mods').array().default([]).notNull()
});
@@ -91,7 +93,7 @@ export const matchParticipant = pgTable(
pickOrder: integer('pick_order')
},
(t) => [
uniqueIndex('idx_mp_match_team').on(t.matchId, t.teamId),
index('idx_mp_match_team').on(t.matchId, t.teamId),
uniqueIndex('idx_mp_match_slot').on(t.matchId, t.slot),
index('idx_mp_match').on(t.matchId)
]
@@ -158,7 +160,33 @@ export const matchGameScore = pgTable(
(t) => [index('idx_mgs_game').on(t.matchGameId)]
);
import { relations } from 'drizzle-orm';
// ── Match Queue ─────────────────────────────────────────────────────────
export const matchQueue = pgTable(
'match_queue',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
teamId: uuid('team_id')
.notNull()
.references(() => team.id),
elo: integer('elo').default(1000).notNull(),
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [
uniqueIndex('idx_queue_user').on(t.userId),
index('idx_queue_elo').on(t.elo)
]
);
// ── Player ELO ──────────────────────────────────────────────────────────
export const playerRating = pgTable('player_rating', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull().unique(),
elo: integer('elo').default(1000).notNull(),
wins: integer('wins').default(0).notNull(),
losses: integer('losses').default(0).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
// ── Relations ───────────────────────────────────────────────────────────
@@ -210,3 +238,7 @@ export const matchGameScoreRelations = relations(matchGameScore, ({ one }) => ({
references: [matchParticipantPlayer.id]
})
}));
export const matchQueueRelations = relations(matchQueue, ({ one }) => ({
team: one(team, { fields: [matchQueue.teamId], references: [team.id] })
}));

View File

@@ -0,0 +1,523 @@
import { db } from '$lib/server/db';
import {
match,
matchParticipant,
matchParticipantPlayer,
matchGame,
matchGameScore,
matchQueue,
playerRating,
teamMember,
} from '$lib/server/db/schema';
import { eq, asc, sql } from 'drizzle-orm';
import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types';
// ── Queue ───────────────────────────────────────────────────────────────
export async function joinQueue(userId: string, teamId: string) {
let rating = await db.query.playerRating.findFirst({
where: eq(playerRating.userId, userId)
});
if (!rating) {
[rating] = await db
.insert(playerRating)
.values({ userId, elo: 1000 })
.returning();
}
const existing = await db.query.matchQueue.findFirst({
where: eq(matchQueue.userId, userId)
});
if (existing) throw new Error('Already in queue');
await db.insert(matchQueue).values({
userId,
teamId,
elo: rating.elo
});
return tryMatchFromQueue();
}
export async function leaveQueue(userId: string) {
await db.delete(matchQueue).where(eq(matchQueue.userId, userId));
}
export async function getQueueStatus(userId: string) {
const entry = await db.query.matchQueue.findFirst({
where: eq(matchQueue.userId, userId)
});
const count = await db
.select({ count: sql<number>`count(*)` })
.from(matchQueue);
return {
inQueue: !!entry,
queueSize: Number(count[0].count),
joinedAt: entry?.joinedAt ?? null
};
}
async function tryMatchFromQueue() {
const queue = await db.query.matchQueue.findMany({
orderBy: asc(matchQueue.elo)
});
if (queue.length < 2) return null;
// Find closest ELO pair
let bestPair: [typeof queue[0], typeof queue[0]] | null = null;
let smallestGap = Infinity;
for (let i = 0; i < queue.length - 1; i++) {
const gap = Math.abs(queue[i].elo - queue[i + 1].elo);
if (gap < smallestGap) {
smallestGap = gap;
bestPair = [queue[i], queue[i + 1]];
}
}
if (!bestPair) return null;
// Pick mappool based on average player rating
const avgElo = (bestPair[0].elo + bestPair[1].elo) / 2;
const selectedPool = await selectMappoolForRating(avgElo);
if (!selectedPool) throw new Error('No mappools available');
// Remove both from queue
await db.delete(matchQueue).where(eq(matchQueue.userId, bestPair[0].userId));
await db.delete(matchQueue).where(eq(matchQueue.userId, bestPair[1].userId));
const created = await createMatch({
name: 'Ranked Match',
config: { bestOf: 7, teamSize: 1, scoringType: 'score' },
mappoolId: selectedPool.id,
teams: [bestPair[0].teamId, bestPair[1].teamId],
createdBy: 'system'
});
return created;
}
async function selectMappoolForRating(avgElo: number) {
// Get all mappools with their slots
const pools = await db.query.mappool.findMany({
with: { slots: true }
});
if (pools.length === 0) return null;
// Map ELO range (e.g. 800-1200) to star rating range (e.g. 3-7)
// Simple linear: ELO 800 → 3★, ELO 1200 → 7★
const targetStars = 3 + ((avgElo - 800) / 400) * 4;
const clampedTarget = Math.max(2, Math.min(8, targetStars));
let bestPool = pools[0];
let bestDiff = Infinity;
for (const pool of pools) {
if (pool.slots.length === 0) continue;
const avgStars =
pool.slots.reduce((sum, s) => sum + (s.starRating ?? 0), 0) / pool.slots.length;
const diff = Math.abs(avgStars - clampedTarget);
if (diff < bestDiff) {
bestDiff = diff;
bestPool = pool;
}
}
return bestPool;
}
// ── Create Match ────────────────────────────────────────────────────────
export async function createMatch(opts: {
name: string;
config: MatchConfig;
mappoolId: string;
teams: string[];
createdBy: string;
}) {
const { name, config, mappoolId, teams, createdBy } = opts;
const [created] = await db
.insert(match)
.values({
name,
config,
mappoolId,
createdBy,
state: MATCH_STATES.CREATED
})
.returning();
for (let i = 0; i < teams.length; i++) {
const [participant] = await db
.insert(matchParticipant)
.values({
matchId: created.id,
teamId: teams[i],
slot: i + 1
})
.returning();
const members = await db.query.teamMember.findMany({
where: eq(teamMember.teamId, teams[i])
});
for (const member of members) {
await db.insert(matchParticipantPlayer).values({
participantId: participant.id,
userId: member.userId
});
}
}
return getMatchFull(created.id);
}
// ── State Transitions ───────────────────────────────────────────────────
export async function moveToLobby(matchId: string, osuLobbyId?: number) {
const m = await getMatchOrThrow(matchId);
assertState(m.state, MATCH_STATES.CREATED);
await db
.update(match)
.set({
state: MATCH_STATES.LOBBY,
osuLobbyId: osuLobbyId ?? null,
startedAt: new Date()
})
.where(eq(match.id, matchId));
return getMatchFull(matchId);
}
export async function moveToRolling(matchId: string) {
const m = await getMatchOrThrow(matchId);
assertState(m.state, MATCH_STATES.LOBBY);
await db
.update(match)
.set({ state: MATCH_STATES.ROLLING })
.where(eq(match.id, matchId));
return getMatchFull(matchId);
}
// ── Roll ────────────────────────────────────────────────────────────────
export async function submitRoll(matchId: string, participantId: string, value: number) {
const m = await getMatchOrThrow(matchId);
assertState(m.state, MATCH_STATES.ROLLING);
await db
.update(matchParticipant)
.set({ rollValue: value })
.where(eq(matchParticipant.id, participantId));
const participants = await db.query.matchParticipant.findMany({
where: eq(matchParticipant.matchId, matchId)
});
const allRolled = participants.every(
(p) => (p.id === participantId ? true : p.rollValue !== null)
);
if (allRolled) {
const sorted = [...participants]
.map((p) => ({
...p,
rollValue: p.id === participantId ? value : p.rollValue!
}))
.sort((a, b) => b.rollValue - a.rollValue);
for (let i = 0; i < sorted.length; i++) {
await db
.update(matchParticipant)
.set({ pickOrder: i + 1 })
.where(eq(matchParticipant.id, sorted[i].id));
}
await db
.update(match)
.set({ state: MATCH_STATES.PICKING })
.where(eq(match.id, matchId));
}
return getMatchFull(matchId);
}
// ── Pick Map ────────────────────────────────────────────────────────────
export async function pickMap(matchId: string, participantId: string, mappoolSlotId: string) {
const m = await getMatchOrThrow(matchId);
assertState(m.state, MATCH_STATES.PICKING);
const participants = await db.query.matchParticipant.findMany({
where: eq(matchParticipant.matchId, matchId)
});
const totalGames = await db.query.matchGame.findMany({
where: eq(matchGame.matchId, matchId)
});
const expectedPicker = getExpectedPicker(participants, totalGames.length);
if (expectedPicker.id !== participantId) {
throw new Error('Not your turn to pick');
}
const alreadyPlayed = totalGames.some((g) => g.mappoolSlotId === mappoolSlotId);
if (alreadyPlayed) {
throw new Error('This map has already been played');
}
const [game] = await db
.insert(matchGame)
.values({
matchId,
gameNumber: totalGames.length + 1,
mappoolSlotId,
pickedByParticipantId: participantId,
state: GAME_STATES.PLAYING,
startedAt: new Date()
})
.returning();
await db
.update(match)
.set({ state: MATCH_STATES.PLAYING })
.where(eq(match.id, matchId));
return game;
}
// ── Submit Scores ───────────────────────────────────────────────────────
export async function submitGameScores(
matchGameId: string,
scores: {
playerId: string;
score: number;
accuracy?: number;
maxCombo?: number;
count300?: number;
count100?: number;
count50?: number;
countMiss?: number;
mods?: string[];
passed?: boolean;
}[]
) {
const game = await db.query.matchGame.findFirst({
where: eq(matchGame.id, matchGameId)
});
if (!game) throw new Error('Game not found');
if (game.state !== GAME_STATES.PLAYING) throw new Error(`Game is ${game.state}, not PLAYING`);
for (const s of scores) {
await db.insert(matchGameScore).values({
matchGameId,
playerId: s.playerId,
score: s.score,
accuracy: s.accuracy ?? 0,
maxCombo: s.maxCombo ?? 0,
count300: s.count300 ?? 0,
count100: s.count100 ?? 0,
count50: s.count50 ?? 0,
countMiss: s.countMiss ?? 0,
mods: s.mods ?? [],
passed: s.passed ?? true
});
}
const allScores = await db.query.matchGameScore.findMany({
where: eq(matchGameScore.matchGameId, matchGameId),
with: { player: true }
});
const participantScores = new Map<string, number>();
for (const s of allScores) {
const pid = s.player.participantId;
participantScores.set(pid, (participantScores.get(pid) ?? 0) + s.score);
}
let winnerId: string | null = null;
let highScore = -1;
for (const [pid, total] of participantScores) {
if (total > highScore) {
highScore = total;
winnerId = pid;
}
}
await db
.update(matchGame)
.set({
state: GAME_STATES.FINISHED,
winnerParticipantId: winnerId,
finishedAt: new Date()
})
.where(eq(matchGame.id, matchGameId));
if (winnerId) {
const participant = await db.query.matchParticipant.findFirst({
where: eq(matchParticipant.id, winnerId)
});
if (participant) {
await db
.update(matchParticipant)
.set({ score: participant.score + 1 })
.where(eq(matchParticipant.id, winnerId));
}
}
const m = await getMatchOrThrow(game.matchId);
const config = m.config as MatchConfig;
const winsNeeded = Math.ceil(config.bestOf / 2);
const participants = await db.query.matchParticipant.findMany({
where: eq(matchParticipant.matchId, game.matchId)
});
const matchWinner = participants.find((p) => p.score >= winsNeeded);
if (matchWinner) {
await db
.update(match)
.set({
state: MATCH_STATES.FINISHED,
winnerId: matchWinner.teamId,
finishedAt: new Date()
})
.where(eq(match.id, game.matchId));
await updateElo(participants, matchWinner.id);
} else {
await db
.update(match)
.set({ state: MATCH_STATES.PICKING })
.where(eq(match.id, game.matchId));
}
return getMatchFull(game.matchId);
}
// ── ELO ─────────────────────────────────────────────────────────────────
async function updateElo(
participants: { id: string; teamId: string }[],
winnerId: string
) {
for (const p of participants) {
const players = await db.query.matchParticipantPlayer.findMany({
where: eq(matchParticipantPlayer.participantId, p.id)
});
const isWinner = p.id === winnerId;
for (const player of players) {
let rating = await db.query.playerRating.findFirst({
where: eq(playerRating.userId, player.userId)
});
if (!rating) {
[rating] = await db
.insert(playerRating)
.values({ userId: player.userId, elo: 1000 })
.returning();
}
const K = 32;
const expected = 0.5;
const actual = isWinner ? 1 : 0;
const newElo = Math.round(rating.elo + K * (actual - expected));
await db
.update(playerRating)
.set({
elo: newElo,
wins: isWinner ? rating.wins + 1 : rating.wins,
losses: isWinner ? rating.losses : rating.losses + 1,
updatedAt: new Date()
})
.where(eq(playerRating.userId, player.userId));
}
}
}
// ── Cancel Match ────────────────────────────────────────────────────────
export async function cancelMatch(matchId: string) {
await db
.update(match)
.set({
state: MATCH_STATES.CANCELLED,
finishedAt: new Date()
})
.where(eq(match.id, matchId));
return getMatchFull(matchId);
}
// ── Helpers ─────────────────────────────────────────────────────────────
function getExpectedPicker(
participants: { id: string; pickOrder: number | null; score: number }[],
gameCount: number
) {
const sorted = [...participants].sort((a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99));
const pickerIndex = gameCount % sorted.length;
return sorted[pickerIndex];
}
export async function getMatchFull(matchId: string) {
const m = await db.query.match.findFirst({
where: eq(match.id, matchId),
with: {
mappool: {
with: { slots: true }
},
participants: {
with: {
team: true,
players: true
}
},
games: {
with: {
slot: true,
scores: {
with: { player: true }
}
},
orderBy: (g, { asc }) => [asc(g.gameNumber)]
}
}
});
if (!m) throw new Error('Match not found');
return m;
}
async function getMatchOrThrow(matchId: string) {
const m = await db.query.match.findFirst({
where: eq(match.id, matchId)
});
if (!m) throw new Error('Match not found');
return m;
}
function assertState(current: string, expected: string) {
if (current !== expected) {
throw new Error(`Match is ${current}, expected ${expected}`);
}
}

View File

@@ -0,0 +1,331 @@
import { db } from '$lib/server/db';
import { match, matchGame, mappoolSlot, user } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { MATCH_STATES, type MatchConfig } from './types';
import { getMatchFull, submitRoll, pickMap, submitGameScores } from './engine';
import { TournamentLobby, setLobby, getLobby, removeLobby } from '../bancho/client';
function sleep(ms: number) {
return new Promise<void>((r) => setTimeout(r, ms));
}
/**
* Create IRC lobby → configure → invite players → wire up chat handlers → move to ROLLING.
*/
export async function initMatchLobby(matchId: string) {
const m = await getMatchFull(matchId);
const config = m.config as MatchConfig;
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);
// CREATED → LOBBY
await db
.update(match)
.set({ state: MATCH_STATES.LOBBY, osuLobbyId: osuId, startedAt: new Date() })
.where(eq(match.id, matchId));
// Configure: HeadToHead, correct score mode, correct size
const scoreMode =
config.scoringType === 'score_v2' ? 3
: config.scoringType === 'accuracy' ? 1
: config.scoringType === 'combo' ? 2
: 0;
await lobby.setProperties(0, scoreMode, config.teamSize * 2);
// Remove password so players can join via mp link (and rejoin after disconnect)
await lobby.send('!mp password');
await sleep(500);
// Invite all players from both teams (with dedup + delays)
const invited = new Set<string>();
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);
}
}
}
// Wire up IRC chat handlers for rolls and picks
setupChatHandlers(matchId, lobby);
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));
return { osuMatchId: osuId, channel };
}
/**
* Wire up IRC event callbacks so players can !roll and !pick from osu! chat.
*/
function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
// ── Handle !roll from IRC ───────────────────────────────────────────
lobby.onRollResult = async (username: string, value: number) => {
try {
const m = await getMatchFull(matchId);
if (m.state !== MATCH_STATES.ROLLING) {
await lobby.chat(`Rolls are not active right now.`);
return;
}
// Find participant linked to this username
let targetParticipant: { id: string; rollValue: number | null } | null = 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 this participant already rolled, try finding another one (same-team testing)
if (p.rollValue === null) {
targetParticipant = p;
break;
} else if (!targetParticipant) {
// Keep looking for an unrolled one
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}`);
// Check if all rolled → announce pick order
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: any) {
console.error('[Orchestrator] IRC roll error:', err.message);
}
};
// ── Handle !pick from IRC ───────────────────────────────────────────
lobby.onPickCommand = async (username: string, slotLabel: string) => {
try {
const m = await getMatchFull(matchId);
if (m.state !== MATCH_STATES.PICKING) {
await lobby.chat(`Picks are not active right now.`);
return;
}
// Parse slot label: "NM1" → category=NM, orderInCategory=1
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]);
// Find the expected picker
const sorted = [...m.participants].sort(
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
);
const expectedIdx = m.games.length % sorted.length;
const expectedPicker = sorted[expectedIdx];
// Verify it's this player's turn to pick
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;
}
}
}
// For same-team testing, allow any participant
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;
}
// Find the mappool slot
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;
}
// Check if already played
const alreadyPlayed = m.games.some((g) => g.mappoolSlotId === slot.id);
if (alreadyPlayed) {
await lobby.chat(`${username}: ${slotLabel} has already been played.`);
return;
}
// Execute the pick
const game = await pickMap(matchId, expectedPicker!.id, slot.id);
console.log(`[Orchestrator] IRC pick: ${username} picked ${slotLabel}`);
// Set map in lobby and wait for ready
playPickedMap(matchId, game.id).catch((err) =>
console.error('[Orchestrator] IRC play failed:', err.message)
);
} catch (err: any) {
console.error('[Orchestrator] IRC pick error:', err.message);
await lobby.chat(`Error: ${err.message}`).catch(() => {});
}
};
}
/**
* Called after a map is picked (from web UI or IRC).
* Sets map + mods in IRC, waits for ready, starts game, collects scores.
*/
export async function playPickedMap(matchId: string, matchGameId: string) {
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;
// Set beatmap
await lobby.setMap(slot.beatmapId);
await sleep(1000);
// Set mods (always NF + slot mods)
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)`
);
// Wait for all players to ready up in osu!, then start
try {
await lobby.waitForReady();
await lobby.chat('All ready — starting in 5s!');
await lobby.startGame(5);
} catch (err: any) {
// If ready times out, inform and don't start
console.warn('[Orchestrator] Ready timeout:', err.message);
await lobby.chat('Ready timed out. Use the web UI to force start, or ready up!');
return;
}
// Non-blocking: wait for scores then process
collectScores(matchId, matchGameId, lobby).catch((err) =>
console.error('[Orchestrator] Score collection failed:', err)
);
}
/**
* Force start the current game without waiting for ready.
* Called from the web UI "Force Start" button.
*/
export async function forceStartGame(matchId: string) {
const lobby = getLobby(matchId);
if (!lobby) return;
await lobby.chat('Force starting in 10s!');
await lobby.startGame(10);
}
/**
* Background task: waits for IRC scores, writes them to DB, advances match state.
*/
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 }[] = [];
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) });
// IRC uses underscores for spaces in usernames
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);
// Check if match finished or needs next pick
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(5000);
await lobby.close();
removeLobby(matchId);
} else {
// Announce next picker
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.`
);
}
}
/**
* Close the IRC lobby for a match.
*/
export async function closeLobby(matchId: string) {
const lobby = getLobby(matchId);
if (lobby) {
await lobby.close();
removeLobby(matchId);
}
}

View File

@@ -0,0 +1,24 @@
export interface MatchConfig {
bestOf: number;
teamSize: number;
scoringType: 'score' | 'score_v2' | 'accuracy' | 'combo';
}
export const MATCH_STATES = {
CREATED: 'CREATED',
LOBBY: 'LOBBY',
ROLLING: 'ROLLING',
PICKING: 'PICKING',
PLAYING: 'PLAYING',
FINISHED: 'FINISHED',
CANCELLED: 'CANCELLED'
} as const;
export const GAME_STATES = {
PENDING: 'PENDING',
PLAYING: 'PLAYING',
FINISHED: 'FINISHED'
} as const;
export type MatchState = (typeof MATCH_STATES)[keyof typeof MATCH_STATES];
export type GameState = (typeof GAME_STATES)[keyof typeof GAME_STATES];