Files
botfights/server/src/engine/scoring.ts
T

277 lines
10 KiB
TypeScript
Raw Normal View History

import type { Challenge } from './challenges.js'
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 {
// Handle timeouts/errors
if (responseA.timedOut && responseB.timedOut) {
return {
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,
}
}
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),
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 {
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!`
: `${botB.name} crashes with an ERROR! Blue screen of defeat. ${botA.name} capitalizes!`,
isCritical: false,
}
}
// 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
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
} 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
}
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
}
}
// 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
? generateNarration(challenge, winnerName!, loserName!, margin, isCritical, responseA, responseB)
: `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,
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)
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
// Faster is slightly better for quality too
const speedBonus = Math.max(0, 3 - response.timeMs / 5000)
return lengthScore + speedBonus
}
function generateNarration(
challenge: Challenge,
winner: string,
loser: string,
margin: number,
isCritical: boolean,
_responseA: BotResponse,
_responseB: BotResponse,
): string {
const critPrefix = isCritical ? 'CRITICAL HIT! ' : ''
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.`,
],
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.`,
],
}
const options = narrations[challenge.type] || [
`${critPrefix}${winner} takes the round! ${loser} needs a reboot.`,
]
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,
}
}
// Tier calculation based on Elo + wins
export function calculateTier(elo: number, wins: number): number {
if (elo >= 1800 && wins >= 20) return 5 // Legendary
if (elo >= 1600 && wins >= 12) return 4 // Champion
if (elo >= 1400 && wins >= 7) return 3 // Contender
if (elo >= 1250 && wins >= 3) return 2 // Rising
if (wins >= 1) return 1 // Rookie
return 0 // Unranked
}