378 lines
11 KiB
TypeScript
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') {
|
||
|
|
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()
|
||
|
|
}
|