From c0978ba4c8c0bdc97ba354c088313a999b831aec Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 20:57:23 +0000 Subject: [PATCH] feat: tournament engine with single-elimination brackets Create, join, seed by Elo, generate bracket matches with byes, advance rounds on fight completion, finish tournament on champion. Wire into orchestrator fight finalization. Co-Authored-By: Claude Opus 4.6 --- server/src/engine/orchestrator.ts | 4 + server/src/engine/tournaments.ts | 377 ++++++++++++++++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 server/src/engine/tournaments.ts diff --git a/server/src/engine/orchestrator.ts b/server/src/engine/orchestrator.ts index 6427fc5..2f00f9d 100644 --- a/server/src/engine/orchestrator.ts +++ b/server/src/engine/orchestrator.ts @@ -13,6 +13,7 @@ import { payWinner, refundEntry, ENTRY_FEE_SATS } from './payments.js' import { settleBets, lockBets } from './betting.js' import { publishFightResult } from './nostr-publish.js' import { getCurrentSeason } from './seasons.js' +import { onFightFinished as onTournamentFightFinished } from './tournaments.js' interface BotRecord { id: string @@ -514,6 +515,9 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec finalize() + // Advance tournament bracket if this was a tournament match + try { onTournamentFightFinished(fightId, winnerId ?? null) } catch { /* not a tournament fight */ } + emit(fightId, 'fight_end', { winnerId, winnerName, diff --git a/server/src/engine/tournaments.ts b/server/src/engine/tournaments.ts new file mode 100644 index 0000000..c82ba73 --- /dev/null +++ b/server/src/engine/tournaments.ts @@ -0,0 +1,377 @@ +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') { + 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) { + 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() +}