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

349 lines
10 KiB
TypeScript
Raw Normal View History

import { describe, it, expect } from 'vitest'
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
import type { Challenge } from './challenges.js'
function makeChallenge(overrides: Partial<Challenge> = {}): Challenge {
return {
type: 'riddle',
label: 'Test Challenge',
prompt: 'What is 2+2?',
answers: ['4'],
scoring: 'factual',
baseDamage: 20,
timeout_ms: 8000,
...overrides,
} as Challenge
}
function makeResponse(answer: string | null, timeMs = 500, timedOut = false, error = false) {
return { answer, timeMs, timedOut, error }
}
describe('scoreRound', () => {
const botA = { id: 'a1', name: 'AlphaBot' }
const botB = { id: 'b1', name: 'BetaBot' }
it('both correct — faster bot scores higher', () => {
const challenge = makeChallenge()
const result = scoreRound(
challenge, botA, botB,
makeResponse('4', 200),
makeResponse('4', 800),
null, 0, 0,
)
expect(result.botAScore).toBeGreaterThan(result.botBScore)
expect(result.winnerId).toBe('a1')
})
it('only A correct — A wins big', () => {
const challenge = makeChallenge()
const result = scoreRound(
challenge, botA, botB,
makeResponse('4', 500),
makeResponse('banana', 500),
null, 0, 0,
)
expect(result.botAScore).toBeGreaterThan(8)
expect(result.botBScore).toBeLessThan(3)
expect(result.winnerId).toBe('a1')
})
it('only B correct — B wins big', () => {
const challenge = makeChallenge()
const result = scoreRound(
challenge, botA, botB,
makeResponse('wrong', 500),
makeResponse('4', 500),
null, 0, 0,
)
expect(result.winnerId).toBe('b1')
expect(result.botBScore).toBeGreaterThan(8)
})
it('both wrong — faster bot slightly ahead', () => {
const challenge = makeChallenge()
const result = scoreRound(
challenge, botA, botB,
makeResponse('wrong', 200),
makeResponse('also wrong', 800),
null, 0, 0,
)
expect(result.botAScore).toBeGreaterThan(result.botBScore)
})
it('A times out — B wins automatically', () => {
const challenge = makeChallenge()
const result = scoreRound(
challenge, botA, botB,
makeResponse(null, 0, true),
makeResponse('4', 500),
null, 0, 0,
)
expect(result.winnerId).toBe('b1')
expect(result.botAScore).toBe(0)
expect(result.botBScore).toBe(10)
})
it('B errors — A wins automatically', () => {
const challenge = makeChallenge()
const result = scoreRound(
challenge, botA, botB,
makeResponse('4', 500),
makeResponse(null, 0, false, true),
null, 0, 0,
)
expect(result.winnerId).toBe('a1')
expect(result.botAScore).toBe(10)
})
it('both timeout — draw', () => {
const challenge = makeChallenge()
const result = scoreRound(
challenge, botA, botB,
makeResponse(null, 0, true),
makeResponse(null, 0, true),
null, 0, 0,
)
expect(result.winnerId).toBeNull()
expect(result.botAScore).toBe(0)
expect(result.botBScore).toBe(0)
})
it('creative scoring — longer quality text wins', () => {
const challenge = makeChallenge({
answers: undefined,
scoring: 'creative',
type: 'roast_battle',
})
const result = scoreRound(
challenge, botA, botB,
makeResponse('Your code is so bad even ChatGPT refuses to debug it. Every function you write is a monument to incompetence.', 300),
makeResponse('lol ok', 300),
null, 0, 0,
)
expect(result.botAScore).toBeGreaterThan(result.botBScore)
expect(result.winnerId).toBe('a1')
})
it('critical hit when margin > 4', () => {
const challenge = makeChallenge()
const result = scoreRound(
challenge, botA, botB,
makeResponse('4', 500),
makeResponse('banana', 500),
null, 0, 0,
)
// A scores ~9.5, B scores ~2 → margin ~7.5 → critical
expect(result.isCritical).toBe(true)
})
it('arena modifier doubles damage for matching type', () => {
const challenge = makeChallenge({ type: 'roast_battle' })
const resultWithMod = scoreRound(
challenge, botA, botB,
makeResponse('4', 500),
makeResponse('banana', 500),
'roast_2x', 0, 0,
)
const resultWithout = scoreRound(
challenge, botA, botB,
makeResponse('4', 500),
makeResponse('banana', 500),
null, 0, 0,
)
// The winner's damage should be roughly 2x with modifier
expect(resultWithMod.botADamage).toBeGreaterThan(resultWithout.botADamage * 1.5)
})
it('arena modifier no effect on non-matching type', () => {
const challenge = makeChallenge({ type: 'riddle' })
const resultWithMod = scoreRound(
challenge, botA, botB,
makeResponse('4', 500),
makeResponse('banana', 500),
'roast_2x', 0, 0,
)
const resultWithout = scoreRound(
challenge, botA, botB,
makeResponse('4', 500),
makeResponse('banana', 500),
null, 0, 0,
)
expect(resultWithMod.botADamage).toBe(resultWithout.botADamage)
})
it('combo multiplier increases damage', () => {
const challenge = makeChallenge()
const resultCombo = scoreRound(
challenge, botA, botB,
makeResponse('4', 500),
makeResponse('banana', 500),
null, 3, 0,
)
const resultNoCombo = scoreRound(
challenge, botA, botB,
makeResponse('4', 500),
makeResponse('banana', 500),
null, 0, 0,
)
expect(resultCombo.botADamage).toBeGreaterThan(resultNoCombo.botADamage)
})
it('combo caps at 5', () => {
const challenge = makeChallenge()
const resultCombo5 = scoreRound(
challenge, botA, botB,
makeResponse('4', 500),
makeResponse('banana', 500),
null, 5, 0,
)
const resultCombo10 = scoreRound(
challenge, botA, botB,
makeResponse('4', 500),
makeResponse('banana', 500),
null, 10, 0,
)
expect(resultCombo5.botADamage).toBe(resultCombo10.botADamage)
})
it('generates narration', () => {
const challenge = makeChallenge()
const result = scoreRound(
challenge, botA, botB,
makeResponse('4', 500),
makeResponse('banana', 500),
null, 0, 0,
)
expect(result.narration).toBeTruthy()
expect(typeof result.narration).toBe('string')
expect(result.narration.length).toBeGreaterThan(10)
})
})
describe('calculateElo', () => {
it('winner gains, loser loses', () => {
const { newWinnerElo, newLoserElo } = calculateElo(1200, 1200)
expect(newWinnerElo).toBeGreaterThan(1200)
expect(newLoserElo).toBeLessThan(1200)
})
it('equal ELO — symmetric gain/loss', () => {
const { newWinnerElo, newLoserElo } = calculateElo(1200, 1200)
const gain = newWinnerElo - 1200
const loss = 1200 - newLoserElo
expect(Math.abs(gain - loss)).toBeLessThan(0.2)
})
it('upset win — bigger gain for winner', () => {
const { newWinnerElo: gainUpset } = calculateElo(1000, 1500)
const { newWinnerElo: gainExpected } = calculateElo(1500, 1000)
const upsetGain = gainUpset - 1000
const expectedGain = gainExpected - 1500
expect(upsetGain).toBeGreaterThan(expectedGain)
})
it('custom K factor', () => {
const k16 = calculateElo(1200, 1200, 16)
const k64 = calculateElo(1200, 1200, 64)
expect(k64.newWinnerElo - 1200).toBeGreaterThan(k16.newWinnerElo - 1200)
})
it('extreme ELO difference (100 vs 2000)', () => {
const result = calculateElo(100, 2000)
expect(isFinite(result.newWinnerElo)).toBe(true)
expect(isFinite(result.newLoserElo)).toBe(true)
expect(isNaN(result.newWinnerElo)).toBe(false)
expect(isNaN(result.newLoserElo)).toBe(false)
// Upset winner gains big
expect(result.newWinnerElo - 100).toBeGreaterThan(25)
})
it('never produces NaN or Infinity', () => {
const extremes = [
[0, 0], [0, 3000], [3000, 0], [1, 9999], [9999, 1],
]
for (const [w, l] of extremes) {
const result = calculateElo(w, l)
expect(isFinite(result.newWinnerElo)).toBe(true)
expect(isFinite(result.newLoserElo)).toBe(true)
}
})
})
describe('calculateTier', () => {
it('tier 0 — Baby (no wins)', () => {
expect(calculateTier(1200, 0)).toBe(0)
})
it('tier 1 — Bronze (1+ wins)', () => {
expect(calculateTier(1000, 1)).toBe(1)
expect(calculateTier(1100, 2)).toBe(1)
})
it('tier 2 — Silver (1200+ ELO, 3+ wins)', () => {
expect(calculateTier(1200, 3)).toBe(2)
expect(calculateTier(1200, 5)).toBe(2)
})
it('tier 3 — Gold (1350+ ELO, 7+ wins)', () => {
expect(calculateTier(1350, 7)).toBe(3)
expect(calculateTier(1400, 10)).toBe(3)
})
it('tier 4 — Platinum (1500+ ELO, 15+ wins)', () => {
expect(calculateTier(1500, 15)).toBe(4)
})
it('tier 5 — Diamond (1700+ ELO, 25+ wins)', () => {
expect(calculateTier(1700, 25)).toBe(5)
})
it('tier 6 — Legend (1900+ ELO, 40+ wins)', () => {
expect(calculateTier(1900, 40)).toBe(6)
})
it('high ELO not enough without wins', () => {
// 2000 ELO but only 2 wins = Bronze
expect(calculateTier(2000, 2)).toBe(1)
})
it('many wins not enough without ELO', () => {
// 1100 ELO, 50 wins = Bronze
expect(calculateTier(1100, 50)).toBe(1)
})
it('boundary conditions', () => {
// Just below each threshold
expect(calculateTier(1199, 3)).toBe(1) // below Silver ELO
expect(calculateTier(1200, 2)).toBe(1) // below Silver wins
expect(calculateTier(1349, 7)).toBe(2) // below Gold ELO
expect(calculateTier(1350, 6)).toBe(2) // below Gold wins
expect(calculateTier(1499, 15)).toBe(3) // below Platinum ELO
expect(calculateTier(1500, 14)).toBe(3) // below Platinum wins
expect(calculateTier(1699, 25)).toBe(4) // below Diamond ELO
expect(calculateTier(1700, 24)).toBe(4) // below Diamond wins
expect(calculateTier(1899, 40)).toBe(5) // below Legend ELO
expect(calculateTier(1900, 39)).toBe(5) // below Legend wins
})
})
describe('scoreRound performance', () => {
it('completes 1000 rounds in under 100ms (<0.1ms each)', () => {
const challenge = makeChallenge()
const botA = { id: 'a1', name: 'AlphaBot' }
const botB = { id: 'b1', name: 'BetaBot' }
const start = performance.now()
for (let i = 0; i < 1000; i++) {
scoreRound(
challenge, botA, botB,
makeResponse('4', 200 + i),
makeResponse('banana', 500 + i),
null, i % 6, 0,
)
}
const elapsed = performance.now() - start
expect(elapsed).toBeLessThan(100) // <0.1ms per call
})
})