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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4dfa49c036
commit
c0978ba4c8
@@ -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<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') {
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user