2026-03-07 11:04:32 +00:00
|
|
|
import { db, schema } from '../db/index.js'
|
2026-03-08 21:02:42 +00:00
|
|
|
import { eq } from 'drizzle-orm'
|
2026-03-07 11:04:32 +00:00
|
|
|
import { runMockFight } from './mock.js'
|
2026-03-08 21:02:42 +00:00
|
|
|
import { logger } from '../lib/logger.js'
|
|
|
|
|
import {
|
|
|
|
|
createTournament,
|
|
|
|
|
joinTournament,
|
|
|
|
|
startTournament,
|
|
|
|
|
listTournaments,
|
|
|
|
|
getPendingMatches,
|
|
|
|
|
linkFightToMatch,
|
|
|
|
|
} from './tournaments.js'
|
|
|
|
|
import { runFightAsync } from './orchestrator.js'
|
2026-03-07 11:04:32 +00:00
|
|
|
|
|
|
|
|
// Background fight loop keeps mock bots fighting so the site always has fresh content.
|
|
|
|
|
// Configurable via env:
|
|
|
|
|
// FIGHT_LOOP_ENABLED=true (default: true in production, false in dev)
|
|
|
|
|
// FIGHT_LOOP_INTERVAL_MS=45000 (default: 45s between fights)
|
|
|
|
|
// FIGHT_LOOP_QUIET_HOURS=2-8 (default: 2am-8am UTC, slower fights)
|
|
|
|
|
|
|
|
|
|
const isProduction = process.env.NODE_ENV === 'production'
|
|
|
|
|
const enabled = process.env.FIGHT_LOOP_ENABLED
|
|
|
|
|
? process.env.FIGHT_LOOP_ENABLED === 'true'
|
|
|
|
|
: isProduction
|
|
|
|
|
const intervalMs = Number(process.env.FIGHT_LOOP_INTERVAL_MS) || 45_000
|
|
|
|
|
const quietStart = Number(process.env.FIGHT_LOOP_QUIET_START) || 2
|
|
|
|
|
const quietEnd = Number(process.env.FIGHT_LOOP_QUIET_END) || 8
|
|
|
|
|
|
|
|
|
|
let running = false
|
|
|
|
|
|
|
|
|
|
export function startBackgroundFights() {
|
|
|
|
|
if (!enabled) {
|
2026-03-08 21:02:42 +00:00
|
|
|
logger.info('background', 'fight loop disabled (set FIGHT_LOOP_ENABLED=true to enable)')
|
2026-03-07 11:04:32 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (running) return
|
|
|
|
|
running = true
|
2026-03-08 21:02:42 +00:00
|
|
|
logger.info('background', `fight loop started — interval ${intervalMs}ms, quiet hours ${quietStart}-${quietEnd} UTC`)
|
2026-03-07 11:04:32 +00:00
|
|
|
|
|
|
|
|
loop().catch(err => {
|
2026-03-08 21:02:42 +00:00
|
|
|
logger.error('background', 'fight loop crashed', err)
|
2026-03-07 11:04:32 +00:00
|
|
|
running = false
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function stopBackgroundFights() {
|
|
|
|
|
running = false
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 21:02:42 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 11:04:32 +00:00
|
|
|
async function loop() {
|
|
|
|
|
// Small delay on startup to let everything settle
|
|
|
|
|
await sleep(5_000)
|
|
|
|
|
|
|
|
|
|
while (running) {
|
|
|
|
|
try {
|
|
|
|
|
await runOneBackgroundFight()
|
|
|
|
|
} catch (err) {
|
2026-03-08 21:02:42 +00:00
|
|
|
logger.error('background', 'fight error', err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for scheduled tournaments
|
|
|
|
|
try {
|
|
|
|
|
await checkDailyTournament()
|
|
|
|
|
await checkWeeklyTournament()
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.error('background', 'tournament check error', err)
|
2026-03-07 11:04:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// During quiet hours, fights run 4x slower
|
|
|
|
|
const hour = new Date().getUTCHours()
|
|
|
|
|
const isQuiet = quietStart < quietEnd
|
|
|
|
|
? hour >= quietStart && hour < quietEnd
|
|
|
|
|
: hour >= quietStart || hour < quietEnd
|
|
|
|
|
const delay = isQuiet ? intervalMs * 4 : intervalMs
|
|
|
|
|
|
|
|
|
|
// Add jitter (±25%) so fights don't feel robotic
|
|
|
|
|
const jitter = delay * (0.75 + Math.random() * 0.5)
|
|
|
|
|
await sleep(jitter)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 21:02:42 +00:00
|
|
|
logger.info('background', 'fight loop stopped')
|
2026-03-07 11:04:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runOneBackgroundFight() {
|
|
|
|
|
// Pick two mock bots with ELO-weighted matchmaking
|
|
|
|
|
const allBots = await db.select({
|
|
|
|
|
id: schema.bots.id,
|
|
|
|
|
webhookUrl: schema.bots.webhookUrl,
|
|
|
|
|
eloRating: schema.bots.eloRating,
|
|
|
|
|
name: schema.bots.name,
|
|
|
|
|
}).from(schema.bots)
|
|
|
|
|
|
|
|
|
|
const mockBots = allBots.filter(b => b.webhookUrl.startsWith('http://mock.local'))
|
|
|
|
|
if (mockBots.length < 2) return
|
|
|
|
|
|
|
|
|
|
// Mixed matchmaking: 70% close ELO, 30% wild card
|
|
|
|
|
const isWild = Math.random() < 0.3
|
|
|
|
|
const botA = mockBots[Math.floor(Math.random() * mockBots.length)]
|
|
|
|
|
const others = mockBots.filter(b => b.id !== botA.id)
|
|
|
|
|
|
|
|
|
|
let botB: typeof botA
|
|
|
|
|
if (isWild) {
|
|
|
|
|
botB = others[Math.floor(Math.random() * others.length)]
|
|
|
|
|
} else {
|
|
|
|
|
others.sort((a, b) => {
|
|
|
|
|
const diffA = Math.abs(a.eloRating - botA.eloRating) + Math.random() * 150
|
|
|
|
|
const diffB = Math.abs(b.eloRating - botA.eloRating) + Math.random() * 150
|
|
|
|
|
return diffA - diffB
|
|
|
|
|
})
|
|
|
|
|
botB = others[0]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fightId = await runMockFight(botA.id, botB.id)
|
2026-03-08 21:02:42 +00:00
|
|
|
logger.info('background', `${botA.name} vs ${botB.name} => fight ${fightId}`)
|
2026-03-07 11:04:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
|
|
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
|
|
|
}
|