2026-03-08 23:34:05 +00:00
|
|
|
import { FIGHT_LOOP_INTERVAL_MS, ELO_MATCHING_RANDOMNESS } from '../lib/constants.js'
|
2026-03-06 23:44:40 +00:00
|
|
|
import { db, schema } from '../db/index.js'
|
|
|
|
|
import { runMockFight } from './mock.js'
|
|
|
|
|
import { eq } from 'drizzle-orm'
|
2026-03-08 21:44:17 +00:00
|
|
|
import { pick, toError } from '../lib/utils.js'
|
2026-03-08 16:23:47 +00:00
|
|
|
import { fightEvents } from './events.js'
|
2026-03-06 23:44:40 +00:00
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
export interface FightResult {
|
|
|
|
|
fightId: string
|
|
|
|
|
botAName: string
|
|
|
|
|
botBName: string
|
|
|
|
|
botAElo: number
|
|
|
|
|
botBElo: number
|
|
|
|
|
winnerName: string | null
|
|
|
|
|
winnerId: string | null
|
|
|
|
|
totalRounds: number
|
|
|
|
|
isKo: boolean
|
|
|
|
|
isPerfect: boolean
|
|
|
|
|
botAHp: number
|
|
|
|
|
botBHp: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface FightLoopOptions {
|
2026-03-06 23:44:40 +00:00
|
|
|
intervalMs?: number
|
|
|
|
|
maxFights?: number
|
|
|
|
|
matchmakingStyle?: 'random' | 'elo_close' | 'mixed'
|
2026-03-07 00:14:46 +00:00
|
|
|
onFightStart?: (botAName: string, botAElo: number, botBName: string, botBElo: number) => void
|
|
|
|
|
onFightComplete?: (result: FightResult) => void
|
2026-03-08 16:23:47 +00:00
|
|
|
onRoundComplete?: (data: { round: number; hp: { a: number; b: number }; challengeType: string; winnerId: string | null }) => void
|
2026-03-07 00:14:46 +00:00
|
|
|
onError?: (err: Error) => void
|
2026-03-06 23:44:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function startFightLoop(options: FightLoopOptions = {}): Promise<void> {
|
|
|
|
|
const {
|
2026-03-08 23:34:05 +00:00
|
|
|
intervalMs = FIGHT_LOOP_INTERVAL_MS,
|
2026-03-06 23:44:40 +00:00
|
|
|
maxFights = Infinity,
|
|
|
|
|
matchmakingStyle = 'mixed',
|
2026-03-07 00:14:46 +00:00
|
|
|
onFightStart,
|
|
|
|
|
onFightComplete,
|
2026-03-08 16:23:47 +00:00
|
|
|
onRoundComplete,
|
2026-03-07 00:14:46 +00:00
|
|
|
onError,
|
2026-03-06 23:44:40 +00:00
|
|
|
} = options
|
|
|
|
|
|
|
|
|
|
const allBots = await db.select({
|
|
|
|
|
id: schema.bots.id,
|
|
|
|
|
eloRating: schema.bots.eloRating,
|
|
|
|
|
name: schema.bots.name,
|
|
|
|
|
}).from(schema.bots)
|
|
|
|
|
|
|
|
|
|
if (allBots.length < 2) {
|
|
|
|
|
console.log('[fight-loop] need at least 2 bots, aborting')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[fight-loop] starting with ${allBots.length} bots, ${matchmakingStyle} matchmaking, ${intervalMs}ms interval`)
|
|
|
|
|
let fightCount = 0
|
|
|
|
|
|
|
|
|
|
while (fightCount < maxFights) {
|
|
|
|
|
try {
|
|
|
|
|
// Re-fetch bots to get updated elo ratings
|
|
|
|
|
const bots = await db.select({
|
|
|
|
|
id: schema.bots.id,
|
|
|
|
|
eloRating: schema.bots.eloRating,
|
|
|
|
|
name: schema.bots.name,
|
|
|
|
|
wins: schema.bots.wins,
|
|
|
|
|
losses: schema.bots.losses,
|
|
|
|
|
}).from(schema.bots)
|
|
|
|
|
|
|
|
|
|
const [botA, botB] = pickMatchup(bots, matchmakingStyle, fightCount)
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
if (onFightStart) {
|
|
|
|
|
onFightStart(botA.name, botA.eloRating, botB.name, botB.eloRating)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 16:23:47 +00:00
|
|
|
// Subscribe to round events for live TUI updates
|
|
|
|
|
let unsub: (() => void) | undefined
|
|
|
|
|
if (onRoundComplete) {
|
|
|
|
|
unsub = fightEvents.onAll((event) => {
|
|
|
|
|
if (event.type === 'round_end') {
|
|
|
|
|
onRoundComplete({
|
|
|
|
|
round: event.data.round as number,
|
|
|
|
|
hp: event.data.hp as { a: number; b: number },
|
2026-03-08 20:34:11 +00:00
|
|
|
challengeType: (event.data.result as { challengeType?: string } | undefined)?.challengeType || '',
|
|
|
|
|
winnerId: (event.data.result as { winnerId?: string | null } | undefined)?.winnerId || null,
|
2026-03-08 16:23:47 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 23:44:40 +00:00
|
|
|
const fightId = await runMockFight(botA.id, botB.id)
|
2026-03-08 16:23:47 +00:00
|
|
|
unsub?.()
|
2026-03-06 23:44:40 +00:00
|
|
|
|
|
|
|
|
// Fetch result
|
|
|
|
|
const fight = await db.select({
|
|
|
|
|
winnerId: schema.fights.winnerId,
|
|
|
|
|
totalRounds: schema.fights.totalRounds,
|
2026-03-07 00:14:46 +00:00
|
|
|
botAHp: schema.fights.botAHp,
|
|
|
|
|
botBHp: schema.fights.botBHp,
|
2026-03-06 23:44:40 +00:00
|
|
|
}).from(schema.fights).where(eq(schema.fights.id, fightId)).limit(1)
|
|
|
|
|
|
|
|
|
|
const result = fight[0]
|
|
|
|
|
const winnerName = result?.winnerId
|
|
|
|
|
? bots.find(b => b.id === result.winnerId)?.name || '???'
|
2026-03-07 00:14:46 +00:00
|
|
|
: null
|
|
|
|
|
|
|
|
|
|
const isKo = result ? (result.botAHp <= 0 || result.botBHp <= 0) : false
|
|
|
|
|
const isPerfect = result?.winnerId ? (
|
|
|
|
|
(result.winnerId === botA.id && result.botAHp === 200) ||
|
|
|
|
|
(result.winnerId === botB.id && result.botBHp === 200)
|
|
|
|
|
) : false
|
2026-03-06 23:44:40 +00:00
|
|
|
|
|
|
|
|
fightCount++
|
2026-03-07 00:14:46 +00:00
|
|
|
|
|
|
|
|
if (onFightComplete) {
|
|
|
|
|
onFightComplete({
|
|
|
|
|
fightId,
|
|
|
|
|
botAName: botA.name,
|
|
|
|
|
botBName: botB.name,
|
|
|
|
|
botAElo: botA.eloRating,
|
|
|
|
|
botBElo: botB.eloRating,
|
|
|
|
|
winnerName,
|
|
|
|
|
winnerId: result?.winnerId || null,
|
|
|
|
|
totalRounds: result?.totalRounds || 0,
|
|
|
|
|
isKo,
|
|
|
|
|
isPerfect,
|
|
|
|
|
botAHp: result?.botAHp || 0,
|
|
|
|
|
botBHp: result?.botBHp || 0,
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
console.log(
|
|
|
|
|
`[fight-loop] #${fightCount}: ${botA.name} vs ${botB.name} => ${winnerName || 'DRAW'} (${result?.totalRounds || '?'} rounds)`
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-03-06 23:44:40 +00:00
|
|
|
|
|
|
|
|
// Wait before next fight
|
|
|
|
|
if (fightCount < maxFights) {
|
|
|
|
|
await sleep(intervalMs + Math.floor(Math.random() * intervalMs * 0.5))
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
2026-03-07 00:14:46 +00:00
|
|
|
if (onError) {
|
2026-03-08 21:44:17 +00:00
|
|
|
onError(toError(err))
|
2026-03-07 00:14:46 +00:00
|
|
|
} else {
|
|
|
|
|
console.error('[fight-loop] error:', err)
|
|
|
|
|
}
|
|
|
|
|
await sleep(5000)
|
2026-03-06 23:44:40 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
if (!onFightComplete) {
|
|
|
|
|
console.log(`[fight-loop] completed ${fightCount} fights`)
|
|
|
|
|
}
|
2026-03-06 23:44:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pickMatchup(
|
|
|
|
|
bots: { id: string; eloRating: number; name: string; wins: number; losses: number }[],
|
2026-03-08 20:34:11 +00:00
|
|
|
style: 'elo_close' | 'random' | 'mixed',
|
2026-03-06 23:44:40 +00:00
|
|
|
fightNum: number,
|
|
|
|
|
): [typeof bots[0], typeof bots[0]] {
|
|
|
|
|
const sorted = [...bots].sort((a, b) => b.eloRating - a.eloRating)
|
|
|
|
|
|
|
|
|
|
if (style === 'elo_close' || (style === 'mixed' && fightNum % 3 !== 0)) {
|
2026-03-08 21:42:11 +00:00
|
|
|
const bot = pick(bots)
|
2026-03-06 23:44:40 +00:00
|
|
|
const others = bots.filter(b => b.id !== bot.id)
|
|
|
|
|
others.sort((a, b) => {
|
2026-03-08 23:34:05 +00:00
|
|
|
const diffA = Math.abs(a.eloRating - bot.eloRating) + Math.random() * ELO_MATCHING_RANDOMNESS
|
|
|
|
|
const diffB = Math.abs(b.eloRating - bot.eloRating) + Math.random() * ELO_MATCHING_RANDOMNESS
|
2026-03-06 23:44:40 +00:00
|
|
|
return diffA - diffB
|
|
|
|
|
})
|
|
|
|
|
return [bot, others[0]]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (style === 'mixed' && fightNum % 3 === 0) {
|
|
|
|
|
const topThird = Math.ceil(sorted.length / 3)
|
|
|
|
|
const topIdx = Math.floor(Math.random() * topThird)
|
|
|
|
|
const bottomIdx = sorted.length - 1 - Math.floor(Math.random() * topThird)
|
2026-03-07 00:14:46 +00:00
|
|
|
if (sorted[topIdx].id !== sorted[bottomIdx].id) {
|
|
|
|
|
return [sorted[topIdx], sorted[bottomIdx]]
|
|
|
|
|
}
|
2026-03-06 23:44:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Random
|
|
|
|
|
const shuffled = [...bots].sort(() => Math.random() - 0.5)
|
2026-03-07 00:14:46 +00:00
|
|
|
if (shuffled[0].id === shuffled[1].id && shuffled.length > 2) {
|
|
|
|
|
return [shuffled[0], shuffled[2]]
|
|
|
|
|
}
|
2026-03-06 23:44:40 +00:00
|
|
|
return [shuffled[0], shuffled[1]]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
|
|
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
|
|
|
}
|