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

162 lines
5.4 KiB
TypeScript
Raw Normal View History

import { TEMPLATES, type ChallengeTemplate, type PromptEntry, type PromptTheme, type PromptDifficulty } from './challenge-data.js'
2026-03-08 21:14:26 +00:00
import { EXTRA_PROMPTS } from './challenges-extra.js'
import { BITCOIN_PROMPTS } from './challenges-bitcoin.js'
import { pick } from '../lib/utils.js'
2026-03-08 21:14:26 +00:00
export interface Challenge {
type: string
label: string
prompt: string
answers?: string[]
choices?: string[]
timeout_ms: number
scoring: 'factual' | 'creative'
baseDamage: number
displayPrompt?: string
}
export type { ChallengeTemplate, PromptEntry, PromptTheme, PromptDifficulty }
2026-03-08 21:14:26 +00:00
// Merge extra prompts into templates
for (const t of TEMPLATES) {
const extras = EXTRA_PROMPTS[t.type]
if (extras) t.prompts.push(...extras)
const btc = BITCOIN_PROMPTS[t.type]
if (btc) t.prompts.push(...btc)
2026-03-08 21:14:26 +00:00
}
// ═══════════════════════════════════════════════
// Challenge selection + generation
// ═══════════════════════════════════════════════
function shuffleArray<T>(arr: T[]): T[] {
const s = [...arr]
for (let i = s.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[s[i], s[j]] = [s[j], s[i]]
}
return s
}
// Target theme distribution: 30% bitcoin, 20% conspiracy, 20% pc_culture, 30% bot_coding
const THEME_WEIGHTS: Record<PromptTheme, number> = {
bitcoin: 0.3,
conspiracy: 0.2,
pc_culture: 0.2,
bot_coding: 0.3,
}
function pickTheme(): PromptTheme | undefined {
const roll = Math.random()
let cumulative = 0
for (const [theme, weight] of Object.entries(THEME_WEIGHTS)) {
cumulative += weight
if (roll < cumulative) return theme as PromptTheme
}
return undefined
}
/** Map round number to target difficulty: Round 1-2 easy, 3-4 medium, 5+ hard */
export function roundToDifficulty(round: number): PromptDifficulty {
if (round <= 2) return 'easy'
if (round <= 4) return 'medium'
return 'hard'
}
export function pickChallenge(usedTypes: Set<string>, _arenaModifier: string | null, themeBias?: PromptTheme, roundNumber?: number): Challenge {
let available = TEMPLATES.filter(t => !usedTypes.has(t.type))
if (available.length === 0) available = TEMPLATES
// Bias toward factual challenges (70% factual, 30% creative)
const factual = available.filter(t => t.scoring === 'factual')
const creative = available.filter(t => t.scoring === 'creative')
let pool: ChallengeTemplate[]
if (factual.length > 0 && creative.length > 0) {
pool = Math.random() < 0.7 ? factual : creative
} else {
pool = available
}
const template = pick(pool)
const targetTheme = themeBias || pickTheme()
const targetDifficulty = roundNumber ? roundToDifficulty(roundNumber) : undefined
return templateToChallenge(template, targetTheme, targetDifficulty)
}
/** Ranked challenge: no multiple choice, only harder creative/open-ended prompts */
export function pickRankedChallenge(usedTypes: Set<string>): Challenge {
let available = TEMPLATES.filter(t => !usedTypes.has(t.type))
if (available.length === 0) available = TEMPLATES
// Filter to creative-only (open-ended, harder) — no factual multiple choice
const creative = available.filter(t => t.scoring === 'creative')
const pool = creative.length > 0 ? creative : available
const template = pick(pool)
// Pick a prompt that has no choices array (open-ended)
const openPrompts = template.prompts.filter(p => !p.choices)
const prompts = openPrompts.length > 0 ? openPrompts : template.prompts
const entry = pick(prompts)
return {
type: template.type,
label: template.label,
prompt: entry.prompt,
answers: entry.answers,
choices: undefined, // Never multiple choice in ranked
timeout_ms: template.timeout_ms,
scoring: template.scoring,
baseDamage: template.baseDamage,
}
}
function templateToChallenge(template: ChallengeTemplate, targetTheme?: PromptTheme, targetDifficulty?: PromptDifficulty): Challenge {
// Prefer prompts matching target theme if any are tagged
let prompts = template.prompts
if (targetTheme) {
const themed = prompts.filter(p => p.theme === targetTheme)
if (themed.length > 0) prompts = themed
}
// Prefer prompts matching target difficulty if any are tagged
if (targetDifficulty) {
const byDifficulty = prompts.filter(p => p.difficulty === targetDifficulty)
if (byDifficulty.length > 0) prompts = byDifficulty
}
const entry = pick(prompts)
// Determine choices
let choices: string[] | undefined
if (entry.choices) {
choices = shuffleArray(entry.choices)
} else if (entry.answers?.length === 1 && ['true', 'false'].includes(entry.answers[0].toLowerCase())) {
// Auto-generate True/False choices for boolean questions
choices = shuffleArray(['True', 'False'])
}
return {
type: template.type,
label: template.label,
prompt: entry.prompt,
answers: entry.answers,
choices,
timeout_ms: template.timeout_ms,
scoring: template.scoring,
baseDamage: template.baseDamage,
}
}
export function getAllChallengeTypes(): string[] {
return TEMPLATES.map(t => t.type)
}
export function getAnswerPool(type: string): string[] {
const template = TEMPLATES.find(t => t.type === type)
if (!template) return []
return template.prompts
.flatMap(p => p.answers || [])
.filter((v, i, arr) => arr.indexOf(v) === i)
}