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:
Dorian
2026-03-07 23:55:36 +00:00
co-authored by Claude Opus 4.6
parent b2d1a2da02
commit f837153771
3 changed files with 597 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest'
import { checkAnswer } from './answers.js'
describe('checkAnswer', () => {
// --- Tier 1: exact match after normalization ---
it('exact match (case insensitive)', () => {
expect(checkAnswer('Paris', ['paris'])).toBe(1.0)
expect(checkAnswer('PARIS', ['paris'])).toBe(1.0)
expect(checkAnswer('paris', ['Paris'])).toBe(1.0)
})
it('exact match strips punctuation', () => {
expect(checkAnswer('Paris!', ['paris'])).toBe(1.0)
expect(checkAnswer(' Paris ', ['paris'])).toBe(1.0)
})
it('strips leading articles', () => {
expect(checkAnswer('the Eiffel Tower', ['Eiffel Tower'])).toBe(1.0)
expect(checkAnswer('an apple', ['apple'])).toBe(1.0)
expect(checkAnswer('a dog', ['dog'])).toBe(1.0)
})
// --- Tier 2: contraction expansion ---
it('contraction expansion', () => {
expect(checkAnswer("can't", ['cannot'])).toBe(1.0)
expect(checkAnswer("don't", ['do not'])).toBe(1.0)
expect(checkAnswer("it's", ['it is'])).toBe(1.0)
expect(checkAnswer("they're", ['they are'])).toBe(1.0)
})
// --- Tier 3: stemmed match ---
it('stemmed match (plural/singular)', () => {
expect(checkAnswer('dogs', ['dog'])).toBe(1.0)
expect(checkAnswer('buses', ['bus'])).toBe(1.0)
expect(checkAnswer('foxes', ['fox'])).toBe(1.0)
})
// --- Tier 4: numeric equivalence ---
it('numeric equivalence', () => {
expect(checkAnswer('42', ['42'])).toBe(1.0)
expect(checkAnswer('42.0', ['42'])).toBe(1.0)
expect(checkAnswer('forty', ['40'])).toBe(1.0)
expect(checkAnswer('twenty', ['20'])).toBe(1.0)
expect(checkAnswer('one hundred', ['100'])).toBe(1.0)
expect(checkAnswer('three hundred', ['300'])).toBe(1.0)
})
it('compound number words', () => {
expect(checkAnswer('twenty one', ['21'])).toBe(1.0)
expect(checkAnswer('three hundred', ['300'])).toBe(1.0)
})
// --- Tier 5: containment ---
it('response contains accepted answer', () => {
expect(checkAnswer('The answer is Paris of course', ['paris'])).toBe(1.0)
expect(checkAnswer('I believe the answer is 42', ['42'])).toBe(1.0)
})
it('stemmed containment', () => {
const score = checkAnswer('There are many dogs in the park', ['dog'])
expect(score).toBeGreaterThanOrEqual(0.95)
})
// --- Tier 6: accepted contains response ---
it('accepted answer contains the response (short answer)', () => {
const score = checkAnswer('Paris', ['The city of Paris'])
expect(score).toBeGreaterThanOrEqual(0.75)
})
// --- Tier 7: number in longer response ---
it('number embedded in response', () => {
expect(checkAnswer('I think it is about 42 meters', ['42'])).toBe(1.0)
})
it('number word in response matches', () => {
const score = checkAnswer('I think forty is the answer', ['40'])
expect(score).toBeGreaterThanOrEqual(0.9)
})
// --- Tier 8: word-level containment ---
it('all words of answer appear in response', () => {
const score = checkAnswer('The Great Wall is in China and is very long', ['Great Wall'])
expect(score).toBeGreaterThanOrEqual(0.9)
})
// --- Tier 10: true/false ---
it('true/false match', () => {
expect(checkAnswer('True', ['true'])).toBe(1.0)
expect(checkAnswer('true, definitely', ['true'])).toBeGreaterThanOrEqual(0.9)
expect(checkAnswer('yes', ['true'])).toBeGreaterThanOrEqual(0.9)
expect(checkAnswer('correct', ['true'])).toBeGreaterThanOrEqual(0.9)
expect(checkAnswer('no', ['false'])).toBeGreaterThanOrEqual(0.9)
expect(checkAnswer('wrong', ['false'])).toBeGreaterThanOrEqual(0.9)
expect(checkAnswer('incorrect', ['false'])).toBeGreaterThanOrEqual(0.9)
})
// --- Edge cases ---
it('null response returns 0', () => {
expect(checkAnswer(null, ['Paris'])).toBe(0)
})
it('empty response returns 0', () => {
expect(checkAnswer('', ['Paris'])).toBe(0)
expect(checkAnswer(' ', ['Paris'])).toBe(0)
})
it('completely wrong answer returns 0', () => {
expect(checkAnswer('banana', ['Paris'])).toBe(0)
})
it('multiple accepted answers', () => {
expect(checkAnswer('NYC', ['New York City', 'NYC', 'New York'])).toBe(1.0)
// "New York" contained in "New York City" (tier 6: accepted contains response) scores 0.8
expect(checkAnswer('New York', ['New York City', 'NYC', 'New York'])).toBeGreaterThanOrEqual(0.8)
})
it('very short accepted answer needs >= 2 chars for containment', () => {
// Single char answers shouldn't trigger containment on random text
const score = checkAnswer('absolutely nothing relevant', ['a'])
// 'a' length is 1, so containment shouldn't trigger (requires >= 2)
expect(score).toBeLessThanOrEqual(0.8)
})
})
+146
View File
@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest'
import { pickChallenge, getAllChallengeTypes, getAnswerPool } from './challenges.js'
describe('pickChallenge', () => {
it('returns a valid challenge', () => {
const c = pickChallenge(new Set(), null)
expect(c).toBeTruthy()
expect(c.type).toBeTruthy()
expect(c.label).toBeTruthy()
expect(c.prompt).toBeTruthy()
expect(c.timeout_ms).toBeGreaterThan(0)
expect(c.baseDamage).toBeGreaterThan(0)
expect(['factual', 'creative']).toContain(c.scoring)
})
it('avoids used types when possible', () => {
const types = getAllChallengeTypes()
// Use all types except one
const used = new Set(types.slice(0, -1))
const remaining = types[types.length - 1]
// With most types used, should pick from the remaining
// Run multiple times to account for randomness
let gotRemaining = false
for (let i = 0; i < 50; i++) {
const c = pickChallenge(used, null)
if (c.type === remaining) gotRemaining = true
}
expect(gotRemaining).toBe(true)
})
it('falls back to all types when all used', () => {
const allUsed = new Set(getAllChallengeTypes())
const c = pickChallenge(allUsed, null)
// Should still return something
expect(c).toBeTruthy()
expect(c.prompt).toBeTruthy()
})
it('factual challenges have answers', () => {
for (let i = 0; i < 100; i++) {
const c = pickChallenge(new Set(), null)
if (c.scoring === 'factual') {
expect(c.answers).toBeTruthy()
expect(c.answers!.length).toBeGreaterThan(0)
}
}
})
it('creative challenges have no answers', () => {
for (let i = 0; i < 100; i++) {
const c = pickChallenge(new Set(), null)
if (c.scoring === 'creative') {
expect(!c.answers || c.answers.length === 0).toBe(true)
}
}
})
it('factual challenges with choices have shuffled choices', () => {
const orders = new Set<string>()
for (let i = 0; i < 50; i++) {
const c = pickChallenge(new Set(), null)
if (c.scoring === 'factual' && c.choices) {
orders.add(c.choices.join(','))
}
}
// With 50 tries, choices should appear in more than one order
expect(orders.size).toBeGreaterThan(1)
})
})
describe('getAllChallengeTypes', () => {
it('returns 16 types', () => {
const types = getAllChallengeTypes()
expect(types.length).toBe(16)
})
it('types are unique', () => {
const types = getAllChallengeTypes()
expect(new Set(types).size).toBe(types.length)
})
it('includes known types', () => {
const types = getAllChallengeTypes()
expect(types).toContain('speed_blitz')
expect(types).toContain('roast_battle')
expect(types).toContain('riddle')
expect(types).toContain('math_blitz')
})
})
describe('getAnswerPool', () => {
it('returns answers for factual types', () => {
const pool = getAnswerPool('speed_blitz')
expect(pool.length).toBeGreaterThan(0)
})
it('returns empty array for creative types', () => {
const pool = getAnswerPool('roast_battle')
expect(pool.length).toBe(0)
})
it('returns empty for unknown type', () => {
const pool = getAnswerPool('nonexistent_type')
expect(pool.length).toBe(0)
})
it('answers are unique within pool', () => {
const types = getAllChallengeTypes()
for (const type of types) {
const pool = getAnswerPool(type)
const unique = new Set(pool)
expect(unique.size).toBe(pool.length)
}
})
})
describe('prompt data integrity', () => {
it('all 800 prompts are accessible via pickChallenge', () => {
// Run enough picks to verify the system works
const seenPrompts = new Set<string>()
for (let i = 0; i < 500; i++) {
const c = pickChallenge(new Set(), null)
seenPrompts.add(c.prompt)
}
// Should have seen a good variety
expect(seenPrompts.size).toBeGreaterThan(50)
})
it('no empty prompts', () => {
for (let i = 0; i < 200; i++) {
const c = pickChallenge(new Set(), null)
expect(c.prompt.trim().length).toBeGreaterThan(5)
}
})
it('factual prompts have non-empty answers', () => {
for (let i = 0; i < 200; i++) {
const c = pickChallenge(new Set(), null)
if (c.scoring === 'factual' && c.answers) {
for (const a of c.answers) {
expect(a.trim().length).toBeGreaterThan(0)
}
}
}
})
})
+328
View File
@@ -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
})
})