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
+106
-99
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user