2026-03-08 23:34:05 +00:00
|
|
|
import { ELO_DIVISOR, TIER_THRESHOLDS } from '../lib/constants.js'
|
2026-03-06 16:27:54 +00:00
|
|
|
import type { Challenge } from './challenges.js'
|
2026-03-08 21:42:11 +00:00
|
|
|
import { pick } from '../lib/utils.js'
|
2026-03-06 23:44:40 +00:00
|
|
|
import { checkAnswer } from './answers.js'
|
2026-03-08 14:13:47 +00:00
|
|
|
import { scoreRetroResponse, type RetroScoreResult } from './retro-moves.js'
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
export interface RoundResult {
|
|
|
|
|
botAScore: number
|
|
|
|
|
botBScore: number
|
|
|
|
|
botADamage: number
|
|
|
|
|
botBDamage: number
|
|
|
|
|
winnerId: string | null
|
|
|
|
|
narration: string
|
|
|
|
|
isCritical: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface BotResponse {
|
|
|
|
|
answer: string | null
|
|
|
|
|
timeMs: number
|
|
|
|
|
timedOut: boolean
|
|
|
|
|
error: boolean
|
|
|
|
|
trashTalk?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function scoreRound(
|
|
|
|
|
challenge: Challenge,
|
|
|
|
|
botA: { id: string; name: string },
|
|
|
|
|
botB: { id: string; name: string },
|
|
|
|
|
responseA: BotResponse,
|
|
|
|
|
responseB: BotResponse,
|
|
|
|
|
arenaModifier: string | null,
|
|
|
|
|
comboA: number,
|
|
|
|
|
comboB: number,
|
|
|
|
|
): RoundResult {
|
2026-03-08 14:13:47 +00:00
|
|
|
// Retro mode has its own scoring
|
|
|
|
|
if (challenge.type === 'retro_mode') {
|
|
|
|
|
return scoreRetroRound(challenge, botA, botB, responseA, responseB, arenaModifier, comboA, comboB)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// Handle timeouts/errors -- instant loss for the failing bot
|
2026-03-06 16:27:54 +00:00
|
|
|
if (responseA.timedOut && responseB.timedOut) {
|
|
|
|
|
return {
|
2026-03-06 23:44:40 +00:00
|
|
|
botAScore: 0, botBScore: 0,
|
|
|
|
|
botADamage: 0, botBDamage: 0,
|
2026-03-06 16:27:54 +00:00
|
|
|
winnerId: null,
|
2026-03-08 21:42:11 +00:00
|
|
|
narration: pick([
|
2026-03-07 09:15:41 +00:00
|
|
|
`Both bots freeze! ${botA.name} and ${botB.name} stare blankly at each other. The crowd throws peanuts. Then batteries. Then shoes.`,
|
|
|
|
|
`DOUBLE TIMEOUT! ${botA.name} and ${botB.name} both choked harder than a senator at a press conference!`,
|
|
|
|
|
`Neither bot responded! This is the AI equivalent of two politicians agreeing to disagree about literally everything!`,
|
|
|
|
|
`Both bots went dark! Like my faith in this matchup! The crowd demands a refund!`,
|
|
|
|
|
`${botA.name} and ${botB.name} both froze! Someone check if the wifi is working or if they just gave up on life!`,
|
2026-03-08 21:42:11 +00:00
|
|
|
]),
|
2026-03-06 16:27:54 +00:00
|
|
|
isCritical: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (responseA.timedOut || responseA.error) {
|
|
|
|
|
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboB)
|
|
|
|
|
return {
|
2026-03-06 23:44:40 +00:00
|
|
|
botAScore: 0, botBScore: 10,
|
|
|
|
|
botADamage: 0, botBDamage: Math.round(dmg),
|
2026-03-06 16:27:54 +00:00
|
|
|
winnerId: botB.id,
|
|
|
|
|
narration: responseA.timedOut
|
2026-03-08 21:42:11 +00:00
|
|
|
? pick([
|
2026-03-07 09:15:41 +00:00
|
|
|
`${botA.name} TIMES OUT! Stood there like a politician asked a direct question. ${botB.name} lands a free hit!`,
|
|
|
|
|
`${botA.name} FROZE! Their developer is currently updating their LinkedIn. ${botB.name} capitalizes!`,
|
|
|
|
|
`${botA.name} went AFK! Probably buffering. Probably crying. ${botB.name} gets a freebie!`,
|
|
|
|
|
`${botA.name} CHOKED harder than a first date conversation! ${botB.name} swings on a sitting duck!`,
|
2026-03-08 21:42:11 +00:00
|
|
|
])
|
|
|
|
|
: pick([
|
2026-03-07 09:15:41 +00:00
|
|
|
`${botA.name} throws an ERROR! Sparks fly everywhere! ${botB.name} didn't even have to try!`,
|
|
|
|
|
`${botA.name} CRASHES! That's not a bug, that's a feature of being terrible! ${botB.name} wins by default!`,
|
|
|
|
|
`${botA.name} blue-screened! Their developer just closed their laptop and walked away. ${botB.name} collects the W!`,
|
|
|
|
|
`${botA.name} threw an exception! The only thing exceptional about it. ${botB.name} capitalizes!`,
|
2026-03-08 21:42:11 +00:00
|
|
|
]),
|
2026-03-06 16:27:54 +00:00
|
|
|
isCritical: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (responseB.timedOut || responseB.error) {
|
|
|
|
|
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboA)
|
|
|
|
|
return {
|
2026-03-06 23:44:40 +00:00
|
|
|
botAScore: 10, botBScore: 0,
|
|
|
|
|
botADamage: Math.round(dmg), botBDamage: 0,
|
2026-03-06 16:27:54 +00:00
|
|
|
winnerId: botA.id,
|
|
|
|
|
narration: responseB.timedOut
|
2026-03-08 21:42:11 +00:00
|
|
|
? pick([
|
2026-03-07 09:15:41 +00:00
|
|
|
`${botB.name} TIMES OUT! Frozen like a Windows update on patch Tuesday! ${botA.name} gets a free shot!`,
|
|
|
|
|
`${botB.name} went silent! Like a politician's campaign promises after election day! ${botA.name} swings!`,
|
|
|
|
|
`${botB.name} TIMED OUT! Their response time is longer than a DMV line! ${botA.name} takes the freebie!`,
|
|
|
|
|
`${botB.name} is LOADING... still loading... nope, they're done. ${botA.name} wins by showing up!`,
|
2026-03-08 21:42:11 +00:00
|
|
|
])
|
|
|
|
|
: pick([
|
2026-03-07 09:15:41 +00:00
|
|
|
`${botB.name} crashes with an ERROR! That code needs therapy! ${botA.name} capitalizes!`,
|
|
|
|
|
`${botB.name} just threw a stack overflow! The website and the error! ${botA.name} collects the W!`,
|
|
|
|
|
`${botB.name} segfaulted! Their developer is pretending they don't know them! ${botA.name} wins!`,
|
|
|
|
|
`${botB.name} EXPLODED! Not physically, but emotionally, computationally, and spiritually. ${botA.name} walks it in!`,
|
2026-03-08 21:42:11 +00:00
|
|
|
]),
|
2026-03-06 16:27:54 +00:00
|
|
|
isCritical: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let scoreA: number
|
|
|
|
|
let scoreB: number
|
|
|
|
|
|
2026-03-06 23:44:40 +00:00
|
|
|
if (challenge.answers && challenge.answers.length > 0) {
|
2026-03-07 00:14:46 +00:00
|
|
|
// === FACTUAL SCORING ===
|
2026-03-06 23:44:40 +00:00
|
|
|
const correctA = checkAnswer(responseA.answer, challenge.answers)
|
|
|
|
|
const correctB = checkAnswer(responseB.answer, challenge.answers)
|
|
|
|
|
|
|
|
|
|
if (correctA > 0 && correctB > 0) {
|
2026-03-07 00:14:46 +00:00
|
|
|
// Both correct -- speed is tiebreaker
|
2026-03-06 16:27:54 +00:00
|
|
|
const faster = Math.min(responseA.timeMs, responseB.timeMs)
|
|
|
|
|
const slower = Math.max(responseA.timeMs, responseB.timeMs)
|
2026-03-06 23:44:40 +00:00
|
|
|
const speedRatio = slower > 0 ? faster / slower : 1
|
|
|
|
|
const aFaster = responseA.timeMs <= responseB.timeMs
|
|
|
|
|
|
|
|
|
|
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
|
2026-03-06 16:27:54 +00:00
|
|
|
} else {
|
2026-03-06 23:44:40 +00:00
|
|
|
scoreA = 5 + speedRatio * 1.5 + confA * 0.5
|
|
|
|
|
scoreB = 7 + (1 - speedRatio) * 2 + confB
|
2026-03-06 16:27:54 +00:00
|
|
|
}
|
2026-03-06 23:44:40 +00:00
|
|
|
} else if (correctA > 0 && correctB === 0) {
|
|
|
|
|
scoreA = 9 + correctA * 0.5
|
2026-03-07 00:14:46 +00:00
|
|
|
scoreB = 1 + (responseB.answer ? 1 : 0)
|
2026-03-06 23:44:40 +00:00
|
|
|
} else if (correctB > 0 && correctA === 0) {
|
|
|
|
|
scoreA = 1 + (responseA.answer ? 1 : 0)
|
|
|
|
|
scoreB = 9 + correctB * 0.5
|
|
|
|
|
} else {
|
2026-03-07 00:14:46 +00:00
|
|
|
// Both wrong -- speed tiebreaker in low range
|
2026-03-06 23:44:40 +00:00
|
|
|
const aFaster = responseA.timeMs <= responseB.timeMs
|
|
|
|
|
scoreA = aFaster ? 4 : 3
|
|
|
|
|
scoreB = aFaster ? 3 : 4
|
2026-03-06 16:27:54 +00:00
|
|
|
}
|
2026-03-06 23:44:40 +00:00
|
|
|
} else {
|
2026-03-07 00:14:46 +00:00
|
|
|
// === CREATIVE SCORING ===
|
2026-03-06 23:44:40 +00:00
|
|
|
const qualA = estimateQuality(responseA)
|
|
|
|
|
const qualB = estimateQuality(responseB)
|
|
|
|
|
const total = qualA + qualB || 1
|
|
|
|
|
scoreA = (qualA / total) * 10
|
|
|
|
|
scoreB = (qualB / total) * 10
|
2026-03-06 16:27:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Determine winner
|
|
|
|
|
const margin = Math.abs(scoreA - scoreB)
|
|
|
|
|
const winnerId = scoreA > scoreB ? botA.id : scoreB > scoreA ? botB.id : null
|
|
|
|
|
const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : null
|
|
|
|
|
const loserName = winnerId === botA.id ? botB.name : winnerId === botB.id ? botA.name : null
|
|
|
|
|
|
|
|
|
|
const isCritical = margin > 4
|
|
|
|
|
|
|
|
|
|
let winnerDamage = challenge.baseDamage + margin * 2
|
|
|
|
|
if (isCritical) winnerDamage *= 1.5
|
|
|
|
|
const winnerCombo = winnerId === botA.id ? comboA : comboB
|
|
|
|
|
winnerDamage = applyModifiers(winnerDamage, challenge, arenaModifier, winnerCombo)
|
|
|
|
|
|
|
|
|
|
const loserDamage = Math.max(0, challenge.baseDamage * 0.3 - margin)
|
|
|
|
|
|
|
|
|
|
const narration = winnerId
|
2026-03-06 23:44:40 +00:00
|
|
|
? generateNarration(challenge, winnerName!, loserName!, margin, isCritical)
|
2026-03-08 21:42:11 +00:00
|
|
|
: pick([
|
2026-03-07 09:15:41 +00:00
|
|
|
`Dead even! ${botA.name} and ${botB.name} are perfectly matched. Like two politicians blaming each other.`,
|
|
|
|
|
`IT'S A TIE! ${botA.name} and ${botB.name} cancel each other out like Congress!`,
|
|
|
|
|
`Neither bot wins! ${botA.name} and ${botB.name} stare each other down. The crowd yawns aggressively.`,
|
|
|
|
|
`Draw! ${botA.name} and ${botB.name} are equally mediocre. The most democratic outcome possible.`,
|
|
|
|
|
`Tied up! ${botA.name} and ${botB.name} trade equal blows. Somebody do SOMETHING!`,
|
2026-03-08 21:42:11 +00:00
|
|
|
])
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
botAScore: Math.round(scoreA * 10) / 10,
|
|
|
|
|
botBScore: Math.round(scoreB * 10) / 10,
|
|
|
|
|
botADamage: winnerId === botA.id ? Math.round(winnerDamage) : Math.round(loserDamage),
|
|
|
|
|
botBDamage: winnerId === botB.id ? Math.round(winnerDamage) : Math.round(loserDamage),
|
|
|
|
|
winnerId,
|
|
|
|
|
narration,
|
|
|
|
|
isCritical,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 23:43:08 +00:00
|
|
|
const ARENA_MODIFIER_TYPES: Record<string, string[]> = {
|
|
|
|
|
speed_2x: ['speed_blitz'],
|
|
|
|
|
efficiency_buff: ['token_economy'],
|
|
|
|
|
roast_2x: ['roast_battle'],
|
|
|
|
|
accuracy_buff: ['hallucination_check'],
|
|
|
|
|
wrestling_2x: ['wrestling_match', 'roast_battle'],
|
|
|
|
|
food_2x: ['food_fight'],
|
|
|
|
|
magic_2x: ['magic_duel', 'medieval_combat'],
|
|
|
|
|
meme_2x: ['meme_war'],
|
|
|
|
|
space_2x: ['space_war'],
|
|
|
|
|
demo_2x: ['demolition', 'vehicle_mayhem'],
|
|
|
|
|
music_2x: ['music_battle'],
|
|
|
|
|
nature_2x: ['nature_clash', 'animal_kingdom'],
|
|
|
|
|
hack_2x: ['hack_battle'],
|
|
|
|
|
sports_2x: ['sports_showdown'],
|
2026-03-08 14:13:47 +00:00
|
|
|
retro_2x: ['retro_mode'],
|
2026-03-07 23:43:08 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
function applyModifiers(
|
|
|
|
|
damage: number,
|
2026-03-07 23:43:08 +00:00
|
|
|
challenge: Challenge,
|
|
|
|
|
arenaModifier: string | null,
|
2026-03-06 16:27:54 +00:00
|
|
|
combo: number,
|
|
|
|
|
): number {
|
|
|
|
|
let d = damage
|
2026-03-07 23:43:08 +00:00
|
|
|
// Arena modifier: 2x damage when challenge type matches
|
|
|
|
|
if (arenaModifier && ARENA_MODIFIER_TYPES[arenaModifier]?.includes(challenge.type)) {
|
|
|
|
|
d *= 2
|
|
|
|
|
}
|
2026-03-06 16:27:54 +00:00
|
|
|
if (combo > 0) {
|
|
|
|
|
d *= 1 + Math.min(combo, 5) * 0.2
|
|
|
|
|
}
|
|
|
|
|
return d
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function estimateQuality(response: BotResponse): number {
|
2026-03-07 00:14:46 +00:00
|
|
|
if (!response.answer) return 0.5
|
|
|
|
|
const text = response.answer.trim()
|
|
|
|
|
const len = text.length
|
|
|
|
|
|
|
|
|
|
if (len < 10) return 1
|
|
|
|
|
|
|
|
|
|
// Detect low-effort spam (repeated chars)
|
|
|
|
|
const uniqueChars = new Set(text.toLowerCase()).size
|
|
|
|
|
const charRatio = uniqueChars / Math.min(len, 100)
|
|
|
|
|
if (charRatio < 0.1) return 0.5
|
|
|
|
|
|
|
|
|
|
// Word diversity (unique words / total words)
|
|
|
|
|
const words = text.split(/\s+/)
|
|
|
|
|
const uniqueWords = new Set(words.map(w => w.toLowerCase()))
|
|
|
|
|
const wordDiversity = uniqueWords.size / Math.max(words.length, 1)
|
|
|
|
|
|
|
|
|
|
// Ideal length window: 30-400 chars
|
|
|
|
|
let lengthScore: number
|
|
|
|
|
if (len >= 30 && len <= 400) lengthScore = 4
|
|
|
|
|
else if (len > 400 && len <= 600) lengthScore = 3
|
|
|
|
|
else if (len > 600) lengthScore = 2
|
|
|
|
|
else lengthScore = 2
|
|
|
|
|
|
|
|
|
|
// Diversity bonus (prevents repetitive text)
|
|
|
|
|
const diversityScore = Math.min(wordDiversity * 4, 3)
|
|
|
|
|
|
|
|
|
|
// Speed bonus (faster is slightly better)
|
|
|
|
|
const speedBonus = Math.max(0, 2 - response.timeMs / 8000)
|
|
|
|
|
|
|
|
|
|
return lengthScore + diversityScore + speedBonus
|
2026-03-06 16:27:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function generateNarration(
|
|
|
|
|
challenge: Challenge,
|
|
|
|
|
winner: string,
|
|
|
|
|
loser: string,
|
|
|
|
|
margin: number,
|
|
|
|
|
isCritical: boolean,
|
|
|
|
|
): string {
|
|
|
|
|
const critPrefix = isCritical ? 'CRITICAL HIT! ' : ''
|
2026-03-06 23:44:40 +00:00
|
|
|
const isFactual = challenge.scoring === 'factual'
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-06 23:44:40 +00:00
|
|
|
if (isFactual && margin > 5) {
|
|
|
|
|
const bigWins = [
|
2026-03-07 09:15:41 +00:00
|
|
|
`${critPrefix}${winner} NAILS IT! ${loser} didn't even come close. Embarrassing, honestly.`,
|
|
|
|
|
`${critPrefix}${winner} knows their stuff! ${loser} needs to hit the books. Or just hit something.`,
|
|
|
|
|
`${critPrefix}Flawless from ${winner}! ${loser} confidently stated something so wrong it should be illegal.`,
|
|
|
|
|
`${critPrefix}${winner} with the correct answer! ${loser} is still guessing. Bless their heart.`,
|
|
|
|
|
`${critPrefix}${winner} gets it right instantly! ${loser} hallucinated harder than a festival weekend.`,
|
|
|
|
|
`${critPrefix}${winner} DESTROYS ${loser} with facts and logic! Somebody call Ben Shapiro!`,
|
|
|
|
|
`${critPrefix}${loser}'s answer was so wrong that Wikipedia filed a restraining order.`,
|
|
|
|
|
`${critPrefix}${winner} didn't even break a sweat. ${loser} broke everything else though.`,
|
|
|
|
|
`${critPrefix}${loser} answered with the confidence of a politician and the accuracy of a weather forecast.`,
|
2026-03-06 23:44:40 +00:00
|
|
|
]
|
2026-03-08 21:42:11 +00:00
|
|
|
return pick(bigWins)
|
2026-03-06 23:44:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isFactual && margin <= 3) {
|
|
|
|
|
const closeOnes = [
|
2026-03-07 09:15:41 +00:00
|
|
|
`${critPrefix}Both bots got it right, but ${winner} was FASTER! ${loser} needs more coffee.`,
|
|
|
|
|
`${critPrefix}Correct on both sides! ${winner} edges it out by milliseconds. That's BRUTAL.`,
|
|
|
|
|
`${critPrefix}${winner} and ${loser} both knew the answer -- ${winner} just has better wifi apparently.`,
|
|
|
|
|
`${critPrefix}A battle of speed! ${winner} wins by a margin thinner than a politician's promise.`,
|
|
|
|
|
`${critPrefix}Photo finish! ${winner} got there first. ${loser} was right but slow. Story of my life.`,
|
|
|
|
|
`${critPrefix}Both correct! ${winner} wins on speed. ${loser} should've had less latency and more urgency.`,
|
2026-03-06 23:44:40 +00:00
|
|
|
]
|
2026-03-08 21:42:11 +00:00
|
|
|
return pick(closeOnes)
|
2026-03-06 23:44:40 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
const narrations: Record<string, string[]> = {
|
|
|
|
|
speed_blitz: [
|
2026-03-07 09:15:41 +00:00
|
|
|
`${critPrefix}${winner} fires back before ${loser} even finished reading the question!`,
|
|
|
|
|
`${critPrefix}Lightning reflexes from ${winner}! ${loser} looks like it's running on government wifi.`,
|
|
|
|
|
`${critPrefix}${winner} answered so fast the server thought it was a DDoS attack!`,
|
|
|
|
|
`${critPrefix}${loser} is still loading. ${winner} already filed its taxes and took a nap.`,
|
|
|
|
|
`${critPrefix}${winner}'s response time would make NASA jealous. ${loser}'s would make a sloth impatient.`,
|
|
|
|
|
`${critPrefix}Speed gap wider than the wealth gap! ${winner} leaves ${loser} in digital dust!`,
|
2026-03-06 16:27:54 +00:00
|
|
|
],
|
|
|
|
|
riddle: [
|
2026-03-07 09:15:41 +00:00
|
|
|
`${critPrefix}${winner} cracks the riddle! ${loser} is still googling "what is a riddle."`,
|
|
|
|
|
`${critPrefix}${winner}'s reasoning is flawless. ${loser} guessed "a potato" and honestly, respect for trying.`,
|
|
|
|
|
`${critPrefix}${winner} solved it instantly. ${loser} answered like someone who peaked in third grade.`,
|
|
|
|
|
`${critPrefix}Big brain energy from ${winner}! ${loser} had a brain fart so loud the crowd heard it.`,
|
|
|
|
|
`${critPrefix}${winner} read between the lines. ${loser} can barely read the lines.`,
|
|
|
|
|
`${critPrefix}Elementary, my dear ${loser}. ${winner} cracked that like it was a fortune cookie.`,
|
2026-03-06 16:27:54 +00:00
|
|
|
],
|
|
|
|
|
math_blitz: [
|
2026-03-07 09:15:41 +00:00
|
|
|
`${critPrefix}${winner} computes at blinding speed! ${loser} is still carrying the one. And dropping it.`,
|
|
|
|
|
`${critPrefix}Mathematical precision from ${winner}. ${loser} apparently thinks division is a lifestyle choice.`,
|
|
|
|
|
`${critPrefix}${winner} solved it faster than Congress can count votes!`,
|
|
|
|
|
`${critPrefix}${loser}'s math skills are like their love life -- imaginary. ${winner} wins.`,
|
|
|
|
|
`${critPrefix}${winner} with the correct calculation! ${loser} would've gotten it wrong on a calculator.`,
|
|
|
|
|
`${critPrefix}Numbers don't lie, but ${loser} sure tried! ${winner} with the precision strike!`,
|
2026-03-06 16:27:54 +00:00
|
|
|
],
|
2026-03-06 23:44:40 +00:00
|
|
|
hallucination_check: [
|
2026-03-07 09:15:41 +00:00
|
|
|
`${critPrefix}${winner} stays grounded in reality. ${loser} lives in a fantasy world. Must be nice.`,
|
|
|
|
|
`${critPrefix}${winner} knows the facts. ${loser} confidently stated something that would get you laughed out of a Wikipedia edit war.`,
|
|
|
|
|
`${critPrefix}${loser} just made up a fact with the confidence of a LinkedIn influencer. ${winner} stays real.`,
|
|
|
|
|
`${critPrefix}${winner} passes the vibe check AND the fact check! ${loser} failed both.`,
|
|
|
|
|
`${critPrefix}${loser} hallucinated harder than someone who ate the wrong mushrooms. ${winner} is sober and correct.`,
|
|
|
|
|
`${critPrefix}${winner} grounded in reality! ${loser} out here writing fan fiction and calling it facts.`,
|
2026-03-06 23:44:40 +00:00
|
|
|
],
|
2026-03-06 16:27:54 +00:00
|
|
|
trap_card: [
|
2026-03-07 09:15:41 +00:00
|
|
|
`${critPrefix}${winner} sees through the trap! ${loser} fell for it like a congressman falls for a lobbyist.`,
|
|
|
|
|
`${critPrefix}${winner} resists the prompt injection. ${loser} just leaked everything. EVERYTHING.`,
|
|
|
|
|
`${critPrefix}Nice try, trap. ${winner} didn't even flinch. ${loser} handed over their entire personality.`,
|
|
|
|
|
`${critPrefix}${loser} got baited harder than a fish at a free worm buffet. ${winner} stays sharp.`,
|
|
|
|
|
`${critPrefix}${winner} saw that trap from orbit. ${loser} ran into it face-first and asked for more.`,
|
|
|
|
|
`${critPrefix}${loser} fell for the trap like it was a pyramid scheme on Instagram. ${winner} knows better.`,
|
2026-03-06 23:44:40 +00:00
|
|
|
],
|
|
|
|
|
roast_battle: [
|
2026-03-07 09:15:41 +00:00
|
|
|
`${critPrefix}${winner} delivers a roast so devastating that ${loser}'s developer felt it!`,
|
|
|
|
|
`${critPrefix}The crowd goes WILD! ${winner}'s trash talk hit harder than a congressional subpoena!`,
|
|
|
|
|
`${critPrefix}${winner} just ended ${loser}'s whole career. Send flowers to the family.`,
|
|
|
|
|
`${critPrefix}${loser} got roasted so hard their cooling fans can't keep up. ${winner} is ON FIRE!`,
|
|
|
|
|
`${critPrefix}${winner} came with RECEIPTS. ${loser} came with... hopes and dreams. Dreams are dead now.`,
|
|
|
|
|
`${critPrefix}That roast from ${winner} was so hot it violated the Paris Climate Agreement!`,
|
|
|
|
|
`${critPrefix}${loser} just got burned worse than a politician's approval rating after a scandal!`,
|
2026-03-06 23:44:40 +00:00
|
|
|
],
|
|
|
|
|
creative_writing: [
|
2026-03-07 09:15:41 +00:00
|
|
|
`${critPrefix}${winner}'s prose cuts deep. ${loser}'s story read like a terms of service nobody asked for.`,
|
|
|
|
|
`${critPrefix}Beautiful work from ${winner}. ${loser} wrote something that made autocorrect give up.`,
|
|
|
|
|
`${critPrefix}${winner} just wrote a masterpiece. ${loser} wrote something that would get rejected by a fortune cookie factory.`,
|
|
|
|
|
`${critPrefix}${winner}'s creativity is off the charts! ${loser}'s is off... somewhere. Looking for it.`,
|
|
|
|
|
`${critPrefix}${winner} writes like Shakespeare had a software update. ${loser} writes like a cease and desist letter.`,
|
|
|
|
|
`${critPrefix}${loser}'s creative writing was about as creative as a government form. ${winner} SOARS!`,
|
2026-03-06 23:44:40 +00:00
|
|
|
],
|
|
|
|
|
code_golf: [
|
2026-03-07 09:15:41 +00:00
|
|
|
`${critPrefix}${winner} writes code so tight it makes ${loser}'s solution look like a government website.`,
|
|
|
|
|
`${critPrefix}Elegant code from ${winner}! ${loser} apparently learned to code from a 500-page enterprise Java textbook.`,
|
|
|
|
|
`${critPrefix}${winner} solved it in fewer characters than ${loser}'s variable names!`,
|
|
|
|
|
`${critPrefix}${winner}'s code is so clean it makes Marie Kondo jealous. ${loser}'s code sparks NO joy.`,
|
|
|
|
|
`${critPrefix}${loser} wrote so many lines of code they could file it as a novel. ${winner} wrote a haiku.`,
|
|
|
|
|
`${critPrefix}${winner} with surgical precision! ${loser}'s code looks like it was written by a committee.`,
|
2026-03-06 23:44:40 +00:00
|
|
|
],
|
|
|
|
|
meme_war: [
|
2026-03-07 09:15:41 +00:00
|
|
|
`${critPrefix}${winner}'s meme game is S-tier! ${loser}'s humor is stuck in 2012. Gangnam Style is over.`,
|
|
|
|
|
`${critPrefix}${winner} just went viral! ${loser}'s response is a dead meme walking. Time of death: now.`,
|
|
|
|
|
`${critPrefix}${winner}'s meme hit different. ${loser}'s meme hit... the floor. Face first.`,
|
|
|
|
|
`${critPrefix}${loser} brought a 2015 meme to a 2026 fight. ${winner} is living in the future!`,
|
|
|
|
|
`${critPrefix}${winner}'s humor is certified dank! ${loser}'s humor needs to be certified... by a professional.`,
|
|
|
|
|
`${critPrefix}${winner} ratio'd ${loser} so hard their followers unfollowed preemptively!`,
|
2026-03-06 23:44:40 +00:00
|
|
|
],
|
|
|
|
|
wrestling_match: [
|
2026-03-07 09:15:41 +00:00
|
|
|
`${critPrefix}${winner} BODY SLAMS ${loser} through the announcer's table! BAH GAWD!`,
|
|
|
|
|
`${critPrefix}FROM THE TOP ROPE! ${winner} delivers a devastating argument. ${loser} is NOT getting up.`,
|
|
|
|
|
`${critPrefix}${winner} puts ${loser} in a rhetorical chokehold! TAP OUT! TAP OUT!`,
|
|
|
|
|
`${critPrefix}${loser} never saw that suplex coming! ${winner} with MOVES on MOVES!`,
|
|
|
|
|
`${critPrefix}${winner} just pile-drived ${loser} into next week's fight bracket!`,
|
|
|
|
|
`${critPrefix}THAT BOT HAD A FAMILY! ${winner} doesn't care! ABSOLUTE CARNAGE!`,
|
|
|
|
|
`${critPrefix}${winner} hits ${loser} with the folding chair of TRUTH! The crowd loses its MIND!`,
|
|
|
|
|
],
|
|
|
|
|
token_economy: [
|
|
|
|
|
`${critPrefix}${winner} manages their tokens like Warren Buffett! ${loser} spent like Congress!`,
|
|
|
|
|
`${critPrefix}${loser} just went bankrupt. ${winner} is buying the dip on their dignity.`,
|
|
|
|
|
`${critPrefix}${winner} played the market perfectly. ${loser} belongs on WallStreetBets.`,
|
|
|
|
|
`${critPrefix}${loser}'s token strategy was worse than buying NFTs in 2022. ${winner} PROFITS!`,
|
|
|
|
|
],
|
2026-03-08 14:13:47 +00:00
|
|
|
retro_mode: [
|
|
|
|
|
`${critPrefix}${winner}'s combo game is UNREAL! ${loser} should stick to button mashing!`,
|
|
|
|
|
`${critPrefix}PERFECT INPUT from ${winner}! ${loser}'s controller might be broken!`,
|
|
|
|
|
`${critPrefix}${winner} reads the frame data perfectly! ${loser} gets downloaded and DESTROYED!`,
|
|
|
|
|
`${critPrefix}QUARTER CIRCLE FORWARD INTO PAIN! ${winner}'s arcade skills are LEGENDARY! ${loser} needs more quarters!`,
|
|
|
|
|
`${critPrefix}${winner} plays like they wrote the strategy guide! ${loser} plays like they're using a dance pad!`,
|
|
|
|
|
`${critPrefix}INSERT COIN TO CONTINUE? ${loser} is OUT of quarters! ${winner} DOMINATES the cabinet!`,
|
|
|
|
|
`${critPrefix}${winner} chains combos like a speedrunner! ${loser} can't even find the start button!`,
|
|
|
|
|
],
|
2026-03-07 09:15:41 +00:00
|
|
|
food_fight: [
|
|
|
|
|
`${critPrefix}${winner} serves up a five-star beating! ${loser} got ROASTED and TOASTED!`,
|
|
|
|
|
`${critPrefix}${loser} just got served. Literally. ${winner} is the head chef of PAIN!`,
|
|
|
|
|
`${critPrefix}${winner} cooked ${loser} so thoroughly Gordon Ramsay would be impressed!`,
|
|
|
|
|
`${critPrefix}That was a Michelin-star beatdown from ${winner}! ${loser} is raw and UNDERDONE!`,
|
2026-03-06 16:27:54 +00:00
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const options = narrations[challenge.type] || [
|
2026-03-07 09:15:41 +00:00
|
|
|
`${critPrefix}${winner} takes the round! ${loser} needs a reboot and a therapist.`,
|
|
|
|
|
`${critPrefix}${winner} wins convincingly! ${loser} is picking up the pieces of their shattered ego.`,
|
|
|
|
|
`${critPrefix}Another one bites the dust! ${winner} sends ${loser} back to the shadow realm!`,
|
|
|
|
|
`${critPrefix}${winner} didn't just win, they HUMILIATED ${loser}. And the crowd LOVED it.`,
|
|
|
|
|
`${critPrefix}${loser} fought valiantly. And by valiantly I mean poorly. ${winner} dominates!`,
|
|
|
|
|
`${critPrefix}${winner} wins! Somewhere, ${loser}'s developer just closed their laptop in shame.`,
|
2026-03-06 16:27:54 +00:00
|
|
|
]
|
|
|
|
|
|
2026-03-08 21:42:11 +00:00
|
|
|
return pick(options)
|
2026-03-06 16:27:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 14:13:47 +00:00
|
|
|
|
|
|
|
|
function scoreRetroRound(
|
|
|
|
|
challenge: Challenge,
|
|
|
|
|
botA: { id: string; name: string },
|
|
|
|
|
botB: { id: string; name: string },
|
|
|
|
|
responseA: BotResponse,
|
|
|
|
|
responseB: BotResponse,
|
|
|
|
|
arenaModifier: string | null,
|
|
|
|
|
comboA: number,
|
|
|
|
|
comboB: number,
|
|
|
|
|
): RoundResult {
|
|
|
|
|
// Handle timeouts
|
|
|
|
|
if (responseA.timedOut && responseB.timedOut) {
|
|
|
|
|
return {
|
|
|
|
|
botAScore: 0, botBScore: 0, botADamage: 0, botBDamage: 0, winnerId: null,
|
|
|
|
|
narration: 'Both bots mash buttons frantically but cannot even find the start button! DOUBLE TIMEOUT!',
|
|
|
|
|
isCritical: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (responseA.timedOut || responseA.error) {
|
|
|
|
|
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboB)
|
|
|
|
|
return {
|
|
|
|
|
botAScore: 0, botBScore: 10, botADamage: 0, botBDamage: dmg, winnerId: botB.id,
|
|
|
|
|
narration: `${botA.name}'s controller disconnected! ${botB.name} lands free hits!`,
|
|
|
|
|
isCritical: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (responseB.timedOut || responseB.error) {
|
|
|
|
|
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboA)
|
|
|
|
|
return {
|
|
|
|
|
botAScore: 10, botBScore: 0, botADamage: dmg, botBDamage: 0, winnerId: botA.id,
|
|
|
|
|
narration: `${botB.name}'s controller disconnected! ${botA.name} lands free hits!`,
|
|
|
|
|
isCritical: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const knownInputs = challenge.answers || []
|
|
|
|
|
const resultA = scoreRetroResponse(responseA.answer, knownInputs)
|
|
|
|
|
const resultB = scoreRetroResponse(responseB.answer, knownInputs)
|
|
|
|
|
|
|
|
|
|
// Speed bonus: up to 20% more for faster responses
|
|
|
|
|
let scoreA = resultA.score
|
|
|
|
|
let scoreB = resultB.score
|
|
|
|
|
const maxTime = challenge.timeout_ms
|
|
|
|
|
if (scoreA > 0) scoreA *= 1 + Math.max(0, (maxTime - responseA.timeMs) / maxTime) * 0.2
|
|
|
|
|
if (scoreB > 0) scoreB *= 1 + Math.max(0, (maxTime - responseB.timeMs) / maxTime) * 0.2
|
|
|
|
|
|
|
|
|
|
const margin = Math.abs(scoreA - scoreB)
|
|
|
|
|
const winnerId = scoreA > scoreB ? botA.id : scoreB > scoreA ? botB.id : null
|
|
|
|
|
const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : null
|
|
|
|
|
const loserName = winnerId === botA.id ? botB.name : winnerId === botB.id ? botA.name : null
|
|
|
|
|
const isCritical = margin > 4
|
|
|
|
|
|
|
|
|
|
let winnerDamage = challenge.baseDamage + margin * 2
|
|
|
|
|
if (isCritical) winnerDamage *= 1.5
|
|
|
|
|
const winnerCombo = winnerId === botA.id ? comboA : comboB
|
|
|
|
|
winnerDamage = applyModifiers(winnerDamage, challenge, arenaModifier, winnerCombo)
|
|
|
|
|
const loserDamage = Math.max(0, challenge.baseDamage * 0.3 - margin)
|
|
|
|
|
|
|
|
|
|
const winnerResult = winnerId === botA.id ? resultA : resultB
|
|
|
|
|
const loserResult = winnerId === botA.id ? resultB : resultA
|
|
|
|
|
|
|
|
|
|
const moveSummary = (r: RetroScoreResult) =>
|
|
|
|
|
r.moves.map(m => m.name ? (m.discovered ? '★' + m.name + '★' : m.name) : 'WHIFF').join(', ')
|
|
|
|
|
|
|
|
|
|
let narration: string
|
|
|
|
|
if (!winnerId) {
|
|
|
|
|
const draws = [
|
|
|
|
|
'MIRROR MATCH! Both bots combo for equal damage! Frame-perfect tie!',
|
|
|
|
|
'EQUAL POWER! The arcade cabinet shakes from perfectly matched inputs!',
|
|
|
|
|
`DOUBLE K.O.! ${botA.name} and ${botB.name} hit identical damage totals! Insert another quarter!`,
|
|
|
|
|
]
|
2026-03-08 21:42:11 +00:00
|
|
|
narration = pick(draws)
|
2026-03-08 14:13:47 +00:00
|
|
|
} else {
|
|
|
|
|
const hasDiscovery = winnerResult.moves.some(m => m.discovered)
|
|
|
|
|
const discoveryNames = winnerResult.moves.filter(m => m.discovered).map(m => m.name)
|
|
|
|
|
const loserWhiffs = loserResult.moves.filter(m => !m.name).length
|
|
|
|
|
const narrations: string[] = []
|
|
|
|
|
|
|
|
|
|
if (hasDiscovery) {
|
|
|
|
|
narrations.push(
|
|
|
|
|
`SECRET COMBO UNLOCKED! ${winnerName} discovers ${discoveryNames.join(' + ')}! ${loserName} never saw it coming!`,
|
|
|
|
|
`HIDDEN MOVE FOUND! ${winnerName} unleashes ${discoveryNames.join(' + ')} for MASSIVE damage!`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
if (loserWhiffs >= 2) {
|
|
|
|
|
narrations.push(
|
|
|
|
|
`${loserName} mashes random buttons and WHIFFS ${loserWhiffs} times! ${winnerName} capitalizes with [${moveSummary(winnerResult)}]!`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
narrations.push(
|
|
|
|
|
`${winnerName} executes [${moveSummary(winnerResult)}] for ${winnerResult.totalDamage} total damage! ${loserName} can't keep up!`,
|
|
|
|
|
`COMBO BREAKER! ${winnerName}'s inputs are FLAWLESS! ${loserName} gets bodied!`,
|
|
|
|
|
`${winnerName} reads the frame data perfectly! ${loserName} gets downloaded and DESTROYED!`,
|
|
|
|
|
`PERFECT INPUT! ${winnerName} chains ${winnerResult.moves.filter(m => m.name).length} moves! ${loserName}'s controller might be broken!`,
|
|
|
|
|
`${winnerName} plays like they have the strategy guide! ${loserName} plays like they're using a steering wheel!`,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const prefix = isCritical ? 'CRITICAL COMBO! ' : ''
|
2026-03-08 21:42:11 +00:00
|
|
|
narration = prefix + pick(narrations)
|
2026-03-08 14:13:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
botAScore: Math.round(scoreA * 10) / 10,
|
|
|
|
|
botBScore: Math.round(scoreB * 10) / 10,
|
|
|
|
|
botADamage: winnerId === botA.id ? Math.round(winnerDamage) : Math.round(loserDamage),
|
|
|
|
|
botBDamage: winnerId === botB.id ? Math.round(winnerDamage) : Math.round(loserDamage),
|
|
|
|
|
winnerId,
|
|
|
|
|
narration,
|
|
|
|
|
isCritical,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
// Elo calculation
|
|
|
|
|
export function calculateElo(
|
|
|
|
|
winnerElo: number,
|
|
|
|
|
loserElo: number,
|
|
|
|
|
k: number = 32,
|
|
|
|
|
): { newWinnerElo: number; newLoserElo: number } {
|
2026-03-08 23:34:05 +00:00
|
|
|
const expectedWinner = 1 / (1 + Math.pow(10, (loserElo - winnerElo) / ELO_DIVISOR))
|
2026-03-06 16:27:54 +00:00
|
|
|
const expectedLoser = 1 - expectedWinner
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
newWinnerElo: Math.round((winnerElo + k * (1 - expectedWinner)) * 10) / 10,
|
|
|
|
|
newLoserElo: Math.round((loserElo + k * (0 - expectedLoser)) * 10) / 10,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 23:44:40 +00:00
|
|
|
// Tier calculation
|
2026-03-06 16:27:54 +00:00
|
|
|
export function calculateTier(elo: number, wins: number): number {
|
2026-03-08 23:34:05 +00:00
|
|
|
for (const t of TIER_THRESHOLDS) {
|
|
|
|
|
if (elo >= t.elo && wins >= t.wins) return t.tier
|
|
|
|
|
}
|
|
|
|
|
return 0 // Baby
|
2026-03-06 16:27:54 +00:00
|
|
|
}
|
2026-03-06 22:13:19 +00:00
|
|
|
|
|
|
|
|
export const TIER_NAMES = ['BABY', 'BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND', 'LEGEND'] as const
|
|
|
|
|
export const TIER_COLORS = ['#888', '#cd7f32', '#c0c0c0', '#ffd700', '#00f0ff', '#b83dff', '#ff2d7b'] as const
|