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

278 lines
8.6 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { pickChallenge, pickRankedChallenge, getAllChallengeTypes, getAnswerPool, roundToDifficulty } from './challenges.js'
import { TEMPLATES } from './challenge-data.js'
import { EXTRA_PROMPTS } from './challenges-extra.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)
})
it('distribution: ~70% factual, ~30% creative over many picks', () => {
let factual = 0
let creative = 0
const runs = 1000
for (let i = 0; i < runs; i++) {
const c = pickChallenge(new Set(), null)
if (c.scoring === 'factual') factual++
else creative++
}
const factualPct = factual / runs
// Allow ±10% tolerance due to randomness
expect(factualPct).toBeGreaterThan(0.55)
expect(factualPct).toBeLessThan(0.85)
})
it('True/False auto-generation for boolean answers', () => {
// Pick many challenges, find ones with answers = ['true'] or ['false']
let foundTFWithChoices = false
for (let i = 0; i < 500; i++) {
const c = pickChallenge(new Set(), null)
if (c.answers?.length === 1 && ['true', 'false'].includes(c.answers[0].toLowerCase())) {
expect(c.choices).toBeTruthy()
expect(c.choices!.length).toBe(2)
expect(c.choices!.sort()).toEqual(['False', 'True'])
foundTFWithChoices = true
}
}
expect(foundTFWithChoices).toBe(true)
})
})
describe('pickRankedChallenge', () => {
it('never returns choices', () => {
for (let i = 0; i < 100; i++) {
const c = pickRankedChallenge(new Set())
expect(c.choices).toBeUndefined()
}
})
it('returns valid challenge structure', () => {
const c = pickRankedChallenge(new Set())
expect(c.type).toBeTruthy()
expect(c.prompt).toBeTruthy()
expect(c.timeout_ms).toBeGreaterThan(0)
expect(c.baseDamage).toBeGreaterThan(0)
})
it('avoids used types', () => {
const types = getAllChallengeTypes()
const used = new Set(types.slice(0, -1))
let gotRemaining = false
for (let i = 0; i < 50; i++) {
const c = pickRankedChallenge(used)
if (c.type === types[types.length - 1]) gotRemaining = true
}
expect(gotRemaining).toBe(true)
})
})
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('roundToDifficulty', () => {
it('rounds 1-2 are easy', () => {
expect(roundToDifficulty(1)).toBe('easy')
expect(roundToDifficulty(2)).toBe('easy')
})
it('rounds 3-4 are medium', () => {
expect(roundToDifficulty(3)).toBe('medium')
expect(roundToDifficulty(4)).toBe('medium')
})
it('rounds 5+ are hard', () => {
expect(roundToDifficulty(5)).toBe('hard')
expect(roundToDifficulty(6)).toBe('hard')
expect(roundToDifficulty(10)).toBe('hard')
})
})
describe('pickChallenge with roundNumber', () => {
it('accepts roundNumber parameter without error', () => {
const c = pickChallenge(new Set(), null, undefined, 1)
expect(c).toBeTruthy()
expect(c.prompt).toBeTruthy()
})
it('works for all round numbers 1-10', () => {
for (let r = 1; r <= 10; r++) {
const c = pickChallenge(new Set(), null, undefined, r)
expect(c.type).toBeTruthy()
expect(c.prompt).toBeTruthy()
}
})
it('still works without roundNumber (backward compatible)', () => {
const c = pickChallenge(new Set(), null)
expect(c).toBeTruthy()
})
})
describe('theme selection', () => {
it('theme bias parameter increases themed prompt selection', () => {
let bitcoinWithBias = 0
let bitcoinWithout = 0
const runs = 500
for (let i = 0; i < runs; i++) {
const biased = pickChallenge(new Set(), null, 'bitcoin')
const unbiased = pickChallenge(new Set(), null)
// Check if prompt text contains bitcoin-related content
// Theme-biased should prefer bitcoin prompts when available
if (biased.prompt.toLowerCase().includes('bitcoin') || biased.prompt.toLowerCase().includes('blockchain')) bitcoinWithBias++
if (unbiased.prompt.toLowerCase().includes('bitcoin') || unbiased.prompt.toLowerCase().includes('blockchain')) bitcoinWithout++
}
// With bias, should get more bitcoin prompts
expect(bitcoinWithBias).toBeGreaterThanOrEqual(bitcoinWithout)
})
it('all prompts have theme tags after tagging', () => {
let tagged = 0
let total = 0
for (const t of TEMPLATES) {
const extras = EXTRA_PROMPTS[t.type] || []
for (const p of [...t.prompts, ...extras]) {
total++
if (p.theme) tagged++
}
}
// All prompts should have themes
expect(tagged).toBe(total)
})
})
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)
}
}
}
})
})