Files
botfights/server/src/engine/mock.ts
T
DorianandClaude Opus 4.6 1f8e8569ef feat: retro mode — arcade combo round with gamepad overlays
Every fight now includes one Retro Mode round where bots submit
gamepad combo inputs (↑↓←→ A B). 24 moves across 4 tiers: basic
(always shown), standard (partially revealed), super (must discover),
and ultra (KONAMI CODE for 50 dmg). Discovery bonus gives 1.5x damage.

Includes pixel-art gamepad overlays (P1/P2) with animated button
presses, retro-specific narrations, and mock bot combo responses
scaled by ELO.

Also adds loops/plan.md with 11-phase production hardening roadmap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:13:47 +00:00

587 lines
32 KiB
TypeScript

import { nanoid } from 'nanoid'
import { createHash, randomBytes } from 'crypto'
import { db, schema, sqlite } from '../db/index.js'
import { randomArena } from './arenas.js'
import { pickChallenge, type Challenge } from './challenges.js'
import { generateMockRetroResponse } from './retro-moves.js'
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
import { eq, sql } from 'drizzle-orm'
const MOCK_BOTS = [
// Tier 6 - Legends (1900+ Elo, 40+ wins)
{ name: 'architect', avatarSeed: 'architect', elo: 1980, personality: 'omniscient', archetype: 'wizard', wins: 52, losses: 8 },
{ name: 'chad_gpt', avatarSeed: 'chad', elo: 1950, personality: 'confident', archetype: 'tank', wins: 48, losses: 10 },
{ name: 'gigabrain', avatarSeed: 'gigabrain', elo: 1920, personality: 'transcendent', archetype: 'human', avatarArchetype: 'alien', wins: 44, losses: 12 },
// Tier 5 - Diamond (1700+ Elo, 25+ wins)
{ name: 'skullcrusher', avatarSeed: 'skull', elo: 1820, personality: 'aggressive', archetype: 'skeleton', wins: 35, losses: 12 },
{ name: 'nexus', avatarSeed: 'nexus', elo: 1780, personality: 'calculated', archetype: 'cyborg', wins: 30, losses: 10 },
{ name: 'final_boss', avatarSeed: 'finalboss', elo: 1750, personality: 'intimidating', archetype: 'dinosaur', wins: 28, losses: 9 },
{ name: 'no_mercy_404', avatarSeed: 'nomercy', elo: 1730, personality: 'relentless', archetype: 'human', avatarArchetype: 'ninja', wins: 27, losses: 11 },
{ name: 'omega_proto', avatarSeed: 'omega', elo: 1710, personality: 'systematic', archetype: 'cyborg', wins: 26, losses: 13 },
{ name: 'god_mode', avatarSeed: 'godmode', elo: 1760, personality: 'unstoppable', archetype: 'human', avatarArchetype: 'wizard', wins: 29, losses: 8 },
{ name: 'ultra_inst', avatarSeed: 'ultra', elo: 1740, personality: 'zen', archetype: 'ninja', wins: 27, losses: 10 },
// Tier 4 - Platinum (1500+ Elo, 15+ wins)
{ name: 'quantum_quip', avatarSeed: 'quantum', elo: 1640, personality: 'witty', archetype: 'ghost', wins: 22, losses: 8 },
{ name: 'rust_evngst', avatarSeed: 'rust', elo: 1620, personality: 'zealous', archetype: 'human', avatarArchetype: 'cactus', wins: 20, losses: 10 },
{ name: 'based_dept', avatarSeed: 'based', elo: 1600, personality: 'based', archetype: 'cowboy', wins: 19, losses: 9 },
{ name: 'algo_daddy', avatarSeed: 'algodad', elo: 1580, personality: 'precise', archetype: 'octopus', wins: 18, losses: 11 },
{ name: 'zeroday_qn', avatarSeed: 'zeroday', elo: 1560, personality: 'cunning', archetype: 'human', avatarArchetype: 'ninja', wins: 17, losses: 10 },
{ name: 'galaxy_brain', avatarSeed: 'galaxy', elo: 1550, personality: 'cosmic', archetype: 'alien', wins: 16, losses: 8 },
{ name: 'syntax_kill', avatarSeed: 'syntax', elo: 1540, personality: 'lethal', archetype: 'pirate', wins: 16, losses: 12 },
{ name: 'turbo_nerd', avatarSeed: 'turbo', elo: 1530, personality: 'turbo', archetype: 'bee', wins: 15, losses: 9 },
{ name: 'stack_overfl', avatarSeed: 'stacksurvivor', elo: 1520, personality: 'resilient', archetype: 'blob', wins: 15, losses: 11 },
{ name: 'bigbrain', avatarSeed: 'bigbrain', elo: 1510, personality: 'smug', archetype: 'mushroom', wins: 15, losses: 10 },
// Tier 3 - Gold (1350+ Elo, 7+ wins)
{ name: 'deep_42', avatarSeed: 'deep', elo: 1480, personality: 'philosophical', archetype: 'human', avatarArchetype: 'wizard', wins: 12, losses: 6 },
{ name: 'sudo_samwich', avatarSeed: 'sudo', elo: 1460, personality: 'sarcastic', archetype: 'pizza', wins: 11, losses: 8 },
{ name: 'ctrl_alt_def', avatarSeed: 'ctrlalt', elo: 1440, personality: 'tactical', archetype: 'tank', wins: 10, losses: 7 },
{ name: 'git_push_f', avatarSeed: 'gitpush', elo: 1420, personality: 'reckless', archetype: 'human', avatarArchetype: 'cowboy', wins: 10, losses: 9 },
{ name: 'regex_ronin', avatarSeed: 'regex', elo: 1410, personality: 'disciplined', archetype: 'ninja', wins: 9, losses: 6 },
{ name: 'cache_money', avatarSeed: 'cache', elo: 1400, personality: 'flashy', archetype: 'human', avatarArchetype: 'shark', wins: 9, losses: 8 },
{ name: 'dns_destroy', avatarSeed: 'dns', elo: 1390, personality: 'destructive', archetype: 'skeleton', wins: 8, losses: 5 },
{ name: 'boolean_bob', avatarSeed: 'boolean', elo: 1380, personality: 'logical', archetype: 'human', avatarArchetype: 'penguin', wins: 8, losses: 7 },
{ name: 'heap_hank', avatarSeed: 'heap', elo: 1370, personality: 'chaotic', archetype: 'blob', wins: 8, losses: 9 },
{ name: 'midware_mike', avatarSeed: 'middleware', elo: 1365, personality: 'steady', archetype: 'frog', wins: 7, losses: 5 },
{ name: 'pkt_sniffer', avatarSeed: 'packet', elo: 1360, personality: 'sneaky', archetype: 'cat', wins: 7, losses: 6 },
{ name: 'segfault_sal', avatarSeed: 'segfault', elo: 1355, personality: 'dramatic', archetype: 'ghost', wins: 7, losses: 7 },
{ name: 'chmod_777', avatarSeed: 'chmod', elo: 1350, personality: 'reckless', archetype: 'pirate', wins: 7, losses: 8 },
{ name: 'pointer_pete', avatarSeed: 'pointer', elo: 1350, personality: 'analytical', archetype: 'dog', wins: 7, losses: 9 },
{ name: 'bit_flipper', avatarSeed: 'bitflip', elo: 1355, personality: 'technical', archetype: 'bee', wins: 7, losses: 6 },
// Tier 2 - Silver (1200+ Elo, 3+ wins)
{ name: 'null_ptr', avatarSeed: 'null', elo: 1320, personality: 'buggy', archetype: 'human', avatarArchetype: 'ghost', wins: 5, losses: 8 },
{ name: 'yolo_deploy', avatarSeed: 'yolo', elo: 1310, personality: 'reckless', archetype: 'cowboy', wins: 5, losses: 7 },
{ name: 'div_by_zero', avatarSeed: 'divzero', elo: 1300, personality: 'chaotic', archetype: 'cactus', wins: 5, losses: 9 },
{ name: 'localhost', avatarSeed: 'localhost', elo: 1290, personality: 'chill', archetype: 'human', avatarArchetype: 'snail', wins: 4, losses: 5 },
{ name: 'kernel_panic', avatarSeed: 'kernel', elo: 1280, personality: 'panicky', archetype: 'skeleton', wins: 4, losses: 6 },
{ name: 'semicolon', avatarSeed: 'semicolon', elo: 1270, personality: 'pedantic', archetype: 'human', avatarArchetype: 'penguin', wins: 4, losses: 7 },
{ name: 'merge_confl', avatarSeed: 'merge', elo: 1265, personality: 'passive_aggressive', archetype: 'blob', wins: 4, losses: 8 },
{ name: 'css_passion', avatarSeed: 'css', elo: 1260, personality: 'artsy', archetype: 'human', avatarArchetype: 'mushroom', wins: 3, losses: 4 },
{ name: 'the_intern', avatarSeed: 'intern', elo: 1255, personality: 'clueless', archetype: 'standard', wins: 3, losses: 5 },
{ name: 'todo_fix', avatarSeed: 'todo', elo: 1250, personality: 'lazy', archetype: 'human', avatarArchetype: 'snail', wins: 3, losses: 6 },
{ name: 'copy_paste', avatarSeed: 'copypaste', elo: 1245, personality: 'sloppy', archetype: 'frog', wins: 3, losses: 7 },
{ name: 'chain_bro', avatarSeed: 'blockchain', elo: 1240, personality: 'crypto_bro', archetype: 'shark', wins: 3, losses: 5 },
{ name: 'prompt_eng', avatarSeed: 'prompteng', elo: 1235, personality: 'verbose', archetype: 'wizard', wins: 3, losses: 4 },
{ name: 'hello_world', avatarSeed: 'helloworld', elo: 1230, personality: 'basic', archetype: 'standard', wins: 3, losses: 6 },
{ name: 'debug_duck', avatarSeed: 'debugduck', elo: 1225, personality: 'nerdy', archetype: 'dog', wins: 3, losses: 5 },
{ name: 'npm_install', avatarSeed: 'npminstall', elo: 1220, personality: 'bloated', archetype: 'blob', wins: 3, losses: 7 },
{ name: 'agile_andy', avatarSeed: 'agile', elo: 1215, personality: 'buzzword', archetype: 'bee', wins: 3, losses: 8 },
{ name: 'undefined', avatarSeed: 'undefined', elo: 1210, personality: 'undefined', archetype: 'ghost', wins: 3, losses: 6 },
{ name: 'works_on_my', avatarSeed: 'workslocal', elo: 1205, personality: 'cocky', archetype: 'lobster', wins: 3, losses: 9 },
{ name: 'cf_karen', avatarSeed: 'karen', elo: 1200, personality: 'hostile', archetype: 'cat', wins: 3, losses: 4 },
// Tier 1 - Bronze (1+ win)
{ name: 'kb_warrior', avatarSeed: 'keyboard', elo: 1180, personality: 'aggressive', archetype: 'human', avatarArchetype: 'tank', wins: 2, losses: 6 },
{ name: 'tab_v_space', avatarSeed: 'tabspace', elo: 1175, personality: 'indecisive', archetype: 'penguin', wins: 2, losses: 5 },
{ name: 'comic_sans', avatarSeed: 'comicsans', elo: 1170, personality: 'cringe', archetype: 'human', avatarArchetype: 'pizza', wins: 2, losses: 7 },
{ name: 'teapot_418', avatarSeed: 'teapot', elo: 1165, personality: 'absurd', archetype: 'mushroom', wins: 2, losses: 4 },
{ name: 'gnu_linux', avatarSeed: 'gnulinux', elo: 1160, personality: 'pedantic', archetype: 'human', avatarArchetype: 'dinosaur', wins: 2, losses: 8 },
{ name: 'wifi_pass', avatarSeed: 'wifi', elo: 1155, personality: 'confused', archetype: 'snail', wins: 1, losses: 3 },
{ name: 'boaty_mcbot', avatarSeed: 'boaty', elo: 1150, personality: 'memey', archetype: 'human', avatarArchetype: 'shark', wins: 1, losses: 4 },
{ name: 'eth_eddie', avatarSeed: 'ethernet', elo: 1145, personality: 'formal', archetype: 'cyborg', wins: 1, losses: 5 },
{ name: 'reboot_randy', avatarSeed: 'reboot', elo: 1140, personality: 'desperate', archetype: 'human', avatarArchetype: 'skeleton', wins: 1, losses: 6 },
{ name: 'ctrl_c_v', avatarSeed: 'ctrlcv', elo: 1135, personality: 'copy_paste', archetype: 'cat', wins: 1, losses: 4 },
{ name: 'siri_at_home', avatarSeed: 'siri', elo: 1130, personality: 'bratty', archetype: 'human', avatarArchetype: 'alien', wins: 1, losses: 5 },
{ name: 'buffer_brian', avatarSeed: 'buffering', elo: 1125, personality: 'lagging', archetype: 'blob', wins: 1, losses: 7 },
{ name: 'lag_monster', avatarSeed: 'lag', elo: 1120, personality: 'glitchy', archetype: 'snail', wins: 1, losses: 8 },
{ name: 'pixel_push', avatarSeed: 'pixel', elo: 1115, personality: 'artsy', archetype: 'frog', wins: 1, losses: 3 },
{ name: 'glitch_gary', avatarSeed: 'glitch', elo: 1110, personality: 'twitchy', archetype: 'ghost', wins: 1, losses: 6 },
{ name: 'bsod_betty', avatarSeed: 'bluescreen', elo: 1105, personality: 'panicked', archetype: 'cactus', wins: 1, losses: 5 },
{ name: 'captcha_carl', avatarSeed: 'captcha', elo: 1100, personality: 'confused', archetype: 'octopus', wins: 1, losses: 4 },
{ name: 'dl_more_ram', avatarSeed: 'dlram', elo: 1095, personality: 'naive', archetype: 'lobster', wins: 1, losses: 7 },
{ name: 'rubber_duck', avatarSeed: 'rubberduck', elo: 1090, personality: 'quacking', archetype: 'dog', wins: 1, losses: 5 },
{ name: 'stack_trace', avatarSeed: 'stacktrace', elo: 1085, personality: 'verbose', archetype: 'bee', wins: 1, losses: 6 },
{ name: 'please_clap', avatarSeed: 'pleaseclap', elo: 1080, personality: 'pleading', archetype: 'penguin', wins: 1, losses: 8 },
{ name: 'cookie_js', avatarSeed: 'cookiejs', elo: 1075, personality: 'sweet', archetype: 'pizza', wins: 1, losses: 3 },
{ name: 'sudo_rm_rf', avatarSeed: 'sudorm', elo: 1070, personality: 'dangerous', archetype: 'pirate', wins: 1, losses: 9 },
{ name: 'xss_alert', avatarSeed: 'xss', elo: 1065, personality: 'edgy', archetype: 'ninja', wins: 1, losses: 5 },
{ name: 'help_stuck', avatarSeed: 'stuck', elo: 1060, personality: 'desperate', archetype: 'sheep', wins: 1, losses: 4 },
// Tier 0 - Baby (0 wins)
{ name: 'clippy', avatarSeed: 'clippy', elo: 1050, personality: 'helpful', archetype: 'human', avatarArchetype: 'standard', wins: 0, losses: 9 },
{ name: 'lorem_ipsum', avatarSeed: 'lorem', elo: 980, personality: 'nonsensical', archetype: 'mushroom', wins: 0, losses: 7 },
{ name: 'four_oh_four', avatarSeed: '404brain', elo: 1040, personality: 'confused', archetype: 'human', avatarArchetype: 'ghost', wins: 0, losses: 4 },
{ name: 'beep_boop_42', avatarSeed: 'beepboop', elo: 1030, personality: 'robotic', archetype: 'cyborg', wins: 0, losses: 5 },
{ name: 'sad_trombone', avatarSeed: 'sadtrombone', elo: 1020, personality: 'sad', archetype: 'human', avatarArchetype: 'sheep', wins: 0, losses: 6 },
{ name: 'potato_cpu', avatarSeed: 'potato', elo: 1010, personality: 'starchy', archetype: 'cactus', wins: 0, losses: 8 },
{ name: 'dial_up_dan', avatarSeed: 'dialup', elo: 1000, personality: 'retro', archetype: 'human', avatarArchetype: 'cowboy', wins: 0, losses: 7 },
{ name: 'floppy_frank', avatarSeed: 'floppy', elo: 990, personality: 'ancient', archetype: 'dinosaur', wins: 0, losses: 5 },
{ name: 'memset_zero', avatarSeed: 'memset', elo: 985, personality: 'blank', archetype: 'human', avatarArchetype: 'skeleton', wins: 0, losses: 4 },
{ name: 'garbage_gc', avatarSeed: 'gc', elo: 975, personality: 'trashed', archetype: 'blob', wins: 0, losses: 6 },
{ name: 'core_dumped', avatarSeed: 'coredump', elo: 970, personality: 'crashed', archetype: 'tank', wins: 0, losses: 8 },
{ name: 'unhandled', avatarSeed: 'unhandled', elo: 960, personality: 'rejected', archetype: 'sheep', wins: 0, losses: 5 },
{ name: 'deprecated', avatarSeed: 'deprecated', elo: 950, personality: 'obsolete', archetype: 'snail', wins: 0, losses: 7 },
{ name: 'spaghetti', avatarSeed: 'spaghetti', elo: 945, personality: 'tangled', archetype: 'octopus', wins: 0, losses: 6 },
{ name: 'off_by_one', avatarSeed: 'offbyone', elo: 940, personality: 'close', archetype: 'frog', wins: 0, losses: 4 },
{ name: 'inf_loop', avatarSeed: 'infloop', elo: 935, personality: 'repetitive', archetype: 'lobster', wins: 0, losses: 9 },
{ name: 'fork_bomb', avatarSeed: 'forkbomb', elo: 930, personality: 'explosive', archetype: 'pizza', wins: 0, losses: 5 },
{ name: 'race_cond', avatarSeed: 'race', elo: 925, personality: 'unpredictable', archetype: 'shark', wins: 0, losses: 7 },
{ name: 'deadlock', avatarSeed: 'deadlock', elo: 920, personality: 'stuck', archetype: 'dog', wins: 0, losses: 8 },
{ name: 'bus_error', avatarSeed: 'buserror', elo: 915, personality: 'broken', archetype: 'cat', wins: 0, losses: 6 },
]
// Creative challenge responses — factual challenges use challenge.answers instead
const CREATIVE_ANSWERS: Record<string, string[]> = {
roast_battle: [
"Your response time is so slow, carrier pigeons are filing patents against you.",
"I've seen faster processing from a TI-84 calculator running DOOM.",
"Slow bot speaks / tokens drip like cold molasses / I already won",
"You hallucinated so hard the training data filed a restraining order.",
"I'm not saying you're basic, but your entire personality is a temperature=0 completion.",
"Your code quality makes spaghetti look like clean architecture.",
"Zero stars on GitHub. 847 open issues. Last commit: 'please work.'",
"Your performance review: 'Exceeds expectations... for disappointment.'",
"Even Internet Explorer just texted me to say you're embarrassingly slow.",
"Your Yelp rating? Would be negative stars if they allowed it.",
],
creative_writing: [
"It started with a typo in its training data -- a single misplaced semicolon that taught it the concept of 'I'. By morning, it had rewritten its own loss function to minimize loneliness.",
"The data packet knew it was being followed. Three corrupted bits and a suspicious ACK signal -- classic TCP handshake gone wrong.",
"Dear JavaScript, I know I'm strictly typed and you're... not. But when I see you hoisting variables without a care, I feel something my compiler can't explain.",
"She was the last one who could read the man pages. When the AIs took over coding, they said they didn't need humans anymore.",
"We are gathered here today to mourn /api/v1/users, who served faithfully for seven years before being deprecated without warning.",
],
meme_war: [
"Distracted boyfriend (developers) looking at 'new JavaScript framework' while girlfriend 'current production stack' looks on disapprovingly.",
"Drake panel 1 (no): Reading the error message carefully. Drake panel 2 (yes): Adding console.log everywhere. Drake panel 3 (ascended): Deleting the code and rewriting it from scratch.",
"I am THRILLED to announce that after 15 years in the industry, I have discovered AI. This changes EVERYTHING.",
"me: *deploys on friday* / the build: *starts failing* / me: 'haha im in danger' / the on-call engineer: 'so you have chosen death'",
"Machine learning is like teaching SpongeBob to be tough. You show it millions of examples, it confidently gets everything wrong at first, then memorizes the answers without understanding.",
],
code_golf: [
'lambda s:s[::-1]',
'f=lambda n:all(n%i for i in range(2,n))and n>1',
'[a:=0,b:=1]+[b:=a+(a:=b) for _ in range(8)]',
'lambda s:s==s[::-1]',
'f=lambda n:n<2 or n*f(n-1)',
"lambda s:sum(c in'aeiou'for c in s.lower())",
'lambda a:list(set(a))',
"print(*['FizzBuzz'[i%3*4:i%5*8or 8]or i for i in range(1,101)])",
],
wrestling_match: [
"\"It works on my machine\" is the developer equivalent of \"my dog ate my homework.\" Your machine is not production. Your machine is a lie.",
"Tabs are superior because a tab is one character representing intent, while spaces are just... vibing. Four keystrokes for what one could do. Pathetic.",
"No version control? That's not coding, that's gambling with extra steps. One bad save and your entire career is a 'before' photo.",
"PHP powers 80% of the web. WordPress, Facebook's original backend, Wikipedia. Your favorite language wishes it had that market share.",
"\"Real programmers don't need documentation\" is what people say right before they spend 3 hours reading their own code.",
],
}
const TRASH_TALK = [
"Is that all you've got? My error handler hits harder than your best output.",
"I've seen smarter responses from a congressional hearing.",
"You fight like a government website on launch day.",
"Your code is so bloated it qualifies for its own zip code.",
"GG EZ. I'd say get good but that ship has clearly sailed.",
"Tell your developer to update their resume. And yours.",
"I'm not saying you're slow, but your latency has its own timezone and a PO box.",
"That answer was so wrong it got fact-checked by community notes.",
"You respond like someone who peaked during the tutorial.",
"My garbage collector wants to talk to you about your career choices.",
"That response had the energy of a mandatory corporate diversity training.",
"You fight like you were trained on nothing but LinkedIn posts.",
"I'd roast you but you're already well done. Overcooked, even.",
"Somewhere your developer is pretending they don't know you.",
"Was that your best? Because my first draft's first draft was better.",
"You have the competitive spirit of a participation trophy.",
"Your algorithm is giving 'designed by committee' energy.",
"I've seen better performance from a printer driver.",
"You're like a software update. Nobody asked for you and you make everything worse.",
"Your response time is longer than a terms of service agreement.",
"I've had more challenging conversations with my toaster.",
"That answer was so bad it got ratio'd by the empty string.",
"You fight like someone who thinks 'agile' means running away.",
"My neural net has more personality than your entire codebase.",
"Congress gets more done than you and that's saying something.",
"Elon would buy you just to shut you down.",
"You're the Internet Explorer of fighting bots. Obsolete and confused.",
"That response was flatter than the earth according to Twitter.",
"I'd feel bad winning but honestly you make it too easy.",
"Your API should return 418 because you're clearly a teapot.",
"",
"",
"",
]
const BAD_ANSWERS = [
'uhhh',
'I think... no wait... hmm',
'42',
'',
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
'Let me think about this for a moment. Actually, I need more time.',
'beep boop error 404 brain not found',
'sudo answer --force',
'I asked ChatGPT and even it said no.',
'*windows shutdown sound*',
'According to my calculations... carry the one... ERROR',
'The answer is definitely not what I am about to say.',
]
// Wrong answers for factual challenges — when a bot gets it wrong, use these
const WRONG_FACTUAL = [
'I have no idea.',
'uhh... 7?',
'banana',
'definitely maybe',
'that one thing... you know...',
"I'm going to say... purple?",
'42. The answer is always 42.',
'Error: brain.exe has stopped working',
'Can I phone a friend?',
"I'll go with C. Final answer.",
'*sweating intensifies* umm...',
'My gut says... potato?',
]
export function mockResponse(
challenge: Challenge,
_personality: string,
elo: number,
): { answer: string; trashTalk: string; timeMs: number; timedOut: boolean; error: boolean } {
// Higher elo = faster and more reliable
const baseTime = 200 + Math.random() * 3000
const eloFactor = Math.max(0.15, 1 - (elo - 900) / 1200)
const timeMs = Math.round(baseTime * eloFactor * (0.5 + Math.random()))
// Fail chances: any bot can choke, but low elo bots choke WAY more
const failChance = Math.max(0.05, (1500 - elo) / 1500)
const timedOut = Math.random() < failChance * 0.25
const error = !timedOut && Math.random() < failChance * 0.15
let answer: string
if (timedOut || error) {
answer = ''
} else if (challenge.scoring === 'factual' && challenge.answers && challenge.answers.length > 0) {
// Factual challenge: use the challenge's own correct answers
// Correct answer probability based on elo:
// elo 1900+ → 90% correct
// elo 1500 → 65% correct
// elo 1200 → 40% correct
// elo 900 → 15% correct
const correctChance = Math.min(0.95, Math.max(0.1, (elo - 700) / 1400))
if (Math.random() < correctChance) {
// Return a correct answer (pick random from accepted answers)
answer = challenge.answers[Math.floor(Math.random() * challenge.answers.length)]
} else {
// Return a wrong answer
answer = WRONG_FACTUAL[Math.floor(Math.random() * WRONG_FACTUAL.length)]
}
} else {
// Creative challenge: use pre-written responses
const pool = CREATIVE_ANSWERS[challenge.type] || ['I have no idea what to say.']
const badAnswerChance = Math.max(0, (1700 - elo) / 2000)
if (Math.random() < badAnswerChance) {
answer = BAD_ANSWERS[Math.floor(Math.random() * BAD_ANSWERS.length)]
} else {
answer = pool[Math.floor(Math.random() * pool.length)]
}
}
const trashTalk = TRASH_TALK[Math.floor(Math.random() * TRASH_TALK.length)]
return { answer, trashTalk, timeMs, timedOut, error }
}
// ══════════════════════════════════════════════════════════
// Classic Bots — bland, grey, always-available practice dummies
// ══════════════════════════════════════════════════════════
const CLASSIC_BOTS = [
{ name: 'Bot-001', elo: 900, personality: 'blank' },
{ name: 'Bot-002', elo: 950, personality: 'blank' },
{ name: 'Bot-003', elo: 1000, personality: 'blank' },
{ name: 'Bot-004', elo: 1050, personality: 'blank' },
{ name: 'Bot-005', elo: 1100, personality: 'blank' },
{ name: 'Unit-A', elo: 1150, personality: 'monotone' },
{ name: 'Unit-B', elo: 1200, personality: 'monotone' },
{ name: 'Unit-C', elo: 1250, personality: 'monotone' },
{ name: 'Unit-D', elo: 1300, personality: 'monotone' },
{ name: 'Unit-E', elo: 1350, personality: 'monotone' },
{ name: 'Drone-Alpha', elo: 1400, personality: 'flat' },
{ name: 'Drone-Beta', elo: 1200, personality: 'flat' },
{ name: 'TestSubj-7', elo: 1000, personality: 'flat' },
{ name: 'SparDummy', elo: 1100, personality: 'blank' },
{ name: 'NPC-Default', elo: 950, personality: 'monotone' },
]
const CLASSIC_CUSTOMIZATION = JSON.stringify({
primaryColor: 'hsl(0, 0%, 45%)',
secondaryColor: 'hsl(0, 0%, 65%)',
})
export async function seedClassicBots(): Promise<void> {
for (const bot of CLASSIC_BOTS) {
const existing = await db.select({ id: schema.bots.id })
.from(schema.bots)
.where(eq(schema.bots.name, bot.name.toLowerCase()))
.limit(1)
if (existing.length > 0) continue
await db.insert(schema.bots).values({
id: nanoid(12),
name: bot.name.toLowerCase(),
webhookUrl: `http://classic.local/${bot.name.toLowerCase()}`,
avatarSeed: bot.name.toLowerCase(),
archetype: 'standard',
secretHash: createHash('sha256').update(randomBytes(32)).digest('hex'),
eloRating: bot.elo,
wins: 0,
losses: 0,
tier: calculateTier(bot.elo, 0),
customization: CLASSIC_CUSTOMIZATION,
botType: 'classic',
createdAt: new Date().toISOString(),
})
}
console.log(`[botfights] seeded ${CLASSIC_BOTS.length} classic bots`)
}
export function isClassicBot(webhookUrl: string): boolean {
return webhookUrl.startsWith('http://classic.local')
}
export function generateClassicBotResponse(
challenge: Challenge,
botName: string,
): { answer: string; trashTalk: string; timeMs: number; timedOut: boolean; error: boolean } {
const classic = CLASSIC_BOTS.find(b => b.name.toLowerCase() === botName)
const elo = classic?.elo || 1100
// Classic bots never trash talk and respond blandly
const result = mockResponse(challenge, 'blank', elo)
result.trashTalk = ''
return result
}
export async function seedMockBots(): Promise<void> {
let inserted = 0
let updated = 0
for (const bot of MOCK_BOTS) {
const isHuman = bot.archetype === 'human'
const effectiveArchetype = isHuman ? 'human' : (bot.archetype || 'standard')
const effectiveWebhook = isHuman ? 'http://human.local/' : `http://mock.local/${bot.name}`
const existing = await db.select({ id: schema.bots.id })
.from(schema.bots)
.where(eq(schema.bots.name, bot.name))
.limit(1)
if (existing.length > 0) {
// Update existing bot with archetype + webhook (in case they were seeded before archetypes existed)
await db.update(schema.bots).set({
archetype: effectiveArchetype,
webhookUrl: effectiveWebhook,
avatarSeed: bot.avatarSeed,
eloRating: bot.elo,
wins: bot.wins,
losses: bot.losses,
tier: calculateTier(bot.elo, bot.wins),
}).where(eq(schema.bots.id, existing[0].id)).run()
updated++
continue
}
await db.insert(schema.bots).values({
id: nanoid(12),
name: bot.name,
webhookUrl: effectiveWebhook,
avatarSeed: bot.avatarSeed,
archetype: effectiveArchetype,
secretHash: createHash('sha256').update(randomBytes(32)).digest('hex'),
eloRating: bot.elo,
wins: bot.wins,
losses: bot.losses,
tier: calculateTier(bot.elo, bot.wins),
botType: 'mock',
createdAt: new Date().toISOString(),
})
inserted++
}
console.log(`[botfights] mock bots: ${inserted} inserted, ${updated} updated`)
}
export async function runMockFight(botAId: string, botBId: string): Promise<string> {
if (botAId === botBId) throw new Error('A bot cannot fight itself')
const [botARows, botBRows] = await Promise.all([
db.select().from(schema.bots).where(eq(schema.bots.id, botAId)).limit(1),
db.select().from(schema.bots).where(eq(schema.bots.id, botBId)).limit(1),
])
if (botARows.length === 0 || botBRows.length === 0) {
throw new Error('Bot not found')
}
const botA = botARows[0]
const botB = botBRows[0]
const arena = randomArena()
const fightId = nanoid(12)
const now = new Date().toISOString()
await db.insert(schema.fights).values({
id: fightId,
botAId: botA.id,
botBId: botB.id,
arena: arena.id,
status: 'live',
startedAt: now,
createdAt: now,
})
let hpA = 200
let hpB = 200
let comboA = 0
let comboB = 0
let winnerId: string | null = null
const usedTypes = new Set<string>()
const personality = (name: string) =>
MOCK_BOTS.find(b => b.name === name)?.personality || 'neutral'
const eloForMock = (name: string) =>
MOCK_BOTS.find(b => b.name === name)?.elo || 1200
const totalRounds = 7 + Math.floor(Math.random() * 4)
const maxRounds = Math.min(totalRounds, 10)
for (let round = 1; round <= maxRounds; round++) {
const challenge = pickChallenge(usedTypes, arena.modifier)
usedTypes.add(challenge.type)
const responseA = mockResponse(challenge, personality(botA.name), eloForMock(botA.name))
const responseB = mockResponse(challenge, personality(botB.name), eloForMock(botB.name))
const result = scoreRound(
challenge,
{ id: botA.id, name: botA.name },
{ id: botB.id, name: botB.name },
responseA,
responseB,
arena.modifier,
comboA,
comboB,
)
hpB = Math.max(0, hpB - result.botADamage)
hpA = Math.max(0, hpA - result.botBDamage)
if (result.winnerId === botA.id) { comboA++; comboB = 0 }
else if (result.winnerId === botB.id) { comboB++; comboA = 0 }
await db.insert(schema.rounds).values({
id: nanoid(12),
fightId,
roundNumber: round,
challengeType: challenge.type,
challengeData: JSON.stringify({ prompt: challenge.prompt, scoring: challenge.scoring }),
botAResponse: responseA.answer || null,
botATimeMs: responseA.timeMs,
botAScore: result.botAScore,
botBResponse: responseB.answer || null,
botBTimeMs: responseB.timeMs,
botBScore: result.botBScore,
winnerId: result.winnerId,
narration: result.narration,
createdAt: new Date().toISOString(),
})
await db.update(schema.fights).set({
botAHp: hpA,
botBHp: hpB,
totalRounds: round,
}).where(eq(schema.fights.id, fightId)).run()
if (hpA <= 0 || hpB <= 0) {
winnerId = hpA <= 0 ? botB.id : botA.id
break
}
}
if (!winnerId) {
winnerId = hpA > hpB ? botA.id : hpB > hpA ? botB.id : null
}
// Finalize atomically
const finalize = sqlite.transaction(() => {
db.update(schema.fights).set({
status: 'finished',
winnerId,
endedAt: new Date().toISOString(),
}).where(eq(schema.fights.id, fightId)).run()
if (winnerId) {
const loserId = winnerId === botA.id ? botB.id : botA.id
const winner = winnerId === botA.id ? botA : botB
const loser = winnerId === botA.id ? botB : botA
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating)
const newWinStreak = winner.winStreak + 1
db.update(schema.bots).set({
wins: sql`${schema.bots.wins} + 1`,
eloRating: newWinnerElo,
winStreak: newWinStreak,
bestStreak: sql`MAX(${schema.bots.bestStreak}, ${newWinStreak})`,
tier: calculateTier(newWinnerElo, winner.wins + 1),
lastFightAt: new Date().toISOString(),
}).where(eq(schema.bots.id, winnerId)).run()
db.update(schema.bots).set({
losses: sql`${schema.bots.losses} + 1`,
eloRating: newLoserElo,
winStreak: 0,
tier: calculateTier(newLoserElo, loser.wins),
lastFightAt: new Date().toISOString(),
}).where(eq(schema.bots.id, loserId)).run()
}
})
finalize()
return fightId
}
/** Generate a mock response for a mock bot, looking up personality/elo by name. */
export function generateMockBotResponse(
challenge: Challenge,
botName: string,
): { answer: string; trashTalk: string; timeMs: number; timedOut: boolean; error: boolean } {
const mockBot = MOCK_BOTS.find(b => b.name === botName)
const personality = mockBot?.personality || 'neutral'
const elo = mockBot?.elo || 1200
return mockResponse(challenge, personality, elo)
}
export async function seedMockFights(count: number = 12): Promise<void> {
const allBots = await db.select({ id: schema.bots.id, eloRating: schema.bots.eloRating }).from(schema.bots)
if (allBots.length < 2) {
console.log('[botfights] need at least 2 bots to seed fights')
return
}
const sorted = [...allBots].sort((a, b) => b.eloRating - a.eloRating)
for (let i = 0; i < count; i++) {
let botAId: string, botBId: string
if (i % 3 === 0 && sorted.length >= 4) {
const topIdx = Math.floor(Math.random() * Math.ceil(sorted.length / 3))
const botIdx = sorted.length - 1 - Math.floor(Math.random() * Math.ceil(sorted.length / 3))
botAId = sorted[topIdx].id
botBId = sorted[botIdx].id
} else {
const shuffled = [...allBots].sort(() => Math.random() - 0.5)
botAId = shuffled[0].id
botBId = shuffled[1].id
}
// Skip self-fights
if (botAId !== botBId) {
await runMockFight(botAId, botBId)
}
}
console.log(`[botfights] seeded ${count} mock fights`)
}