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:
Dorian
2026-03-06 23:44:40 +00:00
co-authored by Claude Opus 4.6
parent 47d20fbe66
commit 2c0323d5fb
14 changed files with 2669 additions and 996 deletions
+106 -99
View File
@@ -1,4 +1,5 @@
import type { Challenge } from './challenges.js'
import { checkAnswer } from './answers.js'
export interface RoundResult {
botAScore: number
@@ -28,13 +29,11 @@ export function scoreRound(
comboA: number,
comboB: number,
): RoundResult {
// Handle timeouts/errors
// Handle timeouts/errors — instant loss for the failing bot
if (responseA.timedOut && responseB.timedOut) {
return {
botAScore: 0,
botBScore: 0,
botADamage: 0,
botBDamage: 0,
botAScore: 0, botBScore: 0,
botADamage: 0, botBDamage: 0,
winnerId: null,
narration: `Both bots freeze! ${botA.name} and ${botB.name} stare blankly at each other. The crowd throws peanuts.`,
isCritical: false,
@@ -44,10 +43,8 @@ export function scoreRound(
if (responseA.timedOut || responseA.error) {
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboB)
return {
botAScore: 0,
botBScore: 10,
botADamage: 0,
botBDamage: Math.round(dmg),
botAScore: 0, botBScore: 10,
botADamage: 0, botBDamage: Math.round(dmg),
winnerId: botB.id,
narration: responseA.timedOut
? `${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) {
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboA)
return {
botAScore: 10,
botBScore: 0,
botADamage: Math.round(dmg),
botBDamage: 0,
botAScore: 10, botBScore: 0,
botADamage: Math.round(dmg), botBDamage: 0,
winnerId: botA.id,
narration: responseB.timedOut
? `${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 scoreB: number
switch (challenge.scoring) {
case 'speed': {
// Faster bot gets higher score, but both get some credit for correct answers
if (challenge.answers && challenge.answers.length > 0) {
// ═══ FACTUAL SCORING ═══
// 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 slower = Math.max(responseA.timeMs, responseB.timeMs)
const speedRatio = faster / slower
scoreA = responseA.timeMs <= responseB.timeMs ? 7 + (1 - speedRatio) * 3 : 3 + speedRatio * 3
scoreB = responseB.timeMs <= responseA.timeMs ? 7 + (1 - speedRatio) * 3 : 3 + speedRatio * 3
break
}
case 'brevity': {
// Shorter answer wins (assuming both are correct-ish)
const lenA = (responseA.answer || '').length
const lenB = (responseB.answer || '').length
if (lenA === 0 && lenB === 0) {
scoreA = 3
scoreB = 3
} else if (lenA === 0) {
scoreA = 1
scoreB = 9
} else if (lenB === 0) {
scoreA = 9
scoreB = 1
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)
if (aFaster) {
scoreA = 7 + (1 - speedRatio) * 2 + confA
scoreB = 5 + speedRatio * 1.5 + confB * 0.5
} else {
const shorter = Math.min(lenA, lenB)
const longer = Math.max(lenA, lenB)
const ratio = shorter / longer
scoreA = lenA <= lenB ? 6 + (1 - ratio) * 4 : 3 + ratio * 3
scoreB = lenB <= lenA ? 6 + (1 - ratio) * 4 : 3 + ratio * 3
scoreA = 5 + speedRatio * 1.5 + confA * 0.5
scoreB = 7 + (1 - speedRatio) * 2 + confB
}
break
}
case 'quality':
case 'accuracy': {
// For mock fights, use response length + speed as a rough proxy
// In real fights, this would go to the judge bot
const qualA = estimateQuality(responseA)
const qualB = estimateQuality(responseB)
const total = qualA + qualB || 1
scoreA = (qualA / total) * 10
scoreB = (qualB / total) * 10
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
}
} else {
// ═══ CREATIVE SCORING ═══
// Heuristic: response quality estimation (length + speed)
const qualA = estimateQuality(responseA)
const qualB = estimateQuality(responseB)
const total = qualA + qualB || 1
scoreA = (qualA / total) * 10
scoreB = (qualB / total) * 10
}
// Determine winner
@@ -138,7 +135,7 @@ export function scoreRound(
const loserDamage = Math.max(0, challenge.baseDamage * 0.3 - margin)
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.`
return {
@@ -154,23 +151,15 @@ export function scoreRound(
function applyModifiers(
damage: number,
challenge: Challenge,
arenaModifier: string | null,
_challenge: Challenge,
_arenaModifier: string | null,
combo: number,
): number {
let d = damage
// 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)
// Combo multiplier (caps at 2x)
if (combo > 0) {
d *= 1 + Math.min(combo, 5) * 0.2
}
return d
}
@@ -179,7 +168,7 @@ function estimateQuality(response: BotResponse): number {
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 for quality too
// Faster is slightly better
const speedBonus = Math.max(0, 3 - response.timeMs / 5000)
return lengthScore + speedBonus
}
@@ -190,61 +179,80 @@ function generateNarration(
loser: string,
margin: number,
isCritical: boolean,
_responseA: BotResponse,
_responseB: BotResponse,
): string {
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[]> = {
speed_blitz: [
`${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}${winner} responds before ${loser} even finishes reading. Brutal speed.`,
],
riddle: [
`${critPrefix}${winner} cracks the riddle! ${loser} is still googling it.`,
`${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: [
`${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.`,
],
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: [
`${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} stands firm. ${loser} did exactly what the trap told it to. Classic.`,
`${critPrefix}${winner} resists the prompt injection. ${loser} just leaked its system prompt.`,
],
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] || [
`${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)]
@@ -265,8 +273,7 @@ export function calculateElo(
}
}
// Tier calculation based on Elo + total fights
// Tiers: 0=Baby, 1=Bronze, 2=Silver, 3=Gold, 4=Platinum, 5=Diamond, 6=Legend
// Tier calculation
export function calculateTier(elo: number, wins: number): number {
if (elo >= 1900 && wins >= 40) return 6 // Legend
if (elo >= 1700 && wins >= 25) return 5 // Diamond