Files
botfights/server/src/engine/tournaments.ts
T
2026-03-08 21:48:22 +00:00

378 lines
11 KiB
TypeScript

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<number>`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()
}