osu match playing
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
>
|
||||
V
|
||||
</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>
|
||||
|
||||
<!-- Nav -->
|
||||
|
||||
281
src/lib/server/bancho/client.ts
Normal file
281
src/lib/server/bancho/client.ts
Normal 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);
|
||||
}
|
||||
@@ -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] })
|
||||
}));
|
||||
|
||||
523
src/lib/server/match/engine.ts
Normal file
523
src/lib/server/match/engine.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
331
src/lib/server/match/orchestrator.ts
Normal file
331
src/lib/server/match/orchestrator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
24
src/lib/server/match/types.ts
Normal file
24
src/lib/server/match/types.ts
Normal 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];
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import type { LayoutServerLoad } from "./$types";
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
|
||||
52
src/routes/(app)/+page.server.ts
Normal file
52
src/routes/(app)/+page.server.ts
Normal 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)
|
||||
};
|
||||
};
|
||||
@@ -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<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>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<h1 class="font-700 text-2xl tracking-tight">
|
||||
{#if data.user}
|
||||
Welcome back, <span class="text-accent">{data.user.name}</span>
|
||||
{:else}
|
||||
Welcome to <span class="text-accent">VASH</span>
|
||||
{/if}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-text-secondary">Automated esports match management for osu!</p>
|
||||
{#if !data.user}
|
||||
<div class="mt-20 text-center">
|
||||
<h1 class="text-4xl font-800 tracking-tight"><span class="text-accent">VASH</span></h1>
|
||||
<p class="mt-3 text-text-secondary">Automated esports match management for osu!</p>
|
||||
<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">
|
||||
Sign in with osu!
|
||||
</a>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
<!-- 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="/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>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -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]
|
||||
});
|
||||
|
||||
|
||||
71
src/routes/(app)/matches/+page.server.ts
Normal file
71
src/routes/(app)/matches/+page.server.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
188
src/routes/(app)/matches/+page.svelte
Normal file
188
src/routes/(app)/matches/+page.svelte
Normal 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>
|
||||
136
src/routes/(app)/matches/[id]/+page.server.ts
Normal file
136
src/routes/(app)/matches/[id]/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
389
src/routes/(app)/matches/[id]/+page.svelte
Normal file
389
src/routes/(app)/matches/[id]/+page.svelte
Normal 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">←</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} · First to {winsNeeded}
|
||||
{#if m.osuLobbyId}
|
||||
· <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}] · ★{bm.starRating.toFixed(1)} · {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)} · {bm.bpm}bpm · {formatLength(bm.totalLength)}</p>
|
||||
{/if}
|
||||
<p class="mt-3 text-xs text-text-secondary animate-pulse">
|
||||
Waiting for players to ready up & 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>
|
||||
41
src/routes/api/matches/+server.ts
Normal file
41
src/routes/api/matches/+server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
25
src/routes/api/matches/[id]/+server.ts
Normal file
25
src/routes/api/matches/[id]/+server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
16
src/routes/api/matches/[id]/lobby/+server.ts
Normal file
16
src/routes/api/matches/[id]/lobby/+server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
19
src/routes/api/matches/[id]/pick/+server.ts
Normal file
19
src/routes/api/matches/[id]/pick/+server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
19
src/routes/api/matches/[id]/roll/+server.ts
Normal file
19
src/routes/api/matches/[id]/roll/+server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
14
src/routes/api/matches/[id]/rolling/+server.ts
Normal file
14
src/routes/api/matches/[id]/rolling/+server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
19
src/routes/api/matches/[id]/score/+server.ts
Normal file
19
src/routes/api/matches/[id]/score/+server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
42
src/routes/api/queue/+server.ts
Normal file
42
src/routes/api/queue/+server.ts
Normal 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 });
|
||||
};
|
||||
Reference in New Issue
Block a user