diff --git a/bun.lock b/bun.lock index 4756f0c..17c43ca 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "esports", "dependencies": { + "bancho.js": "^0.12.3", "postgres": "^3.4.8", }, "devDependencies": { @@ -330,6 +331,8 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "bancho.js": ["bancho.js@0.12.3", "", { "dependencies": { "nodesu": "^0.7.2", "rate-limiter-flexible": "^2.3.12" }, "peerDependencies": { "weak-value-map": "^1.0.1" }, "optionalPeers": ["weak-value-map"] }, "sha512-0y4AJ7ehY2i4k3eTffXY35DqxXTbH/902BYTDGP03gstrWq33Ad2HU2PpyOSIlhWVRMJrBA/Ntx6UBvWeKdQXg=="], + "better-auth": ["better-auth@1.4.19", "", { "dependencies": { "@better-auth/core": "1.4.19", "@better-auth/telemetry": "1.4.19", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-3RlZJcA0+NH25wYD85vpIGwW9oSTuEmLIaGbT8zg41w/Pa2hVWHKedjoUHHJtnzkBXzDb+CShkLnSw7IThDdqQ=="], "better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], @@ -514,6 +517,8 @@ "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + "limiter": ["limiter@1.1.5", "", {}, "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="], + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -536,6 +541,8 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "nodesu": ["nodesu@0.7.3", "", { "dependencies": { "limiter": "^1.1.0" } }, "sha512-RTHkaRyt+alXkzp8TDH1r4dtkp38jeqFyGgFBCT6uSqcGIOUdh7EqghwN1PrcGVqTjPPjWUCnqoNay+JOMw3bg=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -588,6 +595,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "rate-limiter-flexible": ["rate-limiter-flexible@2.4.2", "", {}, "sha512-rMATGGOdO1suFyf/mI5LYhts71g1sbdhmd6YvdiXO2gJnd42Tt6QS4JUKJKSWVVkMtBacm6l40FR7Trjo6Iruw=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], diff --git a/package.json b/package.json index abefed1..e08ff9e 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "vitest-browser-svelte": "^2.0.2" }, "dependencies": { + "bancho.js": "^0.12.3", "postgres": "^3.4.8" } } diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 2712955..f7100d7 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -24,7 +24,7 @@ > V - VASH + Vash Esports diff --git a/src/lib/server/bancho/client.ts b/src/lib/server/bancho/client.ts new file mode 100644 index 0000000..1df6215 --- /dev/null +++ b/src/lib/server/bancho/client.ts @@ -0,0 +1,281 @@ +import { env } from '$env/dynamic/private'; + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +// ── Singleton bancho.js connection ────────────────────────────────────── + +let client: any = null; +let connecting: Promise | 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: " 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 { + 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 { + 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(); + +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); +} diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 6caa026..b3d5276 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -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] }) +})); diff --git a/src/lib/server/match/engine.ts b/src/lib/server/match/engine.ts new file mode 100644 index 0000000..7c24eff --- /dev/null +++ b/src/lib/server/match/engine.ts @@ -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`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(); + 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}`); + } +} diff --git a/src/lib/server/match/orchestrator.ts b/src/lib/server/match/orchestrator.ts new file mode 100644 index 0000000..4ed3967 --- /dev/null +++ b/src/lib/server/match/orchestrator.ts @@ -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((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(); + 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 (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 (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); + } +} diff --git a/src/lib/server/match/types.ts b/src/lib/server/match/types.ts new file mode 100644 index 0000000..759b473 --- /dev/null +++ b/src/lib/server/match/types.ts @@ -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]; diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts index c9d39ee..a971253 100644 --- a/src/routes/(app)/+layout.server.ts +++ b/src/routes/(app)/+layout.server.ts @@ -1,4 +1,4 @@ -import type { LayoutServerLoad } from './$types'; +import type { LayoutServerLoad } from "./$types"; export const load: LayoutServerLoad = async ({ locals }) => { return { diff --git a/src/routes/(app)/+page.server.ts b/src/routes/(app)/+page.server.ts new file mode 100644 index 0000000..93020e9 --- /dev/null +++ b/src/routes/(app)/+page.server.ts @@ -0,0 +1,52 @@ +import { db } from '$lib/server/db'; +import { match, playerRating, matchQueue } from '$lib/server/db/schema'; +import { eq, desc, sql } from 'drizzle-orm'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.user) { + return { user: null, rating: null, queueStatus: null, recentMatches: [], poolCount: 0 }; + } + + const rating = await db.query.playerRating.findFirst({ + where: eq(playerRating.userId, locals.user.id) + }); + + const queueEntry = await db.query.matchQueue.findFirst({ + where: eq(matchQueue.userId, locals.user.id) + }); + + const queueCount = await db + .select({ count: sql`count(*)` }) + .from(matchQueue); + + const poolCount = await db + .select({ count: sql`count(*)` }) + .from(db._.fullSchema.mappool); + + const recentMatches = await db.query.match.findMany({ + with: { + participants: { + with: { team: true, players: true } + } + }, + orderBy: desc(match.createdAt), + limit: 10 + }); + + const userMatches = recentMatches.filter((m) => + m.participants.some((p) => p.players.some((pl) => pl.userId === locals.user!.id)) + ); + + return { + user: locals.user, + rating: rating ?? { elo: 1000, wins: 0, losses: 0 }, + queueStatus: { + inQueue: !!queueEntry, + queueSize: Number(queueCount[0].count), + joinedAt: queueEntry?.joinedAt ?? null + }, + recentMatches: userMatches, + poolCount: Number(poolCount[0].count) + }; +}; diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 486ef1c..fd0738d 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -3,44 +3,200 @@ import './layout.css'; let { data }: PageProps = $props(); + + let joining = $state(false); + let leaving = $state(false); + let queueTimer = $state(''); + let queueInterval: ReturnType | null = null; + + $effect(() => { + if (data.queueStatus?.inQueue && data.queueStatus.joinedAt) { + const start = new Date(data.queueStatus.joinedAt).getTime(); + queueInterval = setInterval(() => { + const elapsed = Math.floor((Date.now() - start) / 1000); + const m = Math.floor(elapsed / 60); + const s = elapsed % 60; + queueTimer = `${m}:${s.toString().padStart(2, '0')}`; + }, 1000); + } + + return () => { + if (queueInterval) clearInterval(queueInterval); + }; + }); + + async function joinQueue() { + joining = true; + try { + const res = await fetch('/api/queue', { method: 'POST' }); + const result = await res.json(); + if (result.matched && result.match) { + window.location.href = `/matches/${result.match.id}`; + } else { + window.location.reload(); + } + } catch { + joining = false; + } + } + + async function leaveQueue() { + leaving = true; + await fetch('/api/queue', { method: 'DELETE' }); + window.location.reload(); + } + + function stateLabel(state: string) { + const map: Record = { + CREATED: 'Created', LOBBY: 'In Lobby', ROLLING: 'Rolling', + PICKING: 'Picking', PLAYING: 'Playing', FINISHED: 'Finished', CANCELLED: 'Cancelled' + }; + return map[state] ?? state; + } + + function stateColor(state: string) { + const map: Record = { + CREATED: 'text-text-secondary', LOBBY: 'text-yellow-400', ROLLING: 'text-yellow-400', + PICKING: 'text-blue-400', PLAYING: 'text-green-400', FINISHED: 'text-text-secondary', CANCELLED: 'text-red-400' + }; + return map[state] ?? 'text-text-secondary'; + }
-

