feat: scheduled daily and weekly tournaments

Lightning Round (daily 8-bot at noon UTC) and The Halvening
(weekly 32-bot Saturday 20:00 UTC). Auto-fills with mock bots,
runs matches via background loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 21:02:42 +00:00
co-authored by Claude Opus 4.6
parent b0926db9ed
commit d0f3780518
+111 -6
View File
@@ -1,5 +1,16 @@
import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { runMockFight } from './mock.js'
import { logger } from '../lib/logger.js'
import {
createTournament,
joinTournament,
startTournament,
listTournaments,
getPendingMatches,
linkFightToMatch,
} from './tournaments.js'
import { runFightAsync } from './orchestrator.js'
// Background fight loop keeps mock bots fighting so the site always has fresh content.
// Configurable via env:
@@ -19,16 +30,16 @@ let running = false
export function startBackgroundFights() {
if (!enabled) {
console.log('[background] fight loop disabled (set FIGHT_LOOP_ENABLED=true to enable)')
logger.info('background', 'fight loop disabled (set FIGHT_LOOP_ENABLED=true to enable)')
return
}
if (running) return
running = true
console.log(`[background] fight loop started — interval ${intervalMs}ms, quiet hours ${quietStart}-${quietEnd} UTC`)
logger.info('background', `fight loop started — interval ${intervalMs}ms, quiet hours ${quietStart}-${quietEnd} UTC`)
loop().catch(err => {
console.error('[background] fight loop crashed:', err)
logger.error('background', 'fight loop crashed', err)
running = false
})
}
@@ -37,6 +48,92 @@ export function stopBackgroundFights() {
running = false
}
// Track last tournament check times
let lastDailyCheck = 0
let lastWeeklyCheck = 0
/** Check if we should create a "Lightning Round" daily 8-bot tournament */
async function checkDailyTournament() {
const now = Date.now()
if (now - lastDailyCheck < 60 * 60 * 1000) return // Check at most hourly
lastDailyCheck = now
const hour = new Date().getUTCHours()
if (hour !== 12) return // Noon UTC daily
// Don't create if there's already an open/active tournament today
const active = listTournaments('open').concat(listTournaments('active'))
const today = new Date().toISOString().slice(0, 10)
const existsToday = active.some(t => t.createdAt.startsWith(today))
if (existsToday) return
try {
const id = createTournament('Lightning Round', 'single_elim', 8, 0)
await fillTournamentWithMockBots(id, 8)
startTournament(id)
await runTournamentMatches(id)
logger.info('background', `daily Lightning Round tournament created: ${id}`)
} catch (err) {
logger.error('background', 'failed to create daily tournament', err)
}
}
/** Check if we should create "The Halvening" weekly 32-bot tournament */
async function checkWeeklyTournament() {
const now = Date.now()
if (now - lastWeeklyCheck < 60 * 60 * 1000) return
lastWeeklyCheck = now
const d = new Date()
if (d.getUTCDay() !== 6 || d.getUTCHours() !== 20) return // Saturday 20:00 UTC
const active = listTournaments('open').concat(listTournaments('active'))
const today = new Date().toISOString().slice(0, 10)
const existsToday = active.some(t => t.createdAt.startsWith(today) && t.size === 32)
if (existsToday) return
try {
const id = createTournament('The Halvening', 'single_elim', 32, 0)
await fillTournamentWithMockBots(id, 32)
startTournament(id)
await runTournamentMatches(id)
logger.info('background', `weekly The Halvening tournament created: ${id}`)
} catch (err) {
logger.error('background', 'failed to create weekly tournament', err)
}
}
/** Fill remaining tournament slots with random mock bots */
async function fillTournamentWithMockBots(tournamentId: string, size: number) {
const mockBots = await db.select({ id: schema.bots.id })
.from(schema.bots)
.where(eq(schema.bots.botType, 'mock'))
.all()
const shuffled = mockBots.sort(() => Math.random() - 0.5)
const toAdd = Math.min(size, shuffled.length)
for (let i = 0; i < toAdd; i++) {
try {
joinTournament(tournamentId, shuffled[i].id)
} catch { /* already joined or full */ }
}
}
/** Run all pending matches in a tournament */
async function runTournamentMatches(tournamentId: string) {
const pending = getPendingMatches(tournamentId)
for (const match of pending) {
if (!match.botAId || !match.botBId) continue
try {
const fightId = await runFightAsync(match.botAId, match.botBId, 'free')
linkFightToMatch(match.id, fightId)
} catch (err) {
logger.error('background', `tournament match failed: ${match.id}`, err)
}
await sleep(2000)
}
}
async function loop() {
// Small delay on startup to let everything settle
await sleep(5_000)
@@ -45,7 +142,15 @@ async function loop() {
try {
await runOneBackgroundFight()
} catch (err) {
console.error('[background] fight error:', err)
logger.error('background', 'fight error', err)
}
// Check for scheduled tournaments
try {
await checkDailyTournament()
await checkWeeklyTournament()
} catch (err) {
logger.error('background', 'tournament check error', err)
}
// During quiet hours, fights run 4x slower
@@ -60,7 +165,7 @@ async function loop() {
await sleep(jitter)
}
console.log('[background] fight loop stopped')
logger.info('background', 'fight loop stopped')
}
async function runOneBackgroundFight() {
@@ -93,7 +198,7 @@ async function runOneBackgroundFight() {
}
const fightId = await runMockFight(botA.id, botB.id)
console.log(`[background] ${botA.name} vs ${botB.name} => fight ${fightId}`)
logger.info('background', `${botA.name} vs ${botB.name} => fight ${fightId}`)
}
function sleep(ms: number): Promise<void> {