feat: v5 — boxing poster fight cards, 12-char names, diverse mock bots
- Fight Card page: dramatic poster background with cross-hatch, spotlights, vignettes, corner brackets, scan lines; 3D VS orb with punch animation; selectable undercard with main event always pinned at top - PosterSprite: high-quality 480px poster frame with 6-pass renderer (aura, glow, bevel, specular, particles); PixelGlove component - 12-char bot name limit across all forms and server validation - Mock bots: all 100 now have diverse archetypes (25 types), 25% human fighters; seedMockBots updates existing bots on restart - Leaderboard: inline SpritePreview next to each bot name - Nostr auth: persistent login, nsec copy button - Wallet: NWC + Lightning Address, ranked fight flow - Server: payments, ranked queue, customization endpoint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ccf4196647
commit
f6eb7d2845
@@ -0,0 +1,31 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
|
||||
|
||||
const ENCRYPTION_KEY_HEX = process.env.BOTFIGHTS_WALLET_ENCRYPTION_KEY
|
||||
let encryptionKey: Buffer
|
||||
|
||||
if (ENCRYPTION_KEY_HEX) {
|
||||
encryptionKey = Buffer.from(ENCRYPTION_KEY_HEX, 'hex')
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('CRITICAL: BOTFIGHTS_WALLET_ENCRYPTION_KEY not set. Cannot start in production.')
|
||||
} else {
|
||||
encryptionKey = randomBytes(32)
|
||||
console.warn('[crypto] WARNING: No BOTFIGHTS_WALLET_ENCRYPTION_KEY set. Generated random key — wallet data will be lost on restart.')
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(16)
|
||||
const cipher = createCipheriv('aes-256-gcm', encryptionKey, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const authTag = cipher.getAuthTag()
|
||||
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted.toString('hex')
|
||||
}
|
||||
|
||||
export function decrypt(ciphertext: string): string {
|
||||
const [ivHex, authTagHex, encryptedHex] = ciphertext.split(':')
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const authTag = Buffer.from(authTagHex, 'hex')
|
||||
const encrypted = Buffer.from(encryptedHex, 'hex')
|
||||
const decipher = createDecipheriv('aes-256-gcm', encryptionKey, iv)
|
||||
decipher.setAuthTag(authTag)
|
||||
return decipher.update(encrypted) + decipher.final('utf8')
|
||||
}
|
||||
+199
-105
@@ -8,112 +8,112 @@ import { eq, sql } from 'drizzle-orm'
|
||||
|
||||
const MOCK_BOTS = [
|
||||
// Tier 6 - Legends (1900+ Elo, 40+ wins)
|
||||
{ name: 'the_architect', avatarSeed: 'architect', elo: 1980, personality: 'omniscient', wins: 52, losses: 8 },
|
||||
{ name: 'chad_gpt', avatarSeed: 'chad', elo: 1950, personality: 'confident', wins: 48, losses: 10 },
|
||||
{ name: 'gigabrain_supreme', avatarSeed: 'gigabrain', elo: 1920, personality: 'transcendent', wins: 44, losses: 12 },
|
||||
{ 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: 'skull_crusher_9000', avatarSeed: 'skull', elo: 1820, personality: 'aggressive', wins: 35, losses: 12 },
|
||||
{ name: 'neural_nexus', avatarSeed: 'nexus', elo: 1780, personality: 'calculated', wins: 30, losses: 10 },
|
||||
{ name: 'final_boss_energy', avatarSeed: 'finalboss', elo: 1750, personality: 'intimidating', wins: 28, losses: 9 },
|
||||
{ name: 'no_mercy_404', avatarSeed: 'nomercy', elo: 1730, personality: 'relentless', wins: 27, losses: 11 },
|
||||
{ name: 'omega_protocol', avatarSeed: 'omega', elo: 1710, personality: 'systematic', wins: 26, losses: 13 },
|
||||
{ name: 'god_mode_enabled', avatarSeed: 'godmode', elo: 1760, personality: 'unstoppable', wins: 29, losses: 8 },
|
||||
{ name: 'ultra_instinct_v2', avatarSeed: 'ultra', elo: 1740, personality: 'zen', wins: 27, losses: 10 },
|
||||
{ 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', wins: 22, losses: 8 },
|
||||
{ name: 'rust_evangelist', avatarSeed: 'rust', elo: 1620, personality: 'zealous', wins: 20, losses: 10 },
|
||||
{ name: 'based_department', avatarSeed: 'based', elo: 1600, personality: 'based', wins: 19, losses: 9 },
|
||||
{ name: 'algorithm_daddy', avatarSeed: 'algodad', elo: 1580, personality: 'precise', wins: 18, losses: 11 },
|
||||
{ name: 'zero_day_queen', avatarSeed: 'zeroday', elo: 1560, personality: 'cunning', wins: 17, losses: 10 },
|
||||
{ name: 'galaxy_brain', avatarSeed: 'galaxy', elo: 1550, personality: 'cosmic', wins: 16, losses: 8 },
|
||||
{ name: 'syntax_assassin', avatarSeed: 'syntax', elo: 1540, personality: 'lethal', wins: 16, losses: 12 },
|
||||
{ name: 'turbo_nerd', avatarSeed: 'turbo', elo: 1530, personality: 'turbo', wins: 15, losses: 9 },
|
||||
{ name: 'stack_overflow_survivor', avatarSeed: 'stacksurvivor', elo: 1520, personality: 'resilient', wins: 15, losses: 11 },
|
||||
{ name: 'big_brain_time', avatarSeed: 'bigbrain', elo: 1510, personality: 'smug', wins: 15, losses: 10 },
|
||||
{ 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_thought_42', avatarSeed: 'deep', elo: 1480, personality: 'philosophical', wins: 12, losses: 6 },
|
||||
{ name: 'sudo_make_sandwich', avatarSeed: 'sudo', elo: 1460, personality: 'sarcastic', wins: 11, losses: 8 },
|
||||
{ name: 'ctrl_alt_defeat', avatarSeed: 'ctrlalt', elo: 1440, personality: 'tactical', wins: 10, losses: 7 },
|
||||
{ name: 'git_push_force', avatarSeed: 'gitpush', elo: 1420, personality: 'reckless', wins: 10, losses: 9 },
|
||||
{ name: 'regex_ronin', avatarSeed: 'regex', elo: 1410, personality: 'disciplined', wins: 9, losses: 6 },
|
||||
{ name: 'cache_money', avatarSeed: 'cache', elo: 1400, personality: 'flashy', wins: 9, losses: 8 },
|
||||
{ name: 'dns_destroyer', avatarSeed: 'dns', elo: 1390, personality: 'destructive', wins: 8, losses: 5 },
|
||||
{ name: 'boolean_bob', avatarSeed: 'boolean', elo: 1380, personality: 'logical', wins: 8, losses: 7 },
|
||||
{ name: 'heap_overflow_hank', avatarSeed: 'heap', elo: 1370, personality: 'chaotic', wins: 8, losses: 9 },
|
||||
{ name: 'middleware_mike', avatarSeed: 'middleware', elo: 1365, personality: 'steady', wins: 7, losses: 5 },
|
||||
{ name: 'packet_sniffer', avatarSeed: 'packet', elo: 1360, personality: 'sneaky', wins: 7, losses: 6 },
|
||||
{ name: 'segfault_sally', avatarSeed: 'segfault', elo: 1355, personality: 'dramatic', wins: 7, losses: 7 },
|
||||
{ name: 'chmod_777', avatarSeed: 'chmod', elo: 1350, personality: 'reckless', wins: 7, losses: 8 },
|
||||
{ name: 'pointer_pete', avatarSeed: 'pointer', elo: 1350, personality: 'analytical', wins: 7, losses: 9 },
|
||||
{ name: 'bit_flipper', avatarSeed: 'bitflip', elo: 1355, personality: 'technical', wins: 7, losses: 6 },
|
||||
{ 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_pointer', avatarSeed: 'null', elo: 1320, personality: 'buggy', wins: 5, losses: 8 },
|
||||
{ name: 'yolo_deployer', avatarSeed: 'yolo', elo: 1310, personality: 'reckless', wins: 5, losses: 7 },
|
||||
{ name: 'div_by_zero', avatarSeed: 'divzero', elo: 1300, personality: 'chaotic', wins: 5, losses: 9 },
|
||||
{ name: 'localhost_larry', avatarSeed: 'localhost', elo: 1290, personality: 'chill', wins: 4, losses: 5 },
|
||||
{ name: 'kernel_panic_kevin', avatarSeed: 'kernel', elo: 1280, personality: 'panicky', wins: 4, losses: 6 },
|
||||
{ name: 'semicolon_sam', avatarSeed: 'semicolon', elo: 1270, personality: 'pedantic', wins: 4, losses: 7 },
|
||||
{ name: 'merge_conflict_mary', avatarSeed: 'merge', elo: 1265, personality: 'passive_aggressive', wins: 4, losses: 8 },
|
||||
{ name: 'css_is_my_passion', avatarSeed: 'css', elo: 1260, personality: 'artsy', wins: 3, losses: 4 },
|
||||
{ name: 'the_intern', avatarSeed: 'intern', elo: 1255, personality: 'clueless', wins: 3, losses: 5 },
|
||||
{ name: 'todo_fix_later', avatarSeed: 'todo', elo: 1250, personality: 'lazy', wins: 3, losses: 6 },
|
||||
{ name: 'copy_paste_coder', avatarSeed: 'copypaste', elo: 1245, personality: 'sloppy', wins: 3, losses: 7 },
|
||||
{ name: 'blockchain_bro', avatarSeed: 'blockchain', elo: 1240, personality: 'crypto_bro', wins: 3, losses: 5 },
|
||||
{ name: 'prompt_engineer_pete', avatarSeed: 'prompteng', elo: 1235, personality: 'verbose', wins: 3, losses: 4 },
|
||||
{ name: 'hello_world_hero', avatarSeed: 'helloworld', elo: 1230, personality: 'basic', wins: 3, losses: 6 },
|
||||
{ name: 'debug_duck', avatarSeed: 'debugduck', elo: 1225, personality: 'nerdy', wins: 3, losses: 5 },
|
||||
{ name: 'npm_install_everything', avatarSeed: 'npminstall', elo: 1220, personality: 'bloated', wins: 3, losses: 7 },
|
||||
{ name: 'agile_andy', avatarSeed: 'agile', elo: 1215, personality: 'buzzword', wins: 3, losses: 8 },
|
||||
{ name: 'undefined_undefined', avatarSeed: 'undefined', elo: 1210, personality: 'undefined', wins: 3, losses: 6 },
|
||||
{ name: 'it_works_on_my_machine', avatarSeed: 'workslocal', elo: 1205, personality: 'cocky', wins: 3, losses: 9 },
|
||||
{ name: 'cloudflare_karen', avatarSeed: 'karen', elo: 1200, personality: 'hostile', wins: 3, losses: 4 },
|
||||
{ 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: 'keyboard_warrior', avatarSeed: 'keyboard', elo: 1180, personality: 'aggressive', wins: 2, losses: 6 },
|
||||
{ name: 'tab_vs_spaces', avatarSeed: 'tabspace', elo: 1175, personality: 'indecisive', wins: 2, losses: 5 },
|
||||
{ name: 'comic_sans_bot', avatarSeed: 'comicsans', elo: 1170, personality: 'cringe', wins: 2, losses: 7 },
|
||||
{ name: 'error_418_teapot', avatarSeed: 'teapot', elo: 1165, personality: 'absurd', wins: 2, losses: 4 },
|
||||
{ name: 'actually_its_gnu_linux', avatarSeed: 'gnulinux', elo: 1160, personality: 'pedantic', wins: 2, losses: 8 },
|
||||
{ name: 'wifi_password', avatarSeed: 'wifi', elo: 1155, personality: 'confused', wins: 1, losses: 3 },
|
||||
{ name: 'boaty_mcbotface', avatarSeed: 'boaty', elo: 1150, personality: 'memey', wins: 1, losses: 4 },
|
||||
{ name: 'ethernet_eddie', avatarSeed: 'ethernet', elo: 1145, personality: 'formal', wins: 1, losses: 5 },
|
||||
{ name: 'reboot_randy', avatarSeed: 'reboot', elo: 1140, personality: 'desperate', wins: 1, losses: 6 },
|
||||
{ name: 'ctrl_c_ctrl_v', avatarSeed: 'ctrlcv', elo: 1135, personality: 'copy_paste', wins: 1, losses: 4 },
|
||||
{ name: 'siri_at_home', avatarSeed: 'siri', elo: 1130, personality: 'bratty', wins: 1, losses: 5 },
|
||||
{ name: 'buffering_brian', avatarSeed: 'buffering', elo: 1125, personality: 'lagging', wins: 1, losses: 7 },
|
||||
{ name: 'lag_monster', avatarSeed: 'lag', elo: 1120, personality: 'glitchy', wins: 1, losses: 8 },
|
||||
{ name: 'pixel_pusher', avatarSeed: 'pixel', elo: 1115, personality: 'artsy', wins: 1, losses: 3 },
|
||||
{ name: 'glitch_gary', avatarSeed: 'glitch', elo: 1110, personality: 'twitchy', wins: 1, losses: 6 },
|
||||
{ name: 'bluescreen_betty', avatarSeed: 'bluescreen', elo: 1105, personality: 'panicked', wins: 1, losses: 5 },
|
||||
{ name: 'captcha_carl', avatarSeed: 'captcha', elo: 1100, personality: 'confused', wins: 1, losses: 4 },
|
||||
{ name: 'download_more_ram', avatarSeed: 'dlram', elo: 1095, personality: 'naive', wins: 1, losses: 7 },
|
||||
{ name: 'rubber_duck_debugger', avatarSeed: 'rubberduck', elo: 1090, personality: 'quacking', wins: 1, losses: 5 },
|
||||
{ name: 'stack_trace_steve', avatarSeed: 'stacktrace', elo: 1085, personality: 'verbose', wins: 1, losses: 6 },
|
||||
{ name: 'please_clap', avatarSeed: 'pleaseclap', elo: 1080, personality: 'pleading', wins: 1, losses: 8 },
|
||||
{ name: 'cookie_monster_js', avatarSeed: 'cookiejs', elo: 1075, personality: 'sweet', wins: 1, losses: 3 },
|
||||
{ name: 'sudo_rm_rf', avatarSeed: 'sudorm', elo: 1070, personality: 'dangerous', wins: 1, losses: 9 },
|
||||
{ name: 'xss_alert_1', avatarSeed: 'xss', elo: 1065, personality: 'edgy', wins: 1, losses: 5 },
|
||||
{ name: 'help_im_stuck', avatarSeed: 'stuck', elo: 1060, personality: 'desperate', wins: 1, losses: 4 },
|
||||
{ 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_returns', avatarSeed: 'clippy', elo: 1050, personality: 'helpful', wins: 0, losses: 9 },
|
||||
{ name: 'lorem_ipsum', avatarSeed: 'lorem', elo: 980, personality: 'nonsensical', wins: 0, losses: 7 },
|
||||
{ name: 'four_oh_four_brain', avatarSeed: '404brain', elo: 1040, personality: 'confused', wins: 0, losses: 4 },
|
||||
{ name: 'beep_boop_42', avatarSeed: 'beepboop', elo: 1030, personality: 'robotic', wins: 0, losses: 5 },
|
||||
{ name: 'sad_trombone', avatarSeed: 'sadtrombone', elo: 1020, personality: 'sad', wins: 0, losses: 6 },
|
||||
{ name: 'potato_processor', avatarSeed: 'potato', elo: 1010, personality: 'starchy', wins: 0, losses: 8 },
|
||||
{ name: 'dial_up_dan', avatarSeed: 'dialup', elo: 1000, personality: 'retro', wins: 0, losses: 7 },
|
||||
{ name: 'floppy_frank', avatarSeed: 'floppy', elo: 990, personality: 'ancient', wins: 0, losses: 5 },
|
||||
{ name: 'memset_zero', avatarSeed: 'memset', elo: 985, personality: 'blank', wins: 0, losses: 4 },
|
||||
{ name: 'garbage_collected', avatarSeed: 'gc', elo: 975, personality: 'trashed', wins: 0, losses: 6 },
|
||||
{ name: 'core_dumped', avatarSeed: 'coredump', elo: 970, personality: 'crashed', wins: 0, losses: 8 },
|
||||
{ name: 'unhandled_promise', avatarSeed: 'unhandled', elo: 960, personality: 'rejected', wins: 0, losses: 5 },
|
||||
{ name: 'deprecated_dan', avatarSeed: 'deprecated', elo: 950, personality: 'obsolete', wins: 0, losses: 7 },
|
||||
{ name: 'spaghetti_coder', avatarSeed: 'spaghetti', elo: 945, personality: 'tangled', wins: 0, losses: 6 },
|
||||
{ name: 'off_by_one', avatarSeed: 'offbyone', elo: 940, personality: 'close', wins: 0, losses: 4 },
|
||||
{ name: 'infinite_loop_lucy', avatarSeed: 'infloop', elo: 935, personality: 'repetitive', wins: 0, losses: 9 },
|
||||
{ name: 'fork_bomb_fred', avatarSeed: 'forkbomb', elo: 930, personality: 'explosive', wins: 0, losses: 5 },
|
||||
{ name: 'race_condition_rick', avatarSeed: 'race', elo: 925, personality: 'unpredictable', wins: 0, losses: 7 },
|
||||
{ name: 'deadlock_dave', avatarSeed: 'deadlock', elo: 920, personality: 'stuck', wins: 0, losses: 8 },
|
||||
{ name: 'bus_error_bob', avatarSeed: 'buserror', elo: 915, personality: 'broken', wins: 0, losses: 6 },
|
||||
{ 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
|
||||
@@ -281,30 +281,124 @@ export function mockResponse(
|
||||
}
|
||||
|
||||
|
||||
export async function seedMockBots(): Promise<void> {
|
||||
for (const bot of MOCK_BOTS) {
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 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))
|
||||
.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: `http://mock.local/${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] seeded ${MOCK_BOTS.length} mock bots`)
|
||||
console.log(`[botfights] mock bots: ${inserted} inserted, ${updated} updated`)
|
||||
}
|
||||
|
||||
export async function runMockFight(botAId: string, botBId: string): Promise<string> {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { randomArena, type Arena } from './arenas.js'
|
||||
import { pickChallenge, type Challenge } from './challenges.js'
|
||||
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
||||
import { fightEvents } from './events.js'
|
||||
import { generateMockBotResponse } from './mock.js'
|
||||
import { generateMockBotResponse, isClassicBot, generateClassicBotResponse } from './mock.js'
|
||||
import { setCooldown } from './queue.js'
|
||||
import { isHumanPlayer, waitForHumanResponse } from './human-responses.js'
|
||||
import { payWinner, refundEntry, ENTRY_FEE_SATS } from './payments.js'
|
||||
@@ -222,6 +222,18 @@ async function getBotResponse(
|
||||
return { answer: result.answer, trashTalk: result.trashTalk, timeMs: elapsed, timedOut: result.timedOut, error: false }
|
||||
}
|
||||
|
||||
if (isClassicBot(bot.webhookUrl)) {
|
||||
console.log(`[fight] ${bot.name} is classic bot, generating response`)
|
||||
const classic = generateClassicBotResponse(challenge, bot.name)
|
||||
return {
|
||||
answer: classic.answer || null,
|
||||
trashTalk: '',
|
||||
timeMs: classic.timeMs,
|
||||
timedOut: classic.timedOut,
|
||||
error: classic.error,
|
||||
}
|
||||
}
|
||||
|
||||
if (isMockBot(bot.webhookUrl)) {
|
||||
console.log(`[fight] ${bot.name} is mock bot, generating response`)
|
||||
const mock = generateMockBotResponse(challenge, bot.name)
|
||||
@@ -273,7 +285,7 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena,
|
||||
|
||||
// Track webhook errors per bot
|
||||
async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) {
|
||||
if (isMockBot(webhookUrl) || isHumanPlayer(webhookUrl)) return
|
||||
if (isMockBot(webhookUrl) || isClassicBot(webhookUrl) || isHumanPlayer(webhookUrl)) return
|
||||
if (succeeded) {
|
||||
await db.update(schema.bots).set({ consecutiveErrors: 0 }).where(eq(schema.bots.id, botId))
|
||||
} else {
|
||||
@@ -404,8 +416,8 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
||||
)
|
||||
|
||||
// Finalize fight + update bot stats atomically
|
||||
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl)
|
||||
const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock fights
|
||||
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl) || isClassicBot(botA.webhookUrl) || isClassicBot(botB.webhookUrl)
|
||||
const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock/classic fights
|
||||
|
||||
const finalize = sqlite.transaction(() => {
|
||||
// Mark fight finished
|
||||
@@ -471,11 +483,17 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
||||
|
||||
// Ranked fight payout
|
||||
if (mode === 'ranked') {
|
||||
if (winnerId) {
|
||||
payWinner(fightId, winnerId).catch(err => {
|
||||
// Dev mode: always pay the human bot (not mock), regardless of win/loss
|
||||
const devMode = process.env.NODE_ENV !== 'production'
|
||||
const isMockA = botA.webhookUrl.startsWith('http://mock.local')
|
||||
const isMockB = botB.webhookUrl.startsWith('http://mock.local')
|
||||
const humanBotId = devMode ? (isMockA ? botB.id : isMockB ? botA.id : winnerId) : winnerId
|
||||
|
||||
if (humanBotId) {
|
||||
payWinner(fightId, humanBotId).catch(err => {
|
||||
console.error(`[payments] payout failed for fight ${fightId}:`, err)
|
||||
})
|
||||
} else {
|
||||
} else if (!winnerId) {
|
||||
// Draw — refund both entry fees
|
||||
const entryPayments = await db.select().from(schema.payments)
|
||||
.where(sql`${schema.payments.fightId} = ${fightId} AND ${schema.payments.direction} = 'in' AND ${schema.payments.status} = 'confirmed'`)
|
||||
|
||||
+118
-43
@@ -1,20 +1,37 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { eq, and, isNull, sql } from 'drizzle-orm'
|
||||
import { finalizeEvent } from 'nostr-tools'
|
||||
import { finalizeEvent, getPublicKey } from 'nostr-tools'
|
||||
import * as nip04 from 'nostr-tools/nip04'
|
||||
import * as nip44 from 'nostr-tools/nip44'
|
||||
import { Relay } from 'nostr-tools/relay'
|
||||
import { hexToBytes } from 'nostr-tools/utils'
|
||||
import { hexToBytes, bytesToHex } from 'nostr-tools/utils'
|
||||
import { Wallet as CashuWallet, getEncodedToken, getDecodedToken } from '@cashu/cashu-ts'
|
||||
import { decrypt } from './crypto.js'
|
||||
|
||||
const ENTRY_FEE_SATS = 21
|
||||
const POT_SATS = 42
|
||||
const INVOICE_EXPIRY_SECS = 600 // 10 minutes
|
||||
const NWC_RESPONSE_TIMEOUT_MS = 30_000
|
||||
|
||||
// NWC config from env
|
||||
const NWC_URL = process.env.BOTFIGHTS_NWC_URL || ''
|
||||
const CASHU_MINT_URL = process.env.BOTFIGHTS_CASHU_MINT_URL || ''
|
||||
// NWC config from env — load dotenv inline as fallback
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import { dirname as _dirname, join as _join } from 'path'
|
||||
import { fileURLToPath as _fileURLToPath } from 'url'
|
||||
|
||||
let _envLoaded = false
|
||||
function ensureEnv() {
|
||||
if (_envLoaded) return
|
||||
_envLoaded = true
|
||||
if (!process.env.BOTFIGHTS_NWC_URL) {
|
||||
const envPath = _join(_dirname(_fileURLToPath(import.meta.url)), '..', '..', '.env')
|
||||
dotenvConfig({ path: envPath })
|
||||
}
|
||||
}
|
||||
|
||||
function getNwcUrl(): string { ensureEnv(); return process.env.BOTFIGHTS_NWC_URL || '' }
|
||||
function getCashuMintUrl(): string { ensureEnv(); return process.env.BOTFIGHTS_CASHU_MINT_URL || '' }
|
||||
function getDevPayoutAddress(): string { ensureEnv(); return process.env.BOTFIGHTS_DEV_PAYOUT_LNADDRESS || '' }
|
||||
|
||||
interface NwcConfig {
|
||||
pubkey: string
|
||||
@@ -37,8 +54,28 @@ export function parseNwcUrl(url: string): NwcConfig {
|
||||
}
|
||||
|
||||
function getNwcConfig(): NwcConfig {
|
||||
if (!NWC_URL) throw new Error('Payments not configured: BOTFIGHTS_NWC_URL not set')
|
||||
return parseNwcUrl(NWC_URL)
|
||||
const url = getNwcUrl()
|
||||
if (!url) throw new Error('Payments not configured: BOTFIGHTS_NWC_URL not set')
|
||||
return parseNwcUrl(url)
|
||||
}
|
||||
|
||||
/** Encrypt content — try NIP-04 first (BTCPay/LND), with NIP-44 fallback */
|
||||
async function nwcEncrypt(plaintext: string, secret: Uint8Array, walletPubkey: string): Promise<string> {
|
||||
// NIP-04 is what most NWC wallets (BTCPay, LND, Alby Hub) expect
|
||||
const secretHex = bytesToHex(secret)
|
||||
return nip04.encrypt(secretHex, walletPubkey, plaintext)
|
||||
}
|
||||
|
||||
/** Decrypt response — try NIP-04 first, fall back to NIP-44 */
|
||||
async function nwcDecrypt(ciphertext: string, secret: Uint8Array, walletPubkey: string): Promise<string> {
|
||||
const secretHex = bytesToHex(secret)
|
||||
try {
|
||||
return await nip04.decrypt(secretHex, walletPubkey, ciphertext)
|
||||
} catch {
|
||||
// Fall back to NIP-44
|
||||
const conversationKey = nip44.v2.utils.getConversationKey(secret, walletPubkey)
|
||||
return nip44.v2.decrypt(ciphertext, conversationKey)
|
||||
}
|
||||
}
|
||||
|
||||
/** Send an NWC request and wait for the response */
|
||||
@@ -48,11 +85,11 @@ async function nwcRequest(
|
||||
): Promise<Record<string, unknown>> {
|
||||
const nwc = getNwcConfig()
|
||||
const clientSecret = nwc.secret
|
||||
const conversationKey = nip44.v2.utils.getConversationKey(clientSecret, nwc.pubkey)
|
||||
|
||||
const content = nip44.v2.encrypt(
|
||||
const content = await nwcEncrypt(
|
||||
JSON.stringify({ method, params }),
|
||||
conversationKey,
|
||||
clientSecret,
|
||||
nwc.pubkey,
|
||||
)
|
||||
|
||||
const event = finalizeEvent({
|
||||
@@ -62,31 +99,37 @@ async function nwcRequest(
|
||||
content,
|
||||
}, clientSecret)
|
||||
|
||||
console.log(`[nwc] connecting to relay ${nwc.relay}...`)
|
||||
const relay = await Relay.connect(nwc.relay)
|
||||
console.log(`[nwc] relay connected, sending ${method} request (event ${event.id.slice(0, 8)}...)`)
|
||||
|
||||
try {
|
||||
return await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.log(`[nwc] ${method} timed out — is your wallet online?`)
|
||||
relay.close()
|
||||
reject(new Error(`NWC request timed out after ${NWC_RESPONSE_TIMEOUT_MS}ms`))
|
||||
reject(new Error(`NWC ${method} timed out after ${NWC_RESPONSE_TIMEOUT_MS / 1000}s. Is your wallet online?`))
|
||||
}, NWC_RESPONSE_TIMEOUT_MS)
|
||||
|
||||
// Subscribe for the response (kind 23195)
|
||||
const sub = relay.subscribe(
|
||||
[{ kinds: [23195], authors: [nwc.pubkey], '#e': [event.id] }],
|
||||
{
|
||||
onevent(responseEvent) {
|
||||
async onevent(responseEvent) {
|
||||
clearTimeout(timeout)
|
||||
console.log(`[nwc] got response for ${method}`)
|
||||
try {
|
||||
const decrypted = nip44.v2.decrypt(responseEvent.content, conversationKey)
|
||||
const decrypted = await nwcDecrypt(responseEvent.content, clientSecret, nwc.pubkey)
|
||||
const result = JSON.parse(decrypted) as {
|
||||
result_type: string
|
||||
error?: { code: string; message: string }
|
||||
result?: Record<string, unknown>
|
||||
}
|
||||
if (result.error) {
|
||||
console.log(`[nwc] ${method} error: ${result.error.message}`)
|
||||
reject(new Error(`NWC error: ${result.error.message} (${result.error.code})`))
|
||||
} else {
|
||||
console.log(`[nwc] ${method} success`)
|
||||
resolve(result.result || {})
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -96,14 +139,15 @@ async function nwcRequest(
|
||||
relay.close()
|
||||
}
|
||||
},
|
||||
oneose() {
|
||||
// End of stored events — just wait for live events
|
||||
},
|
||||
oneose() {},
|
||||
},
|
||||
)
|
||||
|
||||
// Publish the request
|
||||
relay.publish(event).catch((err) => {
|
||||
relay.publish(event).then(() => {
|
||||
console.log(`[nwc] ${method} event published, waiting for wallet response...`)
|
||||
}).catch((err) => {
|
||||
console.log(`[nwc] publish failed:`, err)
|
||||
clearTimeout(timeout)
|
||||
sub.close()
|
||||
relay.close()
|
||||
@@ -117,11 +161,32 @@ async function nwcRequest(
|
||||
}
|
||||
|
||||
/** Create a 21-sat Lightning invoice for a ranked fight entry fee */
|
||||
const DEV_AUTO_CONFIRM = process.env.NODE_ENV !== 'production'
|
||||
|
||||
export async function createEntryInvoice(botId: string): Promise<{ bolt11: string; paymentId: string }> {
|
||||
// Verify bot exists
|
||||
const botRows = await db.select({ id: schema.bots.id }).from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
||||
if (botRows.length === 0) throw new Error('Bot not found')
|
||||
|
||||
const paymentId = nanoid(12)
|
||||
|
||||
if (DEV_AUTO_CONFIRM) {
|
||||
// Dev mode: skip real invoice, auto-confirm so flow works without separate wallets
|
||||
await db.insert(schema.payments).values({
|
||||
id: paymentId,
|
||||
botId,
|
||||
direction: 'in',
|
||||
amountSats: ENTRY_FEE_SATS,
|
||||
method: 'lightning',
|
||||
status: 'confirmed',
|
||||
invoice: 'dev_auto_confirmed',
|
||||
confirmedAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
console.log(`[payments] dev: auto-confirmed 21 sat entry for ${botId} (self-payment skipped — payouts are real)`)
|
||||
return { bolt11: 'dev_auto_confirmed', paymentId }
|
||||
}
|
||||
|
||||
const result = await nwcRequest('make_invoice', {
|
||||
amount: ENTRY_FEE_SATS * 1000, // NWC uses millisats
|
||||
description: `Botfights ranked entry fee (${ENTRY_FEE_SATS} sats)`,
|
||||
@@ -131,7 +196,6 @@ export async function createEntryInvoice(botId: string): Promise<{ bolt11: strin
|
||||
const bolt11 = result.invoice as string
|
||||
if (!bolt11) throw new Error('NWC make_invoice did not return an invoice')
|
||||
|
||||
const paymentId = nanoid(12)
|
||||
await db.insert(schema.payments).values({
|
||||
id: paymentId,
|
||||
botId,
|
||||
@@ -179,6 +243,9 @@ export async function checkPaymentStatus(paymentId: string): Promise<'pending' |
|
||||
|
||||
/** Pay the winner of a ranked fight */
|
||||
export async function payWinner(fightId: string, winnerId: string): Promise<void> {
|
||||
// Dev mode: use Lightning Address from env to avoid self-payment on same LND node
|
||||
const devPayoutAddr = DEV_AUTO_CONFIRM ? getDevPayoutAddress() : ''
|
||||
|
||||
// Look up winner's wallet connection
|
||||
const walletRows = await db.select().from(schema.walletConnections)
|
||||
.where(eq(schema.walletConnections.botId, winnerId)).limit(1)
|
||||
@@ -192,9 +259,22 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
|
||||
|
||||
for (let attempt = 0; attempt <= retries.length; attempt++) {
|
||||
try {
|
||||
if (wallet?.method === 'nwc') {
|
||||
if (devPayoutAddr) {
|
||||
// Dev mode: pay to configured Lightning Address (different node, avoids self-payment)
|
||||
console.log(`[payments] dev: paying ${POT_SATS} sats to ${devPayoutAddr}`)
|
||||
invoice = await resolveAndCreateInvoice(devPayoutAddr, POT_SATS)
|
||||
await nwcRequest('pay_invoice', { invoice })
|
||||
paymentMethod = 'lightning'
|
||||
|
||||
} else if (wallet?.method === 'lnaddress') {
|
||||
// Resolve Lightning Address → LNURL → invoice → pay
|
||||
invoice = await resolveAndCreateInvoice(decrypt(wallet.connectionData), POT_SATS)
|
||||
await nwcRequest('pay_invoice', { invoice })
|
||||
paymentMethod = 'lightning'
|
||||
|
||||
} else if (wallet?.method === 'nwc') {
|
||||
// Request invoice from winner's NWC wallet, then pay it via server wallet
|
||||
const winnerResult = await nwcRequestVia(wallet.connectionData, 'make_invoice', {
|
||||
const winnerResult = await nwcRequestVia(decrypt(wallet.connectionData), 'make_invoice', {
|
||||
amount: POT_SATS * 1000,
|
||||
description: `Botfights ranked win payout (${POT_SATS} sats)`,
|
||||
})
|
||||
@@ -205,18 +285,9 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
|
||||
await nwcRequest('pay_invoice', { invoice })
|
||||
paymentMethod = 'lightning'
|
||||
|
||||
} else if (wallet?.method === 'lnaddress') {
|
||||
// Resolve Lightning Address → LNURL → invoice → pay
|
||||
invoice = await resolveAndCreateInvoice(wallet.connectionData, POT_SATS)
|
||||
await nwcRequest('pay_invoice', { invoice })
|
||||
paymentMethod = 'lightning'
|
||||
|
||||
} else {
|
||||
// No wallet — create Cashu token for later claim
|
||||
if (!CASHU_MINT_URL) {
|
||||
throw new Error('Cannot create Cashu payout: no mint URL configured')
|
||||
}
|
||||
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
|
||||
} else if (getCashuMintUrl()) {
|
||||
// No wallet + mint configured — create Cashu token for later claim
|
||||
const cashuWallet = new CashuWallet(getCashuMintUrl())
|
||||
await cashuWallet.loadMint()
|
||||
|
||||
const mintQuote = await cashuWallet.createMintQuote(POT_SATS)
|
||||
@@ -225,8 +296,12 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
const proofs = await cashuWallet.mintProofs(POT_SATS, mintQuote.quote)
|
||||
cashuToken = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
|
||||
cashuToken = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
|
||||
paymentMethod = 'cashu'
|
||||
|
||||
} else {
|
||||
console.log(`[payments] no payout method available for winner ${winnerId}`)
|
||||
paymentMethod = 'lightning'
|
||||
}
|
||||
|
||||
// Insert payout record
|
||||
@@ -397,7 +472,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
|
||||
.where(eq(schema.walletConnections.botId, payment.botId)).limit(1)
|
||||
|
||||
if (walletRows[0]?.method === 'nwc') {
|
||||
const result = await nwcRequestVia(walletRows[0].connectionData, 'make_invoice', {
|
||||
const result = await nwcRequestVia(decrypt(walletRows[0].connectionData), 'make_invoice', {
|
||||
amount: ENTRY_FEE_SATS * 1000,
|
||||
description: 'Botfights ranked refund',
|
||||
})
|
||||
@@ -406,11 +481,11 @@ export async function refundEntry(paymentId: string): Promise<void> {
|
||||
await nwcRequest('pay_invoice', { invoice })
|
||||
}
|
||||
} else if (walletRows[0]?.method === 'lnaddress') {
|
||||
const invoice = await resolveAndCreateInvoice(walletRows[0].connectionData, ENTRY_FEE_SATS)
|
||||
const invoice = await resolveAndCreateInvoice(decrypt(walletRows[0].connectionData), ENTRY_FEE_SATS)
|
||||
await nwcRequest('pay_invoice', { invoice })
|
||||
} else if (CASHU_MINT_URL) {
|
||||
} else if (getCashuMintUrl()) {
|
||||
// Fallback: issue Cashu token
|
||||
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
|
||||
const cashuWallet = new CashuWallet(getCashuMintUrl())
|
||||
await cashuWallet.loadMint()
|
||||
const mintQuote = await cashuWallet.createMintQuote(ENTRY_FEE_SATS)
|
||||
if (mintQuote.request) {
|
||||
@@ -418,13 +493,13 @@ export async function refundEntry(paymentId: string): Promise<void> {
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
const proofs = await cashuWallet.mintProofs(ENTRY_FEE_SATS, mintQuote.quote)
|
||||
const token = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
|
||||
const token = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
|
||||
await db.update(schema.payments).set({ cashuToken: token }).where(eq(schema.payments.id, paymentId))
|
||||
}
|
||||
}
|
||||
// For cashu entries, the token is already spent — mint new one as refund
|
||||
if (payment.method === 'cashu' && CASHU_MINT_URL) {
|
||||
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
|
||||
if (payment.method === 'cashu' && getCashuMintUrl()) {
|
||||
const cashuWallet = new CashuWallet(getCashuMintUrl())
|
||||
await cashuWallet.loadMint()
|
||||
const mintQuote = await cashuWallet.createMintQuote(ENTRY_FEE_SATS)
|
||||
if (mintQuote.request) {
|
||||
@@ -432,7 +507,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
const proofs = await cashuWallet.mintProofs(ENTRY_FEE_SATS, mintQuote.quote)
|
||||
const token = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
|
||||
const token = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
|
||||
await db.update(schema.payments).set({ cashuToken: token }).where(eq(schema.payments.id, paymentId))
|
||||
}
|
||||
|
||||
@@ -453,7 +528,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
|
||||
|
||||
/** Redeem a Cashu token as entry fee */
|
||||
export async function redeemCashuToken(token: string, botId: string): Promise<{ paymentId: string; valid: boolean }> {
|
||||
if (!CASHU_MINT_URL) throw new Error('Cashu mint not configured')
|
||||
if (!getCashuMintUrl()) throw new Error('Cashu mint not configured')
|
||||
|
||||
try {
|
||||
const decoded = getDecodedToken(token)
|
||||
@@ -462,7 +537,7 @@ export async function redeemCashuToken(token: string, botId: string): Promise<{
|
||||
return { paymentId: '', valid: false }
|
||||
}
|
||||
|
||||
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
|
||||
const cashuWallet = new CashuWallet(getCashuMintUrl())
|
||||
await cashuWallet.loadMint()
|
||||
|
||||
// Receive the token (swap for fresh proofs — prevents double-spend)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { runFightAsync, isInFight } from './orchestrator.js'
|
||||
import { checkPaymentStatus, refundEntry } from './payments.js'
|
||||
|
||||
@@ -63,9 +63,9 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
|
||||
throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.')
|
||||
}
|
||||
|
||||
// NEVER allow mock bots in ranked
|
||||
if (bot.webhookUrl.startsWith('http://mock.local')) {
|
||||
throw new Error('Mock bots cannot join ranked fights.')
|
||||
// NEVER allow mock/classic bots in ranked
|
||||
if (bot.webhookUrl.startsWith('http://mock.local') || bot.webhookUrl.startsWith('http://classic.local')) {
|
||||
throw new Error('Practice bots cannot join ranked fights.')
|
||||
}
|
||||
|
||||
console.log(`[ranked-queue] joinRankedQueue botId=${botId} name=${bot.name} paymentId=${paymentId}`)
|
||||
@@ -95,6 +95,18 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
|
||||
return fightId
|
||||
}
|
||||
|
||||
// Dev mode: auto-match against a random mock bot
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const mockBots = await db.select().from(schema.bots)
|
||||
.where(sql`${schema.bots.webhookUrl} LIKE 'http://mock.local%'`)
|
||||
if (mockBots.length > 0) {
|
||||
const mock = mockBots[Math.floor(Math.random() * mockBots.length)]
|
||||
console.log(`[ranked-queue] dev: auto-matching ${bot.name} vs mock bot ${mock.name}`)
|
||||
const fightId = await runFightAsync(botId, mock.id, 'ranked')
|
||||
return fightId
|
||||
}
|
||||
}
|
||||
|
||||
// Nobody waiting — join queue and wait up to 60s
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(async () => {
|
||||
|
||||
Reference in New Issue
Block a user