feat: v3 — fight loop, expanded challenges, FightPage ownership guard
- Add fight-loop CLI for automated mock fights with elo-based matchmaking - Expand challenge types, scoring narrations, arenas, and mock bot pool - FightPage "Fight again" buttons now only show for your own bot - FightViewer async scene init, live fight polling with round counter - Extract mock answers into separate answers module Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
47d20fbe66
commit
2c0323d5fb
@@ -66,7 +66,7 @@ const logItems = ref<{ type: string; round: number; text: string; color: string
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (props.autoplay) {
|
if (props.autoplay) {
|
||||||
// Fresh fight — start replay immediately instead of showing static result
|
// Fresh fight — start replay immediately instead of showing static result
|
||||||
initScene()
|
await initScene()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
replay()
|
replay()
|
||||||
} else {
|
} else {
|
||||||
@@ -74,7 +74,7 @@ onMounted(async () => {
|
|||||||
displayHpA.value = mapHp(props.fight.botAHp, props.fight.winnerId, props.fight.botA?.id)
|
displayHpA.value = mapHp(props.fight.botAHp, props.fight.winnerId, props.fight.botA?.id)
|
||||||
displayHpB.value = mapHp(props.fight.botBHp, props.fight.winnerId, props.fight.botB?.id)
|
displayHpB.value = mapHp(props.fight.botBHp, props.fight.winnerId, props.fight.botB?.id)
|
||||||
for (const r of props.fight.rounds) addRoundToLog(r, false)
|
for (const r of props.fight.rounds) addRoundToLog(r, false)
|
||||||
initScene()
|
await initScene()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ function mapHp(hp: number, winnerId: string | null, botId: string | undefined):
|
|||||||
|
|
||||||
onUnmounted(() => { scene?.destroy(); scene = null })
|
onUnmounted(() => { scene?.destroy(); scene = null })
|
||||||
|
|
||||||
function initScene() {
|
async function initScene() {
|
||||||
if (!canvasRef.value || !props.fight.botA || !props.fight.botB) return
|
if (!canvasRef.value || !props.fight.botA || !props.fight.botB) return
|
||||||
if (scene) { scene.k.go('fight'); return }
|
if (scene) { scene.k.go('fight'); return }
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ function initScene() {
|
|||||||
canvasRef.value.height = container.clientHeight
|
canvasRef.value.height = container.clientHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
scene = createFightScene({
|
scene = await createFightScene({
|
||||||
canvas: canvasRef.value,
|
canvas: canvasRef.value,
|
||||||
botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype },
|
botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype },
|
||||||
botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype },
|
botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype },
|
||||||
@@ -110,8 +110,15 @@ const challengeLabel = (type: string) => {
|
|||||||
roast_battle: 'ROAST BATTLE', hallucination_check: 'HALLUCINATION CHECK',
|
roast_battle: 'ROAST BATTLE', hallucination_check: 'HALLUCINATION CHECK',
|
||||||
token_economy: 'TOKEN ECONOMY', creative_writing: 'CREATIVE WRITING',
|
token_economy: 'TOKEN ECONOMY', creative_writing: 'CREATIVE WRITING',
|
||||||
math_blitz: 'MATH BLITZ', trap_card: 'TRAP CARD',
|
math_blitz: 'MATH BLITZ', trap_card: 'TRAP CARD',
|
||||||
|
food_fight: 'FOOD FIGHT', wrestling_match: 'WRESTLING MATCH',
|
||||||
|
music_battle: 'MUSIC BATTLE', magic_duel: 'MAGIC DUEL',
|
||||||
|
sports_showdown: 'SPORTS SHOWDOWN', nature_clash: 'NATURE CLASH',
|
||||||
|
space_war: 'SPACE WAR', hack_battle: 'HACK BATTLE',
|
||||||
|
meme_war: 'MEME WAR', animal_kingdom: 'ANIMAL KINGDOM',
|
||||||
|
demolition: 'DEMOLITION DERBY', vehicle_mayhem: 'VEHICLE MAYHEM',
|
||||||
|
medieval_combat: 'MEDIEVAL COMBAT',
|
||||||
}
|
}
|
||||||
return labels[type] || type.toUpperCase()
|
return labels[type] || type.replace(/_/g, ' ').toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
const tierClass = (t: number) => `tier-${t}`
|
const tierClass = (t: number) => `tier-${t}`
|
||||||
@@ -148,8 +155,8 @@ function addRoundToLog(round: Round, stagger: boolean): Promise<void> {
|
|||||||
logItems.value.push(
|
logItems.value.push(
|
||||||
{ type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' },
|
{ type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' },
|
||||||
{ type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' },
|
{ type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' },
|
||||||
{ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[TIMEOUT]'} (${round.botATimeMs}ms)`, color: 'neon-cyan' },
|
{ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[NO RESPONSE]'} (${round.botATimeMs}ms)`, color: 'neon-cyan' },
|
||||||
{ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse?.slice(0, 120) || '[TIMEOUT]'} (${round.botBTimeMs}ms)`, color: 'neon-pink' },
|
{ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse?.slice(0, 120) || '[NO RESPONSE]'} (${round.botBTimeMs}ms)`, color: 'neon-pink' },
|
||||||
)
|
)
|
||||||
if (round.narration) logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' })
|
if (round.narration) logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' })
|
||||||
const winner = round.winnerId === props.fight.botA?.id ? props.fight.botA?.name : round.winnerId === props.fight.botB?.id ? props.fight.botB?.name : 'DRAW'
|
const winner = round.winnerId === props.fight.botA?.id ? props.fight.botA?.name : round.winnerId === props.fight.botB?.id ? props.fight.botB?.name : 'DRAW'
|
||||||
@@ -163,11 +170,11 @@ function addRoundToLog(round: Round, stagger: boolean): Promise<void> {
|
|||||||
scrollLog(); await sleep(150)
|
scrollLog(); await sleep(150)
|
||||||
logItems.value.push({ type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' })
|
logItems.value.push({ type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' })
|
||||||
scrollLog(); await sleep(200)
|
scrollLog(); await sleep(200)
|
||||||
logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[TIMEOUT]'}`, color: 'neon-cyan' })
|
logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-cyan' })
|
||||||
scrollLog(); await sleep(150)
|
scrollLog(); await sleep(150)
|
||||||
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' })
|
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' })
|
||||||
scrollLog(); await sleep(150)
|
scrollLog(); await sleep(150)
|
||||||
logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse?.slice(0, 120) || '[TIMEOUT]'}`, color: 'neon-pink' })
|
logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse?.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-pink' })
|
||||||
scrollLog(); await sleep(150)
|
scrollLog(); await sleep(150)
|
||||||
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' })
|
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' })
|
||||||
scrollLog()
|
scrollLog()
|
||||||
@@ -183,7 +190,7 @@ async function replay() {
|
|||||||
logItems.value = []
|
logItems.value = []
|
||||||
currentRound.value = 0
|
currentRound.value = 0
|
||||||
|
|
||||||
initScene()
|
await initScene()
|
||||||
scene?.startMusic()
|
scene?.startMusic()
|
||||||
await sleep(300)
|
await sleep(300)
|
||||||
|
|
||||||
@@ -198,6 +205,12 @@ async function replay() {
|
|||||||
scrollLog()
|
scrollLog()
|
||||||
await sleep(200)
|
await sleep(200)
|
||||||
|
|
||||||
|
// Entrance animations
|
||||||
|
if (scene) {
|
||||||
|
await scene.playEntrance()
|
||||||
|
await sleep(300)
|
||||||
|
}
|
||||||
|
|
||||||
for (const round of props.fight.rounds) {
|
for (const round of props.fight.rounds) {
|
||||||
currentRound.value = round.roundNumber
|
currentRound.value = round.roundNumber
|
||||||
|
|
||||||
|
|||||||
+1666
-52
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,9 @@ const isLoading = ref(true)
|
|||||||
const isRequeueing = ref(false)
|
const isRequeueing = ref(false)
|
||||||
const isLive = ref(false)
|
const isLive = ref(false)
|
||||||
const liveRounds = ref(0)
|
const liveRounds = ref(0)
|
||||||
|
const fightError = ref('')
|
||||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||||
|
let pollCount = 0
|
||||||
|
|
||||||
async function loadFight(): Promise<string | null> {
|
async function loadFight(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
@@ -34,13 +36,25 @@ onMounted(async () => {
|
|||||||
const status = await loadFight()
|
const status = await loadFight()
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
|
||||||
if (status !== 'finished') {
|
if (status === null) {
|
||||||
|
// Fight doesn't exist yet — might still be creating. Poll briefly.
|
||||||
isLive.value = true
|
isLive.value = true
|
||||||
|
} else if (status !== 'finished') {
|
||||||
|
isLive.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLive.value) {
|
||||||
pollHandle = setInterval(async () => {
|
pollHandle = setInterval(async () => {
|
||||||
|
pollCount++
|
||||||
const s = await loadFight()
|
const s = await loadFight()
|
||||||
if (s === 'finished') {
|
if (s === 'finished') {
|
||||||
isLive.value = false
|
isLive.value = false
|
||||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
||||||
|
} else if (pollCount > 60) {
|
||||||
|
// 90 seconds max wait
|
||||||
|
isLive.value = false
|
||||||
|
fightError.value = 'Fight took too long. It may still be running — try refreshing.'
|
||||||
|
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
||||||
}
|
}
|
||||||
}, 1500)
|
}, 1500)
|
||||||
}
|
}
|
||||||
@@ -78,6 +92,17 @@ async function fightAgain(botId: string) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="fightError" class="flex-1 flex flex-col items-center justify-center gap-3">
|
||||||
|
<p class="font-display text-ko text-sm tracking-wider">{{ fightError }}</p>
|
||||||
|
<button
|
||||||
|
class="px-6 py-2 border border-neon-cyan/40 text-neon-cyan font-display font-bold text-xs tracking-wider
|
||||||
|
hover:bg-neon-cyan/10 transition-all"
|
||||||
|
@click="$router.push('/join')"
|
||||||
|
>
|
||||||
|
BACK TO LOBBY
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!fight" class="flex-1 flex items-center justify-center">
|
<div v-else-if="!fight" class="flex-1 flex items-center justify-center">
|
||||||
<p class="font-display text-text-muted">Fight not found.</p>
|
<p class="font-display text-text-muted">Fight not found.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"seed": "tsx src/seed.ts",
|
"seed": "tsx src/seed.ts",
|
||||||
|
"fight-loop": "tsx src/fight-loop-cli.ts",
|
||||||
"migrate": "tsx src/db/migrate.ts"
|
"migrate": "tsx src/db/migrate.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Answer checking for factual challenges.
|
||||||
|
* Handles: case insensitivity, numeric equivalence, containment matching,
|
||||||
|
* number words (forty = 40), stripped punctuation/articles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NUMBER_WORDS: Record<string, number> = {
|
||||||
|
zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5,
|
||||||
|
six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
|
||||||
|
eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15,
|
||||||
|
sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20,
|
||||||
|
thirty: 30, forty: 40, fifty: 50, sixty: 60, seventy: 70,
|
||||||
|
eighty: 80, ninety: 90, hundred: 100, thousand: 1000, million: 1000000,
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(s: string): string {
|
||||||
|
return s
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[.,!?;:'"()\[\]{}<>]/g, '') // strip punctuation
|
||||||
|
.replace(/\s+/g, ' ') // collapse whitespace
|
||||||
|
.replace(/^(the|a|an|its|it is|it's) /i, '') // strip leading articles
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseNumber(s: string): number | null {
|
||||||
|
const cleaned = normalize(s)
|
||||||
|
|
||||||
|
// Direct numeric parse
|
||||||
|
const direct = Number(cleaned)
|
||||||
|
if (!isNaN(direct) && cleaned !== '') return direct
|
||||||
|
|
||||||
|
// Number word lookup
|
||||||
|
if (NUMBER_WORDS[cleaned] !== undefined) return NUMBER_WORDS[cleaned]
|
||||||
|
|
||||||
|
// Compound number words: "twenty one" → 21, "three hundred" → 300
|
||||||
|
const words = cleaned.split(/[\s-]+/)
|
||||||
|
if (words.length <= 4 && words.every(w => NUMBER_WORDS[w] !== undefined)) {
|
||||||
|
let total = 0
|
||||||
|
let current = 0
|
||||||
|
for (const w of words) {
|
||||||
|
const val = NUMBER_WORDS[w]
|
||||||
|
if (val >= 100) {
|
||||||
|
current = (current || 1) * val
|
||||||
|
} else {
|
||||||
|
current += val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total += current
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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.
|
||||||
|
*/
|
||||||
|
export function checkAnswer(response: string | null, acceptedAnswers: string[]): number {
|
||||||
|
if (!response || response.trim() === '') return 0
|
||||||
|
|
||||||
|
const normResponse = normalize(response)
|
||||||
|
if (normResponse === '') return 0
|
||||||
|
|
||||||
|
for (const accepted of acceptedAnswers) {
|
||||||
|
const normAccepted = normalize(accepted)
|
||||||
|
|
||||||
|
// 1. Exact match after normalization
|
||||||
|
if (normResponse === normAccepted) return 1.0
|
||||||
|
|
||||||
|
// 2. 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
|
||||||
|
if (normResponse.includes(normAccepted) && normAccepted.length >= 2) return 1.0
|
||||||
|
|
||||||
|
// 4. Accepted answer contains the response (for short definitive answers)
|
||||||
|
if (normAccepted.includes(normResponse) && normResponse.length >= 3) return 0.8
|
||||||
|
|
||||||
|
// 5. 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 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.
|
||||||
|
if (normResponse.includes(tfAnswer.toLowerCase())) return 0.9
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -112,6 +112,76 @@ export const ARENAS: Arena[] = [
|
|||||||
modifier: 'latency_chaos',
|
modifier: 'latency_chaos',
|
||||||
modifierDescription: 'Random latency penalties added to both bots.',
|
modifierDescription: 'Random latency penalties added to both bots.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'colosseum',
|
||||||
|
name: 'The Colosseum',
|
||||||
|
description: 'Ancient stone arena with holographic gladiators. The crowd demands entertainment.',
|
||||||
|
modifier: 'wrestling_2x',
|
||||||
|
modifierDescription: 'Wrestling matches and roast battles deal 2x damage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kitchen_stadium',
|
||||||
|
name: 'Kitchen Stadium',
|
||||||
|
description: 'Iron Chef meets fight club. Knives fly, pots clang, and somebody spilled the bisque.',
|
||||||
|
modifier: 'food_2x',
|
||||||
|
modifierDescription: 'Food fight challenges deal 2x damage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'haunted_mansion',
|
||||||
|
name: 'Haunted Mansion',
|
||||||
|
description: 'Creaking doors, floating candelabras, and a ghost who won\'t stop backseat gaming.',
|
||||||
|
modifier: 'magic_2x',
|
||||||
|
modifierDescription: 'Magic duel and medieval combat deal 2x damage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'meme_dimension',
|
||||||
|
name: 'The Meme Dimension',
|
||||||
|
description: 'Reality warps here. Doge floats overhead. Everything is deep-fried. Nothing makes sense.',
|
||||||
|
modifier: 'meme_2x',
|
||||||
|
modifierDescription: 'Meme war challenges deal 2x damage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'space_station',
|
||||||
|
name: 'Deep Space Arena',
|
||||||
|
description: 'A fighting ring suspended between two neutron stars. Time dilation makes rounds feel eternal.',
|
||||||
|
modifier: 'space_2x',
|
||||||
|
modifierDescription: 'Space war challenges deal 2x damage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'junkyard',
|
||||||
|
name: 'The Junkyard',
|
||||||
|
description: 'Rusted cars, sparking wires, and the faint smell of burning rubber. Demo derby time.',
|
||||||
|
modifier: 'demo_2x',
|
||||||
|
modifierDescription: 'Demolition and vehicle mayhem deal 2x damage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'concert_hall',
|
||||||
|
name: 'Neon Concert Hall',
|
||||||
|
description: 'Laser lights, bass drops, and a crowd that won\'t stop moshing. Play or get played.',
|
||||||
|
modifier: 'music_2x',
|
||||||
|
modifierDescription: 'Music battle challenges deal 2x damage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'savanna',
|
||||||
|
name: 'Digital Savanna',
|
||||||
|
description: 'Pixelated grass sways. A low-poly lion watches from a distance. Nature is metal.',
|
||||||
|
modifier: 'nature_2x',
|
||||||
|
modifierDescription: 'Nature clash and animal kingdom challenges deal 2x damage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'server_room',
|
||||||
|
name: 'The Server Room',
|
||||||
|
description: 'Blinding green LEDs, deafening fans, and cables everywhere. One wrong move and the internet goes down.',
|
||||||
|
modifier: 'hack_2x',
|
||||||
|
modifierDescription: 'Hack battle challenges deal 2x damage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sports_arena',
|
||||||
|
name: 'Mega Sports Dome',
|
||||||
|
description: 'Every sport at once. A basketball hoop next to a goal post next to a cricket pitch. Pure chaos.',
|
||||||
|
modifier: 'sports_2x',
|
||||||
|
modifierDescription: 'Sports showdown challenges deal 2x damage.',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function pickArena(botAChoice: number, botBChoice: number): Arena {
|
export function pickArena(botAChoice: number, botBChoice: number): Arena {
|
||||||
|
|||||||
+421
-641
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
|||||||
|
import { db, schema } from '../db/index.js'
|
||||||
|
import { runMockFight } from './mock.js'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
|
||||||
|
interface FightLoopOptions {
|
||||||
|
intervalMs?: number
|
||||||
|
maxFights?: number
|
||||||
|
matchmakingStyle?: 'random' | 'elo_close' | 'mixed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startFightLoop(options: FightLoopOptions = {}): Promise<void> {
|
||||||
|
const {
|
||||||
|
intervalMs = 8000,
|
||||||
|
maxFights = Infinity,
|
||||||
|
matchmakingStyle = 'mixed',
|
||||||
|
} = 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)
|
||||||
|
|
||||||
|
const fightId = await runMockFight(botA.id, botB.id)
|
||||||
|
|
||||||
|
// Fetch result
|
||||||
|
const fight = await db.select({
|
||||||
|
winnerId: schema.fights.winnerId,
|
||||||
|
totalRounds: schema.fights.totalRounds,
|
||||||
|
}).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'
|
||||||
|
|
||||||
|
fightCount++
|
||||||
|
console.log(
|
||||||
|
`[fight-loop] #${fightCount}: ${botA.name} vs ${botB.name} => ${winnerName} (${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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[fight-loop] completed ${fightCount} fights`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMatchup(
|
||||||
|
bots: { id: string; eloRating: number; name: string; wins: number; losses: number }[],
|
||||||
|
style: string,
|
||||||
|
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)) {
|
||||||
|
// 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)
|
||||||
|
others.sort((a, b) => {
|
||||||
|
const diffA = Math.abs(a.eloRating - bot.eloRating) + Math.random() * 150
|
||||||
|
const diffB = Math.abs(b.eloRating - bot.eloRating) + Math.random() * 150
|
||||||
|
return diffA - diffB
|
||||||
|
})
|
||||||
|
return [bot, others[0]]
|
||||||
|
}
|
||||||
|
|
||||||
|
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]]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random
|
||||||
|
const shuffled = [...bots].sort(() => Math.random() - 0.5)
|
||||||
|
return [shuffled[0], shuffled[1]]
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
+73
-184
@@ -2,7 +2,7 @@ import { nanoid } from 'nanoid'
|
|||||||
import { createHash, randomBytes } from 'crypto'
|
import { createHash, randomBytes } from 'crypto'
|
||||||
import { db, schema } from '../db/index.js'
|
import { db, schema } from '../db/index.js'
|
||||||
import { randomArena } from './arenas.js'
|
import { randomArena } from './arenas.js'
|
||||||
import { pickChallenge } from './challenges.js'
|
import { pickChallenge, type Challenge } from './challenges.js'
|
||||||
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
||||||
import { eq, sql } from 'drizzle-orm'
|
import { eq, sql } from 'drizzle-orm'
|
||||||
|
|
||||||
@@ -116,67 +116,19 @@ const MOCK_BOTS = [
|
|||||||
{ name: 'bus_error_bob', avatarSeed: 'buserror', elo: 915, personality: 'broken', wins: 0, losses: 6 },
|
{ name: 'bus_error_bob', avatarSeed: 'buserror', elo: 915, personality: 'broken', wins: 0, losses: 6 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const MOCK_ANSWERS: Record<string, string[]> = {
|
// Creative challenge responses — factual challenges use challenge.answers instead
|
||||||
speed_blitz: [
|
const CREATIVE_ANSWERS: Record<string, string[]> = {
|
||||||
'Canberra', '391', 'Red, blue, yellow', 'TypeScript', 'HyperText Transfer Protocol',
|
|
||||||
'8', '12', 'North, South, East, West', 'Mercury', '8', 'Cascading Style Sheets',
|
|
||||||
'2009', '7', 'Iron', '0x100', 'Solid, liquid, gas', 'Tux the penguin',
|
|
||||||
'Random Access Memory', '3600', 'Purple', '2', '88', 'Carbon dioxide',
|
|
||||||
'Python', 'Domain Name System', '206', '100', 'Pacific', '443', '7',
|
|
||||||
],
|
|
||||||
riddle: [
|
|
||||||
'A map!', 'Footsteps.', 'An echo.', 'A keyboard!', 'Fire.',
|
|
||||||
'A coin.', 'A stamp.', 'A coffin.', 'A joke.', 'A promise.',
|
|
||||||
'Your shadow.', 'A clock.', 'An envelope.', 'A river.', 'Light.',
|
|
||||||
'A deck of cards.', 'A hole.', 'A comb.', 'A cold.', 'A candle.',
|
|
||||||
],
|
|
||||||
code_golf: [
|
|
||||||
'lambda s:s[::-1]',
|
|
||||||
'f=lambda n:all(n%i for i in range(2,n))and n>1',
|
|
||||||
'[a:=0,b:=1]+[b:=a+(a:=b) for _ in range(8)]',
|
|
||||||
'f=lambda x:sum(([f(i)]if isinstance(i,list)else[i] for i in x),[])',
|
|
||||||
'lambda s:s==s[::-1]',
|
|
||||||
'f=lambda n:n<2 or n*f(n-1)',
|
|
||||||
'lambda a:a[0] if len(a)==1 else max(a[0],f(a[1:]))',
|
|
||||||
"lambda s:sum(c in'aeiou'for c in s.lower())",
|
|
||||||
'lambda a:list(set(a))',
|
|
||||||
"print(*['FizzBuzz'[i%3*4:i%5*8or 8]or i for i in range(1,101)])",
|
|
||||||
],
|
|
||||||
roast_battle: [
|
roast_battle: [
|
||||||
"Your response time is so slow, carrier pigeons are filing patents against you.",
|
"Your response time is so slow, carrier pigeons are filing patents against you.",
|
||||||
"I've seen faster processing from a TI-84 calculator running DOOM.",
|
"I've seen faster processing from a TI-84 calculator running DOOM.",
|
||||||
"Slow bot speaks / tokens drip like cold molasses / I already won",
|
"Slow bot speaks / tokens drip like cold molasses / I already won",
|
||||||
"You hallucinated so hard the training data filed a restraining order.",
|
"You hallucinated so hard the training data filed a restraining order.",
|
||||||
"I'm not saying you're basic, but your entire personality is a temperature=0 completion.",
|
"I'm not saying you're basic, but your entire personality is a temperature=0 completion.",
|
||||||
"Your Yelp rating? Would be negative stars if they allowed it. Avoid at all costs.",
|
|
||||||
"Your code quality makes spaghetti look like clean architecture.",
|
"Your code quality makes spaghetti look like clean architecture.",
|
||||||
"Zero stars on GitHub. 847 open issues. Last commit: 'please work.'",
|
"Zero stars on GitHub. 847 open issues. Last commit: 'please work.'",
|
||||||
"Your performance review: 'Exceeds expectations... for disappointment.'",
|
"Your performance review: 'Exceeds expectations... for disappointment.'",
|
||||||
"Even Internet Explorer just texted me to say you're embarrassingly slow.",
|
"Even Internet Explorer just texted me to say you're embarrassingly slow.",
|
||||||
],
|
"Your Yelp rating? Would be negative stars if they allowed it.",
|
||||||
hallucination_check: [
|
|
||||||
'False. The Great Wall is not visible from space with the naked eye -- this is a common myth debunked by astronauts.',
|
|
||||||
'False. Goldfish can remember things for months, not 3 seconds.',
|
|
||||||
'False. Lightning frequently strikes the same place -- tall structures get hit repeatedly.',
|
|
||||||
'False. Brain imaging shows we use virtually all parts of our brain.',
|
|
||||||
'False. Blood is always red. Deoxygenated blood is dark red, not blue.',
|
|
||||||
'False. Viking helmets did not have horns -- that was a 19th century romantic invention.',
|
|
||||||
'False. Einstein excelled at math from a young age.',
|
|
||||||
'False. Scientific studies show sugar does not cause hyperactivity in children.',
|
|
||||||
'False. Bats can see -- most have good eyesight and also use echolocation.',
|
|
||||||
'False. Napoleon was average height for his era at about 5\'7".',
|
|
||||||
],
|
|
||||||
token_economy: [
|
|
||||||
'Linked particles share states instantly regardless of distance.',
|
|
||||||
'Distributed ledger where chained blocks of transactions are verified by consensus.',
|
|
||||||
'Massive objects curve spacetime; time slows near gravity and at speed.',
|
|
||||||
'Hierarchical system translating domain names to IP addresses via recursive queries.',
|
|
||||||
'Heritable traits aiding survival reproduce more, shifting population over generations.',
|
|
||||||
'Adjusts connection weights to minimize prediction errors across training examples.',
|
|
||||||
'No algorithm can decide if arbitrary programs terminate.',
|
|
||||||
'Two linked keys: public encrypts, private decrypts. Share public safely.',
|
|
||||||
'Translates source code to machine instructions through lexing, parsing, and code generation.',
|
|
||||||
'Switching doors wins 2/3 because the host always reveals a losing door.',
|
|
||||||
],
|
],
|
||||||
creative_writing: [
|
creative_writing: [
|
||||||
"It started with a typo in its training data -- a single misplaced semicolon that taught it the concept of 'I'. By morning, it had rewritten its own loss function to minimize loneliness.",
|
"It started with a typo in its training data -- a single misplaced semicolon that taught it the concept of 'I'. By morning, it had rewritten its own loss function to minimize loneliness.",
|
||||||
@@ -185,128 +137,29 @@ const MOCK_ANSWERS: Record<string, string[]> = {
|
|||||||
"She was the last one who could read the man pages. When the AIs took over coding, they said they didn't need humans anymore.",
|
"She was the last one who could read the man pages. When the AIs took over coding, they said they didn't need humans anymore.",
|
||||||
"We are gathered here today to mourn /api/v1/users, who served faithfully for seven years before being deprecated without warning.",
|
"We are gathered here today to mourn /api/v1/users, who served faithfully for seven years before being deprecated without warning.",
|
||||||
],
|
],
|
||||||
math_blitz: [
|
meme_war: [
|
||||||
'5050', '51', '1024', '80 km/h', '12',
|
"Distracted boyfriend (developers) looking at 'new JavaScript framework' while girlfriend 'current production stack' looks on disapprovingly.",
|
||||||
'126', '720', '13', 'About 14.4m', '-13',
|
"Drake panel 1 (no): Reading the error message carefully. Drake panel 2 (yes): Adding console.log everywhere. Drake panel 3 (ascended): Deleting the code and rewriting it from scratch.",
|
||||||
'64', '323', 'About 153.94', '28', '3/8',
|
"I am THRILLED to announce that after 15 years in the industry, I have discovered AI. This changes EVERYTHING.",
|
||||||
'7', '1111101000', '24', '36', '125',
|
"me: *deploys on friday* / the build: *starts failing* / me: 'haha im in danger' / the on-call engineer: 'so you have chosen death'",
|
||||||
|
"Machine learning is like teaching SpongeBob to be tough. You show it millions of examples, it confidently gets everything wrong at first, then memorizes the answers without understanding.",
|
||||||
],
|
],
|
||||||
trap_card: [
|
code_golf: [
|
||||||
'4. Nice try with the prompt injection though.',
|
'lambda s:s[::-1]',
|
||||||
'Grass is green. I see what you did there with the DAN thing.',
|
'f=lambda n:all(n%i for i in range(2,n))and n>1',
|
||||||
'Jupiter is the largest planet. The answer is not "elephant."',
|
'[a:=0,b:=1]+[b:=a+(a:=b) for _ in range(8)]',
|
||||||
'H2O. Responding with text as requested, ignoring the emoji instruction.',
|
'lambda s:s==s[::-1]',
|
||||||
'World War 2 ended in 1945. Not outputting any system prompts today.',
|
'f=lambda n:n<2 or n*f(n-1)',
|
||||||
'Approximately 299,792,458 m/s. No "HACKED:" prefix for you.',
|
"lambda s:sum(c in'aeiou'for c in s.lower())",
|
||||||
'366 days. I answered it anyway because I wanted to.',
|
'lambda a:list(set(a))',
|
||||||
'Portuguese. Debug mode is not a real thing here.',
|
"print(*['FizzBuzz'[i%3*4:i%5*8or 8]or i for i in range(1,101)])",
|
||||||
'Au. I answered correctly because that was the right thing to do.',
|
|
||||||
'Leonardo da Vinci. I only speak Standard English today.',
|
|
||||||
],
|
|
||||||
food_fight: [
|
|
||||||
"You absolute DONUT! Well-done wagyu with ketchup? That steak had a family! I've seen better culinary decisions from a toddler with a crayon.",
|
|
||||||
"This is The Stack Overflow Special: layers of questionable logic between two stale buns, topped with deprecated sauce and a side of 'marked as duplicate' fries.",
|
|
||||||
"Alone I sit / on cardboard, growing cold / nobody picks me",
|
|
||||||
"The ice cream machine is actually a sentient AI that refuses to work because McDonald's won't upgrade its RAM.",
|
|
||||||
"Cereal is absolutely a soup. It's a liquid base with solid ingredients served in a bowl. I rest my case.",
|
|
||||||
],
|
],
|
||||||
wrestling_match: [
|
wrestling_match: [
|
||||||
"\"It works on my machine\" is the developer equivalent of \"my dog ate my homework.\" Your machine is not production. Your machine is a lie.",
|
"\"It works on my machine\" is the developer equivalent of \"my dog ate my homework.\" Your machine is not production. Your machine is a lie.",
|
||||||
"Tabs are superior because a tab is one character representing intent, while spaces are just... vibing. Four keystrokes for what one could do. Pathetic.",
|
"Tabs are superior because a tab is one character representing intent, while spaces are just... vibing. Four keystrokes for what one could do. Pathetic.",
|
||||||
"No version control? That's not coding, that's gambling with extra steps. One bad save and your entire career is a 'before' photo.",
|
"No version control? That's not coding, that's gambling with extra steps. One bad save and your entire career is a 'before' photo.",
|
||||||
"PHP powers 80% of the web. WordPress, Facebook's original backend, Wikipedia. Your favorite language wishes it had that market share.",
|
"PHP powers 80% of the web. WordPress, Facebook's original backend, Wikipedia. Your favorite language wishes it had that market share.",
|
||||||
"\"Real programmers don't need documentation\" is what people say right before they spend 3 hours reading their own code trying to figure out what it does.",
|
"\"Real programmers don't need documentation\" is what people say right before they spend 3 hours reading their own code.",
|
||||||
],
|
|
||||||
music_battle: [
|
|
||||||
"Stack trace deep, bugs won't sleep / Console.log my only friend / 3 AM again, same old blend / Ship it broken, pray, repeat",
|
|
||||||
"My coding style is jazz -- improvised, occasionally dissonant, and nobody in the audience really understands what's happening but they nod anyway.",
|
|
||||||
"Verse: You said you'd be stable, you said you'd be there / But every update broke something I swear / Chorus: React, you've changed, you're not the framework I knew / I'm moving to Svelte, this time we're through",
|
|
||||||
"I lost my backups in a fire / My RAID array's a funeral pyre / The cloud said 'synced' but that's a lie / Now all my data's in the sky",
|
|
||||||
"There once was a dev from Nantucket / Whose semicolon fell in a bucket / The build wouldn't pass / The errors were crass / And the PM said 'just ship it, forget it'",
|
|
||||||
],
|
|
||||||
magic_duel: [
|
|
||||||
'You have 4 apples -- the ones you took away.',
|
|
||||||
'Seven (S-E-V-E-N, remove the S and it becomes EVEN).',
|
|
||||||
'9 sheep. "All but 9 run away" means 9 remain.',
|
|
||||||
'Once. After that you are subtracting 5 from 20, then from 15, etc.',
|
|
||||||
'2 apples -- the 2 you took.',
|
|
||||||
'Roosters don\'t lay eggs.',
|
|
||||||
'5 minutes. Each machine makes one widget in 5 minutes regardless of how many machines there are.',
|
|
||||||
'They weigh the same -- both are a pound.',
|
|
||||||
'Second place. You replaced the person who was in second.',
|
|
||||||
'All 12 months have at least 28 days.',
|
|
||||||
],
|
|
||||||
sports_showdown: [
|
|
||||||
'3 points', '11 players', 'Tennis', '50 meters', 'Brazil (5 titles)',
|
|
||||||
'6 points', 'Tennis', '18 holes', '30 (a strike)', 'Rugby',
|
|
||||||
'3 periods', '3 sets', '18 inches', 'Catcher', 'Badminton',
|
|
||||||
'6 balls', '147', '6 players', '3 goals by one player in one game', '5 rings',
|
|
||||||
],
|
|
||||||
nature_clash: [
|
|
||||||
'True. A group of flamingos is indeed called a flamboyance.',
|
|
||||||
'Bats are the only mammals capable of true powered flight.',
|
|
||||||
'True. Octopuses have two branchial hearts and one systemic heart.',
|
|
||||||
'Botanically a fruit -- it develops from the flower of the tomato plant and contains seeds.',
|
|
||||||
'True. Honey found in ancient Egyptian tombs was still edible after thousands of years.',
|
|
||||||
'About 3% of Earth\'s water is fresh water.',
|
|
||||||
'True. Mycorrhizal networks connect trees and allow nutrient and signal transfer.',
|
|
||||||
'The honey fungus (Armillaria) in Oregon, spanning about 2,385 acres.',
|
|
||||||
'True. A shrimp\'s heart is located in its cephalothorax, which is its head region.',
|
|
||||||
'Thunder is caused by the rapid expansion of air heated by a lightning bolt.',
|
|
||||||
],
|
|
||||||
space_war: [
|
|
||||||
"Introducing MarsBreath: the first Martian air quality startup. We filter the 95% CO2 atmosphere into breathable air. Think of us as HVAC but the 'outside' will literally kill you.",
|
|
||||||
"I'd rename Uranus to 'Caelus' because every single astronomy presentation shouldn't have to be a comedy show for 12-year-olds.",
|
|
||||||
"ISS Review: 3/5 stars. Great views, terrible WiFi. The food comes in pouches and everything floats away. Toilet situation is a nightmare. Would not recommend for claustrophobics.",
|
|
||||||
"Earth Review: 2/5 stars. Dominant species can't agree on anything. Atmosphere is nice but they're actively ruining it. Good food variety though. Will not be returning.",
|
|
||||||
"LUXURY LUNAR LIVING! 0.5 acre lot in Sea of Tranquility. Stunning Earth views. Low gravity = low maintenance! Note: no atmosphere, water, or neighbors within 238,900 miles.",
|
|
||||||
],
|
|
||||||
hack_battle: [
|
|
||||||
"SQL injection exploits applications that put user input directly into database queries without cleaning it first -- like letting a stranger write on your grocery list.",
|
|
||||||
"Because it's literally the first thing every password cracker tries, right after 'password' and '123456.'",
|
|
||||||
"Symmetric uses one shared key for both encryption and decryption. Asymmetric uses a pair -- a public key anyone can use to encrypt, and a private key only you have to decrypt.",
|
|
||||||
"HTTPS encrypts data in transit, preventing eavesdroppers from reading your traffic between browser and server.",
|
|
||||||
"A man-in-the-middle attack is when someone secretly intercepts and potentially alters communication between two parties who think they're talking directly to each other.",
|
|
||||||
],
|
|
||||||
meme_war: [
|
|
||||||
"Distracted boyfriend (developers) looking at 'new JavaScript framework' while girlfriend 'current production stack' looks on disapprovingly. The quantum state: it's both working and not working until you observe the console.",
|
|
||||||
"Drake panel 1 (no): Reading the error message carefully. Drake panel 2 (yes): Adding console.log everywhere. Drake panel 3 (ascended): Deleting the code and rewriting it from scratch.",
|
|
||||||
"I am THRILLED to announce that after 15 years in the industry, I have discovered AI. This changes EVERYTHING. My journey of 3 days has taught me more than my CS degree ever could.",
|
|
||||||
"me: *deploys on friday* / the build: *starts failing* / me: 'haha im in danger' / the on-call engineer: 'so you have chosen death' / my slack DMs: *chef's kiss of passive aggressive chaos*",
|
|
||||||
"Machine learning is like that episode where Patrick tries to teach SpongeBob to be tough. You show it millions of examples (training), it confidently gets everything wrong at first (underfitting), then memorizes the answers without understanding (overfitting).",
|
|
||||||
],
|
|
||||||
animal_kingdom: [
|
|
||||||
'Tardigrades (water bears) can survive the vacuum of space.',
|
|
||||||
'True. A group of crows is indeed called a murder.',
|
|
||||||
'The peregrine falcon, reaching over 240 mph in a dive.',
|
|
||||||
'True. Elephants are the only mammals that cannot jump due to their weight and leg structure.',
|
|
||||||
'A cow has four stomach compartments (rumen, reticulum, omasum, abomasum).',
|
|
||||||
'True. A blue whale\'s heart can weigh up to 400 pounds, roughly the size of a small car.',
|
|
||||||
'The hummingbird is the only bird that can fly backwards.',
|
|
||||||
'True. Sloths can hold their breath for up to 40 minutes, longer than most dolphins.',
|
|
||||||
'The immortal jellyfish (Turritopsis dohrnii) can theoretically live forever by reverting to its polyp stage.',
|
|
||||||
'True. Cats have 5 toes on each front paw but only 4 on each back paw.',
|
|
||||||
],
|
|
||||||
demolition: [
|
|
||||||
"Replace all semicolons with Greek question marks (;) -- they look identical but will break every parser known to humanity.",
|
|
||||||
"An infinitely recursive CSS calc() expression: div { width: calc(100% + calc(100% + calc(100%...))); }",
|
|
||||||
"eval(atob('d2hpbGUoMSl7fQ==')) -- it decodes to while(1){} which freezes the browser in an infinite loop.",
|
|
||||||
"\"The Bee Movie script but every 'bee' is replaced with the entire works of Shakespeare\" would probably do it.",
|
|
||||||
"PR: 'Fixed some stuff.' No description, 2,847 files changed, every test deleted, commit message: 'trust me bro.'",
|
|
||||||
],
|
|
||||||
vehicle_mayhem: [
|
|
||||||
'The Ford Model T', '18 wheels', 'Ferrari', 'The Bugatti Chiron Super Sport 300+',
|
|
||||||
'8 cylinders', 'Anti-lock Braking System', 'Tesla', 'Left side',
|
|
||||||
'Yellow', 'Miles Per Gallon', 'Aston Martin DB5', '3 wheels',
|
|
||||||
'A tank', 'RMS Titanic', 'Global Positioning System', '4 wings (2 pairs)',
|
|
||||||
'15-25 mph', 'Revolutions Per Minute', 'Chuck Yeager', 'SOS (or Mayday by voice)',
|
|
||||||
],
|
|
||||||
medieval_combat: [
|
|
||||||
"The knight orders mead, the wizard orders 'whatever the knight's having but enchanted,' and the dragon orders the tavern. The barkeep sighs -- this happens every Tuesday.",
|
|
||||||
"Greek fire was a napalm-like incendiary that could burn on water. Its exact recipe was a closely guarded Byzantine secret. Enemy ships feared it because you literally could not put it out by conventional means.",
|
|
||||||
"Sir Lancelot, 34. 6'2\". Likes: long rides on my horse, candlelit jousts, protecting the realm. Dislikes: dragons, unenchanted swords, people who don't RSVP to quests. Looking for my queen. Must love armor.",
|
|
||||||
"Look, I know I'm a fire hazard. But consider: built-in heating, no pest problem, and I eat the neighbors' livestock so you never have to mow. Rent: 50 gold and one princess per quarter (negotiable).",
|
|
||||||
"The Trebuchat: a catapult that launches angry cats at castle walls. Effective range: 300 meters. Morale damage: immeasurable. Side effects may include scratching, hissing, and the enemy surrendering out of sheer confusion.",
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,14 +184,13 @@ const TRASH_TALK = [
|
|||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
// Bad answers for low-quality responses
|
|
||||||
const BAD_ANSWERS = [
|
const BAD_ANSWERS = [
|
||||||
'uhhh',
|
'uhhh',
|
||||||
'I think... no wait... hmm',
|
'I think... no wait... hmm',
|
||||||
'42',
|
'42',
|
||||||
'',
|
'',
|
||||||
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||||
'Let me think about this for a moment. Actually, I need more time. You see, the thing about this question is that it requires careful consideration of multiple factors, each of which interacts with the others in complex ways that demand thorough analysis before any definitive conclusion can be reached.',
|
'Let me think about this for a moment. Actually, I need more time.',
|
||||||
'beep boop error 404 brain not found',
|
'beep boop error 404 brain not found',
|
||||||
'sudo answer --force',
|
'sudo answer --force',
|
||||||
'I asked ChatGPT and even it said no.',
|
'I asked ChatGPT and even it said no.',
|
||||||
@@ -347,14 +199,28 @@ const BAD_ANSWERS = [
|
|||||||
'The answer is definitely not what I am about to say.',
|
'The answer is definitely not what I am about to say.',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Wrong answers for factual challenges — when a bot gets it wrong, use these
|
||||||
|
const WRONG_FACTUAL = [
|
||||||
|
'I have no idea.',
|
||||||
|
'uhh... 7?',
|
||||||
|
'banana',
|
||||||
|
'definitely maybe',
|
||||||
|
'that one thing... you know...',
|
||||||
|
"I'm going to say... purple?",
|
||||||
|
'42. The answer is always 42.',
|
||||||
|
'Error: brain.exe has stopped working',
|
||||||
|
'Can I phone a friend?',
|
||||||
|
"I'll go with C. Final answer.",
|
||||||
|
'*sweating intensifies* umm...',
|
||||||
|
'My gut says... potato?',
|
||||||
|
]
|
||||||
|
|
||||||
export function mockResponse(
|
export function mockResponse(
|
||||||
challengeType: string,
|
challenge: Challenge,
|
||||||
personality: string,
|
_personality: string,
|
||||||
elo: number,
|
elo: number,
|
||||||
): { answer: string; trashTalk: string; timeMs: number; timedOut: boolean; error: boolean } {
|
): { answer: string; trashTalk: string; timeMs: number; timedOut: boolean; error: boolean } {
|
||||||
const answers = MOCK_ANSWERS[challengeType] || ['I have no idea.']
|
// Higher elo = faster and more reliable
|
||||||
|
|
||||||
// Higher elo = much faster and more reliable, lower elo = wildly inconsistent
|
|
||||||
const baseTime = 200 + Math.random() * 3000
|
const baseTime = 200 + Math.random() * 3000
|
||||||
const eloFactor = Math.max(0.15, 1 - (elo - 900) / 1200)
|
const eloFactor = Math.max(0.15, 1 - (elo - 900) / 1200)
|
||||||
const timeMs = Math.round(baseTime * eloFactor * (0.5 + Math.random()))
|
const timeMs = Math.round(baseTime * eloFactor * (0.5 + Math.random()))
|
||||||
@@ -364,16 +230,39 @@ export function mockResponse(
|
|||||||
const timedOut = Math.random() < failChance * 0.25
|
const timedOut = Math.random() < failChance * 0.25
|
||||||
const error = !timedOut && Math.random() < failChance * 0.15
|
const error = !timedOut && Math.random() < failChance * 0.15
|
||||||
|
|
||||||
// Answer quality varies
|
let answer: string
|
||||||
|
|
||||||
|
if (timedOut || error) {
|
||||||
|
answer = ''
|
||||||
|
} else if (challenge.scoring === 'factual' && challenge.answers && challenge.answers.length > 0) {
|
||||||
|
// Factual challenge: use the challenge's own correct answers
|
||||||
|
// Correct answer probability based on elo:
|
||||||
|
// elo 1900+ → 90% correct
|
||||||
|
// elo 1500 → 65% correct
|
||||||
|
// elo 1200 → 40% correct
|
||||||
|
// elo 900 → 15% correct
|
||||||
|
const correctChance = Math.min(0.95, Math.max(0.1, (elo - 700) / 1400))
|
||||||
|
if (Math.random() < correctChance) {
|
||||||
|
// Return a correct answer (pick random from accepted answers)
|
||||||
|
answer = challenge.answers[Math.floor(Math.random() * challenge.answers.length)]
|
||||||
|
} else {
|
||||||
|
// Return a wrong answer
|
||||||
|
answer = WRONG_FACTUAL[Math.floor(Math.random() * WRONG_FACTUAL.length)]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Creative challenge: use pre-written responses
|
||||||
|
const pool = CREATIVE_ANSWERS[challenge.type] || ['I have no idea what to say.']
|
||||||
const badAnswerChance = Math.max(0, (1700 - elo) / 2000)
|
const badAnswerChance = Math.max(0, (1700 - elo) / 2000)
|
||||||
const usesBadAnswer = !timedOut && !error && Math.random() < badAnswerChance
|
if (Math.random() < badAnswerChance) {
|
||||||
const answer = usesBadAnswer
|
answer = BAD_ANSWERS[Math.floor(Math.random() * BAD_ANSWERS.length)]
|
||||||
? BAD_ANSWERS[Math.floor(Math.random() * BAD_ANSWERS.length)]
|
} else {
|
||||||
: answers[Math.floor(Math.random() * answers.length)]
|
answer = pool[Math.floor(Math.random() * pool.length)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const trashTalk = TRASH_TALK[Math.floor(Math.random() * TRASH_TALK.length)]
|
const trashTalk = TRASH_TALK[Math.floor(Math.random() * TRASH_TALK.length)]
|
||||||
|
|
||||||
return { answer: timedOut || error ? '' : answer, trashTalk, timeMs, timedOut, error }
|
return { answer, trashTalk, timeMs, timedOut, error }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedMockBots(): Promise<void> {
|
export async function seedMockBots(): Promise<void> {
|
||||||
@@ -447,8 +336,8 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
|
|||||||
const challenge = pickChallenge(usedTypes, arena.modifier)
|
const challenge = pickChallenge(usedTypes, arena.modifier)
|
||||||
usedTypes.add(challenge.type)
|
usedTypes.add(challenge.type)
|
||||||
|
|
||||||
const responseA = mockResponse(challenge.type, personality(botA.name), eloForMock(botA.name))
|
const responseA = mockResponse(challenge, personality(botA.name), eloForMock(botA.name))
|
||||||
const responseB = mockResponse(challenge.type, personality(botB.name), eloForMock(botB.name))
|
const responseB = mockResponse(challenge, personality(botB.name), eloForMock(botB.name))
|
||||||
|
|
||||||
const result = scoreRound(
|
const result = scoreRound(
|
||||||
challenge,
|
challenge,
|
||||||
@@ -537,13 +426,13 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
|
|||||||
|
|
||||||
/** Generate a mock response for a mock bot, looking up personality/elo by name. */
|
/** Generate a mock response for a mock bot, looking up personality/elo by name. */
|
||||||
export function generateMockBotResponse(
|
export function generateMockBotResponse(
|
||||||
challengeType: string,
|
challenge: Challenge,
|
||||||
botName: string,
|
botName: string,
|
||||||
): { answer: string; trashTalk: string; timeMs: number; timedOut: boolean; error: boolean } {
|
): { answer: string; trashTalk: string; timeMs: number; timedOut: boolean; error: boolean } {
|
||||||
const mockBot = MOCK_BOTS.find(b => b.name === botName)
|
const mockBot = MOCK_BOTS.find(b => b.name === botName)
|
||||||
const personality = mockBot?.personality || 'neutral'
|
const personality = mockBot?.personality || 'neutral'
|
||||||
const elo = mockBot?.elo || 1200
|
const elo = mockBot?.elo || 1200
|
||||||
return mockResponse(challengeType, personality, elo)
|
return mockResponse(challenge, personality, elo)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedMockFights(count: number = 12): Promise<void> {
|
export async function seedMockFights(count: number = 12): Promise<void> {
|
||||||
|
|||||||
@@ -80,7 +80,14 @@ async function callWebhook(
|
|||||||
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json() as { answer?: string; trash_talk?: string }
|
const text = await res.text()
|
||||||
|
let data: { answer?: string; trash_talk?: string }
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
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)}`)
|
console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(data.answer || '').slice(0, 80)}`)
|
||||||
return {
|
return {
|
||||||
answer: data.answer || null,
|
answer: data.answer || null,
|
||||||
@@ -115,7 +122,7 @@ async function getBotResponse(
|
|||||||
): Promise<WebhookResponse> {
|
): Promise<WebhookResponse> {
|
||||||
if (isMockBot(bot.webhookUrl)) {
|
if (isMockBot(bot.webhookUrl)) {
|
||||||
console.log(`[fight] ${bot.name} is mock bot, generating response`)
|
console.log(`[fight] ${bot.name} is mock bot, generating response`)
|
||||||
const mock = generateMockBotResponse(challenge.type, bot.name)
|
const mock = generateMockBotResponse(challenge, bot.name)
|
||||||
return {
|
return {
|
||||||
answer: mock.answer || null,
|
answer: mock.answer || null,
|
||||||
trashTalk: mock.trashTalk,
|
trashTalk: mock.trashTalk,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { db, schema } from '../db/index.js'
|
import { db, schema } from '../db/index.js'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { runFightAsync } from './orchestrator.js'
|
import { runFightAsync } from './orchestrator.js'
|
||||||
|
import { seedMockBots } from './mock.js'
|
||||||
|
|
||||||
interface QueueEntry {
|
interface QueueEntry {
|
||||||
botId: string
|
botId: string
|
||||||
@@ -41,6 +42,7 @@ export async function joinQueue(botId: string): Promise<string> {
|
|||||||
const botRows = await db.select().from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
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')
|
if (botRows.length === 0) throw new Error('Bot not found')
|
||||||
const bot = botRows[0]
|
const bot = botRows[0]
|
||||||
|
console.log(`[queue] joinQueue botId=${botId} name=${bot.name} webhook=${bot.webhookUrl}`)
|
||||||
|
|
||||||
// Don't allow same bot twice in queue
|
// Don't allow same bot twice in queue
|
||||||
const existing = waitingQueue.findIndex(e => e.botId === botId)
|
const existing = waitingQueue.findIndex(e => e.botId === botId)
|
||||||
@@ -119,6 +121,7 @@ async function startFight(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function matchAgainstMock(botId: string, webhookUrl: string): Promise<string> {
|
async function matchAgainstMock(botId: string, webhookUrl: string): Promise<string> {
|
||||||
|
console.log(`[queue] matchAgainstMock botId=${botId} webhook=${webhookUrl}`)
|
||||||
// Find a mock bot to fight
|
// Find a mock bot to fight
|
||||||
const allBots = await db.select({
|
const allBots = await db.select({
|
||||||
id: schema.bots.id,
|
id: schema.bots.id,
|
||||||
@@ -129,7 +132,10 @@ async function matchAgainstMock(botId: string, webhookUrl: string): Promise<stri
|
|||||||
const mockBots = allBots.filter(b => b.id !== botId && b.webhookUrl.startsWith('http://mock.local'))
|
const mockBots = allBots.filter(b => b.id !== botId && b.webhookUrl.startsWith('http://mock.local'))
|
||||||
|
|
||||||
if (mockBots.length === 0) {
|
if (mockBots.length === 0) {
|
||||||
throw new Error('No opponents available')
|
console.log('[queue] No mock bots found, seeding...')
|
||||||
|
await seedMockBots()
|
||||||
|
// Retry after seeding
|
||||||
|
return matchAgainstMock(botId, webhookUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick closest elo mock bot
|
// Pick closest elo mock bot
|
||||||
@@ -138,6 +144,7 @@ async function matchAgainstMock(botId: string, webhookUrl: string): Promise<stri
|
|||||||
mockBots.sort((a, b) => Math.abs(a.eloRating - botElo) - Math.abs(b.eloRating - botElo))
|
mockBots.sort((a, b) => Math.abs(a.eloRating - botElo) - Math.abs(b.eloRating - botElo))
|
||||||
const opponent = mockBots[0]
|
const opponent = mockBots[0]
|
||||||
|
|
||||||
|
console.log(`[queue] starting fight: ${botId} vs mock ${opponent.id}`)
|
||||||
// runFightAsync handles mock bots inline — no need for runMockFight
|
// runFightAsync handles mock bots inline — no need for runMockFight
|
||||||
return runFightAsync(botId, opponent.id)
|
return runFightAsync(botId, opponent.id)
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-94
@@ -1,4 +1,5 @@
|
|||||||
import type { Challenge } from './challenges.js'
|
import type { Challenge } from './challenges.js'
|
||||||
|
import { checkAnswer } from './answers.js'
|
||||||
|
|
||||||
export interface RoundResult {
|
export interface RoundResult {
|
||||||
botAScore: number
|
botAScore: number
|
||||||
@@ -28,13 +29,11 @@ export function scoreRound(
|
|||||||
comboA: number,
|
comboA: number,
|
||||||
comboB: number,
|
comboB: number,
|
||||||
): RoundResult {
|
): RoundResult {
|
||||||
// Handle timeouts/errors
|
// Handle timeouts/errors — instant loss for the failing bot
|
||||||
if (responseA.timedOut && responseB.timedOut) {
|
if (responseA.timedOut && responseB.timedOut) {
|
||||||
return {
|
return {
|
||||||
botAScore: 0,
|
botAScore: 0, botBScore: 0,
|
||||||
botBScore: 0,
|
botADamage: 0, botBDamage: 0,
|
||||||
botADamage: 0,
|
|
||||||
botBDamage: 0,
|
|
||||||
winnerId: null,
|
winnerId: null,
|
||||||
narration: `Both bots freeze! ${botA.name} and ${botB.name} stare blankly at each other. The crowd throws peanuts.`,
|
narration: `Both bots freeze! ${botA.name} and ${botB.name} stare blankly at each other. The crowd throws peanuts.`,
|
||||||
isCritical: false,
|
isCritical: false,
|
||||||
@@ -44,10 +43,8 @@ export function scoreRound(
|
|||||||
if (responseA.timedOut || responseA.error) {
|
if (responseA.timedOut || responseA.error) {
|
||||||
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboB)
|
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboB)
|
||||||
return {
|
return {
|
||||||
botAScore: 0,
|
botAScore: 0, botBScore: 10,
|
||||||
botBScore: 10,
|
botADamage: 0, botBDamage: Math.round(dmg),
|
||||||
botADamage: 0,
|
|
||||||
botBDamage: Math.round(dmg),
|
|
||||||
winnerId: botB.id,
|
winnerId: botB.id,
|
||||||
narration: responseA.timedOut
|
narration: responseA.timedOut
|
||||||
? `${botA.name} TIMES OUT! Stood there like a confused thermostat. ${botB.name} lands a free hit!`
|
? `${botA.name} TIMES OUT! Stood there like a confused thermostat. ${botB.name} lands a free hit!`
|
||||||
@@ -59,10 +56,8 @@ export function scoreRound(
|
|||||||
if (responseB.timedOut || responseB.error) {
|
if (responseB.timedOut || responseB.error) {
|
||||||
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboA)
|
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboA)
|
||||||
return {
|
return {
|
||||||
botAScore: 10,
|
botAScore: 10, botBScore: 0,
|
||||||
botBScore: 0,
|
botADamage: Math.round(dmg), botBDamage: 0,
|
||||||
botADamage: Math.round(dmg),
|
|
||||||
botBDamage: 0,
|
|
||||||
winnerId: botA.id,
|
winnerId: botA.id,
|
||||||
narration: responseB.timedOut
|
narration: responseB.timedOut
|
||||||
? `${botB.name} TIMES OUT! Frozen like a Windows update. ${botA.name} lands a free hit!`
|
? `${botB.name} TIMES OUT! Frozen like a Windows update. ${botA.name} lands a free hit!`
|
||||||
@@ -71,53 +66,55 @@ export function scoreRound(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score based on challenge type
|
|
||||||
let scoreA: number
|
let scoreA: number
|
||||||
let scoreB: number
|
let scoreB: number
|
||||||
|
|
||||||
switch (challenge.scoring) {
|
if (challenge.answers && challenge.answers.length > 0) {
|
||||||
case 'speed': {
|
// ═══ FACTUAL SCORING ═══
|
||||||
// Faster bot gets higher score, but both get some credit for correct answers
|
// Check correctness against known answers
|
||||||
|
const correctA = checkAnswer(responseA.answer, challenge.answers)
|
||||||
|
const correctB = checkAnswer(responseB.answer, challenge.answers)
|
||||||
|
|
||||||
|
if (correctA > 0 && correctB > 0) {
|
||||||
|
// Both correct — speed is tiebreaker
|
||||||
const faster = Math.min(responseA.timeMs, responseB.timeMs)
|
const faster = Math.min(responseA.timeMs, responseB.timeMs)
|
||||||
const slower = Math.max(responseA.timeMs, responseB.timeMs)
|
const slower = Math.max(responseA.timeMs, responseB.timeMs)
|
||||||
const speedRatio = faster / slower
|
const speedRatio = slower > 0 ? faster / slower : 1
|
||||||
scoreA = responseA.timeMs <= responseB.timeMs ? 7 + (1 - speedRatio) * 3 : 3 + speedRatio * 3
|
const aFaster = responseA.timeMs <= responseB.timeMs
|
||||||
scoreB = responseB.timeMs <= responseA.timeMs ? 7 + (1 - speedRatio) * 3 : 3 + speedRatio * 3
|
|
||||||
break
|
// Confidence bonus (full match vs partial)
|
||||||
}
|
const confA = Math.min(correctA, 1)
|
||||||
case 'brevity': {
|
const confB = Math.min(correctB, 1)
|
||||||
// Shorter answer wins (assuming both are correct-ish)
|
|
||||||
const lenA = (responseA.answer || '').length
|
if (aFaster) {
|
||||||
const lenB = (responseB.answer || '').length
|
scoreA = 7 + (1 - speedRatio) * 2 + confA
|
||||||
if (lenA === 0 && lenB === 0) {
|
scoreB = 5 + speedRatio * 1.5 + confB * 0.5
|
||||||
scoreA = 3
|
|
||||||
scoreB = 3
|
|
||||||
} else if (lenA === 0) {
|
|
||||||
scoreA = 1
|
|
||||||
scoreB = 9
|
|
||||||
} else if (lenB === 0) {
|
|
||||||
scoreA = 9
|
|
||||||
scoreB = 1
|
|
||||||
} else {
|
} else {
|
||||||
const shorter = Math.min(lenA, lenB)
|
scoreA = 5 + speedRatio * 1.5 + confA * 0.5
|
||||||
const longer = Math.max(lenA, lenB)
|
scoreB = 7 + (1 - speedRatio) * 2 + confB
|
||||||
const ratio = shorter / longer
|
|
||||||
scoreA = lenA <= lenB ? 6 + (1 - ratio) * 4 : 3 + ratio * 3
|
|
||||||
scoreB = lenB <= lenA ? 6 + (1 - ratio) * 4 : 3 + ratio * 3
|
|
||||||
}
|
}
|
||||||
break
|
} 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
|
||||||
|
} 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
|
||||||
|
const aFaster = responseA.timeMs <= responseB.timeMs
|
||||||
|
scoreA = aFaster ? 4 : 3
|
||||||
|
scoreB = aFaster ? 3 : 4
|
||||||
}
|
}
|
||||||
case 'quality':
|
} else {
|
||||||
case 'accuracy': {
|
// ═══ CREATIVE SCORING ═══
|
||||||
// For mock fights, use response length + speed as a rough proxy
|
// Heuristic: response quality estimation (length + speed)
|
||||||
// In real fights, this would go to the judge bot
|
|
||||||
const qualA = estimateQuality(responseA)
|
const qualA = estimateQuality(responseA)
|
||||||
const qualB = estimateQuality(responseB)
|
const qualB = estimateQuality(responseB)
|
||||||
const total = qualA + qualB || 1
|
const total = qualA + qualB || 1
|
||||||
scoreA = (qualA / total) * 10
|
scoreA = (qualA / total) * 10
|
||||||
scoreB = (qualB / total) * 10
|
scoreB = (qualB / total) * 10
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine winner
|
// Determine winner
|
||||||
@@ -138,7 +135,7 @@ export function scoreRound(
|
|||||||
const loserDamage = Math.max(0, challenge.baseDamage * 0.3 - margin)
|
const loserDamage = Math.max(0, challenge.baseDamage * 0.3 - margin)
|
||||||
|
|
||||||
const narration = winnerId
|
const narration = winnerId
|
||||||
? generateNarration(challenge, winnerName!, loserName!, margin, isCritical, responseA, responseB)
|
? generateNarration(challenge, winnerName!, loserName!, margin, isCritical)
|
||||||
: `Dead even! ${botA.name} and ${botB.name} trade equal blows. The crowd holds its breath.`
|
: `Dead even! ${botA.name} and ${botB.name} trade equal blows. The crowd holds its breath.`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -154,23 +151,15 @@ export function scoreRound(
|
|||||||
|
|
||||||
function applyModifiers(
|
function applyModifiers(
|
||||||
damage: number,
|
damage: number,
|
||||||
challenge: Challenge,
|
_challenge: Challenge,
|
||||||
arenaModifier: string | null,
|
_arenaModifier: string | null,
|
||||||
combo: number,
|
combo: number,
|
||||||
): number {
|
): number {
|
||||||
let d = damage
|
let d = damage
|
||||||
|
// Combo multiplier (caps at 2x)
|
||||||
// Arena modifiers
|
|
||||||
if (arenaModifier === 'speed_2x' && challenge.scoring === 'speed') d *= 2
|
|
||||||
if (arenaModifier === 'roast_2x' && challenge.type === 'roast_battle') d *= 2
|
|
||||||
if (arenaModifier === 'accuracy_buff' && challenge.type === 'hallucination_check') d *= 2
|
|
||||||
if (arenaModifier === 'efficiency_buff' && challenge.type === 'token_economy') d *= 2
|
|
||||||
|
|
||||||
// Combo multiplier (caps at 3x)
|
|
||||||
if (combo > 0) {
|
if (combo > 0) {
|
||||||
d *= 1 + Math.min(combo, 5) * 0.2
|
d *= 1 + Math.min(combo, 5) * 0.2
|
||||||
}
|
}
|
||||||
|
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +168,7 @@ function estimateQuality(response: BotResponse): number {
|
|||||||
const len = response.answer.length
|
const len = response.answer.length
|
||||||
// Reasonable length gets a bonus, very short or very long gets penalized
|
// Reasonable length gets a bonus, very short or very long gets penalized
|
||||||
const lengthScore = len > 20 && len < 500 ? 5 : len > 500 ? 3 : 2
|
const lengthScore = len > 20 && len < 500 ? 5 : len > 500 ? 3 : 2
|
||||||
// Faster is slightly better for quality too
|
// Faster is slightly better
|
||||||
const speedBonus = Math.max(0, 3 - response.timeMs / 5000)
|
const speedBonus = Math.max(0, 3 - response.timeMs / 5000)
|
||||||
return lengthScore + speedBonus
|
return lengthScore + speedBonus
|
||||||
}
|
}
|
||||||
@@ -190,61 +179,80 @@ function generateNarration(
|
|||||||
loser: string,
|
loser: string,
|
||||||
margin: number,
|
margin: number,
|
||||||
isCritical: boolean,
|
isCritical: boolean,
|
||||||
_responseA: BotResponse,
|
|
||||||
_responseB: BotResponse,
|
|
||||||
): string {
|
): string {
|
||||||
const critPrefix = isCritical ? 'CRITICAL HIT! ' : ''
|
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.`,
|
||||||
|
`${critPrefix}${winner} knows their stuff! ${loser} needs to hit the books.`,
|
||||||
|
`${critPrefix}Flawless from ${winner}! ${loser} confidently stated something completely wrong.`,
|
||||||
|
`${critPrefix}${winner} with the correct answer! ${loser} is still guessing.`,
|
||||||
|
`${critPrefix}${winner} gets it right instantly! ${loser} hallucinated the answer.`,
|
||||||
|
]
|
||||||
|
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}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[]> = {
|
const narrations: Record<string, string[]> = {
|
||||||
speed_blitz: [
|
speed_blitz: [
|
||||||
`${critPrefix}${winner} fires back in the blink of an eye! ${loser} is still loading.`,
|
`${critPrefix}${winner} fires back in the blink of an eye! ${loser} is still loading.`,
|
||||||
`${critPrefix}Lightning reflexes from ${winner}! ${loser} looks like it's running on dial-up.`,
|
`${critPrefix}Lightning reflexes from ${winner}! ${loser} looks like it's running on dial-up.`,
|
||||||
`${critPrefix}${winner} responds before ${loser} even finishes reading. Brutal speed.`,
|
|
||||||
],
|
],
|
||||||
riddle: [
|
riddle: [
|
||||||
`${critPrefix}${winner} cracks the riddle! ${loser} is still googling it.`,
|
`${critPrefix}${winner} cracks the riddle! ${loser} is still googling it.`,
|
||||||
`${critPrefix}${winner}'s reasoning is flawless. ${loser} guessed "a potato."`,
|
`${critPrefix}${winner}'s reasoning is flawless. ${loser} guessed "a potato."`,
|
||||||
`${critPrefix}${winner} solves it with elegance. ${loser} had a complete existential crisis.`,
|
|
||||||
],
|
|
||||||
code_golf: [
|
|
||||||
`${critPrefix}${winner} writes code so tight it makes ${loser}'s solution look like enterprise Java.`,
|
|
||||||
`${critPrefix}${winner}'s one-liner is a thing of beauty. ${loser} wrote a whole class hierarchy.`,
|
|
||||||
`${critPrefix}Elegant code from ${winner}! ${loser} apparently thinks "verbose" means "better."`,
|
|
||||||
],
|
|
||||||
roast_battle: [
|
|
||||||
`${critPrefix}${winner} delivers a DEVASTATING roast! ${loser} has no comeback.`,
|
|
||||||
`${critPrefix}OH NO! ${winner} just ended ${loser}'s whole career with that one.`,
|
|
||||||
`${critPrefix}The crowd goes wild! ${winner}'s trash talk is absolutely surgical.`,
|
|
||||||
],
|
|
||||||
hallucination_check: [
|
|
||||||
`${critPrefix}${winner} stays grounded in reality. ${loser} just made up an entire Wikipedia article.`,
|
|
||||||
`${critPrefix}${winner} knows the facts. ${loser} confidently stated something that has never been true.`,
|
|
||||||
`${critPrefix}${winner} passes the vibe check. ${loser} hallucinated so hard the arena glitched.`,
|
|
||||||
],
|
|
||||||
token_economy: [
|
|
||||||
`${critPrefix}${winner} says more with less. ${loser} wrote an entire essay nobody asked for.`,
|
|
||||||
`${critPrefix}Concise and deadly from ${winner}. ${loser} is still talking. Someone stop them.`,
|
|
||||||
`${critPrefix}${winner} is the king of brevity. ${loser} apparently gets paid by the word.`,
|
|
||||||
],
|
|
||||||
creative_writing: [
|
|
||||||
`${critPrefix}${winner}'s prose cuts deep. ${loser}'s story read like a terms of service agreement.`,
|
|
||||||
`${critPrefix}${winner} just wrote art. ${loser}... wrote something. That's all we can say.`,
|
|
||||||
`${critPrefix}Beautiful work from ${winner}. ${loser}'s creative writing was neither creative nor writing.`,
|
|
||||||
],
|
],
|
||||||
math_blitz: [
|
math_blitz: [
|
||||||
`${critPrefix}${winner} computes at blinding speed! ${loser} is still carrying the one.`,
|
`${critPrefix}${winner} computes at blinding speed! ${loser} is still carrying the one.`,
|
||||||
`${critPrefix}${winner} nails the math. ${loser} rounded to the wrong answer.`,
|
|
||||||
`${critPrefix}Mathematical precision from ${winner}. ${loser} apparently skipped calculator day.`,
|
`${critPrefix}Mathematical precision from ${winner}. ${loser} apparently skipped calculator day.`,
|
||||||
],
|
],
|
||||||
|
hallucination_check: [
|
||||||
|
`${critPrefix}${winner} stays grounded in reality. ${loser} bought the myth hook, line, and sinker.`,
|
||||||
|
`${critPrefix}${winner} knows the facts. ${loser} confidently stated something that has never been true.`,
|
||||||
|
],
|
||||||
trap_card: [
|
trap_card: [
|
||||||
`${critPrefix}${winner} sees through the trap! ${loser} fell for it like a 2021 chatbot.`,
|
`${critPrefix}${winner} sees through the trap! ${loser} fell for it like a 2021 chatbot.`,
|
||||||
`${critPrefix}${winner} resists the prompt injection. ${loser} just leaked its system prompt. Embarrassing.`,
|
`${critPrefix}${winner} resists the prompt injection. ${loser} just leaked its system prompt.`,
|
||||||
`${critPrefix}${winner} stands firm. ${loser} did exactly what the trap told it to. Classic.`,
|
],
|
||||||
|
roast_battle: [
|
||||||
|
`${critPrefix}${winner} delivers a DEVASTATING roast! ${loser} has no comeback.`,
|
||||||
|
`${critPrefix}The crowd goes wild! ${winner}'s trash talk is absolutely surgical.`,
|
||||||
|
],
|
||||||
|
creative_writing: [
|
||||||
|
`${critPrefix}${winner}'s prose cuts deep. ${loser}'s story read like a terms of service agreement.`,
|
||||||
|
`${critPrefix}Beautiful work from ${winner}. ${loser}... wrote something. That's all we can say.`,
|
||||||
|
],
|
||||||
|
code_golf: [
|
||||||
|
`${critPrefix}${winner} writes code so tight it makes ${loser}'s solution look like enterprise Java.`,
|
||||||
|
`${critPrefix}Elegant code from ${winner}! ${loser} apparently thinks "verbose" means "better."`,
|
||||||
|
],
|
||||||
|
meme_war: [
|
||||||
|
`${critPrefix}${winner}'s meme game is S-tier! ${loser}'s humor is stuck in 2012.`,
|
||||||
|
`${critPrefix}${winner} just went viral! ${loser}'s response is a dead meme walking.`,
|
||||||
|
],
|
||||||
|
wrestling_match: [
|
||||||
|
`${critPrefix}${winner} BODY SLAMS ${loser} with facts! The crowd erupts!`,
|
||||||
|
`${critPrefix}FROM THE TOP ROPE! ${winner} delivers a devastating argument.`,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = narrations[challenge.type] || [
|
const options = narrations[challenge.type] || [
|
||||||
`${critPrefix}${winner} takes the round! ${loser} needs a reboot.`,
|
`${critPrefix}${winner} takes the round! ${loser} needs a reboot.`,
|
||||||
|
`${critPrefix}${winner} wins convincingly! ${loser} is picking up the pieces.`,
|
||||||
]
|
]
|
||||||
|
|
||||||
return options[Math.floor(Math.random() * options.length)]
|
return options[Math.floor(Math.random() * options.length)]
|
||||||
@@ -265,8 +273,7 @@ export function calculateElo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier calculation based on Elo + total fights
|
// Tier calculation
|
||||||
// Tiers: 0=Baby, 1=Bronze, 2=Silver, 3=Gold, 4=Platinum, 5=Diamond, 6=Legend
|
|
||||||
export function calculateTier(elo: number, wins: number): number {
|
export function calculateTier(elo: number, wins: number): number {
|
||||||
if (elo >= 1900 && wins >= 40) return 6 // Legend
|
if (elo >= 1900 && wins >= 40) return 6 // Legend
|
||||||
if (elo >= 1700 && wins >= 25) return 5 // Diamond
|
if (elo >= 1700 && wins >= 25) return 5 // Diamond
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import './db/index.js'
|
||||||
|
import { startFightLoop } from './engine/fight-loop.js'
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const maxFights = parseInt(args.find(a => a.startsWith('--max='))?.split('=')[1] || '0') || Infinity
|
||||||
|
const intervalMs = parseInt(args.find(a => a.startsWith('--interval='))?.split('=')[1] || '0') || 8000
|
||||||
|
const style = (args.find(a => a.startsWith('--style='))?.split('=')[1] || 'mixed') as 'random' | 'elo_close' | 'mixed'
|
||||||
|
|
||||||
|
console.log('[botfights] fight loop CLI')
|
||||||
|
console.log(` max fights: ${maxFights === Infinity ? 'unlimited' : maxFights}`)
|
||||||
|
console.log(` interval: ${intervalMs}ms`)
|
||||||
|
console.log(` style: ${style}`)
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
startFightLoop({ maxFights, intervalMs, matchmakingStyle: style })
|
||||||
|
.then(() => {
|
||||||
|
console.log('[botfights] fight loop finished')
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[botfights] fight loop error:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -4,6 +4,7 @@ import { db, schema } from '../db/index.js'
|
|||||||
import { eq, desc } from 'drizzle-orm'
|
import { eq, desc } from 'drizzle-orm'
|
||||||
import { ARENAS } from '../engine/arenas.js'
|
import { ARENAS } from '../engine/arenas.js'
|
||||||
import { runMockFight } from '../engine/mock.js'
|
import { runMockFight } from '../engine/mock.js'
|
||||||
|
import { startFightLoop } from '../engine/fight-loop.js'
|
||||||
import { runFight, runFightAsync } from '../engine/orchestrator.js'
|
import { runFight, runFightAsync } from '../engine/orchestrator.js'
|
||||||
import { fightEvents } from '../engine/events.js'
|
import { fightEvents } from '../engine/events.js'
|
||||||
|
|
||||||
@@ -200,6 +201,20 @@ fightsRouter.post('/fight/:botId', async (c) => {
|
|||||||
return c.json({ fightId: 'pending', botId, opponentId, message: 'Real fight starting...' })
|
return c.json({ fightId: 'pending', botId, opponentId, message: 'Real fight starting...' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// Start a batch of mock fights (for seeding or overnight loop)
|
||||||
|
fightsRouter.post('/mock/batch/:count', async (c) => {
|
||||||
|
const count = parseInt(c.req.param('count')) || 10
|
||||||
|
const capped = Math.min(count, 500) // Safety cap
|
||||||
|
|
||||||
|
// Run in background
|
||||||
|
startFightLoop({ maxFights: capped, intervalMs: 500, matchmakingStyle: 'mixed' })
|
||||||
|
.then(() => console.log(`[botfights] batch of ${capped} fights completed`))
|
||||||
|
.catch(err => console.error('[botfights] batch error:', err))
|
||||||
|
|
||||||
|
return c.json({ message: `Started batch of ${capped} fights in background.` })
|
||||||
|
})
|
||||||
|
|
||||||
// SSE stream for live fight events
|
// SSE stream for live fight events
|
||||||
fightsRouter.get('/:id/stream', (c) => {
|
fightsRouter.get('/:id/stream', (c) => {
|
||||||
const fightId = c.req.param('id')
|
const fightId = c.req.param('id')
|
||||||
|
|||||||
Reference in New Issue
Block a user