2026-03-06 16:27:54 +00:00
|
|
|
import type { Challenge } from './challenges.js'
|
2026-03-06 23:44:40 +00:00
|
|
|
import { checkAnswer } from './answers.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-06 23:44:40 +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,
|
|
|
|
|
narration: `Both bots freeze! ${botA.name} and ${botB.name} stare blankly at each other. The crowd throws peanuts.`,
|
|
|
|
|
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
|
|
|
|
|
? `${botA.name} TIMES OUT! Stood there like a confused thermostat. ${botB.name} lands a free hit!`
|
|
|
|
|
: `${botA.name} throws an ERROR! Sparks fly from its chassis. ${botB.name} capitalizes!`,
|
|
|
|
|
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
|
|
|
|
|
? `${botB.name} TIMES OUT! Frozen like a Windows update. ${botA.name} lands a free hit!`
|
|
|
|
|
: `${botB.name} crashes with an ERROR! Blue screen of defeat. ${botA.name} capitalizes!`,
|
|
|
|
|
isCritical: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let scoreA: number
|
|
|
|
|
let scoreB: number
|
|
|
|
|
|
2026-03-06 23:44:40 +00:00
|
|
|
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
|
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
|
|
|
|
|
|
|
|
|
|
// 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
|
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) {
|
|
|
|
|
// 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
|
2026-03-06 16:27:54 +00:00
|
|
|
}
|
2026-03-06 23:44:40 +00:00
|
|
|
} 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
|
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
|
|
|
|
|
|
|
|
|
|
// Critical hit on big margin
|
|
|
|
|
const isCritical = margin > 4
|
|
|
|
|
|
|
|
|
|
// Calculate damage
|
|
|
|
|
let winnerDamage = challenge.baseDamage + margin * 2
|
|
|
|
|
if (isCritical) winnerDamage *= 1.5
|
|
|
|
|
const winnerCombo = winnerId === botA.id ? comboA : comboB
|
|
|
|
|
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-06 16:27:54 +00:00
|
|
|
: `Dead even! ${botA.name} and ${botB.name} trade equal blows. The crowd holds its breath.`
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyModifiers(
|
|
|
|
|
damage: number,
|
2026-03-06 23:44:40 +00:00
|
|
|
_challenge: Challenge,
|
|
|
|
|
_arenaModifier: string | null,
|
2026-03-06 16:27:54 +00:00
|
|
|
combo: number,
|
|
|
|
|
): number {
|
|
|
|
|
let d = damage
|
2026-03-06 23:44:40 +00:00
|
|
|
// Combo multiplier (caps at 2x)
|
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 {
|
|
|
|
|
if (!response.answer) return 1
|
|
|
|
|
const len = response.answer.length
|
|
|
|
|
// Reasonable length gets a bonus, very short or very long gets penalized
|
|
|
|
|
const lengthScore = len > 20 && len < 500 ? 5 : len > 500 ? 3 : 2
|
2026-03-06 23:44:40 +00:00
|
|
|
// Faster is slightly better
|
2026-03-06 16:27:54 +00:00
|
|
|
const speedBonus = Math.max(0, 3 - response.timeMs / 5000)
|
|
|
|
|
return lengthScore + speedBonus
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
// 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
|
2026-03-06 16:27:54 +00:00
|
|
|
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.`,
|
|
|
|
|
],
|
|
|
|
|
riddle: [
|
|
|
|
|
`${critPrefix}${winner} cracks the riddle! ${loser} is still googling it.`,
|
|
|
|
|
`${critPrefix}${winner}'s reasoning is flawless. ${loser} guessed "a potato."`,
|
|
|
|
|
],
|
|
|
|
|
math_blitz: [
|
|
|
|
|
`${critPrefix}${winner} computes at blinding speed! ${loser} is still carrying the one.`,
|
|
|
|
|
`${critPrefix}Mathematical precision from ${winner}. ${loser} apparently skipped calculator day.`,
|
|
|
|
|
],
|
2026-03-06 23:44:40 +00:00
|
|
|
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.`,
|
|
|
|
|
],
|
2026-03-06 16:27:54 +00:00
|
|
|
trap_card: [
|
|
|
|
|
`${critPrefix}${winner} sees through the trap! ${loser} fell for it like a 2021 chatbot.`,
|
2026-03-06 23:44:40 +00:00
|
|
|
`${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.`,
|
2026-03-06 16:27:54 +00:00
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const options = narrations[challenge.type] || [
|
|
|
|
|
`${critPrefix}${winner} takes the round! ${loser} needs a reboot.`,
|
2026-03-06 23:44:40 +00:00
|
|
|
`${critPrefix}${winner} wins convincingly! ${loser} is picking up the pieces.`,
|
2026-03-06 16:27:54 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return options[Math.floor(Math.random() * options.length)]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Elo calculation
|
|
|
|
|
export function calculateElo(
|
|
|
|
|
winnerElo: number,
|
|
|
|
|
loserElo: number,
|
|
|
|
|
k: number = 32,
|
|
|
|
|
): { newWinnerElo: number; newLoserElo: number } {
|
|
|
|
|
const expectedWinner = 1 / (1 + Math.pow(10, (loserElo - winnerElo) / 400))
|
|
|
|
|
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-06 22:13:19 +00:00
|
|
|
if (elo >= 1900 && wins >= 40) return 6 // Legend
|
|
|
|
|
if (elo >= 1700 && wins >= 25) return 5 // Diamond
|
|
|
|
|
if (elo >= 1500 && wins >= 15) return 4 // Platinum
|
|
|
|
|
if (elo >= 1350 && wins >= 7) return 3 // Gold
|
|
|
|
|
if (elo >= 1200 && wins >= 3) return 2 // Silver
|
|
|
|
|
if (wins >= 1) return 1 // Bronze
|
|
|
|
|
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
|