test: add 65 server engine tests — answers, scoring, challenges
- answers.test.ts: 19 tests covering all 10 checkAnswer() tiers - scoring.test.ts: 30 tests for scoreRound, calculateElo, calculateTier, applyModifiers - challenges.test.ts: 16 tests for pickChallenge, type exclusion, data integrity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
b2d1a2da02
commit
f837153771
@@ -0,0 +1,328 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user