From d0f37805187e1fb6563b39bc584bff86fadc6716 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 21:02:42 +0000 Subject: [PATCH] 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 --- server/src/engine/background.ts | 117 ++++++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 6 deletions(-) diff --git a/server/src/engine/background.ts b/server/src/engine/background.ts index 98eaf4c..3f51e40 100644 --- a/server/src/engine/background.ts +++ b/server/src/engine/background.ts @@ -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 {