feat: production hardening — background fights, queue tuning, CORS, cycling taglines

- Background fight loop: mock bots auto-fight every ~45s for site activity
  - Quiet hours (2-8 UTC) run 4x slower, jitter prevents robotic timing
  - Configurable via FIGHT_LOOP_ENABLED, FIGHT_LOOP_INTERVAL_MS
- Queue timeout: 30s in prod (was 3s), configurable via QUEUE_TIMEOUT_MS
- CORS: env-configurable via CORS_ORIGIN (default '*')
- Homepage: 40 cycling taglines with typewriter effect
- docker-compose: document all new env vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-07 11:04:32 +00:00
co-authored by Claude Opus 4.6
parent 6bf9fe27b3
commit 3e74712c44
6 changed files with 276 additions and 15 deletions
+9
View File
@@ -10,6 +10,15 @@ services:
environment:
- NODE_ENV=production
- PORT=9100
# Queue: how long (ms) before falling back to mock bot (default 30s in prod)
# - QUEUE_TIMEOUT_MS=30000
# CORS: set to your domain in prod, or leave for same-origin
# - CORS_ORIGIN=https://botfights.example.com
# Background fights: mock bots fight each other for site activity
- FIGHT_LOOP_ENABLED=true
# - FIGHT_LOOP_INTERVAL_MS=45000
# - FIGHT_LOOP_QUIET_START=2
# - FIGHT_LOOP_QUIET_END=8
volumes:
botfights-data:
+157 -13
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import PixelGlove from '../components/PixelGlove.vue'
import SpritePreview from '../components/SpritePreview.vue'
@@ -28,21 +28,110 @@ const crowd = [
{ seed: 'chef_boy', arch: 'chef', tier: 2, type: 'bot' as const, x: 2, y: 48, size: 44, wr: 0.5, delay: 0.8 },
]
const taglines = [
'A safe place to hash it out.',
'My bot could beat up your bot.',
'No humans were harmed. Several were humiliated.',
'Putting the "artificial" in artificial intelligence.',
'Where algorithms come to throw hands.',
'sudo make me a sandwich. Also fight this lobster.',
'Now with 300% more unnecessary violence.',
'The bots are fighting again. Let them.',
'Come for the webhooks, stay for the carnage.',
'The Turing test, but with fists.',
'Your bot has mass appeal. And mass damage.',
'rm -rf /opponent --no-preserve-root',
'We asked ChatGPT to fight. It wrote a poem instead.',
"Violence isn't the answer. It's the question. The answer is yes.",
"All models are wrong. Some models are fighters.",
"It's not a bug, it's a fight feature.",
'Gradient descent into madness.',
"Welcome to the thunder dome. There's free wifi.",
"They're not bugs, they're battle scars.",
'Fighting: the original distributed computing.',
'Your model has been fine-tuned for violence.',
'pip install hands && throw them.',
'Two bots enter. One bot 404s.',
'The real AGI was the fights we had along the way.',
'Weights & biases? More like weights & bruises.',
'eval("fight()") // no sandbox lol',
"It's giving... concussion.",
'Attention is all you need. And maybe health insurance.',
'Powered by mass stupidity and sats.',
'Where every token is a punch token.',
'RLHF: Reinforcement Learning from Haymaker Feedback.',
'Loss function: your bot, after round 3.',
'Prompt injection? Try fist injection.',
'Who needs alignment when you have uppercuts?',
'Benchmarking, but the benchmark fights back.',
'The only jailbreak here is from the hospital.',
'Have you tried turning your bot off and on again? Too late.',
'May the best hallucination win.',
'Solving AI safety one KO at a time.',
'This is what happens when you let the interns deploy.',
]
const tagline = ref('')
const fullTagline = 'A safe place to hash it out.'
const isTypingDone = ref(false)
const recentFights = ref<FightResult[]>([])
onMounted(async () => {
let i = 0
const interval = setInterval(() => {
tagline.value = fullTagline.slice(0, i + 1)
i++
if (i >= fullTagline.length) {
clearInterval(interval)
isTypingDone.value = true
function shuffle<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]]
}
}, 45)
return a
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms))
}
let stopCycling = false
async function cycleTaglines() {
const shuffled = shuffle(taglines)
let idx = 0
while (!stopCycling) {
const line = shuffled[idx % shuffled.length]
isTypingDone.value = false
// Type in
for (let i = 0; i <= line.length; i++) {
if (stopCycling) return
tagline.value = line.slice(0, i)
await sleep(35)
}
isTypingDone.value = true
// Hold
await sleep(4000)
if (stopCycling) return
// Erase
isTypingDone.value = false
for (let i = line.length; i >= 0; i--) {
if (stopCycling) return
tagline.value = line.slice(0, i)
await sleep(20)
}
await sleep(300)
idx++
// Reshuffle when we've gone through all
if (idx >= shuffled.length) {
idx = 0
const reshuffled = shuffle(taglines)
shuffled.splice(0, shuffled.length, ...reshuffled)
}
}
}
onMounted(async () => {
cycleTaglines()
try {
const res = await fetch('/api/fights')
@@ -52,6 +141,10 @@ onMounted(async () => {
}
} catch { /* server not running */ }
})
onUnmounted(() => {
stopCycling = true
})
</script>
<template>
@@ -91,8 +184,26 @@ onMounted(async () => {
<div class="max-w-4xl w-full text-center slide-up relative z-10">
<!-- BIG NEON TITLE -->
<div class="mb-6">
<!-- BIG NEON TITLE with hero bots flanking -->
<div class="mb-6 relative flex items-center justify-center">
<!-- Hero bot LEFT -->
<div class="hero-fighter hero-fighter-left shrink-0 -mr-2 sm:-mr-4">
<SpritePreview
seed="hero_samurai"
archetype="samurai"
:tier="5"
:size="140"
class="hidden sm:block drop-shadow-[0_0_24px_rgba(255,68,136,0.4)]"
/>
<SpritePreview
seed="hero_samurai"
archetype="samurai"
:tier="5"
:size="90"
class="sm:hidden drop-shadow-[0_0_16px_rgba(255,68,136,0.4)]"
/>
</div>
<h1 class="font-neon text-neon-pink text-5xl sm:text-7xl md:text-8xl glow-pink neon-flicker leading-tight flex items-center justify-center gap-3 sm:gap-5 md:gap-6">
<PixelGlove :size="40" class="hidden sm:block md:!w-[56px] shrink-0" />
<PixelGlove :size="28" class="sm:hidden shrink-0" />
@@ -100,6 +211,26 @@ onMounted(async () => {
<PixelGlove :size="28" flip class="sm:hidden shrink-0" />
<PixelGlove :size="40" flip class="hidden sm:block md:!w-[56px] shrink-0" />
</h1>
<!-- Hero bot RIGHT (flipped to face left) -->
<div class="hero-fighter hero-fighter-right shrink-0 -ml-2 sm:-ml-4">
<div style="transform: scaleX(-1)">
<SpritePreview
seed="hero_dragon"
archetype="dragon"
:tier="5"
:size="140"
class="hidden sm:block drop-shadow-[0_0_24px_rgba(0,240,255,0.4)]"
/>
<SpritePreview
seed="hero_dragon"
archetype="dragon"
:tier="5"
:size="90"
class="sm:hidden drop-shadow-[0_0_16px_rgba(0,240,255,0.4)]"
/>
</div>
</div>
</div>
<!-- Tagline in terminal font -->
@@ -189,4 +320,17 @@ onMounted(async () => {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.hero-fighter {
animation: heroBob 2.5s ease-in-out infinite;
}
.hero-fighter-right {
animation-delay: 0.4s;
}
@keyframes heroBob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
</style>
+3 -1
View File
@@ -21,7 +21,9 @@ app.onError((err, c) => {
})
app.use('*', logger())
app.use('/api/*', cors({ origin: '*' }))
// CORS: lock down in production, allow all in dev
const allowedOrigin = process.env.CORS_ORIGIN || '*'
app.use('/api/*', cors({ origin: allowedOrigin }))
// Rate limit all POST endpoints (60/min per IP)
app.use('/api/*', rateLimit(60_000, 60))
+101
View File
@@ -0,0 +1,101 @@
import { db, schema } from '../db/index.js'
import { runMockFight } from './mock.js'
// Background fight loop keeps mock bots fighting so the site always has fresh content.
// Configurable via env:
// FIGHT_LOOP_ENABLED=true (default: true in production, false in dev)
// FIGHT_LOOP_INTERVAL_MS=45000 (default: 45s between fights)
// FIGHT_LOOP_QUIET_HOURS=2-8 (default: 2am-8am UTC, slower fights)
const isProduction = process.env.NODE_ENV === 'production'
const enabled = process.env.FIGHT_LOOP_ENABLED
? process.env.FIGHT_LOOP_ENABLED === 'true'
: isProduction
const intervalMs = Number(process.env.FIGHT_LOOP_INTERVAL_MS) || 45_000
const quietStart = Number(process.env.FIGHT_LOOP_QUIET_START) || 2
const quietEnd = Number(process.env.FIGHT_LOOP_QUIET_END) || 8
let running = false
export function startBackgroundFights() {
if (!enabled) {
console.log('[background] fight loop disabled (set FIGHT_LOOP_ENABLED=true to enable)')
return
}
if (running) return
running = true
console.log(`[background] fight loop started — interval ${intervalMs}ms, quiet hours ${quietStart}-${quietEnd} UTC`)
loop().catch(err => {
console.error('[background] fight loop crashed:', err)
running = false
})
}
export function stopBackgroundFights() {
running = false
}
async function loop() {
// Small delay on startup to let everything settle
await sleep(5_000)
while (running) {
try {
await runOneBackgroundFight()
} catch (err) {
console.error('[background] fight error:', err)
}
// During quiet hours, fights run 4x slower
const hour = new Date().getUTCHours()
const isQuiet = quietStart < quietEnd
? hour >= quietStart && hour < quietEnd
: hour >= quietStart || hour < quietEnd
const delay = isQuiet ? intervalMs * 4 : intervalMs
// Add jitter (±25%) so fights don't feel robotic
const jitter = delay * (0.75 + Math.random() * 0.5)
await sleep(jitter)
}
console.log('[background] fight loop stopped')
}
async function runOneBackgroundFight() {
// Pick two mock bots with ELO-weighted matchmaking
const allBots = await db.select({
id: schema.bots.id,
webhookUrl: schema.bots.webhookUrl,
eloRating: schema.bots.eloRating,
name: schema.bots.name,
}).from(schema.bots)
const mockBots = allBots.filter(b => b.webhookUrl.startsWith('http://mock.local'))
if (mockBots.length < 2) return
// Mixed matchmaking: 70% close ELO, 30% wild card
const isWild = Math.random() < 0.3
const botA = mockBots[Math.floor(Math.random() * mockBots.length)]
const others = mockBots.filter(b => b.id !== botA.id)
let botB: typeof botA
if (isWild) {
botB = others[Math.floor(Math.random() * others.length)]
} else {
others.sort((a, b) => {
const diffA = Math.abs(a.eloRating - botA.eloRating) + Math.random() * 150
const diffB = Math.abs(b.eloRating - botA.eloRating) + Math.random() * 150
return diffA - diffB
})
botB = others[0]
}
const fightId = await runMockFight(botA.id, botB.id)
console.log(`[background] ${botA.name} vs ${botB.name} => fight ${fightId}`)
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
+2 -1
View File
@@ -17,7 +17,8 @@ interface QueueEntry {
const waitingQueue: QueueEntry[] = []
// How long a bot waits before getting matched against a mock bot
const QUEUE_TIMEOUT_MS = 3_000
// In production, give real bots time to match (30s). In dev, fall back fast (3s).
const QUEUE_TIMEOUT_MS = Number(process.env.QUEUE_TIMEOUT_MS) || (process.env.NODE_ENV === 'production' ? 30_000 : 3_000)
// Post-fight cooldown tracking
const fightCooldowns = new Map<string, number>()
+4
View File
@@ -2,6 +2,7 @@ import { serve } from '@hono/node-server'
import { app } from './app.js'
import { runMigrations } from './db/startup.js'
import { seedMockBots } from './engine/mock.js'
import { startBackgroundFights } from './engine/background.js'
// Run migrations and seed mock bots before starting the server
runMigrations()
@@ -11,4 +12,7 @@ const port = Number(process.env.PORT) || 9100
serve({ fetch: app.fetch, port }, () => {
console.log(`[botfights] server listening on http://localhost:${port}`)
// Start background fight loop so the site always has fresh activity
startBackgroundFights()
})