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>
587 lines
32 KiB
TypeScript
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`)
|
|
}
|