277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
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
|
||
|
|
}
|