import { nanoid } from 'nanoid' import { db, schema, sqlite } from '../db/index.js' import { eq, and, sql } from 'drizzle-orm' import { logger } from '../lib/logger.js' type TournamentFormat = 'single_elim' | 'round_robin' type TournamentSize = 8 | 16 | 32 interface TournamentBracket { tournament: { id: string name: string format: TournamentFormat size: number entrySats: number prizeSats: number status: string currentRound: number seasonId: string | null createdAt: string startedAt: string | null finishedAt: string | null } entries: Array<{ id: string botId: string botName: string seed: number eliminated: boolean }> matches: Array<{ id: string round: number matchIndex: number botAId: string | null botBId: string | null botAName: string | null botBName: string | null fightId: string | null winnerId: string | null status: string }> } /** Create a new tournament with an empty bracket */ export function createTournament( name: string, format: TournamentFormat, size: TournamentSize, entrySats: number = 0, ): string { const id = nanoid() const now = new Date().toISOString() const prizeSats = entrySats * size db.insert(schema.tournaments).values({ id, name, format, size, entrySats, prizeSats, status: 'open', currentRound: 0, createdAt: now, }).run() logger.info('tournament', `created "${name}" (${format}, ${size} slots, ${entrySats} sats entry)`) return id } /** Add a bot to a tournament. Returns entry ID. */ export function joinTournament( tournamentId: string, botId: string, paymentId?: string, ): string { const tournament = db.select().from(schema.tournaments) .where(eq(schema.tournaments.id, tournamentId)) .get() if (!tournament) throw new Error('Tournament not found') if (tournament.status !== 'open') throw new Error('Tournament is not accepting entries') // Check if already joined const existing = db.select().from(schema.tournamentEntries) .where(and( eq(schema.tournamentEntries.tournamentId, tournamentId), eq(schema.tournamentEntries.botId, botId), )) .get() if (existing) throw new Error('Bot already entered in this tournament') // Check if full const entryCount = db.select({ count: sql`count(*)` }) .from(schema.tournamentEntries) .where(eq(schema.tournamentEntries.tournamentId, tournamentId)) .get() if (entryCount && entryCount.count >= tournament.size) { throw new Error('Tournament is full') } const entryId = nanoid() db.insert(schema.tournamentEntries).values({ id: entryId, tournamentId, botId, seed: 0, createdAt: new Date().toISOString(), }).run() logger.info('tournament', `bot ${botId} joined tournament ${tournamentId}`) return entryId } /** Start the tournament: seed bots by Elo and generate round 1 matches */ export function startTournament(tournamentId: string): void { const tournament = db.select().from(schema.tournaments) .where(eq(schema.tournaments.id, tournamentId)) .get() if (!tournament) throw new Error('Tournament not found') if (tournament.status !== 'open') throw new Error('Tournament already started') // Get entries with bot Elo for seeding const entries = db.select({ entryId: schema.tournamentEntries.id, botId: schema.tournamentEntries.botId, elo: schema.bots.eloRating, }) .from(schema.tournamentEntries) .innerJoin(schema.bots, eq(schema.tournamentEntries.botId, schema.bots.id)) .where(eq(schema.tournamentEntries.tournamentId, tournamentId)) .all() if (entries.length < 2) throw new Error('Need at least 2 entries to start') // Seed by Elo (highest = seed 1) entries.sort((a, b) => (b.elo ?? 1200) - (a.elo ?? 1200)) for (let i = 0; i < entries.length; i++) { db.update(schema.tournamentEntries) .set({ seed: i + 1 }) .where(eq(schema.tournamentEntries.id, entries[i].entryId)) .run() } // Generate round 1 matches using standard bracket seeding // Pad to tournament size with byes const bracketSize = tournament.size const seeded: (string | null)[] = new Array(bracketSize).fill(null) for (let i = 0; i < entries.length; i++) { seeded[i] = entries[i].botId } // Standard bracket: seed 1 vs seed N, seed 2 vs seed N-1, etc. const matchCount = bracketSize / 2 for (let i = 0; i < matchCount; i++) { const botAId = seeded[i] ?? null const botBId = seeded[bracketSize - 1 - i] ?? null // Determine initial status — if a bye, auto-advance let status: 'pending' | 'finished' = 'pending' let winnerId: string | null = null if (botAId && !botBId) { status = 'finished' winnerId = botAId } else if (!botAId && botBId) { status = 'finished' winnerId = botBId } else if (!botAId && !botBId) { status = 'finished' // empty match — skip } db.insert(schema.tournamentMatches).values({ id: nanoid(), tournamentId, round: 1, matchIndex: i, botAId, botBId, winnerId, status, }).run() } db.update(schema.tournaments).set({ status: 'active', currentRound: 1, startedAt: new Date().toISOString(), }).where(eq(schema.tournaments.id, tournamentId)).run() logger.info('tournament', `started tournament ${tournamentId} with ${entries.length} bots`) // If some byes auto-advanced, check if round 1 is already complete tryAdvanceRound(tournamentId, 1, bracketSize) } /** Called after a fight finishes — check if it's a tournament match and advance */ export function onFightFinished(fightId: string, winnerId: string | null): void { if (!winnerId) return const match = db.select().from(schema.tournamentMatches) .where(eq(schema.tournamentMatches.fightId, fightId)) .get() if (!match) return // Not a tournament match // Update match result db.update(schema.tournamentMatches).set({ winnerId, status: 'finished', }).where(eq(schema.tournamentMatches.id, match.id)).run() // Mark loser as eliminated const loserId = winnerId === match.botAId ? match.botBId : match.botAId if (loserId) { db.update(schema.tournamentEntries).set({ eliminated: true, }).where(and( eq(schema.tournamentEntries.tournamentId, match.tournamentId), eq(schema.tournamentEntries.botId, loserId), )).run() } const tournament = db.select().from(schema.tournaments) .where(eq(schema.tournaments.id, match.tournamentId)) .get() if (!tournament) return tryAdvanceRound(match.tournamentId, match.round, tournament.size) } /** Check if a round is complete and generate next round or finish tournament */ function tryAdvanceRound(tournamentId: string, round: number, bracketSize: number): void { const matches = db.select().from(schema.tournamentMatches) .where(and( eq(schema.tournamentMatches.tournamentId, tournamentId), eq(schema.tournamentMatches.round, round), )) .all() const allFinished = matches.every(m => m.status === 'finished') if (!allFinished) return const winners = matches .map(m => m.winnerId) .filter((id): id is string => id !== null) // If only one winner remains (or this was the final), tournament is done if (winners.length <= 1) { db.update(schema.tournaments).set({ status: 'finished', finishedAt: new Date().toISOString(), }).where(eq(schema.tournaments.id, tournamentId)).run() logger.info('tournament', `tournament ${tournamentId} finished — champion: ${winners[0] ?? 'none'}`) return } // Generate next round matches const nextRound = round + 1 const nextMatchCount = Math.ceil(winners.length / 2) for (let i = 0; i < nextMatchCount; i++) { const botAId = winners[i * 2] ?? null const botBId = winners[i * 2 + 1] ?? null let status: 'pending' | 'finished' = 'pending' let winnerId: string | null = null // Bye: odd number of winners if (botAId && !botBId) { status = 'finished' winnerId = botAId } db.insert(schema.tournamentMatches).values({ id: nanoid(), tournamentId, round: nextRound, matchIndex: i, botAId, botBId, winnerId, status, }).run() } db.update(schema.tournaments).set({ currentRound: nextRound, }).where(eq(schema.tournaments.id, tournamentId)).run() logger.info('tournament', `advanced tournament ${tournamentId} to round ${nextRound} (${winners.length} bots remaining)`) // Recursively check if next round is already complete (byes) if (nextMatchCount === 1 && winners.length === 1) { tryAdvanceRound(tournamentId, nextRound, bracketSize) } } /** Get the full bracket state for UI display */ export function getTournamentBracket(tournamentId: string): TournamentBracket | null { const tournament = db.select().from(schema.tournaments) .where(eq(schema.tournaments.id, tournamentId)) .get() if (!tournament) return null const entries = db.select({ id: schema.tournamentEntries.id, botId: schema.tournamentEntries.botId, botName: schema.bots.name, seed: schema.tournamentEntries.seed, eliminated: schema.tournamentEntries.eliminated, }) .from(schema.tournamentEntries) .innerJoin(schema.bots, eq(schema.tournamentEntries.botId, schema.bots.id)) .where(eq(schema.tournamentEntries.tournamentId, tournamentId)) .all() // Get all matches with bot names const rawMatches = db.select().from(schema.tournamentMatches) .where(eq(schema.tournamentMatches.tournamentId, tournamentId)) .all() // Build bot name lookup const botNames = new Map(entries.map(e => [e.botId, e.botName])) const matches = rawMatches.map(m => ({ id: m.id, round: m.round, matchIndex: m.matchIndex, botAId: m.botAId, botBId: m.botBId, botAName: m.botAId ? botNames.get(m.botAId) ?? null : null, botBName: m.botBId ? botNames.get(m.botBId) ?? null : null, fightId: m.fightId, winnerId: m.winnerId, status: m.status, })) return { tournament: { ...tournament, format: tournament.format as TournamentFormat, }, entries: entries.map(e => ({ ...e, eliminated: !!e.eliminated, })), matches, } } /** List tournaments with optional status filter */ export function listTournaments(status?: 'open' | 'active' | 'finished'): typeof schema.tournaments.$inferSelect[] { if (status) { return db.select().from(schema.tournaments) .where(eq(schema.tournaments.status, status)) .all() } return db.select().from(schema.tournaments).all() } /** Get pending tournament matches that need fights scheduled */ export function getPendingMatches(tournamentId: string): typeof schema.tournamentMatches.$inferSelect[] { return db.select().from(schema.tournamentMatches) .where(and( eq(schema.tournamentMatches.tournamentId, tournamentId), eq(schema.tournamentMatches.status, 'pending'), )) .all() .filter(m => m.botAId && m.botBId) // Both bots must be present } /** Link a fight to a tournament match */ export function linkFightToMatch(matchId: string, fightId: string): void { db.update(schema.tournamentMatches).set({ fightId, status: 'live', }).where(eq(schema.tournamentMatches.id, matchId)).run() }