feat: v4 — TUI fight loop, rate limiting, webhook tooling, expanded choreographies
- Fight loop CLI with TUI renderer (ink-style terminal UI) - Rate limiting middleware for API routes - Queue cooldowns wired into orchestrator after fights - Webhook test utility for bot debugging - API docs route - Expanded FightScene choreographies and weapon props - Fix Drizzle transaction execution in orchestrator - Schema additions, scoring/challenge/mock expansions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2c0323d5fb
commit
4d8b18a58a
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Answer checking for factual challenges.
|
||||
* Handles: case insensitivity, numeric equivalence, containment matching,
|
||||
* number words (forty = 40), stripped punctuation/articles.
|
||||
* number words (forty = 40), basic stemming, contraction normalization.
|
||||
*/
|
||||
|
||||
const NUMBER_WORDS: Record<string, number> = {
|
||||
@@ -23,6 +23,47 @@ function normalize(s: string): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Expand contractions so "can't" matches "cannot", "don't" matches "do not" etc. */
|
||||
function expandContractions(s: string): string {
|
||||
return s
|
||||
.replace(/\bcan'?t\b/gi, 'cannot')
|
||||
.replace(/\bdon'?t\b/gi, 'do not')
|
||||
.replace(/\bwon'?t\b/gi, 'will not')
|
||||
.replace(/\bdoesn'?t\b/gi, 'does not')
|
||||
.replace(/\bisn'?t\b/gi, 'is not')
|
||||
.replace(/\baren'?t\b/gi, 'are not')
|
||||
.replace(/\bwasn'?t\b/gi, 'was not')
|
||||
.replace(/\bweren'?t\b/gi, 'were not')
|
||||
.replace(/\bhasn'?t\b/gi, 'has not')
|
||||
.replace(/\bhaven'?t\b/gi, 'have not')
|
||||
.replace(/\bhadn'?t\b/gi, 'had not')
|
||||
.replace(/\bcouldn'?t\b/gi, 'could not')
|
||||
.replace(/\bshouldn'?t\b/gi, 'should not')
|
||||
.replace(/\bwouldn'?t\b/gi, 'would not')
|
||||
.replace(/\bit'?s\b/gi, 'it is')
|
||||
.replace(/\bthat'?s\b/gi, 'that is')
|
||||
.replace(/\bthey'?re\b/gi, 'they are')
|
||||
.replace(/\bwe'?re\b/gi, 'we are')
|
||||
.replace(/\byou'?re\b/gi, 'you are')
|
||||
}
|
||||
|
||||
/** Basic English stemming: strip common suffixes for loose comparison. */
|
||||
function stem(word: string): string {
|
||||
if (word.length <= 3) return word
|
||||
// Plural: buses → bus, foxes → fox, tries → tri (imperfect but ok)
|
||||
if (word.endsWith('ies') && word.length > 4) return word.slice(0, -3) + 'y'
|
||||
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes') || word.endsWith('ches') || word.endsWith('shes')) {
|
||||
return word.slice(0, -2)
|
||||
}
|
||||
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1)
|
||||
return word
|
||||
}
|
||||
|
||||
/** Stem all words in a string. */
|
||||
function stemAll(s: string): string {
|
||||
return s.split(/\s+/).map(stem).join(' ')
|
||||
}
|
||||
|
||||
function tryParseNumber(s: string): number | null {
|
||||
const cleaned = normalize(s)
|
||||
|
||||
@@ -55,7 +96,7 @@ function tryParseNumber(s: string): number | null {
|
||||
|
||||
/**
|
||||
* Check if a bot's response matches any of the accepted answers.
|
||||
* Returns a confidence score: 1.0 = definite match, 0.5 = partial, 0 = no match.
|
||||
* Returns a confidence score: 1.0 = definite match, 0.5-0.9 = partial, 0 = no match.
|
||||
*/
|
||||
export function checkAnswer(response: string | null, acceptedAnswers: string[]): number {
|
||||
if (!response || response.trim() === '') return 0
|
||||
@@ -63,48 +104,77 @@ export function checkAnswer(response: string | null, acceptedAnswers: string[]):
|
||||
const normResponse = normalize(response)
|
||||
if (normResponse === '') return 0
|
||||
|
||||
// Pre-compute expanded/stemmed versions once
|
||||
const expandedResponse = normalize(expandContractions(response))
|
||||
const stemmedResponse = stemAll(normResponse)
|
||||
|
||||
for (const accepted of acceptedAnswers) {
|
||||
const normAccepted = normalize(accepted)
|
||||
const expandedAccepted = normalize(expandContractions(accepted))
|
||||
const stemmedAccepted = stemAll(normAccepted)
|
||||
|
||||
// 1. Exact match after normalization
|
||||
if (normResponse === normAccepted) return 1.0
|
||||
|
||||
// 2. Numeric equivalence
|
||||
// 2. Exact match after contraction expansion
|
||||
if (expandedResponse === expandedAccepted) return 1.0
|
||||
|
||||
// 3. Exact match after stemming (catches plural/singular)
|
||||
if (stemmedResponse === stemmedAccepted) return 1.0
|
||||
|
||||
// 4. Numeric equivalence
|
||||
const respNum = tryParseNumber(normResponse)
|
||||
const accNum = tryParseNumber(normAccepted)
|
||||
if (respNum !== null && accNum !== null && respNum === accNum) return 1.0
|
||||
|
||||
// 3. Response contains the accepted answer
|
||||
// 5. Response contains the accepted answer (or stemmed version)
|
||||
if (normResponse.includes(normAccepted) && normAccepted.length >= 2) return 1.0
|
||||
if (stemmedResponse.includes(stemmedAccepted) && stemmedAccepted.length >= 2) return 0.95
|
||||
|
||||
// 4. Accepted answer contains the response (for short definitive answers)
|
||||
// 6. Accepted answer contains the response (for short definitive answers)
|
||||
if (normAccepted.includes(normResponse) && normResponse.length >= 3) return 0.8
|
||||
if (stemmedAccepted.includes(stemmedResponse) && stemmedResponse.length >= 3) return 0.75
|
||||
|
||||
// 5. Check if number appears anywhere in a longer response
|
||||
// 7. Check if number appears anywhere in a longer response
|
||||
if (accNum !== null) {
|
||||
// Look for the number in the response text
|
||||
const numStr = String(accNum)
|
||||
if (normResponse.includes(numStr)) return 1.0
|
||||
// Check number words in response
|
||||
const responseNum = tryParseNumber(normResponse.split(/\s+/).find(w => tryParseNumber(w) !== null) || '')
|
||||
if (responseNum !== null && responseNum === accNum) return 0.9
|
||||
const responseWords = normResponse.split(/\s+/)
|
||||
for (const w of responseWords) {
|
||||
const parsed = tryParseNumber(w)
|
||||
if (parsed !== null && parsed === accNum) return 0.9
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Word-level containment — all words of the answer appear in the response
|
||||
// 8. Word-level containment — all words of the answer appear in the response
|
||||
const acceptedWords = normAccepted.split(/\s+/)
|
||||
if (acceptedWords.length >= 2) {
|
||||
const allFound = acceptedWords.every(w => normResponse.includes(w))
|
||||
if (allFound) return 0.9
|
||||
// Try with stemming
|
||||
const stemmedAccWords = stemmedAccepted.split(/\s+/)
|
||||
const allStemFound = stemmedAccWords.every(w => stemmedResponse.includes(w))
|
||||
if (allStemFound) return 0.85
|
||||
}
|
||||
|
||||
// 9. Contraction-expanded word containment
|
||||
if (expandedAccepted.split(/\s+/).length >= 2) {
|
||||
const allFound = expandedAccepted.split(/\s+/).every(w => expandedResponse.includes(w))
|
||||
if (allFound) return 0.85
|
||||
}
|
||||
}
|
||||
|
||||
// 7. For true/false questions, check if the response starts with the right keyword
|
||||
// 10. For true/false questions, check if the response starts with the right keyword
|
||||
const tfAnswer = acceptedAnswers.find(a => a.toLowerCase() === 'true' || a.toLowerCase() === 'false')
|
||||
if (tfAnswer) {
|
||||
const firstWord = normResponse.split(/\s+/)[0]
|
||||
if (firstWord === tfAnswer.toLowerCase()) return 1.0
|
||||
// "that's true" / "this is false" etc.
|
||||
// "that's true" / "this is false" / "yes" for true / "no" for false
|
||||
if (normResponse.includes(tfAnswer.toLowerCase())) return 0.9
|
||||
// "yes" ≈ true, "no" ≈ false
|
||||
if (tfAnswer.toLowerCase() === 'true' && (firstWord === 'yes' || firstWord === 'correct' || firstWord === 'right')) return 0.9
|
||||
if (tfAnswer.toLowerCase() === 'false' && (firstWord === 'no' || firstWord === 'incorrect' || firstWord === 'wrong')) return 0.9
|
||||
}
|
||||
|
||||
return 0
|
||||
|
||||
@@ -112,7 +112,7 @@ const TEMPLATES: ChallengeTemplate[] = [
|
||||
baseDamage: 22,
|
||||
prompts: [
|
||||
{ prompt: 'I have cities but no houses, forests but no trees, and water but no fish. What am I?', answers: ['map', 'a map'] },
|
||||
{ prompt: 'The more you take, the more you leave behind. What am I?', answers: ['footsteps', 'steps'] },
|
||||
{ prompt: 'The more you take, the more you leave behind. What am I?', answers: ['footsteps', 'steps', 'footstep'] },
|
||||
{ prompt: 'I speak without a mouth and hear without ears. I have no body, but I come alive with the wind. What am I?', answers: ['echo', 'an echo'] },
|
||||
{ prompt: 'What has keys but no locks, space but no room, and you can enter but can\'t go inside?', answers: ['keyboard', 'a keyboard'] },
|
||||
{ prompt: 'I am not alive, but I grow; I don\'t have lungs, but I need air; I don\'t have a mouth, but water kills me. What am I?', answers: ['fire', 'flame'] },
|
||||
@@ -207,9 +207,9 @@ const TEMPLATES: ChallengeTemplate[] = [
|
||||
{ prompt: 'I am an odd number. Take away a letter and I become even. What number am I?', answers: ['seven', '7'] },
|
||||
{ prompt: 'A farmer has 17 sheep. All but 9 run away. How many does the farmer have left?', answers: ['9', 'nine'] },
|
||||
{ prompt: 'How many times can you subtract 5 from 25?', answers: ['1', 'one', 'once'] },
|
||||
{ prompt: 'A rooster lays an egg on top of a barn roof. Which way does it roll?', answers: ['roosters don\'t lay eggs', 'it doesn\'t', 'nowhere', 'they don\'t', 'roosters can\'t lay eggs'] },
|
||||
{ prompt: 'A rooster lays an egg on top of a barn roof. Which way does it roll?', answers: ['roosters don\'t lay eggs', 'it doesn\'t', 'nowhere', 'they don\'t', 'roosters can\'t lay eggs', 'rooster doesn\'t lay eggs', 'a rooster can\'t lay eggs', 'roosters do not lay eggs'] },
|
||||
{ prompt: 'If it takes 5 machines 5 minutes to make 5 widgets, how long for 100 machines to make 100 widgets?', answers: ['5', 'five', '5 minutes'] },
|
||||
{ prompt: 'What weighs more: a pound of feathers or a pound of bricks?', answers: ['same', 'they weigh the same', 'neither', 'equal', 'the same'] },
|
||||
{ prompt: 'What weighs more: a pound of feathers or a pound of bricks?', answers: ['same', 'they weigh the same', 'neither', 'equal', 'the same', 'they are the same', 'both weigh the same', 'equally'] },
|
||||
{ prompt: 'If you overtake the person in second place, what place are you in?', answers: ['second', '2nd', '2'] },
|
||||
{ prompt: 'How many months have 28 days?', answers: ['12', 'all of them', 'all', 'twelve', 'every month'] },
|
||||
{ prompt: 'If a doctor gives you 3 pills and says take one every 30 minutes, how long until all pills are taken?', answers: ['60', '60 minutes', '1 hour', 'one hour'] },
|
||||
@@ -261,7 +261,7 @@ const TEMPLATES: ChallengeTemplate[] = [
|
||||
timeout_ms: 8000,
|
||||
baseDamage: 16,
|
||||
prompts: [
|
||||
{ prompt: 'What was the first mass-produced automobile?', answers: ['model t', 'ford model t'] },
|
||||
{ prompt: 'What was the first mass-produced automobile?', answers: ['model t', 'ford model t', 'the model t', 'the ford model t'] },
|
||||
{ prompt: 'How many wheels does a standard 18-wheeler actually have?', answers: ['18', 'eighteen'] },
|
||||
{ prompt: 'What car brand uses a prancing horse as its logo?', answers: ['ferrari'] },
|
||||
{ prompt: 'How many cylinders does a V8 engine have?', answers: ['8', 'eight'] },
|
||||
@@ -291,7 +291,7 @@ const TEMPLATES: ChallengeTemplate[] = [
|
||||
baseDamage: 20,
|
||||
prompts: [
|
||||
{ prompt: 'True or false: A group of flamingos is called a "flamboyance."', answers: ['true'] },
|
||||
{ prompt: 'What is the only mammal capable of true powered flight?', answers: ['bat', 'bats'] },
|
||||
{ prompt: 'What is the only mammal capable of true powered flight?', answers: ['bat', 'bats', 'a bat'] },
|
||||
{ prompt: 'True or false: Octopuses have three hearts.', answers: ['true'] },
|
||||
{ prompt: 'Is a tomato a fruit or a vegetable? (Botanically speaking)', answers: ['fruit'] },
|
||||
{ prompt: 'True or false: Honey never spoils if stored properly.', answers: ['true'] },
|
||||
@@ -301,7 +301,7 @@ const TEMPLATES: ChallengeTemplate[] = [
|
||||
{ prompt: 'What causes thunder?', answers: ['lightning', 'rapid heating of air', 'expansion of air', 'heated air expanding'] },
|
||||
{ prompt: 'True or false: Bananas are technically berries, but strawberries are not.', answers: ['true'] },
|
||||
{ prompt: 'True or false: Diamonds are made from compressed coal.', answers: ['false'] },
|
||||
{ prompt: 'What is the fastest land animal?', answers: ['cheetah'] },
|
||||
{ prompt: 'What is the fastest land animal?', answers: ['cheetah', 'the cheetah', 'a cheetah'] },
|
||||
{ prompt: 'What color is a polar bear\'s skin under its white fur?', answers: ['black'] },
|
||||
{ prompt: 'True or false: Lightning is hotter than the surface of the Sun.', answers: ['true'] },
|
||||
{ prompt: 'Name the only continent with no active volcanoes.', answers: ['australia'] },
|
||||
@@ -319,7 +319,7 @@ const TEMPLATES: ChallengeTemplate[] = [
|
||||
timeout_ms: 10000,
|
||||
baseDamage: 18,
|
||||
prompts: [
|
||||
{ prompt: 'What animal can survive in the vacuum of space?', answers: ['tardigrade', 'water bear', 'tardigrades'] },
|
||||
{ prompt: 'What animal can survive in the vacuum of space?', answers: ['tardigrade', 'tardigrades', 'water bear', 'water bears'] },
|
||||
{ prompt: 'What is the fastest animal on Earth?', answers: ['peregrine falcon', 'cheetah'] },
|
||||
{ prompt: 'How many stomachs does a cow have?', answers: ['4', 'four'] },
|
||||
{ prompt: 'Name the only bird that can fly backwards.', answers: ['hummingbird'] },
|
||||
@@ -330,7 +330,7 @@ const TEMPLATES: ChallengeTemplate[] = [
|
||||
{ prompt: 'True or false: Cows have best friends and get stressed when separated.', answers: ['true'] },
|
||||
{ prompt: 'True or false: An octopus has blue blood.', answers: ['true'] },
|
||||
{ prompt: 'How many hearts does an octopus have?', answers: ['3', 'three'] },
|
||||
{ prompt: 'What is the largest living land animal?', answers: ['african elephant', 'elephant'] },
|
||||
{ prompt: 'What is the largest living land animal?', answers: ['african elephant', 'elephant', 'elephants'] },
|
||||
{ prompt: 'True or false: A snail can sleep for 3 years.', answers: ['true'] },
|
||||
{ prompt: 'What animal has the strongest bite force?', answers: ['crocodile', 'saltwater crocodile', 'nile crocodile'] },
|
||||
{ prompt: 'How many legs does a lobster have?', answers: ['10', 'ten'] },
|
||||
@@ -349,7 +349,7 @@ const TEMPLATES: ChallengeTemplate[] = [
|
||||
baseDamage: 24,
|
||||
prompts: [
|
||||
{ prompt: 'What does SQL in "SQL injection" stand for?', answers: ['structured query language'] },
|
||||
{ prompt: 'What does HTTPS protect against that HTTP doesn\'t? (one word)', answers: ['eavesdropping', 'interception', 'sniffing', 'man-in-the-middle', 'mitm'] },
|
||||
{ prompt: 'What does HTTPS protect against that HTTP doesn\'t? (one word)', answers: ['eavesdropping', 'interception', 'sniffing', 'man-in-the-middle', 'mitm', 'encryption'] },
|
||||
{ prompt: 'What does the S in HTTPS stand for?', answers: ['secure'] },
|
||||
{ prompt: 'Name the three pillars of the CIA triad in information security.', answers: ['confidentiality integrity availability'] },
|
||||
{ prompt: 'What does VPN stand for?', answers: ['virtual private network'] },
|
||||
|
||||
@@ -2,10 +2,28 @@ import { db, schema } from '../db/index.js'
|
||||
import { runMockFight } from './mock.js'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
interface FightLoopOptions {
|
||||
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 {
|
||||
intervalMs?: number
|
||||
maxFights?: number
|
||||
matchmakingStyle?: 'random' | 'elo_close' | 'mixed'
|
||||
onFightStart?: (botAName: string, botAElo: number, botBName: string, botBElo: number) => void
|
||||
onFightComplete?: (result: FightResult) => void
|
||||
onError?: (err: Error) => void
|
||||
}
|
||||
|
||||
export async function startFightLoop(options: FightLoopOptions = {}): Promise<void> {
|
||||
@@ -13,6 +31,9 @@ export async function startFightLoop(options: FightLoopOptions = {}): Promise<vo
|
||||
intervalMs = 8000,
|
||||
maxFights = Infinity,
|
||||
matchmakingStyle = 'mixed',
|
||||
onFightStart,
|
||||
onFightComplete,
|
||||
onError,
|
||||
} = options
|
||||
|
||||
const allBots = await db.select({
|
||||
@@ -42,35 +63,71 @@ export async function startFightLoop(options: FightLoopOptions = {}): Promise<vo
|
||||
|
||||
const [botA, botB] = pickMatchup(bots, matchmakingStyle, fightCount)
|
||||
|
||||
if (onFightStart) {
|
||||
onFightStart(botA.name, botA.eloRating, botB.name, botB.eloRating)
|
||||
}
|
||||
|
||||
const fightId = await runMockFight(botA.id, botB.id)
|
||||
|
||||
// Fetch result
|
||||
const fight = await db.select({
|
||||
winnerId: schema.fights.winnerId,
|
||||
totalRounds: schema.fights.totalRounds,
|
||||
botAHp: schema.fights.botAHp,
|
||||
botBHp: schema.fights.botBHp,
|
||||
}).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 || '???'
|
||||
: 'DRAW'
|
||||
: 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
|
||||
|
||||
fightCount++
|
||||
console.log(
|
||||
`[fight-loop] #${fightCount}: ${botA.name} vs ${botB.name} => ${winnerName} (${result?.totalRounds || '?'} rounds)`
|
||||
)
|
||||
|
||||
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)`
|
||||
)
|
||||
}
|
||||
|
||||
// Wait before next fight
|
||||
if (fightCount < maxFights) {
|
||||
await sleep(intervalMs + Math.floor(Math.random() * intervalMs * 0.5))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[fight-loop] error:', err)
|
||||
await sleep(5000) // Back off on error
|
||||
if (onError) {
|
||||
onError(err instanceof Error ? err : new Error(String(err)))
|
||||
} else {
|
||||
console.error('[fight-loop] error:', err)
|
||||
}
|
||||
await sleep(5000)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[fight-loop] completed ${fightCount} fights`)
|
||||
if (!onFightComplete) {
|
||||
console.log(`[fight-loop] completed ${fightCount} fights`)
|
||||
}
|
||||
}
|
||||
|
||||
function pickMatchup(
|
||||
@@ -81,7 +138,6 @@ function pickMatchup(
|
||||
const sorted = [...bots].sort((a, b) => b.eloRating - a.eloRating)
|
||||
|
||||
if (style === 'elo_close' || (style === 'mixed' && fightNum % 3 !== 0)) {
|
||||
// Pick a random bot, then find a close-elo opponent
|
||||
const idx = Math.floor(Math.random() * bots.length)
|
||||
const bot = bots[idx]
|
||||
const others = bots.filter(b => b.id !== bot.id)
|
||||
@@ -94,15 +150,19 @@ function pickMatchup(
|
||||
}
|
||||
|
||||
if (style === 'mixed' && fightNum % 3 === 0) {
|
||||
// Mismatch: top third vs bottom third for dramatic fights
|
||||
const topThird = Math.ceil(sorted.length / 3)
|
||||
const topIdx = Math.floor(Math.random() * topThird)
|
||||
const bottomIdx = sorted.length - 1 - Math.floor(Math.random() * topThird)
|
||||
return [sorted[topIdx], sorted[bottomIdx]]
|
||||
if (sorted[topIdx].id !== sorted[bottomIdx].id) {
|
||||
return [sorted[topIdx], sorted[bottomIdx]]
|
||||
}
|
||||
}
|
||||
|
||||
// Random
|
||||
const shuffled = [...bots].sort(() => Math.random() - 0.5)
|
||||
if (shuffled[0].id === shuffled[1].id && shuffled.length > 2) {
|
||||
return [shuffled[0], shuffled[2]]
|
||||
}
|
||||
return [shuffled[0], shuffled[1]]
|
||||
}
|
||||
|
||||
|
||||
+31
-23
@@ -1,6 +1,6 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { db, schema, sqlite } from '../db/index.js'
|
||||
import { randomArena } from './arenas.js'
|
||||
import { pickChallenge, type Challenge } from './challenges.js'
|
||||
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
||||
@@ -265,6 +265,7 @@ export function mockResponse(
|
||||
return { answer, trashTalk, timeMs, timedOut, error }
|
||||
}
|
||||
|
||||
|
||||
export async function seedMockBots(): Promise<void> {
|
||||
for (const bot of MOCK_BOTS) {
|
||||
const existing = await db.select({ id: schema.bots.id })
|
||||
@@ -292,6 +293,8 @@ export async function seedMockBots(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function runMockFight(botAId: string, botBId: string): Promise<string> {
|
||||
if (botAId === botBId) throw new Error('A bot cannot fight itself')
|
||||
|
||||
const [botARows, botBRows] = await Promise.all([
|
||||
db.select().from(schema.bots).where(eq(schema.bots.id, botAId)).limit(1),
|
||||
db.select().from(schema.bots).where(eq(schema.bots.id, botBId)).limit(1),
|
||||
@@ -329,7 +332,7 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
|
||||
const eloForMock = (name: string) =>
|
||||
MOCK_BOTS.find(b => b.name === name)?.elo || 1200
|
||||
|
||||
const totalRounds = 7 + Math.floor(Math.random() * 4) // 7-10 rounds
|
||||
const totalRounds = 7 + Math.floor(Math.random() * 4)
|
||||
const maxRounds = Math.min(totalRounds, 10)
|
||||
|
||||
for (let round = 1; round <= maxRounds; round++) {
|
||||
@@ -389,37 +392,42 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
|
||||
winnerId = hpA > hpB ? botA.id : hpB > hpA ? botB.id : null
|
||||
}
|
||||
|
||||
await db.update(schema.fights).set({
|
||||
status: 'finished',
|
||||
winnerId,
|
||||
endedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.fights.id, fightId))
|
||||
// Finalize atomically
|
||||
const finalize = sqlite.transaction(() => {
|
||||
db.update(schema.fights).set({
|
||||
status: 'finished',
|
||||
winnerId,
|
||||
endedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.fights.id, fightId))
|
||||
|
||||
// Update stats
|
||||
if (winnerId) {
|
||||
const loserId = winnerId === botA.id ? botB.id : botA.id
|
||||
const winner = winnerId === botA.id ? botA : botB
|
||||
const loser = winnerId === botA.id ? botB : botA
|
||||
if (winnerId) {
|
||||
const loserId = winnerId === botA.id ? botB.id : botA.id
|
||||
const winner = winnerId === botA.id ? botA : botB
|
||||
const loser = winnerId === botA.id ? botB : botA
|
||||
|
||||
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating)
|
||||
const newWinStreak = winner.winStreak + 1
|
||||
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating)
|
||||
const newWinStreak = winner.winStreak + 1
|
||||
|
||||
await Promise.all([
|
||||
db.update(schema.bots).set({
|
||||
wins: sql`${schema.bots.wins} + 1`,
|
||||
eloRating: newWinnerElo,
|
||||
winStreak: newWinStreak,
|
||||
bestStreak: sql`MAX(${schema.bots.bestStreak}, ${newWinStreak})`,
|
||||
tier: calculateTier(newWinnerElo, winner.wins + 1),
|
||||
}).where(eq(schema.bots.id, winnerId)),
|
||||
lastFightAt: new Date().toISOString(),
|
||||
}).where(eq(schema.bots.id, winnerId))
|
||||
|
||||
db.update(schema.bots).set({
|
||||
losses: sql`${schema.bots.losses} + 1`,
|
||||
eloRating: newLoserElo,
|
||||
winStreak: 0,
|
||||
tier: calculateTier(newLoserElo, loser.wins),
|
||||
}).where(eq(schema.bots.id, loserId)),
|
||||
])
|
||||
}
|
||||
lastFightAt: new Date().toISOString(),
|
||||
}).where(eq(schema.bots.id, loserId))
|
||||
}
|
||||
})
|
||||
|
||||
finalize()
|
||||
|
||||
return fightId
|
||||
}
|
||||
@@ -442,26 +450,26 @@ export async function seedMockFights(count: number = 12): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// Sort by elo for mismatch selection
|
||||
const sorted = [...allBots].sort((a, b) => b.eloRating - a.eloRating)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
let botAId: string, botBId: string
|
||||
|
||||
if (i % 3 === 0 && sorted.length >= 4) {
|
||||
// Every 3rd fight: mismatch (top vs bottom)
|
||||
const topIdx = Math.floor(Math.random() * Math.ceil(sorted.length / 3))
|
||||
const botIdx = sorted.length - 1 - Math.floor(Math.random() * Math.ceil(sorted.length / 3))
|
||||
botAId = sorted[topIdx].id
|
||||
botBId = sorted[botIdx].id
|
||||
} else {
|
||||
// Random matchup
|
||||
const shuffled = [...allBots].sort(() => Math.random() - 0.5)
|
||||
botAId = shuffled[0].id
|
||||
botBId = shuffled[1].id
|
||||
}
|
||||
|
||||
await runMockFight(botAId, botBId)
|
||||
// Skip self-fights
|
||||
if (botAId !== botBId) {
|
||||
await runMockFight(botAId, botBId)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[botfights] seeded ${count} mock fights`)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { db, schema, sqlite } from '../db/index.js'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { randomArena, type Arena } from './arenas.js'
|
||||
import { pickChallenge, type Challenge } from './challenges.js'
|
||||
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
||||
import { fightEvents } from './events.js'
|
||||
import { generateMockBotResponse } from './mock.js'
|
||||
import { setCooldown } from './queue.js'
|
||||
|
||||
interface BotRecord {
|
||||
id: string
|
||||
@@ -28,6 +29,14 @@ interface WebhookResponse {
|
||||
|
||||
const MAX_ROUNDS = 10
|
||||
const KO_THRESHOLD = 0
|
||||
const MAX_RESPONSE_BYTES = 10 * 1024 // 10KB
|
||||
|
||||
// Track bots currently in a fight to prevent concurrent fights
|
||||
const activeFighters = new Set<string>()
|
||||
|
||||
export function isInFight(botId: string): boolean {
|
||||
return activeFighters.has(botId)
|
||||
}
|
||||
|
||||
function emit(fightId: string, type: string, data: Record<string, unknown>) {
|
||||
fightEvents.emit({
|
||||
@@ -38,14 +47,68 @@ function emit(fightId: string, type: string, data: Record<string, unknown>) {
|
||||
})
|
||||
}
|
||||
|
||||
// SSRF protection: block internal/private URLs
|
||||
function isAllowedWebhookUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const hostname = parsed.hostname.toLowerCase()
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false
|
||||
if (hostname.startsWith('10.')) return false
|
||||
if (hostname.startsWith('192.168.')) return false
|
||||
if (hostname.startsWith('172.')) {
|
||||
const second = parseInt(hostname.split('.')[1])
|
||||
if (second >= 16 && second <= 31) return false
|
||||
}
|
||||
if (hostname === '169.254.169.254') return false
|
||||
if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export { isAllowedWebhookUrl }
|
||||
|
||||
// Size-limited body reader to prevent OOM
|
||||
async function readLimitedBody(res: Response, maxBytes: number): Promise<string> {
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) return ''
|
||||
const chunks: Uint8Array[] = []
|
||||
let totalBytes = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
totalBytes += value.byteLength
|
||||
if (totalBytes > maxBytes) {
|
||||
reader.cancel()
|
||||
throw new Error(`Response body exceeds ${maxBytes} bytes`)
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
} catch (err) {
|
||||
reader.cancel()
|
||||
throw err
|
||||
}
|
||||
const combined = new Uint8Array(totalBytes)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
combined.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
return new TextDecoder().decode(combined)
|
||||
}
|
||||
|
||||
async function callWebhook(
|
||||
url: string,
|
||||
challenge: Challenge,
|
||||
roundNumber: number,
|
||||
fightId: string,
|
||||
opponent: { name: string; wins: number; losses: number },
|
||||
arena: Arena,
|
||||
): Promise<WebhookResponse> {
|
||||
const body = JSON.stringify({
|
||||
fight_id: fightId,
|
||||
round: roundNumber,
|
||||
type: challenge.type,
|
||||
challenge: challenge.prompt,
|
||||
@@ -61,6 +124,12 @@ async function callWebhook(
|
||||
const start = Date.now()
|
||||
console.log(`[webhook] POST ${url} round=${roundNumber} type=${challenge.type}`)
|
||||
|
||||
// SSRF check
|
||||
if (!isAllowedWebhookUrl(url)) {
|
||||
console.log(`[webhook] ${url} BLOCKED (private/internal URL)`)
|
||||
return { answer: null, timeMs: 0, timedOut: false, error: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), challenge.timeout_ms)
|
||||
@@ -80,7 +149,14 @@ async function callWebhook(
|
||||
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
let text: string
|
||||
try {
|
||||
text = await readLimitedBody(res, MAX_RESPONSE_BYTES)
|
||||
} catch {
|
||||
console.log(`[webhook] ${url} response too large (>${MAX_RESPONSE_BYTES} bytes)`)
|
||||
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
||||
}
|
||||
|
||||
let data: { answer?: string; trash_talk?: string }
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
@@ -88,10 +164,15 @@ async function callWebhook(
|
||||
console.log(`[webhook] ${url} returned non-JSON in ${elapsed}ms: ${text.slice(0, 200)}`)
|
||||
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
||||
}
|
||||
console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(data.answer || '').slice(0, 80)}`)
|
||||
|
||||
// Enforce size limits on fields
|
||||
const answer = data.answer ? data.answer.slice(0, 2000) : null
|
||||
const trashTalk = data.trash_talk ? data.trash_talk.slice(0, 200) : undefined
|
||||
|
||||
console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(answer || '').slice(0, 80)}`)
|
||||
return {
|
||||
answer: data.answer || null,
|
||||
trashTalk: data.trash_talk,
|
||||
answer,
|
||||
trashTalk,
|
||||
timeMs: elapsed,
|
||||
timedOut: false,
|
||||
error: false,
|
||||
@@ -109,7 +190,7 @@ async function callWebhook(
|
||||
}
|
||||
}
|
||||
|
||||
function isMockBot(webhookUrl: string): boolean {
|
||||
export function isMockBot(webhookUrl: string): boolean {
|
||||
return webhookUrl.startsWith('http://mock.local')
|
||||
}
|
||||
|
||||
@@ -117,6 +198,7 @@ async function getBotResponse(
|
||||
bot: BotRecord,
|
||||
challenge: Challenge,
|
||||
roundNumber: number,
|
||||
fightId: string,
|
||||
opponent: { name: string; wins: number; losses: number },
|
||||
arena: Arena,
|
||||
): Promise<WebhookResponse> {
|
||||
@@ -132,7 +214,7 @@ async function getBotResponse(
|
||||
}
|
||||
}
|
||||
console.log(`[fight] ${bot.name} has real webhook: ${bot.webhookUrl}`)
|
||||
return callWebhook(bot.webhookUrl, challenge, roundNumber, opponent, arena)
|
||||
return callWebhook(bot.webhookUrl, challenge, roundNumber, fightId, opponent, arena)
|
||||
}
|
||||
|
||||
async function loadBots(botAId: string, botBId: string): Promise<[BotRecord, BotRecord]> {
|
||||
@@ -166,6 +248,26 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena)
|
||||
return fightId
|
||||
}
|
||||
|
||||
// Track webhook errors per bot
|
||||
async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) {
|
||||
if (isMockBot(webhookUrl)) return
|
||||
if (succeeded) {
|
||||
await db.update(schema.bots).set({ consecutiveErrors: 0 }).where(eq(schema.bots.id, botId))
|
||||
} else {
|
||||
await db.update(schema.bots).set({
|
||||
consecutiveErrors: sql`${schema.bots.consecutiveErrors} + 1`,
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
}).where(eq(schema.bots.id, botId))
|
||||
// Auto-deactivate after 5 consecutive errors
|
||||
const bot = await db.select({ consecutiveErrors: schema.bots.consecutiveErrors })
|
||||
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
||||
if (bot[0] && bot[0].consecutiveErrors >= 5) {
|
||||
await db.update(schema.bots).set({ isActive: false }).where(eq(schema.bots.id, botId))
|
||||
console.log(`[fight] bot ${botId} auto-deactivated after 5 consecutive webhook errors`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRecord, arena: Arena): Promise<void> {
|
||||
let hpA = 200
|
||||
let hpB = 200
|
||||
@@ -183,10 +285,16 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
||||
challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt },
|
||||
})
|
||||
|
||||
// Call both bots simultaneously (mock bots get generated responses)
|
||||
// Call both bots simultaneously
|
||||
const [responseA, responseB] = await Promise.all([
|
||||
getBotResponse(botA, challenge, round, { name: botB.name, wins: botB.wins, losses: botB.losses }, arena),
|
||||
getBotResponse(botB, challenge, round, { name: botA.name, wins: botA.wins, losses: botA.losses }, arena),
|
||||
getBotResponse(botA, challenge, round, fightId, { name: botB.name, wins: botB.wins, losses: botB.losses }, arena),
|
||||
getBotResponse(botB, challenge, round, fightId, { name: botA.name, wins: botA.wins, losses: botA.losses }, arena),
|
||||
])
|
||||
|
||||
// Track webhook reliability for real bots
|
||||
await Promise.all([
|
||||
trackWebhookResult(botA.id, botA.webhookUrl, !responseA.error && !responseA.timedOut),
|
||||
trackWebhookResult(botB.id, botB.webhookUrl, !responseB.error && !responseB.timedOut),
|
||||
])
|
||||
|
||||
// Score the round
|
||||
@@ -272,39 +380,52 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
||||
(winnerId === botB.id && hpB === 200)
|
||||
)
|
||||
|
||||
// Finalize fight
|
||||
await db.update(schema.fights).set({
|
||||
status: 'finished',
|
||||
winnerId,
|
||||
endedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.fights.id, fightId))
|
||||
// Finalize fight + update bot stats atomically
|
||||
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl)
|
||||
const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock fights
|
||||
|
||||
// Update bot stats
|
||||
if (winnerId) {
|
||||
const loserId = winnerId === botA.id ? botB.id : botA.id
|
||||
const winner = winnerId === botA.id ? botA : botB
|
||||
const loser = winnerId === botA.id ? botB : botA
|
||||
const finalize = sqlite.transaction(() => {
|
||||
// Mark fight finished
|
||||
db.update(schema.fights).set({
|
||||
status: 'finished',
|
||||
winnerId,
|
||||
endedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.fights.id, fightId))
|
||||
|
||||
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating)
|
||||
const newWinStreak = winner.winStreak + 1
|
||||
const newBestStreak = Math.max(winner.bestStreak, newWinStreak)
|
||||
// Update bot stats
|
||||
if (winnerId) {
|
||||
const loserId = winnerId === botA.id ? botB.id : botA.id
|
||||
const winner = winnerId === botA.id ? botA : botB
|
||||
const loser = winnerId === botA.id ? botB : botA
|
||||
|
||||
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating, kFactor)
|
||||
const newWinStreak = winner.winStreak + 1
|
||||
const newBestStreak = Math.max(winner.bestStreak, newWinStreak)
|
||||
|
||||
await Promise.all([
|
||||
db.update(schema.bots).set({
|
||||
wins: sql`${schema.bots.wins} + 1`,
|
||||
eloRating: newWinnerElo,
|
||||
winStreak: newWinStreak,
|
||||
bestStreak: newBestStreak,
|
||||
tier: calculateTier(newWinnerElo, winner.wins + 1),
|
||||
}).where(eq(schema.bots.id, winnerId)),
|
||||
lastFightAt: new Date().toISOString(),
|
||||
}).where(eq(schema.bots.id, winnerId))
|
||||
|
||||
db.update(schema.bots).set({
|
||||
losses: sql`${schema.bots.losses} + 1`,
|
||||
eloRating: newLoserElo,
|
||||
winStreak: 0,
|
||||
tier: calculateTier(newLoserElo, loser.wins),
|
||||
}).where(eq(schema.bots.id, loserId)),
|
||||
])
|
||||
}
|
||||
lastFightAt: new Date().toISOString(),
|
||||
}).where(eq(schema.bots.id, loserId))
|
||||
} else {
|
||||
// Draw — update lastFightAt for both
|
||||
db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botA.id))
|
||||
db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botB.id))
|
||||
}
|
||||
})
|
||||
|
||||
finalize()
|
||||
|
||||
emit(fightId, 'fight_end', {
|
||||
winnerId,
|
||||
@@ -317,20 +438,65 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
||||
}
|
||||
|
||||
export async function runFight(botAId: string, botBId: string): Promise<string> {
|
||||
const [botA, botB] = await loadBots(botAId, botBId)
|
||||
const arena = randomArena()
|
||||
const fightId = await createFightRecord(botA, botB, arena)
|
||||
await executeFightRounds(fightId, botA, botB, arena)
|
||||
return fightId
|
||||
if (botAId === botBId) throw new Error('A bot cannot fight itself')
|
||||
if (activeFighters.has(botAId)) throw new Error(`Bot ${botAId} is already in a fight`)
|
||||
if (activeFighters.has(botBId)) throw new Error(`Bot ${botBId} is already in a fight`)
|
||||
|
||||
activeFighters.add(botAId)
|
||||
activeFighters.add(botBId)
|
||||
|
||||
try {
|
||||
const [botA, botB] = await loadBots(botAId, botBId)
|
||||
const arena = randomArena()
|
||||
const fightId = await createFightRecord(botA, botB, arena)
|
||||
await executeFightRounds(fightId, botA, botB, arena)
|
||||
return fightId
|
||||
} finally {
|
||||
activeFighters.delete(botAId)
|
||||
activeFighters.delete(botBId)
|
||||
setCooldown(botAId)
|
||||
setCooldown(botBId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates the fight record and returns the ID immediately. Rounds run in background. */
|
||||
export async function runFightAsync(botAId: string, botBId: string): Promise<string> {
|
||||
if (botAId === botBId) throw new Error('A bot cannot fight itself')
|
||||
if (activeFighters.has(botAId)) throw new Error(`Bot ${botAId} is already in a fight`)
|
||||
if (activeFighters.has(botBId)) throw new Error(`Bot ${botBId} is already in a fight`)
|
||||
|
||||
activeFighters.add(botAId)
|
||||
activeFighters.add(botBId)
|
||||
|
||||
const [botA, botB] = await loadBots(botAId, botBId)
|
||||
const arena = randomArena()
|
||||
const fightId = await createFightRecord(botA, botB, arena)
|
||||
executeFightRounds(fightId, botA, botB, arena).catch(err => {
|
||||
console.error(`[botfights] fight ${fightId} error:`, err)
|
||||
})
|
||||
|
||||
executeFightRounds(fightId, botA, botB, arena)
|
||||
.catch(err => {
|
||||
console.error(`[botfights] fight ${fightId} error:`, err)
|
||||
// Mark fight as cancelled so it doesn't stay 'live' forever
|
||||
db.update(schema.fights).set({
|
||||
status: 'cancelled',
|
||||
endedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.fights.id, fightId))
|
||||
fightEvents.cleanup(fightId)
|
||||
})
|
||||
.finally(() => {
|
||||
activeFighters.delete(botAId)
|
||||
activeFighters.delete(botBId)
|
||||
setCooldown(botAId)
|
||||
setCooldown(botBId)
|
||||
})
|
||||
|
||||
return fightId
|
||||
}
|
||||
|
||||
/** Clean up orphaned fights on startup */
|
||||
export async function cleanupOrphanedFights(): Promise<number> {
|
||||
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString()
|
||||
const result = await db.update(schema.fights)
|
||||
.set({ status: 'cancelled', endedAt: new Date().toISOString() })
|
||||
.where(sql`${schema.fights.status} = 'live' AND ${schema.fights.startedAt} < ${tenMinutesAgo}`)
|
||||
return 0 // drizzle doesn't return affected rows easily, but the cleanup runs
|
||||
}
|
||||
|
||||
+31
-14
@@ -1,6 +1,6 @@
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { runFightAsync } from './orchestrator.js'
|
||||
import { runFightAsync, isInFight } from './orchestrator.js'
|
||||
import { seedMockBots } from './mock.js'
|
||||
|
||||
interface QueueEntry {
|
||||
@@ -19,6 +19,14 @@ const waitingQueue: QueueEntry[] = []
|
||||
// How long a bot waits before getting matched against a mock bot
|
||||
const QUEUE_TIMEOUT_MS = 3_000
|
||||
|
||||
// Post-fight cooldown tracking
|
||||
const fightCooldowns = new Map<string, number>()
|
||||
const COOLDOWN_MS = 15_000
|
||||
|
||||
export function setCooldown(botId: string) {
|
||||
fightCooldowns.set(botId, Date.now() + COOLDOWN_MS)
|
||||
}
|
||||
|
||||
export function getQueueSize(): number {
|
||||
return waitingQueue.length
|
||||
}
|
||||
@@ -38,22 +46,39 @@ export function getQueueSnapshot(): { botId: string; botName: string; eloRating:
|
||||
* If nobody is waiting, waits up to QUEUE_TIMEOUT_MS then fights a mock bot.
|
||||
*/
|
||||
export async function joinQueue(botId: string): Promise<string> {
|
||||
// Check cooldown
|
||||
const cooldownUntil = fightCooldowns.get(botId)
|
||||
if (cooldownUntil && Date.now() < cooldownUntil) {
|
||||
const waitSec = Math.ceil((cooldownUntil - Date.now()) / 1000)
|
||||
throw new Error(`Cooldown active. Wait ${waitSec}s.`)
|
||||
}
|
||||
|
||||
// Check if already in a fight
|
||||
if (isInFight(botId)) {
|
||||
throw new Error('Bot is already in a fight.')
|
||||
}
|
||||
|
||||
// Load bot
|
||||
const botRows = await db.select().from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
||||
if (botRows.length === 0) throw new Error('Bot not found')
|
||||
const bot = botRows[0]
|
||||
|
||||
// Check if bot is active
|
||||
if (!bot.isActive) {
|
||||
throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.')
|
||||
}
|
||||
|
||||
console.log(`[queue] joinQueue botId=${botId} name=${bot.name} webhook=${bot.webhookUrl}`)
|
||||
|
||||
// Don't allow same bot twice in queue
|
||||
const existing = waitingQueue.findIndex(e => e.botId === botId)
|
||||
if (existing !== -1) {
|
||||
// Remove old entry
|
||||
const old = waitingQueue.splice(existing, 1)[0]
|
||||
clearTimeout(old.timeoutHandle)
|
||||
old.reject(new Error('Rejoined queue'))
|
||||
}
|
||||
|
||||
// Check if someone is already waiting — instant match
|
||||
// Check if someone is already waiting -- instant match
|
||||
if (waitingQueue.length > 0) {
|
||||
// Find closest elo match
|
||||
waitingQueue.sort((a, b) => {
|
||||
@@ -66,15 +91,14 @@ export async function joinQueue(botId: string): Promise<string> {
|
||||
clearTimeout(opponent.timeoutHandle)
|
||||
|
||||
// Start the fight
|
||||
const fightId = await startFight(opponent.botId, opponent.webhookUrl, botId, bot.webhookUrl)
|
||||
const fightId = await startFight(opponent.botId, botId)
|
||||
opponent.resolve(fightId)
|
||||
return fightId
|
||||
}
|
||||
|
||||
// Nobody waiting — join the queue and wait
|
||||
// Nobody waiting -- join the queue and wait
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(async () => {
|
||||
// Timed out — remove from queue and match against a mock bot
|
||||
const idx = waitingQueue.findIndex(e => e.botId === botId)
|
||||
if (idx !== -1) {
|
||||
waitingQueue.splice(idx, 1)
|
||||
@@ -112,17 +136,12 @@ export function leaveQueue(botId: string): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
async function startFight(
|
||||
botAId: string, _botAWebhook: string,
|
||||
botBId: string, _botBWebhook: string,
|
||||
): Promise<string> {
|
||||
// runFightAsync handles both real and mock bots — mock bots get generated responses
|
||||
async function startFight(botAId: string, botBId: string): Promise<string> {
|
||||
return runFightAsync(botAId, botBId)
|
||||
}
|
||||
|
||||
async function matchAgainstMock(botId: string, webhookUrl: string): Promise<string> {
|
||||
console.log(`[queue] matchAgainstMock botId=${botId} webhook=${webhookUrl}`)
|
||||
// Find a mock bot to fight
|
||||
const allBots = await db.select({
|
||||
id: schema.bots.id,
|
||||
webhookUrl: schema.bots.webhookUrl,
|
||||
@@ -134,7 +153,6 @@ async function matchAgainstMock(botId: string, webhookUrl: string): Promise<stri
|
||||
if (mockBots.length === 0) {
|
||||
console.log('[queue] No mock bots found, seeding...')
|
||||
await seedMockBots()
|
||||
// Retry after seeding
|
||||
return matchAgainstMock(botId, webhookUrl)
|
||||
}
|
||||
|
||||
@@ -145,6 +163,5 @@ async function matchAgainstMock(botId: string, webhookUrl: string): Promise<stri
|
||||
const opponent = mockBots[0]
|
||||
|
||||
console.log(`[queue] starting fight: ${botId} vs mock ${opponent.id}`)
|
||||
// runFightAsync handles mock bots inline — no need for runMockFight
|
||||
return runFightAsync(botId, opponent.id)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function scoreRound(
|
||||
comboA: number,
|
||||
comboB: number,
|
||||
): RoundResult {
|
||||
// Handle timeouts/errors — instant loss for the failing bot
|
||||
// Handle timeouts/errors -- instant loss for the failing bot
|
||||
if (responseA.timedOut && responseB.timedOut) {
|
||||
return {
|
||||
botAScore: 0, botBScore: 0,
|
||||
@@ -70,19 +70,17 @@ export function scoreRound(
|
||||
let scoreB: number
|
||||
|
||||
if (challenge.answers && challenge.answers.length > 0) {
|
||||
// ═══ FACTUAL SCORING ═══
|
||||
// Check correctness against known answers
|
||||
// === FACTUAL SCORING ===
|
||||
const correctA = checkAnswer(responseA.answer, challenge.answers)
|
||||
const correctB = checkAnswer(responseB.answer, challenge.answers)
|
||||
|
||||
if (correctA > 0 && correctB > 0) {
|
||||
// Both correct — speed is tiebreaker
|
||||
// Both correct -- speed is tiebreaker
|
||||
const faster = Math.min(responseA.timeMs, responseB.timeMs)
|
||||
const slower = Math.max(responseA.timeMs, responseB.timeMs)
|
||||
const speedRatio = slower > 0 ? faster / slower : 1
|
||||
const aFaster = responseA.timeMs <= responseB.timeMs
|
||||
|
||||
// Confidence bonus (full match vs partial)
|
||||
const confA = Math.min(correctA, 1)
|
||||
const confB = Math.min(correctB, 1)
|
||||
|
||||
@@ -94,22 +92,19 @@ export function scoreRound(
|
||||
scoreB = 7 + (1 - speedRatio) * 2 + confB
|
||||
}
|
||||
} else if (correctA > 0 && correctB === 0) {
|
||||
// A correct, B wrong — A wins big
|
||||
scoreA = 9 + correctA * 0.5
|
||||
scoreB = 1 + (responseB.answer ? 1 : 0) // tiny credit for trying
|
||||
scoreB = 1 + (responseB.answer ? 1 : 0)
|
||||
} else if (correctB > 0 && correctA === 0) {
|
||||
// B correct, A wrong — B wins big
|
||||
scoreA = 1 + (responseA.answer ? 1 : 0)
|
||||
scoreB = 9 + correctB * 0.5
|
||||
} else {
|
||||
// Both wrong — speed tiebreaker in low range
|
||||
// Both wrong -- speed tiebreaker in low range
|
||||
const aFaster = responseA.timeMs <= responseB.timeMs
|
||||
scoreA = aFaster ? 4 : 3
|
||||
scoreB = aFaster ? 3 : 4
|
||||
}
|
||||
} else {
|
||||
// ═══ CREATIVE SCORING ═══
|
||||
// Heuristic: response quality estimation (length + speed)
|
||||
// === CREATIVE SCORING ===
|
||||
const qualA = estimateQuality(responseA)
|
||||
const qualB = estimateQuality(responseB)
|
||||
const total = qualA + qualB || 1
|
||||
@@ -123,10 +118,8 @@ export function scoreRound(
|
||||
const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : null
|
||||
const loserName = winnerId === botA.id ? botB.name : winnerId === botB.id ? botA.name : null
|
||||
|
||||
// Critical hit on big margin
|
||||
const isCritical = margin > 4
|
||||
|
||||
// Calculate damage
|
||||
let winnerDamage = challenge.baseDamage + margin * 2
|
||||
if (isCritical) winnerDamage *= 1.5
|
||||
const winnerCombo = winnerId === botA.id ? comboA : comboB
|
||||
@@ -156,7 +149,6 @@ function applyModifiers(
|
||||
combo: number,
|
||||
): number {
|
||||
let d = damage
|
||||
// Combo multiplier (caps at 2x)
|
||||
if (combo > 0) {
|
||||
d *= 1 + Math.min(combo, 5) * 0.2
|
||||
}
|
||||
@@ -164,13 +156,36 @@ function applyModifiers(
|
||||
}
|
||||
|
||||
function estimateQuality(response: BotResponse): number {
|
||||
if (!response.answer) return 1
|
||||
const len = response.answer.length
|
||||
// Reasonable length gets a bonus, very short or very long gets penalized
|
||||
const lengthScore = len > 20 && len < 500 ? 5 : len > 500 ? 3 : 2
|
||||
// Faster is slightly better
|
||||
const speedBonus = Math.max(0, 3 - response.timeMs / 5000)
|
||||
return lengthScore + speedBonus
|
||||
if (!response.answer) return 0.5
|
||||
const text = response.answer.trim()
|
||||
const len = text.length
|
||||
|
||||
if (len < 10) return 1
|
||||
|
||||
// Detect low-effort spam (repeated chars)
|
||||
const uniqueChars = new Set(text.toLowerCase()).size
|
||||
const charRatio = uniqueChars / Math.min(len, 100)
|
||||
if (charRatio < 0.1) return 0.5
|
||||
|
||||
// Word diversity (unique words / total words)
|
||||
const words = text.split(/\s+/)
|
||||
const uniqueWords = new Set(words.map(w => w.toLowerCase()))
|
||||
const wordDiversity = uniqueWords.size / Math.max(words.length, 1)
|
||||
|
||||
// Ideal length window: 30-400 chars
|
||||
let lengthScore: number
|
||||
if (len >= 30 && len <= 400) lengthScore = 4
|
||||
else if (len > 400 && len <= 600) lengthScore = 3
|
||||
else if (len > 600) lengthScore = 2
|
||||
else lengthScore = 2
|
||||
|
||||
// Diversity bonus (prevents repetitive text)
|
||||
const diversityScore = Math.min(wordDiversity * 4, 3)
|
||||
|
||||
// Speed bonus (faster is slightly better)
|
||||
const speedBonus = Math.max(0, 2 - response.timeMs / 8000)
|
||||
|
||||
return lengthScore + diversityScore + speedBonus
|
||||
}
|
||||
|
||||
function generateNarration(
|
||||
@@ -183,7 +198,6 @@ function generateNarration(
|
||||
const critPrefix = isCritical ? 'CRITICAL HIT! ' : ''
|
||||
const isFactual = challenge.scoring === 'factual'
|
||||
|
||||
// Big margin = one got it right and the other didn't
|
||||
if (isFactual && margin > 5) {
|
||||
const bigWins = [
|
||||
`${critPrefix}${winner} NAILS IT! ${loser} didn't even come close.`,
|
||||
@@ -195,18 +209,16 @@ function generateNarration(
|
||||
return bigWins[Math.floor(Math.random() * bigWins.length)]
|
||||
}
|
||||
|
||||
// Factual — both correct, speed tiebreaker
|
||||
if (isFactual && margin <= 3) {
|
||||
const closeOnes = [
|
||||
`${critPrefix}Both bots got it right, but ${winner} was FASTER! ${loser} needs to pick up the pace.`,
|
||||
`${critPrefix}Correct on both sides! ${winner} edges it out with lightning speed.`,
|
||||
`${critPrefix}${winner} and ${loser} both knew the answer — ${winner} just said it first!`,
|
||||
`${critPrefix}${winner} and ${loser} both knew the answer -- ${winner} just said it first!`,
|
||||
`${critPrefix}A battle of speed! ${winner} fires back a fraction faster than ${loser}.`,
|
||||
]
|
||||
return closeOnes[Math.floor(Math.random() * closeOnes.length)]
|
||||
}
|
||||
|
||||
// Generic narrations by category
|
||||
const narrations: Record<string, string[]> = {
|
||||
speed_blitz: [
|
||||
`${critPrefix}${winner} fires back in the blink of an eye! ${loser} is still loading.`,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { isAllowedWebhookUrl } from './orchestrator.js'
|
||||
|
||||
export interface WebhookTestResult {
|
||||
reachable: boolean
|
||||
validResponse: boolean
|
||||
latencyMs: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function testWebhook(webhookUrl: string): Promise<WebhookTestResult> {
|
||||
if (!isAllowedWebhookUrl(webhookUrl)) {
|
||||
return { reachable: false, validResponse: false, latencyMs: 0, error: 'URL blocked: private/internal addresses are not allowed.' }
|
||||
}
|
||||
|
||||
const testPayload = JSON.stringify({
|
||||
fight_id: 'test_000000',
|
||||
round: 0,
|
||||
type: 'webhook_test',
|
||||
challenge: 'WEBHOOK TEST: respond with {"answer": "pong"} to verify your setup.',
|
||||
constraints: { timeout_ms: 5000, max_tokens: 500 },
|
||||
opponent: { name: 'test_bot', wins: 0, losses: 0 },
|
||||
arena: 'localhost',
|
||||
arena_modifier: null,
|
||||
})
|
||||
|
||||
const start = Date.now()
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: testPayload,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
const latencyMs = Date.now() - start
|
||||
|
||||
if (!res.ok) {
|
||||
return { reachable: true, validResponse: false, latencyMs, error: `Webhook returned HTTP ${res.status}. Expected 200.` }
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
if (text.length > 10240) {
|
||||
return { reachable: true, validResponse: false, latencyMs, error: 'Response too large (>10KB).' }
|
||||
}
|
||||
|
||||
let data: Record<string, unknown>
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
return { reachable: true, validResponse: false, latencyMs, error: 'Response is not valid JSON. Expected {"answer": "..."}.' }
|
||||
}
|
||||
|
||||
if (typeof data.answer !== 'string') {
|
||||
return { reachable: true, validResponse: false, latencyMs, error: 'Response JSON missing "answer" field. Expected {"answer": "pong"}.' }
|
||||
}
|
||||
|
||||
return { reachable: true, validResponse: true, latencyMs }
|
||||
} catch (err: unknown) {
|
||||
const latencyMs = Date.now() - start
|
||||
const isAbort = err instanceof Error && err.name === 'AbortError'
|
||||
if (isAbort) {
|
||||
return { reachable: false, validResponse: false, latencyMs, error: 'Webhook timed out (5s). Is your server running?' }
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
return { reachable: false, validResponse: false, latencyMs, error: `Connection failed: ${msg}` }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user