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

@@ -5,6 +5,7 @@
"": { "": {
"name": "esports", "name": "esports",
"dependencies": { "dependencies": {
"bancho.js": "^0.12.3",
"postgres": "^3.4.8", "postgres": "^3.4.8",
}, },
"devDependencies": { "devDependencies": {
@@ -330,6 +331,8 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "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-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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="],

View File

@@ -54,6 +54,7 @@
"vitest-browser-svelte": "^2.0.2" "vitest-browser-svelte": "^2.0.2"
}, },
"dependencies": { "dependencies": {
"bancho.js": "^0.12.3",
"postgres": "^3.4.8" "postgres": "^3.4.8"
} }
} }

View File

@@ -24,7 +24,7 @@
> >
V V
</div> </div>
<span class="font-700 text-base tracking-tight text-text-primary">VASH</span> <span class="font-700 text-base tracking-tight text-text-primary">Vash Esports</span>
</a> </a>
<!-- Nav --> <!-- Nav -->

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, uniqueIndex,
real real
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export * from './auth.schema'; export * from './auth.schema';
@@ -51,6 +52,7 @@ export const mappoolSlot = pgTable('mappool_slot', {
category: text('category').notNull(), category: text('category').notNull(),
orderInCategory: integer('order_in_category').notNull(), orderInCategory: integer('order_in_category').notNull(),
beatmapId: text('beatmap_id').notNull(), beatmapId: text('beatmap_id').notNull(),
starRating: real('star_rating'),
mods: text('mods').array().default([]).notNull() mods: text('mods').array().default([]).notNull()
}); });
@@ -91,7 +93,7 @@ export const matchParticipant = pgTable(
pickOrder: integer('pick_order') pickOrder: integer('pick_order')
}, },
(t) => [ (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), uniqueIndex('idx_mp_match_slot').on(t.matchId, t.slot),
index('idx_mp_match').on(t.matchId) index('idx_mp_match').on(t.matchId)
] ]
@@ -158,7 +160,33 @@ export const matchGameScore = pgTable(
(t) => [index('idx_mgs_game').on(t.matchGameId)] (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 ─────────────────────────────────────────────────────────── // ── Relations ───────────────────────────────────────────────────────────
@@ -210,3 +238,7 @@ export const matchGameScoreRelations = relations(matchGameScore, ({ one }) => ({
references: [matchParticipantPlayer.id] 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];

View File

@@ -1,4 +1,4 @@
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ locals }) => { export const load: LayoutServerLoad = async ({ locals }) => {
return { return {

View File

@@ -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<number>`count(*)` })
.from(matchQueue);
const poolCount = await db
.select({ count: sql<number>`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)
};
};

View File

@@ -3,44 +3,200 @@
import './layout.css'; import './layout.css';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let joining = $state(false);
let leaving = $state(false);
let queueTimer = $state('');
let queueInterval: ReturnType<typeof setInterval> | 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<string, string> = {
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<string, string> = {
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';
}
</script> </script>
<div class="mx-auto max-w-4xl"> <div class="mx-auto max-w-4xl">
<h1 class="font-700 text-2xl tracking-tight"> {#if !data.user}
{#if data.user} <div class="mt-20 text-center">
Welcome back, <span class="text-accent">{data.user.name}</span> <h1 class="text-4xl font-800 tracking-tight"><span class="text-accent">VASH</span></h1>
{:else} <p class="mt-3 text-text-secondary">Automated esports match management for osu!</p>
Welcome to <span class="text-accent">VASH</span> <a href="/api/auth/osu/login" class="mt-6 inline-block rounded-md bg-accent px-6 py-2.5 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover">
{/if} Sign in with osu!
</h1>
<p class="mt-2 text-sm text-text-secondary">Automated esports match management for osu!</p>
<div class="mt-8 grid grid-cols-3 gap-4">
<a
href="/matches"
class="group rounded-lg border border-border bg-surface-800 p-5 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<div class="text-2xl">⚔</div>
<h2 class="font-600 mt-3 text-sm">Matches</h2>
<p class="mt-1 text-xs text-text-secondary">Create and manage competitive matches</p>
</a>
<a
href="/mappools"
class="group rounded-lg border border-border bg-surface-800 p-5 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<div class="text-2xl">♫</div>
<h2 class="font-600 mt-3 text-sm">Mappools</h2>
<p class="mt-1 text-xs text-text-secondary">Build and share beatmap pools</p>
</a>
<a
href="/teams"
class="group rounded-lg border border-border bg-surface-800 p-5 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<div class="text-2xl">⚑</div>
<h2 class="font-600 mt-3 text-sm">Teams</h2>
<p class="mt-1 text-xs text-text-secondary">Manage your teams and rosters</p>
</a> </a>
</div> </div>
{:else}
<!-- Stats -->
<div class="flex items-center gap-6">
<h1 class="text-2xl font-700 tracking-tight">
Welcome back, <span class="text-accent">{data.user.name}</span>
</h1>
<div class="ml-auto flex items-center gap-4">
<div class="rounded-lg border border-border bg-surface-800 px-4 py-2 text-center">
<p class="text-xs text-text-secondary">ELO</p>
<p class="text-lg font-700 tabular-nums">{data.rating?.elo ?? 1000}</p>
</div>
<div class="rounded-lg border border-border bg-surface-800 px-4 py-2 text-center">
<p class="text-xs text-text-secondary">W / L</p>
<p class="text-lg font-700 tabular-nums">
<span class="text-green-400">{data.rating?.wins ?? 0}</span>
<span class="text-text-secondary">/</span>
<span class="text-red-400">{data.rating?.losses ?? 0}</span>
</p>
</div>
</div>
</div>
<!-- Queue -->
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-6">
{#if data.queueStatus?.inQueue}
<div class="flex items-center justify-between">
<div>
<h2 class="text-sm font-600">In Queue</h2>
<p class="mt-1 text-sm text-text-secondary">
Searching for opponent...
<span class="ml-2 font-mono text-accent">{queueTimer}</span>
</p>
<p class="mt-1 text-xs text-text-secondary">
{data.queueStatus.queueSize} player{data.queueStatus.queueSize !== 1 ? 's' : ''} in queue
</p>
</div>
<button onclick={leaveQueue} disabled={leaving} class="rounded-md border border-red-500/30 px-4 py-2 text-sm font-600 text-red-400 transition-colors hover:bg-red-500/10 disabled:opacity-50">
{leaving ? 'Leaving...' : 'Leave Queue'}
</button>
</div>
<div class="mt-4 flex items-center gap-2">
<div class="relative h-2.5 w-2.5">
<div class="absolute inset-0 animate-ping rounded-full bg-accent opacity-75"></div>
<div class="relative h-2.5 w-2.5 rounded-full bg-accent"></div>
</div>
<span class="text-xs text-text-secondary">Matchmaking active</span>
</div>
{:else}
<div class="flex items-center justify-between">
<div>
<h2 class="text-sm font-600">Ranked Queue</h2>
<p class="mt-1 text-xs text-text-secondary">
{#if data.poolCount > 0}
1v1 · Best of 7 ·
{data.queueStatus?.queueSize ?? 0} searching
{:else}
<span class="text-yellow-400">No mappools available — create one first</span>
{/if}
</p>
</div>
<button onclick={joinQueue} disabled={joining || data.poolCount === 0} class="rounded-md bg-accent px-6 py-2.5 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50">
{joining ? 'Joining...' : 'Find Match'}
</button>
</div>
{/if}
</div>
<!-- Recent Matches -->
<div class="mt-6">
<h2 class="text-sm font-600">Recent Matches</h2>
<div class="mt-3 flex flex-col gap-2">
{#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}
<a href="/matches/{m.id}" class="flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/30 hover:bg-surface-700">
<div class="h-10 w-1 rounded-full {won ? 'bg-green-400' : lost ? 'bg-red-400' : 'bg-text-secondary/30'}"></div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-600">{m.name}</span>
<span class="text-xs {stateColor(m.state)}">{stateLabel(m.state)}</span>
</div>
<div class="mt-0.5 text-xs text-text-secondary">
{userP?.team.name ?? '?'} vs {oppP?.team.name ?? '?'}
</div>
</div>
{#if p1 && p2}
<div class="flex items-center gap-1 font-mono text-sm font-700 tabular-nums">
<span class="{isUserP1 ? 'text-accent' : 'text-text-primary'}">{p1.score}</span>
<span class="text-text-secondary">-</span>
<span class="{!isUserP1 ? 'text-accent' : 'text-text-primary'}">{p2.score}</span>
</div>
{/if}
<span class="text-xs text-text-secondary">{new Date(m.createdAt).toLocaleDateString()}</span>
</a>
{:else}
<div class="rounded-lg border border-dashed border-border py-8 text-center">
<p class="text-sm text-text-secondary">No matches yet. Join the queue!</p>
</div>
{/each}
</div>
</div>
<!-- Quick Actions -->
<div class="mt-6 grid grid-cols-3 gap-4">
<a href="/matches" class="group rounded-lg border border-border bg-surface-800 p-5 transition-colors hover:border-accent/40 hover:bg-surface-700">
<div class="text-2xl">⚔</div>
<h2 class="mt-3 text-sm font-600">All Matches</h2>
<p class="mt-1 text-xs text-text-secondary">View and manage matches</p>
</a>
<a href="/mappools" class="group rounded-lg border border-border bg-surface-800 p-5 transition-colors hover:border-accent/40 hover:bg-surface-700">
<div class="text-2xl">♫</div>
<h2 class="mt-3 text-sm font-600">Mappools</h2>
<p class="mt-1 text-xs text-text-secondary">Build beatmap pools</p>
</a>
<a href="/teams" class="group rounded-lg border border-border bg-surface-800 p-5 transition-colors hover:border-accent/40 hover:bg-surface-700">
<div class="text-2xl">⚑</div>
<h2 class="mt-3 text-sm font-600">Teams</h2>
<p class="mt-1 text-xs text-text-secondary">Manage your teams</p>
</a>
</div>
{/if}
</div> </div>

View File

@@ -63,19 +63,18 @@ export const actions: Actions = {
return { error: 'Beatmap ID and category are required' }; return { error: 'Beatmap ID and category are required' };
} }
// Parse URL if needed
const urlMatch = beatmapId.match(/beatmaps\/(\d+)/); const urlMatch = beatmapId.match(/beatmaps\/(\d+)/);
const setMatch = beatmapId.match(/beatmapsets\/\d+#\w+\/(\d+)/); const setMatch = beatmapId.match(/beatmapsets\/\d+#\w+\/(\d+)/);
if (urlMatch) beatmapId = urlMatch[1]; if (urlMatch) beatmapId = urlMatch[1];
else if (setMatch) beatmapId = setMatch[1]; else if (setMatch) beatmapId = setMatch[1];
// Strip non-numeric
if (!/^\d+$/.test(beatmapId)) { if (!/^\d+$/.test(beatmapId)) {
return { error: 'Invalid beatmap ID' }; return { error: 'Invalid beatmap ID' };
} }
let beatmap;
try { try {
await getBeatmap(beatmapId); beatmap = await getBeatmap(beatmapId);
} catch { } catch {
return { error: 'Beatmap not found on osu!' }; return { error: 'Beatmap not found on osu!' };
} }
@@ -92,6 +91,7 @@ export const actions: Actions = {
beatmapId, beatmapId,
category, category,
orderInCategory: existing.length + 1, orderInCategory: existing.length + 1,
starRating: beatmap.difficulty_rating,
mods: category === 'NM' || category === 'TB' || category === 'FM' ? [] : [category] mods: category === 'NM' || category === 'TB' || category === 'FM' ? [] : [category]
}); });

View File

@@ -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}`);
}
};

View File

@@ -0,0 +1,188 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data } = $props();
let creating = $state(false);
let createError = $state('');
function stateColor(state: string) {
const colors: Record<string, string> = {
CREATED: 'border-border text-text-secondary',
LOBBY: 'border-yellow-500/30 text-yellow-400',
ROLLING: 'border-yellow-500/30 text-yellow-400',
PICKING: 'border-blue-500/30 text-blue-400',
PLAYING: 'border-green-500/30 text-green-400',
FINISHED: 'border-border text-text-secondary',
CANCELLED: 'border-red-500/30 text-red-400'
};
return colors[state] ?? 'border-border text-text-secondary';
}
function poolLabel(pool: { name: string; slots: { starRating: number | null }[] }) {
if (pool.slots.length === 0) return pool.name;
const avg = pool.slots.reduce((s, sl) => s + (sl.starRating ?? 0), 0) / pool.slots.length;
return `${pool.name} (★${avg.toFixed(1)}, ${pool.slots.length} maps)`;
}
</script>
<div class="mx-auto max-w-4xl">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-700 tracking-tight">Matches</h1>
<p class="mt-1 text-sm text-text-secondary">All matches</p>
</div>
<button
onclick={() => (creating = !creating)}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
>
{creating ? 'Cancel' : 'New Match'}
</button>
</div>
<!-- Create Match Form -->
{#if creating}
<form
method="post"
action="?/createMatch"
use:enhance={() => {
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"
>
<h2 class="text-sm font-600">Create Match</h2>
<div class="mt-4 grid grid-cols-2 gap-4">
<div>
<label for="match-name" class="text-xs text-text-secondary">Match Name</label>
<input
id="match-name"
type="text"
name="name"
placeholder="e.g. Grand Finals"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
</div>
<div>
<label for="best-of" class="text-xs text-text-secondary">Best Of</label>
<select
id="best-of"
name="bestOf"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
<option value="3">Best of 3</option>
<option value="5">Best of 5</option>
<option value="7" selected>Best of 7</option>
<option value="9">Best of 9</option>
<option value="11">Best of 11</option>
</select>
</div>
<div>
<label for="team1" class="text-xs text-text-secondary">Team 1</label>
<select
id="team1"
name="team1"
required
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
<option value="">Select team...</option>
{#each data.teams as t}
<option value={t.id}>
{t.name}{t.isPersonal ? ' (personal)' : ''}
</option>
{/each}
</select>
</div>
<div>
<label for="team2" class="text-xs text-text-secondary">Team 2</label>
<select
id="team2"
name="team2"
required
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
<option value="">Select team...</option>
{#each data.teams as t}
<option value={t.id}>
{t.name}{t.isPersonal ? ' (personal)' : ''}
</option>
{/each}
</select>
</div>
<div class="col-span-2">
<label for="mappool" class="text-xs text-text-secondary">Mappool</label>
<select
id="mappool"
name="mappool"
required
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
<option value="">Select mappool...</option>
{#each data.mappools as pool}
<option value={pool.id}>{poolLabel(pool)}</option>
{/each}
</select>
</div>
</div>
{#if createError}
<p class="mt-3 text-sm text-red-400">{createError}</p>
{/if}
<button
type="submit"
class="mt-4 rounded-md bg-accent px-5 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
>
Create Match
</button>
</form>
{/if}
<!-- Match List -->
<div class="mt-6 flex flex-col gap-2">
{#each data.matches as m}
{@const p1 = m.participants[0]}
{@const p2 = m.participants[1]}
<a
href="/matches/{m.id}"
class="flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-4 transition-colors hover:border-accent/30 hover:bg-surface-700"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-600">{m.name}</span>
<span class="rounded border px-2 py-0.5 text-xs font-600 {stateColor(m.state)}">
{m.state}
</span>
</div>
<p class="mt-1 text-xs text-text-secondary">
{p1?.team.name ?? '?'} vs {p2?.team.name ?? '?'}
· {new Date(m.createdAt).toLocaleDateString()}
</p>
</div>
{#if p1 && p2}
<div class="flex items-center gap-1 font-mono text-lg font-700 tabular-nums">
<span>{p1.score}</span>
<span class="text-text-secondary">-</span>
<span>{p2.score}</span>
</div>
{/if}
</a>
{:else}
<div class="rounded-lg border border-dashed border-border py-12 text-center">
<p class="text-sm text-text-secondary">No matches yet</p>
</div>
{/each}
</div>
</div>

View File

@@ -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<string, any> = {};
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 <slot> (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');
}
};

View File

@@ -0,0 +1,389 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
let { data } = $props();
let m = $derived(data.match);
let p1 = $derived(m.participants[0]);
let p2 = $derived(m.participants[1]);
const config = $derived(m.config as { bestOf: number; teamSize: number; scoringType: string });
const winsNeeded = $derived(Math.ceil(config.bestOf / 2));
const playedSlotIds = $derived(new Set(m.games.map((g: any) => g.mappoolSlotId)));
// Pick order
const sortedByPick = $derived(
[...m.participants].sort((a: any, b: any) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99))
);
const expectedPickerIdx = $derived(m.games.length % sortedByPick.length);
const expectedPicker = $derived(sortedByPick[expectedPickerIdx]);
const canRoll = $derived(
m.state === 'ROLLING' && m.participants.some((p: any) => p.rollValue === null)
);
const catColors: Record<string, string> = {
NM: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
HD: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
HR: 'bg-red-500/20 text-red-400 border-red-500/30',
DT: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
FM: 'bg-green-500/20 text-green-400 border-green-500/30',
TB: 'bg-pink-500/20 text-pink-400 border-pink-500/30'
};
// Group mappool slots by category
const groupedSlots = $derived(() => {
if (!m.mappool?.slots) return {};
const groups: Record<string, any[]> = {};
for (const slot of m.mappool.slots) {
if (!groups[slot.category]) groups[slot.category] = [];
groups[slot.category].push(slot);
}
for (const cat of Object.keys(groups)) {
groups[cat].sort((a: any, b: any) => a.orderInCategory - b.orderInCategory);
}
return groups;
});
let rolling = $state(false);
let picking = $state(false);
let pickError = $state('');
let forceStarting = $state(false);
// Poll while match is active (every 3s)
let pollInterval: ReturnType<typeof setInterval> | null = null;
$effect(() => {
if (['CREATED', 'LOBBY', 'ROLLING', 'PICKING', 'PLAYING'].includes(m.state)) {
pollInterval = setInterval(() => invalidateAll(), 3000);
}
return () => {
if (pollInterval) clearInterval(pollInterval);
};
});
function formatLength(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function stateLabel(s: string) {
return ({ CREATED: 'Waiting', LOBBY: 'In Lobby', ROLLING: 'Rolling', PICKING: 'Pick Phase', PLAYING: 'Playing', FINISHED: 'Finished', CANCELLED: 'Cancelled' })[s] ?? s;
}
function stateBorder(s: string) {
return ({ ROLLING: 'border-yellow-500/30', PICKING: 'border-blue-500/30', PLAYING: 'border-green-500/30', FINISHED: 'border-accent/30' })[s] ?? 'border-border';
}
</script>
<div class="mx-auto max-w-5xl">
<!-- Header -->
<div class="flex items-center gap-3">
<a href="/matches" class="text-text-secondary transition-colors hover:text-text-primary">&larr;</a>
<div class="flex-1">
<h1 class="text-xl font-700 tracking-tight">{m.name || 'Match'}</h1>
<p class="mt-0.5 text-xs text-text-secondary">
Best of {config.bestOf} &middot; First to {winsNeeded}
{#if m.osuLobbyId}
&middot; <a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a>
{/if}
</p>
</div>
<span class="rounded border px-2.5 py-1 text-xs font-600 {stateBorder(m.state)}">{stateLabel(m.state)}</span>
{#if !['FINISHED', 'CANCELLED'].includes(m.state)}
<form method="post" action="?/cancel" use:enhance>
<button type="submit" onclick={(e) => { if (!confirm('Cancel this match?')) e.preventDefault() }} class="rounded-md border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10">Cancel</button>
</form>
{/if}
</div>
<!-- Scoreboard -->
{#if p1 && p2}
<div class="mt-6 flex items-center gap-4">
<!-- Team 1 -->
<div class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p1.teamId ? 'border-green-500/40 bg-green-500/5' : 'border-border bg-surface-800'}">
{#if p1.team.avatarUrl}
<img src={p1.team.avatarUrl} alt="" class="h-10 w-10 rounded-full" />
{/if}
<div class="flex-1">
<p class="text-sm font-600">{p1.team.name}</p>
{#if p1.rollValue != null}
<p class="text-xs text-text-secondary">Roll: {p1.rollValue}{p1.pickOrder === 1 ? ' ★' : ''}</p>
{/if}
</div>
<span class="text-3xl font-800 tabular-nums {p1.score >= winsNeeded ? 'text-green-400' : 'text-text-primary'}">{p1.score}</span>
</div>
<span class="text-lg font-700 text-text-secondary">vs</span>
<!-- Team 2 -->
<div class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p2.teamId ? 'border-green-500/40 bg-green-500/5' : 'border-border bg-surface-800'}">
<span class="text-3xl font-800 tabular-nums {p2.score >= winsNeeded ? 'text-green-400' : 'text-text-primary'}">{p2.score}</span>
<div class="flex-1 text-right">
<p class="text-sm font-600">{p2.team.name}</p>
{#if p2.rollValue != null}
<p class="text-xs text-text-secondary">{p2.pickOrder === 1 ? '★ ' : ''}Roll: {p2.rollValue}</p>
{/if}
</div>
{#if p2.team.avatarUrl}
<img src={p2.team.avatarUrl} alt="" class="h-10 w-10 rounded-full" />
{/if}
</div>
</div>
{/if}
<!-- ROLLING MODAL -->
{#if m.state === 'ROLLING'}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="w-full max-w-md rounded-xl border border-yellow-500/30 bg-surface-800 p-8 text-center shadow-2xl">
<h2 class="text-lg font-700">🎲 Roll Phase</h2>
<p class="mt-2 text-sm text-text-secondary">Highest roll picks first</p>
<div class="mt-6 flex justify-center gap-6">
{#each m.participants as p}
<div class="rounded-lg border border-border bg-surface-700 px-6 py-4">
<p class="text-xs text-text-secondary">{p.team.name}</p>
{#if p.rollValue != null}
<p class="mt-1 text-2xl font-800 text-yellow-400">{p.rollValue}</p>
{:else}
<p class="mt-1 text-2xl font-800 text-text-secondary/30">—</p>
{/if}
</div>
{/each}
</div>
{#if canRoll}
<form method="post" action="?/roll" use:enhance={() => {
rolling = true;
return async ({ update }) => { rolling = false; await update(); };
}}>
<button type="submit" disabled={rolling} class="mt-6 rounded-md bg-accent px-8 py-3 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50">
{rolling ? 'Rolling...' : '🎲 Roll!'}
</button>
</form>
<p class="mt-3 text-xs text-text-secondary">or type <code class="rounded bg-surface-700 px-1.5 py-0.5 font-mono text-accent">!roll</code> in osu! chat</p>
{:else}
<p class="mt-6 text-sm text-text-secondary animate-pulse">Waiting for all rolls...</p>
{/if}
</div>
</div>
{/if}
<!-- PICK MODAL -->
{#if m.state === 'PICKING'}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="w-full max-w-3xl max-h-[80vh] overflow-y-auto rounded-xl border border-blue-500/30 bg-surface-800 p-6 shadow-2xl">
<div class="text-center">
<h2 class="text-lg font-700">🎯 Pick a Map</h2>
<p class="mt-1 text-sm">
<span class="font-600 text-accent">{expectedPicker?.team.name}</span>'s turn to pick
</p>
<p class="mt-1 text-xs text-text-secondary">
or type <code class="rounded bg-surface-700 px-1.5 py-0.5 font-mono text-accent">!pick NM1</code> in osu! chat
</p>
</div>
{#if pickError}
<p class="mt-3 text-center text-sm text-red-400">{pickError}</p>
{/if}
<div class="mt-6 flex flex-col gap-4">
{#each Object.entries(groupedSlots()) as [category, slots]}
<div>
<span class="mb-2 inline-block rounded border px-2 py-0.5 text-xs font-600 {catColors[category] ?? 'border-border'}">
{category}
</span>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
{#each slots as slot, i}
{@const bm = data.beatmapCache[slot.beatmapId]}
{@const isPlayed = playedSlotIds.has(slot.id)}
<form method="post" action="?/pick" use:enhance={() => {
picking = true;
pickError = '';
return async ({ result, update }) => {
picking = false;
if (result.type === 'failure' || (result.type === 'success' && (result.data as any)?.error)) {
pickError = (result.data as any)?.error ?? 'Pick failed';
} else {
await update();
}
};
}}>
<input type="hidden" name="slotId" value={slot.id} />
<button
type="submit"
disabled={isPlayed || picking}
class="flex w-full items-center gap-3 rounded-lg border p-2 text-left transition-all {isPlayed
? 'border-border/50 bg-surface-900 opacity-30 cursor-not-allowed'
: 'border-border bg-surface-700 hover:border-accent hover:bg-surface-600 cursor-pointer'}"
>
{#if bm?.listCoverUrl}
<img src={bm.listCoverUrl} alt="" class="h-10 w-20 rounded object-cover" />
{:else}
<div class="flex h-10 w-20 items-center justify-center rounded bg-surface-600 text-xs text-text-secondary">?</div>
{/if}
<div class="min-w-0 flex-1">
{#if bm}
<p class="truncate text-xs font-500">{bm.artist} - {bm.title}</p>
<p class="text-xs text-text-secondary">[{bm.version}] &middot; ★{bm.starRating.toFixed(1)} &middot; {bm.bpm}bpm</p>
{:else}
<p class="text-xs text-text-secondary">#{slot.beatmapId}</p>
{/if}
</div>
<span class="text-xs font-600 text-text-secondary">{category}{slot.orderInCategory}</span>
{#if isPlayed}<span class="text-xs text-text-secondary">✓</span>{/if}
</button>
</form>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- PLAYING INDICATOR -->
{#if m.state === 'PLAYING'}
{@const currentGame = m.games[m.games.length - 1]}
{@const bm = currentGame ? data.beatmapCache[currentGame.slot?.beatmapId] : null}
<div class="mt-6 rounded-lg border border-green-500/30 bg-green-500/5 p-5 text-center">
<div class="flex items-center justify-center gap-2">
<div class="relative h-3 w-3">
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
<div class="relative h-3 w-3 rounded-full bg-green-400"></div>
</div>
<span class="text-sm font-600 text-green-400">Now Playing</span>
</div>
{#if bm}
<p class="mt-2 text-sm">{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span></p>
<p class="mt-1 text-xs text-text-secondary">★{bm.starRating.toFixed(1)} &middot; {bm.bpm}bpm &middot; {formatLength(bm.totalLength)}</p>
{/if}
<p class="mt-3 text-xs text-text-secondary animate-pulse">
Waiting for players to ready up &amp; play in osu!...
</p>
<!-- Force Start button -->
<form method="post" action="?/forceStart" use:enhance={() => {
forceStarting = true;
return async ({ update }) => { forceStarting = false; await update(); };
}}>
<button
type="submit"
disabled={forceStarting}
class="mt-4 rounded-md border border-yellow-500/30 px-4 py-2 text-xs font-600 text-yellow-400 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
>
{forceStarting ? 'Starting...' : 'Force Start (skip ready)'}
</button>
</form>
</div>
{/if}
<!-- FINISHED MODAL -->
{#if m.state === 'FINISHED'}
{@const winner = m.participants.find((p: any) => p.teamId === m.winnerId)}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="w-full max-w-md rounded-xl border border-accent/30 bg-surface-800 p-8 text-center shadow-2xl">
<div class="text-4xl">🏆</div>
<h2 class="mt-4 text-2xl font-800">
<span class="text-accent">{winner?.team.name}</span> wins!
</h2>
<p class="mt-2 text-lg font-700 tabular-nums text-text-secondary">
{p1?.score} – {p2?.score}
</p>
<!-- Game summary -->
<div class="mt-6 flex flex-col gap-1">
{#each m.games as game}
{@const bm = data.beatmapCache[game.slot?.beatmapId]}
{@const winnerP = m.participants.find((p: any) => p.id === game.winnerParticipantId)}
<div class="flex items-center gap-2 rounded bg-surface-700 px-3 py-1.5 text-xs">
<span class="font-600 {catColors[game.slot?.category]?.split(' ')[1] ?? 'text-text-secondary'}">{game.slot?.category}{game.slot?.orderInCategory}</span>
<span class="flex-1 truncate text-text-secondary">{bm ? `${bm.artist} - ${bm.title}` : `#${game.slot?.beatmapId}`}</span>
<span class="font-600 {winnerP?.id === p1?.id ? 'text-blue-400' : 'text-red-400'}">{winnerP?.team.name}</span>
</div>
{/each}
</div>
<a href="/" class="mt-6 inline-block rounded-md bg-accent px-6 py-2.5 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover">
Back to Dashboard
</a>
</div>
</div>
{/if}
<!-- GAME HISTORY -->
{#if m.games.length > 0}
<div class="mt-6">
<h2 class="text-sm font-600">Games Played</h2>
<div class="mt-3 flex flex-col gap-2">
{#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}
<div class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3">
<span class="w-6 text-center text-xs font-600 text-text-secondary">{game.gameNumber}</span>
{#if bm?.listCoverUrl}
<img src={bm.listCoverUrl} alt="" class="h-8 w-16 rounded object-cover" />
{/if}
<div class="min-w-0 flex-1">
{#if bm}
<p class="truncate text-sm font-500">{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span></p>
{/if}
<div class="flex items-center gap-2 text-xs text-text-secondary">
<span class="rounded border px-1.5 py-0.5 {catColors[game.slot?.category] ?? 'border-border'}">{game.slot?.category}{game.slot?.orderInCategory}</span>
{#if pickerTeam}<span>Picked by {pickerTeam.name}</span>{/if}
</div>
</div>
{#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)}
<div class="flex items-center gap-2 font-mono text-sm tabular-nums">
<span class="{p1Score > p2Score ? 'font-700 text-green-400' : 'text-text-secondary'}">{p1Score.toLocaleString()}</span>
<span class="text-text-secondary">-</span>
<span class="{p2Score > p1Score ? 'font-700 text-green-400' : 'text-text-secondary'}">{p2Score.toLocaleString()}</span>
</div>
{:else if game.state === 'PLAYING'}
<span class="text-xs text-green-400 animate-pulse">Live</span>
{/if}
{#if winnerTeam}
<span class="text-xs text-green-400">✓ {winnerTeam.name}</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- CREATED / LOBBY waiting states -->
{#if m.state === 'CREATED'}
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-6 text-center">
<p class="text-sm text-text-secondary animate-pulse">Creating osu! lobby...</p>
</div>
{/if}
{#if m.state === 'LOBBY'}
<div class="mt-6 rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-6 text-center">
<p class="text-sm text-text-secondary">Lobby created. Waiting for players to join...</p>
{#if m.osuLobbyId}
<p class="mt-2 text-xs text-text-secondary">
<a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a>
</p>
{/if}
</div>
{/if}
{#if m.state === 'CANCELLED'}
<div class="mt-6 rounded-lg border border-red-500/30 bg-red-500/5 p-6 text-center">
<p class="text-sm text-red-400">This match was cancelled.</p>
</div>
{/if}
</div>

View File

@@ -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);
}
};

View File

@@ -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);
}
};

View File

@@ -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);
}
};

View File

@@ -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);
}
};

View File

@@ -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);
}
};

View File

@@ -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);
}
};

View File

@@ -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);
}
};

View File

@@ -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 });
};