diff --git a/scripts/cancel-matches.ts b/scripts/cancel-matches.ts new file mode 100644 index 0000000..f5b2ff4 --- /dev/null +++ b/scripts/cancel-matches.ts @@ -0,0 +1,44 @@ +/** + * Cancel all active matches (CREATED, LOBBY, ROLLING, PICKING, PLAYING). + * + * Usage: bun scripts/cancel-matches.ts + */ + +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { sql } from 'drizzle-orm'; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) throw new Error('DATABASE_URL not set'); + +const client = postgres(DATABASE_URL); +const db = drizzle(client); + +async function main() { + const result = await db.execute(sql` + UPDATE match + SET state = 'CANCELLED', finished_at = now() + WHERE state IN ('CREATED', 'LOBBY', 'ROLLING', 'PICKING', 'PLAYING') + RETURNING id, name, state + `); + + const rows = result as any[]; + console.log(`Cancelled ${rows.length} active match${rows.length !== 1 ? 'es' : ''}:`); + for (const r of rows) { + console.log(` ${r.id} — ${r.name ?? '(unnamed)'}`); + } + + // Also clear the queue + const queueResult = await db.execute(sql`DELETE FROM match_queue RETURNING id`); + const queueRows = queueResult as any[]; + if (queueRows.length > 0) { + console.log(`Cleared ${queueRows.length} queue entr${queueRows.length !== 1 ? 'ies' : 'y'}`); + } + + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/clear-db.ts b/scripts/clear-db.ts new file mode 100644 index 0000000..6298b72 --- /dev/null +++ b/scripts/clear-db.ts @@ -0,0 +1,59 @@ +/** + * Clear all match data from the database (matches, games, scores, queue, invites, notifications). + * Keeps users, teams, mappools, and ratings intact. + * + * Usage: bun scripts/clear-db.ts + * --all Also clear ratings, teams (except personal), and mappools + * --full Nuclear: clear everything except users and personal teams + */ + +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { sql } from 'drizzle-orm'; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) throw new Error('DATABASE_URL not set'); + +const client = postgres(DATABASE_URL); +const db = drizzle(client); + +const flag = process.argv[2]; + +async function main() { + console.log('Clearing match data...'); + + // Always clear match-related data + await db.execute(sql`DELETE FROM match_game_score`); + await db.execute(sql`DELETE FROM match_game`); + await db.execute(sql`DELETE FROM match_participant_player`); + await db.execute(sql`DELETE FROM match_participant`); + await db.execute(sql`UPDATE match SET winner_id = NULL`); + await db.execute(sql`DELETE FROM match`); + await db.execute(sql`DELETE FROM match_queue`); + await db.execute(sql`DELETE FROM match_invite`); + await db.execute(sql`DELETE FROM notification`); + console.log(' Cleared: matches, games, scores, queue, invites, notifications'); + + if (flag === '--all' || flag === '--full') { + await db.execute(sql`DELETE FROM player_rating`); + console.log(' Cleared: player ratings'); + } + + if (flag === '--full') { + await db.execute(sql`DELETE FROM team_member WHERE team_id IN (SELECT id FROM team WHERE is_personal = false)`); + await db.execute(sql`DELETE FROM team WHERE is_personal = false`); + console.log(' Cleared: non-personal teams'); + + await db.execute(sql`DELETE FROM mappool_slot`); + await db.execute(sql`DELETE FROM mappool`); + console.log(' Cleared: mappools'); + } + + console.log('Done.'); + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/seed-db.ts b/scripts/seed-db.ts new file mode 100644 index 0000000..a60221d --- /dev/null +++ b/scripts/seed-db.ts @@ -0,0 +1,99 @@ +/** + * Seed the database with test data for development. + * Creates fake teams and mappools if none exist. + * + * Usage: bun scripts/seed-db.ts + */ + +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { sql } from 'drizzle-orm'; +import { pgTable, text, uuid, boolean, timestamp, integer, real, jsonb } from 'drizzle-orm/pg-core'; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) throw new Error('DATABASE_URL not set'); + +const client = postgres(DATABASE_URL); +const db = drizzle(client); + +const mappool = pgTable('mappool', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + createdBy: text('created_by'), + createdAt: timestamp('created_at').defaultNow().notNull(), + verifiedAt: timestamp('verified_at') +}); + +const mappoolSlot = pgTable('mappool_slot', { + id: uuid('id').primaryKey().defaultRandom(), + mappoolId: uuid('mappool_id').notNull(), + category: text('category').notNull(), + orderInCategory: integer('order_in_category').notNull(), + beatmapId: text('beatmap_id').notNull(), + starRating: real('star_rating'), + bpm: real('bpm'), + totalLength: integer('total_length'), + mods: text('mods').array().default([]).notNull(), + title: text('title'), + artist: text('artist'), + version: text('version'), + coverUrl: text('cover_url'), + listCoverUrl: text('list_cover_url') +}); + +// Sample beatmap IDs (real osu! maps) +const SAMPLE_MAPS = [ + { id: '75', category: 'NM', title: 'Disco Prince', artist: 'Kenji Ninuma', sr: 2.5, bpm: 120 }, + { id: '163', category: 'NM', title: 'Crazy Noisy Bizarre Town', artist: 'The DU', sr: 3.2, bpm: 140 }, + { id: '250', category: 'HD', title: 'Sakura Kagetsu', artist: 'Mutsuhiko Izumi', sr: 4.1, bpm: 165 }, + { id: '306', category: 'HR', title: 'Airman ga Taosenai', artist: 'Team Nekokan', sr: 5.0, bpm: 200 }, + { id: '390', category: 'DT', title: 'Kira Kira Days', artist: 'MOSAIC.WAV', sr: 4.5, bpm: 175 }, + { id: '455', category: 'FM', title: 'FREEDOM DiVE', artist: 'xi', sr: 6.0, bpm: 222 }, + { id: '500', category: 'TB', title: 'Blue Zenith', artist: 'xi', sr: 6.5, bpm: 200 } +]; + +async function main() { + // Check if mappools exist + const [{ count }] = await db.select({ count: sql`count(*)` }).from(mappool); + + if (Number(count) > 0) { + console.log(`${count} mappool(s) already exist, skipping seed.`); + console.log('Run "bun scripts/clear-db.ts --full" first to clear everything.'); + process.exit(0); + } + + // Create a sample mappool + const [pool] = await db.insert(mappool).values({ + name: 'Dev Test Pool', + verifiedAt: new Date() + }).returning(); + + console.log(`Created mappool: ${pool.name} (${pool.id})`); + + for (let i = 0; i < SAMPLE_MAPS.length; i++) { + const m = SAMPLE_MAPS[i]; + const sameCategory = SAMPLE_MAPS.filter((x, j) => j <= i && x.category === m.category); + await db.insert(mappoolSlot).values({ + mappoolId: pool.id, + category: m.category, + orderInCategory: sameCategory.length, + beatmapId: m.id, + starRating: m.sr, + bpm: m.bpm, + totalLength: 120, + mods: m.category === 'NM' || m.category === 'TB' ? [] : [m.category], + title: m.title, + artist: m.artist, + version: 'Normal' + }); + } + + console.log(` Added ${SAMPLE_MAPS.length} maps (${[...new Set(SAMPLE_MAPS.map((m) => m.category))].join(', ')})`); + console.log('Done. Sign in via osu! OAuth to create a user and personal team.'); + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index e418a45..c732efb 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -6,9 +6,19 @@ import { env } from '$env/dynamic/private'; import { getRequestEvent } from '$app/server'; import { db } from '$lib/server/db'; import { genericOAuth } from 'better-auth/plugins'; -import { team, teamMember } from './db/schema'; +import { team, teamMember, playerRating } from './db/schema'; import { proxyImage } from './storage/r2'; +// Stash the osu! profile between mapProfileToUser and user.create.after +// (both run in the same request, so this is safe) +let pendingOsuProfile: { rank: number | null } | null = null; + +function eloFromRank(rank: number | null): { elo: number; osuRank: number | null } { + if (!rank || rank <= 0) return { elo: 1000, osuRank: null }; + const rawElo = 3500 - Math.log10(rank) * 500; + return { elo: Math.round(Math.max(0, Math.min(3500, rawElo))), osuRank: rank }; +} + export const auth = betterAuth({ baseURL: env.ORIGIN, secret: env.BETTER_AUTH_SECRET, @@ -33,6 +43,17 @@ export const auth = betterAuth({ userId: user.id, role: 'owner' }); + + const { elo, osuRank } = eloFromRank(pendingOsuProfile?.rank ?? null); + pendingOsuProfile = null; + + await db.insert(playerRating).values({ + userId: user.id, + elo, + initialElo: elo, + osuRankAtSeed: osuRank + }); + console.log(`[Auth] Seeded ${user.name} with ${elo} ELO (rank #${osuRank ?? 'unknown'})`); } } } @@ -81,6 +102,7 @@ export const auth = betterAuth({ }; }, async mapProfileToUser(profile) { + pendingOsuProfile = { rank: profile.statistics?.global_rank ?? null }; return { name: profile.username, image: await proxyImage(profile.avatar_url), diff --git a/src/lib/server/match/engine.ts b/src/lib/server/match/engine.ts index ff2ee83..8b9a991 100644 --- a/src/lib/server/match/engine.ts +++ b/src/lib/server/match/engine.ts @@ -18,7 +18,7 @@ import { getUser } from '$lib/server/osu/api'; // ── Initial Rating ────────────────────────────────────────────────────── -async function calculateInitialElo(userId: string): Promise<{ elo: number; osuRank: number | null }> { +export async function calculateInitialElo(userId: string): Promise<{ elo: number; osuRank: number | null }> { const osuAccount = await db.query.account.findFirst({ where: and(eq(account.userId, userId), eq(account.providerId, 'osu')) }); @@ -65,17 +65,11 @@ export async function joinQueue(userId: string, teamId: string) { const activeMatchId = await findRecentActiveMatch(userId); if (activeMatchId) throw new Error('You are already in an active match'); - let rating = await db.query.playerRating.findFirst({ + const rating = await db.query.playerRating.findFirst({ where: eq(playerRating.userId, userId) }); - if (!rating) { - const { elo: initialElo, osuRank } = await calculateInitialElo(userId); - [rating] = await db - .insert(playerRating) - .values({ userId, elo: initialElo, initialElo, osuRankAtSeed: osuRank }) - .returning(); - } + if (!rating) throw new Error('No rating found — please re-register'); const existing = await db.query.matchQueue.findFirst({ where: eq(matchQueue.userId, userId) @@ -581,16 +575,13 @@ async function updateElo(participants: { id: string; teamId: string }[], winnerI const playersWithRatings = []; for (const player of players) { - let rating = await db.query.playerRating.findFirst({ + const rating = await db.query.playerRating.findFirst({ where: eq(playerRating.userId, player.userId) }); if (!rating) { - const { elo: initialElo, osuRank } = await calculateInitialElo(player.userId); - [rating] = await db - .insert(playerRating) - .values({ userId: player.userId, elo: initialElo, initialElo, osuRankAtSeed: osuRank }) - .returning(); + console.warn(`[ELO] No rating for user ${player.userId}, skipping`); + continue; } playersWithRatings.push({ userId: player.userId, rating }); diff --git a/src/routes/(app)/admin/+page.server.ts b/src/routes/(app)/admin/+page.server.ts index bc7ed6d..eb55270 100644 --- a/src/routes/(app)/admin/+page.server.ts +++ b/src/routes/(app)/admin/+page.server.ts @@ -1,8 +1,18 @@ // src/routes/(app)/admin/+page.server.ts import { db } from '$lib/server/db'; import { user } from '$lib/server/db/auth.schema'; -import { playerRating } from '$lib/server/db/schema'; -import { eq, desc } from 'drizzle-orm'; +import { + playerRating, + match, + matchQueue, + matchInvite, + notification, + matchGame, + matchGameScore, + matchParticipant, + matchParticipantPlayer +} from '$lib/server/db/schema'; +import { eq, desc, inArray, sql } from 'drizzle-orm'; import { requireRole, setUserRole, isRootAdmin } from '$lib/server/permissions'; import type { PageServerLoad, Actions } from './$types'; @@ -33,9 +43,26 @@ export const load: PageServerLoad = async ({ locals }) => { }) ); + const [activeMatches, queueSize, pendingInvites] = await Promise.all([ + db + .select({ count: sql`count(*)` }) + .from(match) + .where(inArray(match.state, ['CREATED', 'LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])), + db.select({ count: sql`count(*)` }).from(matchQueue), + db + .select({ count: sql`count(*)` }) + .from(matchInvite) + .where(eq(matchInvite.status, 'pending')) + ]); + return { users: usersWithRatings, - actorIsRootAdmin: isRootAdmin(locals.user!.email) + actorIsRootAdmin: isRootAdmin(locals.user!.email), + stats: { + activeMatches: Number(activeMatches[0].count), + queueSize: Number(queueSize[0].count), + pendingInvites: Number(pendingInvites[0].count) + } }; }; @@ -60,5 +87,66 @@ export const actions: Actions = { } return { success: true }; + }, + + cancelAllMatches: async ({ locals }) => { + requireRole(locals, 'admin'); + + const cancelled = await db + .update(match) + .set({ state: 'CANCELLED', finishedAt: new Date() }) + .where(inArray(match.state, ['CREATED', 'LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])) + .returning({ id: match.id }); + + await db.delete(matchQueue); + + return { success: true, message: `Cancelled ${cancelled.length} match${cancelled.length !== 1 ? 'es' : ''} and cleared queue` }; + }, + + clearQueue: async ({ locals }) => { + requireRole(locals, 'admin'); + const deleted = await db.delete(matchQueue).returning({ id: matchQueue.id }); + return { success: true, message: `Removed ${deleted.length} queue entr${deleted.length !== 1 ? 'ies' : 'y'}` }; + }, + + expireInvites: async ({ locals }) => { + requireRole(locals, 'admin'); + const expired = await db + .update(matchInvite) + .set({ status: 'expired' }) + .where(eq(matchInvite.status, 'pending')) + .returning({ id: matchInvite.id }); + return { success: true, message: `Expired ${expired.length} pending invite${expired.length !== 1 ? 's' : ''}` }; + }, + + clearNotifications: async ({ locals }) => { + requireRole(locals, 'admin'); + const deleted = await db.delete(notification).returning({ id: notification.id }); + return { success: true, message: `Cleared ${deleted.length} notification${deleted.length !== 1 ? 's' : ''}` }; + }, + + resetRatings: async ({ locals }) => { + requireRole(locals, 'admin'); + const reset = await db + .update(playerRating) + .set({ elo: sql`COALESCE(initial_elo, 1000)`, wins: 0, losses: 0, updatedAt: new Date() }) + .returning({ id: playerRating.id }); + return { success: true, message: `Reset ${reset.length} rating${reset.length !== 1 ? 's' : ''} to initial ELO` }; + }, + + clearMatchHistory: async ({ locals }) => { + requireRole(locals, 'admin'); + + await db.delete(matchGameScore); + await db.delete(matchGame); + await db.delete(matchParticipantPlayer); + await db.delete(matchParticipant); + await db.update(match).set({ winnerId: null }); + await db.delete(match); + await db.delete(matchQueue); + await db.delete(matchInvite); + await db.delete(notification); + + return { success: true, message: 'Cleared all match data, invites, and notifications' }; } }; diff --git a/src/routes/(app)/admin/+page.svelte b/src/routes/(app)/admin/+page.svelte index 559d7bb..efd2981 100644 --- a/src/routes/(app)/admin/+page.svelte +++ b/src/routes/(app)/admin/+page.svelte @@ -4,6 +4,7 @@ let { data } = $props(); let actionError = $state(''); + let actionSuccess = $state(''); const roleBadge: Record = { player: { label: 'Player', color: 'bg-surface-600 text-text-secondary border-border' }, @@ -36,6 +37,9 @@ {#if actionError}

{actionError}

{/if} + {#if actionSuccess} +

{actionSuccess}

+ {/if}
@@ -126,6 +130,53 @@ {/each}
+ +
+

Tools

+ + +
+ {data.stats.activeMatches} active match{data.stats.activeMatches !== 1 ? 'es' : ''} + {data.stats.queueSize} in queue + {data.stats.pendingInvites} pending invite{data.stats.pendingInvites !== 1 ? 's' : ''} +
+ + +
+ {#each [ + { action: 'cancelAllMatches', label: 'Cancel All Matches', confirm: 'Cancel all active matches and clear the queue?', color: 'border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10' }, + { action: 'clearQueue', label: 'Clear Queue', confirm: 'Remove everyone from the queue?', color: 'border-border text-text-secondary hover:bg-surface-700' }, + { action: 'expireInvites', label: 'Expire All Invites', confirm: 'Expire all pending invites?', color: 'border-border text-text-secondary hover:bg-surface-700' }, + { action: 'clearNotifications', label: 'Clear Notifications', confirm: 'Delete all notifications?', color: 'border-border text-text-secondary hover:bg-surface-700' }, + { action: 'resetRatings', label: 'Reset Ratings', confirm: 'Reset all ELO ratings to initial values? This cannot be undone.', color: 'border-red-500/30 text-red-400 hover:bg-red-500/10' }, + { action: 'clearMatchHistory', label: 'Clear All Match Data', confirm: 'Delete ALL matches, scores, invites, and notifications? This cannot be undone.', color: 'border-red-500/30 text-red-400 hover:bg-red-500/10' }, + ] as tool} +
{ + actionError = ''; + actionSuccess = ''; + return async ({ result, update }) => { + if (result.type === 'success' && (result.data as any)?.message) { + actionSuccess = (result.data as any).message; + await update(); + } else if (result.type === 'success' && (result.data as any)?.error) { + actionError = (result.data as any).error; + } + }; + }} + > + +
+ {/each} +
+
+

Permission Levels