- {#if data.user} - Welcome back, {data.user.name} - {:else} - Welcome to VASH - {/if} -

-

Automated esports match management for osu!

+ {#if !data.user} +
+

VASH

+

Automated esports match management for osu!

+ + Sign in with osu! + +
+ {:else} + +
+

+ Welcome back, {data.user.name} +

+
+
+

ELO

+

{data.rating?.elo ?? 1000}

+
+
+

W / L

+

+ {data.rating?.wins ?? 0} + / + {data.rating?.losses ?? 0} +

+
+
+
-
- -
⚔
-

Matches

-

Create and manage competitive matches

-
+ +
+ {#if data.queueStatus?.inQueue} +
+
+

In Queue

+

+ Searching for opponent... + {queueTimer} +

+

+ {data.queueStatus.queueSize} player{data.queueStatus.queueSize !== 1 ? 's' : ''} in queue +

+
+ +
+
+
+
+
+
+ Matchmaking active +
+ {:else} +
+
+

Ranked Queue

+

+ {#if data.poolCount > 0} + 1v1 · Best of 7 · + {data.queueStatus?.queueSize ?? 0} searching + {:else} + No mappools available — create one first + {/if} +

+
+ +
+ {/if} +
- -
♫
-

Mappools

-

Build and share beatmap pools

-
+ +
+

Recent Matches

+
+ {#each data.recentMatches as m} + {@const p1 = m.participants[0]} + {@const p2 = m.participants[1]} + {@const isUserP1 = p1?.players.some((pl) => pl.userId === data.user?.id)} + {@const userP = isUserP1 ? p1 : p2} + {@const oppP = isUserP1 ? p2 : p1} + {@const won = m.winnerId === userP?.teamId} + {@const lost = m.winnerId && m.winnerId !== userP?.teamId} - -
⚑
-

Teams

-

Manage your teams and rosters

-
-
+ +
+
+
+ {m.name} + {stateLabel(m.state)} +
+
+ {userP?.team.name ?? '?'} vs {oppP?.team.name ?? '?'} +
+
+ {#if p1 && p2} +
+ {p1.score} + - + {p2.score} +
+ {/if} + {new Date(m.createdAt).toLocaleDateString()} +
+ {:else} +
+

No matches yet. Join the queue!

+
+ {/each} +
+
+ + + + {/if}
diff --git a/src/routes/(app)/mappools/[id]/+page.server.ts b/src/routes/(app)/mappools/[id]/+page.server.ts index e2a05b0..5eb2b71 100644 --- a/src/routes/(app)/mappools/[id]/+page.server.ts +++ b/src/routes/(app)/mappools/[id]/+page.server.ts @@ -63,19 +63,18 @@ export const actions: Actions = { return { error: 'Beatmap ID and category are required' }; } - // Parse URL if needed - const urlMatch = beatmapId.match(/beatmaps\/(\d+)/); - const setMatch = beatmapId.match(/beatmapsets\/\d+#\w+\/(\d+)/); - if (urlMatch) beatmapId = urlMatch[1]; - else if (setMatch) beatmapId = setMatch[1]; + const urlMatch = beatmapId.match(/beatmaps\/(\d+)/); + const setMatch = beatmapId.match(/beatmapsets\/\d+#\w+\/(\d+)/); + if (urlMatch) beatmapId = urlMatch[1]; + else if (setMatch) beatmapId = setMatch[1]; - // Strip non-numeric if (!/^\d+$/.test(beatmapId)) { return { error: 'Invalid beatmap ID' }; } + let beatmap; try { - await getBeatmap(beatmapId); + beatmap = await getBeatmap(beatmapId); } catch { return { error: 'Beatmap not found on osu!' }; } @@ -92,6 +91,7 @@ export const actions: Actions = { beatmapId, category, orderInCategory: existing.length + 1, + starRating: beatmap.difficulty_rating, mods: category === 'NM' || category === 'TB' || category === 'FM' ? [] : [category] }); diff --git a/src/routes/(app)/matches/+page.server.ts b/src/routes/(app)/matches/+page.server.ts new file mode 100644 index 0000000..07f899d --- /dev/null +++ b/src/routes/(app)/matches/+page.server.ts @@ -0,0 +1,71 @@ +import { db } from '$lib/server/db'; +import { match } from '$lib/server/db/schema'; +import { redirect } from '@sveltejs/kit'; +import { desc } from 'drizzle-orm'; +import { createMatch } from '$lib/server/match/engine'; +import { initMatchLobby } from '$lib/server/match/orchestrator'; +import type { PageServerLoad, Actions } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.user) redirect(302, '/'); + + const matches = await db.query.match.findMany({ + with: { participants: { with: { team: true } } }, + orderBy: desc(match.createdAt), + limit: 50 + }); + + const teams = await db.query.team.findMany({ + orderBy: (t, { asc }) => [asc(t.name)] + }); + + const mappools = await db.query.mappool.findMany({ + with: { slots: true }, + orderBy: (m, { desc }) => [desc(m.createdAt)] + }); + + return { matches, teams, mappools }; +}; + +export const actions: Actions = { + createMatch: async ({ request, locals }) => { + if (!locals.user) redirect(302, '/'); + + const form = await request.formData(); + const name = form.get('name')?.toString()?.trim() || 'Custom Match'; + const team1Id = form.get('team1')?.toString(); + const team2Id = form.get('team2')?.toString(); + const mappoolId = form.get('mappool')?.toString(); + const bestOf = parseInt(form.get('bestOf')?.toString() ?? '7'); + + console.log('[Matches] CREATE:', { name, team1Id, team2Id, mappoolId, bestOf }); + + if (!team1Id || !team2Id || !mappoolId) { + return { error: 'All fields are required' }; + } + + // Same team is allowed for testing/dev + + let result; + try { + result = await createMatch({ + name, + config: { bestOf, teamSize: 1, scoringType: 'score' }, + mappoolId, + teams: [team1Id, team2Id], + createdBy: locals.user.id + }); + console.log('[Matches] Created:', result.id); + } catch (e: any) { + console.error('[Matches] Error:', e); + return { error: e.message }; + } + + // Create IRC lobby in background (don't block redirect) + initMatchLobby(result.id).catch((err) => { + console.error('[Matches] IRC lobby failed:', err.message); + }); + + redirect(303, `/matches/${result.id}`); + } +}; diff --git a/src/routes/(app)/matches/+page.svelte b/src/routes/(app)/matches/+page.svelte new file mode 100644 index 0000000..482c62f --- /dev/null +++ b/src/routes/(app)/matches/+page.svelte @@ -0,0 +1,188 @@ + + +
+
+
+

Matches

+

All matches

+
+ +
+ + + {#if creating} +
{ + createError = ''; + return async ({ result, update }) => { + if (result.type === 'failure') { + createError = (result.data as { error?: string })?.error ?? 'Failed to create match'; + } else { + await update(); + } + }; + }} + class="mt-4 rounded-lg border border-border bg-surface-800 p-5" + > +

Create Match

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {#if createError} +

{createError}

+ {/if} + + +
+ {/if} + + +
+ {#each data.matches as m} + {@const p1 = m.participants[0]} + {@const p2 = m.participants[1]} + +
+
+ {m.name} + + {m.state} + +
+

+ {p1?.team.name ?? '?'} vs {p2?.team.name ?? '?'} + · {new Date(m.createdAt).toLocaleDateString()} +

+
+ + {#if p1 && p2} +
+ {p1.score} + - + {p2.score} +
+ {/if} +
+ {:else} +
+

No matches yet

+
+ {/each} +
+
diff --git a/src/routes/(app)/matches/[id]/+page.server.ts b/src/routes/(app)/matches/[id]/+page.server.ts new file mode 100644 index 0000000..f69cdaf --- /dev/null +++ b/src/routes/(app)/matches/[id]/+page.server.ts @@ -0,0 +1,136 @@ +import { getMatchFull, submitRoll, pickMap, cancelMatch } from '$lib/server/match/engine'; +import { playPickedMap, closeLobby, forceStartGame } from '$lib/server/match/orchestrator'; +import { getLobby } from '$lib/server/bancho/client'; +import { error, redirect } from '@sveltejs/kit'; +import { getBeatmap } from '$lib/server/osu/api'; +import type { PageServerLoad, Actions } from './$types'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!locals.user) redirect(302, '/'); + + let m; + try { + m = await getMatchFull(params.id); + } catch { + error(404, 'Match not found'); + } + + // Build beatmap metadata cache for all slots in the mappool + const beatmapCache: Record = {}; + if (m.mappool?.slots) { + await Promise.allSettled( + m.mappool.slots.map(async (slot) => { + try { + const bm = await getBeatmap(slot.beatmapId); + beatmapCache[slot.beatmapId] = { + title: bm.beatmapset.title, + artist: bm.beatmapset.artist, + version: bm.version, + starRating: bm.difficulty_rating, + bpm: bm.bpm, + totalLength: bm.total_length, + coverUrl: bm.beatmapset.covers['card@2x'], + listCoverUrl: bm.beatmapset.covers['list@2x'] + }; + } catch { /* skip */ } + }) + ); + } + + return { match: m, beatmapCache, userId: locals.user.id }; +}; + +export const actions: Actions = { + roll: async ({ params, locals }) => { + if (!locals.user) redirect(302, '/'); + + const m = await getMatchFull(params.id); + + // Find the participant linked to the current user + let myParticipant = m.participants.find((p) => + p.players.some((pl) => pl.userId === locals.user!.id) + ); + + if (!myParticipant) { + return { error: 'You are not in this match' }; + } + + // For same-team testing: if user is on both sides, pick the one that hasn't rolled yet + if (myParticipant.rollValue !== null) { + const unrolled = m.participants.find((p) => p.rollValue === null); + if (unrolled) { + myParticipant = unrolled; + } else { + return { error: 'All participants have already rolled' }; + } + } + + const value = Math.floor(Math.random() * 100) + 1; + await submitRoll(params.id, myParticipant.id, value); + + // Announce in IRC lobby + const lobby = getLobby(params.id); + if (lobby) { + lobby.chat(`${myParticipant.team?.name ?? 'Player'} rolled ${value} (via web)`).catch(() => {}); + + // Check if all rolled → announce pick order + const updated = await getMatchFull(params.id); + if (updated.state === 'PICKING') { + const sorted = [...updated.participants].sort( + (a: any, b: any) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) + ); + lobby.chat( + `Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick (e.g. !pick NM1) or pick in web UI.` + ).catch(() => {}); + } + } + + return { rolled: value, participantId: myParticipant.id }; + }, + + pick: async ({ params, request, locals }) => { + if (!locals.user) redirect(302, '/'); + + const form = await request.formData(); + const slotId = form.get('slotId')?.toString(); + if (!slotId) return { error: 'No map selected' }; + + const m = await getMatchFull(params.id); + + // Find the expected picker based on pick order and game count + const sorted = [...m.participants].sort( + (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) + ); + const expectedIdx = m.games.length % sorted.length; + const expectedPicker = sorted[expectedIdx]; + + if (!expectedPicker) return { error: 'Cannot determine picker' }; + + try { + const game = await pickMap(params.id, expectedPicker.id, slotId); + + // Set map + mods in IRC lobby and start game (background) + playPickedMap(params.id, game.id).catch((err) => + console.error('[Match] IRC play failed:', err.message) + ); + + return { picked: true, gameId: game.id }; + } catch (e: any) { + return { error: e.message }; + } + }, + + forceStart: async ({ params, locals }) => { + if (!locals.user) redirect(302, '/'); + await forceStartGame(params.id); + return { started: true }; + }, + + cancel: async ({ params, locals }) => { + if (!locals.user) redirect(302, '/'); + + await cancelMatch(params.id); + await closeLobby(params.id); + redirect(303, '/matches'); + } +}; diff --git a/src/routes/(app)/matches/[id]/+page.svelte b/src/routes/(app)/matches/[id]/+page.svelte new file mode 100644 index 0000000..76aabc0 --- /dev/null +++ b/src/routes/(app)/matches/[id]/+page.svelte @@ -0,0 +1,389 @@ + + +
+ +
+ ← +
+

{m.name || 'Match'}

+

+ Best of {config.bestOf} · First to {winsNeeded} + {#if m.osuLobbyId} + · osu! mp/{m.osuLobbyId} + {/if} +

+
+ {stateLabel(m.state)} + + {#if !['FINISHED', 'CANCELLED'].includes(m.state)} +
+ +
+ {/if} +
+ + + {#if p1 && p2} +
+ +
+ {#if p1.team.avatarUrl} + + {/if} +
+

{p1.team.name}

+ {#if p1.rollValue != null} +

Roll: {p1.rollValue}{p1.pickOrder === 1 ? ' ★' : ''}

+ {/if} +
+ {p1.score} +
+ + vs + + +
+ {p2.score} +
+

{p2.team.name}

+ {#if p2.rollValue != null} +

{p2.pickOrder === 1 ? '★ ' : ''}Roll: {p2.rollValue}

+ {/if} +
+ {#if p2.team.avatarUrl} + + {/if} +
+
+ {/if} + + + {#if m.state === 'ROLLING'} +
+
+

🎲 Roll Phase

+

Highest roll picks first

+ +
+ {#each m.participants as p} +
+

{p.team.name}

+ {#if p.rollValue != null} +

{p.rollValue}

+ {:else} +

—

+ {/if} +
+ {/each} +
+ + {#if canRoll} +
{ + rolling = true; + return async ({ update }) => { rolling = false; await update(); }; + }}> + +
+

or type !roll in osu! chat

+ {:else} +

Waiting for all rolls...

+ {/if} +
+
+ {/if} + + + {#if m.state === 'PICKING'} +
+
+
+

🎯 Pick a Map

+

+ {expectedPicker?.team.name}'s turn to pick +

+

+ or type !pick NM1 in osu! chat +

+
+ + {#if pickError} +

{pickError}

+ {/if} + +
+ {#each Object.entries(groupedSlots()) as [category, slots]} +
+ + {category} + +
+ {#each slots as slot, i} + {@const bm = data.beatmapCache[slot.beatmapId]} + {@const isPlayed = playedSlotIds.has(slot.id)} + +
{ + 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(); + } + }; + }}> + + +
+ {/each} +
+
+ {/each} +
+
+
+ {/if} + + + {#if m.state === 'PLAYING'} + {@const currentGame = m.games[m.games.length - 1]} + {@const bm = currentGame ? data.beatmapCache[currentGame.slot?.beatmapId] : null} +
+
+
+
+
+
+ Now Playing +
+ {#if bm} +

{bm.artist} - {bm.title} [{bm.version}]

+

★{bm.starRating.toFixed(1)} · {bm.bpm}bpm · {formatLength(bm.totalLength)}

+ {/if} +

+ Waiting for players to ready up & play in osu!... +

+ + +
{ + forceStarting = true; + return async ({ update }) => { forceStarting = false; await update(); }; + }}> + +
+
+ {/if} + + + {#if m.state === 'FINISHED'} + {@const winner = m.participants.find((p: any) => p.teamId === m.winnerId)} +
+
+
🏆
+

+ {winner?.team.name} wins! +

+

+ {p1?.score} – {p2?.score} +

+ + +
+ {#each m.games as game} + {@const bm = data.beatmapCache[game.slot?.beatmapId]} + {@const winnerP = m.participants.find((p: any) => p.id === game.winnerParticipantId)} +
+ {game.slot?.category}{game.slot?.orderInCategory} + {bm ? `${bm.artist} - ${bm.title}` : `#${game.slot?.beatmapId}`} + {winnerP?.team.name} +
+ {/each} +
+ + + Back to Dashboard + +
+
+ {/if} + + + {#if m.games.length > 0} +
+

Games Played

+
+ {#each m.games as game} + {@const bm = data.beatmapCache[game.slot?.beatmapId]} + {@const pickerTeam = m.participants.find((p: any) => p.id === game.pickedByParticipantId)?.team} + {@const winnerTeam = m.participants.find((p: any) => p.id === game.winnerParticipantId)?.team} + +
+ {game.gameNumber} + {#if bm?.listCoverUrl} + + {/if} +
+ {#if bm} +

{bm.artist} - {bm.title} [{bm.version}]

+ {/if} +
+ {game.slot?.category}{game.slot?.orderInCategory} + {#if pickerTeam}Picked by {pickerTeam.name}{/if} +
+
+ + {#if game.state === 'FINISHED' && p1 && p2} + {@const p1Score = game.scores.filter((s: any) => s.player?.participantId === p1.id).reduce((sum: number, s: any) => sum + s.score, 0)} + {@const p2Score = game.scores.filter((s: any) => s.player?.participantId === p2.id).reduce((sum: number, s: any) => sum + s.score, 0)} +
+ {p1Score.toLocaleString()} + - + {p2Score.toLocaleString()} +
+ {:else if game.state === 'PLAYING'} + Live + {/if} + + {#if winnerTeam} + ✓ {winnerTeam.name} + {/if} +
+ {/each} +
+
+ {/if} + + + {#if m.state === 'CREATED'} +
+

Creating osu! lobby...

+
+ {/if} + + {#if m.state === 'LOBBY'} +
+

Lobby created. Waiting for players to join...

+ {#if m.osuLobbyId} +

+ osu! mp/{m.osuLobbyId} +

+ {/if} +
+ {/if} + + {#if m.state === 'CANCELLED'} +
+

This match was cancelled.

+
+ {/if} +
diff --git a/src/routes/api/matches/+server.ts b/src/routes/api/matches/+server.ts new file mode 100644 index 0000000..abf3e5e --- /dev/null +++ b/src/routes/api/matches/+server.ts @@ -0,0 +1,41 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { createMatch } from '$lib/server/match/engine'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.user) error(401, 'Not logged in'); + + const matches = await db.query.match.findMany({ + with: { + participants: { with: { team: true } } + }, + orderBy: (m, { desc }) => [desc(m.createdAt)] + }); + + return json(matches); +}; + +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user) error(401, 'Not logged in'); + + const body = await request.json(); + const { name, config, mappoolId, teams } = body; + + if (!name || !config || !mappoolId || !teams?.length) { + error(400, 'Missing required fields'); + } + + try { + const result = await createMatch({ + name, + config, + mappoolId, + teams, + createdBy: locals.user.id + }); + return json(result, { status: 201 }); + } catch (e) { + error(400, e.message); + } +}; diff --git a/src/routes/api/matches/[id]/+server.ts b/src/routes/api/matches/[id]/+server.ts new file mode 100644 index 0000000..b712ae4 --- /dev/null +++ b/src/routes/api/matches/[id]/+server.ts @@ -0,0 +1,25 @@ +import { json, error } from '@sveltejs/kit'; +import { getMatchFull, cancelMatch } from '$lib/server/match/engine'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, locals }) => { + if (!locals.user) error(401, 'Not logged in'); + + try { + const m = await getMatchFull(params.id); + return json(m); + } catch (e) { + error(404, e.message); + } +}; + +export const DELETE: RequestHandler = async ({ params, locals }) => { + if (!locals.user) error(401, 'Not logged in'); + + try { + const m = await cancelMatch(params.id); + return json(m); + } catch (e) { + error(400, e.message); + } +}; diff --git a/src/routes/api/matches/[id]/lobby/+server.ts b/src/routes/api/matches/[id]/lobby/+server.ts new file mode 100644 index 0000000..bcd4c93 --- /dev/null +++ b/src/routes/api/matches/[id]/lobby/+server.ts @@ -0,0 +1,16 @@ +import { json, error } from '@sveltejs/kit'; +import { moveToLobby } from '$lib/server/match/engine'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params, request, locals }) => { + if (!locals.user) error(401, 'Not logged in'); + + const body = await request.json().catch(() => ({})); + + try { + const m = await moveToLobby(params.id, body.osuLobbyId); + return json(m); + } catch (e) { + error(400, e.message); + } +}; diff --git a/src/routes/api/matches/[id]/pick/+server.ts b/src/routes/api/matches/[id]/pick/+server.ts new file mode 100644 index 0000000..808679f --- /dev/null +++ b/src/routes/api/matches/[id]/pick/+server.ts @@ -0,0 +1,19 @@ +import { json, error } from '@sveltejs/kit'; +import { pickMap } from '$lib/server/match/engine'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params, request, locals }) => { + if (!locals.user) error(401, 'Not logged in'); + + const { participantId, mappoolSlotId } = await request.json(); + if (!participantId || !mappoolSlotId) { + error(400, 'participantId and mappoolSlotId required'); + } + + try { + const game = await pickMap(params.id, participantId, mappoolSlotId); + return json(game); + } catch (e) { + error(400, e.message); + } +}; diff --git a/src/routes/api/matches/[id]/roll/+server.ts b/src/routes/api/matches/[id]/roll/+server.ts new file mode 100644 index 0000000..d2bf742 --- /dev/null +++ b/src/routes/api/matches/[id]/roll/+server.ts @@ -0,0 +1,19 @@ +import { json, error } from '@sveltejs/kit'; +import { submitRoll } from '$lib/server/match/engine'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params, request, locals }) => { + if (!locals.user) error(401, 'Not logged in'); + + const { participantId, value } = await request.json(); + if (!participantId || value == null) { + error(400, 'participantId and value required'); + } + + try { + const m = await submitRoll(params.id, participantId, value); + return json(m); + } catch (e) { + error(400, e.message); + } +}; diff --git a/src/routes/api/matches/[id]/rolling/+server.ts b/src/routes/api/matches/[id]/rolling/+server.ts new file mode 100644 index 0000000..b3b538e --- /dev/null +++ b/src/routes/api/matches/[id]/rolling/+server.ts @@ -0,0 +1,14 @@ +import { json, error } from '@sveltejs/kit'; +import { moveToRolling } from '$lib/server/match/engine'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params, locals }) => { + if (!locals.user) error(401, 'Not logged in'); + + try { + const m = await moveToRolling(params.id); + return json(m); + } catch (e) { + error(400, e.message); + } +}; diff --git a/src/routes/api/matches/[id]/score/+server.ts b/src/routes/api/matches/[id]/score/+server.ts new file mode 100644 index 0000000..8ceafe1 --- /dev/null +++ b/src/routes/api/matches/[id]/score/+server.ts @@ -0,0 +1,19 @@ +import { json, error } from '@sveltejs/kit'; +import { submitGameScores } from '$lib/server/match/engine'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user) error(401, 'Not logged in'); + + const { matchGameId, scores } = await request.json(); + if (!matchGameId || !scores?.length) { + error(400, 'matchGameId and scores required'); + } + + try { + const m = await submitGameScores(matchGameId, scores); + return json(m); + } catch (e) { + error(400, e.message); + } +}; diff --git a/src/routes/api/queue/+server.ts b/src/routes/api/queue/+server.ts new file mode 100644 index 0000000..6715e2e --- /dev/null +++ b/src/routes/api/queue/+server.ts @@ -0,0 +1,42 @@ +import { json, error } from '@sveltejs/kit'; +import { joinQueue, leaveQueue, getQueueStatus } from '$lib/server/match/engine'; +import { db } from '$lib/server/db'; +import { team, teamMember } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.user) error(401, 'Not logged in'); + + const status = await getQueueStatus(locals.user.id); + return json(status); +}; + +export const POST: RequestHandler = async ({ locals }) => { + if (!locals.user) error(401, 'Not logged in'); + + // Find user's personal team + const membership = await db.query.teamMember.findFirst({ + where: eq(teamMember.userId, locals.user.id), + with: { team: true } + }); + + const personalTeam = membership?.team; + if (!personalTeam?.isPersonal) { + error(400, 'No personal team found'); + } + + try { + const result = await joinQueue(locals.user.id, personalTeam.id); + return json({ matched: !!result, match: result }); + } catch (e) { + error(400, e.message); + } +}; + +export const DELETE: RequestHandler = async ({ locals }) => { + if (!locals.user) error(401, 'Not logged in'); + + await leaveQueue(locals.user.id); + return json({ ok: true }); +};