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:
co-authored by
Claude Opus 4.6
parent
6bf9fe27b3
commit
3e74712c44
@@ -10,6 +10,15 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=9100
|
- 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:
|
volumes:
|
||||||
botfights-data:
|
botfights-data:
|
||||||
|
|||||||
+157
-13
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import PixelGlove from '../components/PixelGlove.vue'
|
import PixelGlove from '../components/PixelGlove.vue'
|
||||||
import SpritePreview from '../components/SpritePreview.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 },
|
{ 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 tagline = ref('')
|
||||||
const fullTagline = 'A safe place to hash it out.'
|
|
||||||
const isTypingDone = ref(false)
|
const isTypingDone = ref(false)
|
||||||
const recentFights = ref<FightResult[]>([])
|
const recentFights = ref<FightResult[]>([])
|
||||||
|
|
||||||
onMounted(async () => {
|
function shuffle<T>(arr: T[]): T[] {
|
||||||
let i = 0
|
const a = [...arr]
|
||||||
const interval = setInterval(() => {
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
tagline.value = fullTagline.slice(0, i + 1)
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
i++
|
[a[i], a[j]] = [a[j], a[i]]
|
||||||
if (i >= fullTagline.length) {
|
}
|
||||||
clearInterval(interval)
|
return a
|
||||||
isTypingDone.value = true
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}, 45)
|
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 {
|
try {
|
||||||
const res = await fetch('/api/fights')
|
const res = await fetch('/api/fights')
|
||||||
@@ -52,6 +141,10 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
} catch { /* server not running */ }
|
} catch { /* server not running */ }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopCycling = true
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -91,8 +184,26 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div class="max-w-4xl w-full text-center slide-up relative z-10">
|
<div class="max-w-4xl w-full text-center slide-up relative z-10">
|
||||||
|
|
||||||
<!-- BIG NEON TITLE -->
|
<!-- BIG NEON TITLE with hero bots flanking -->
|
||||||
<div class="mb-6">
|
<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">
|
<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="40" class="hidden sm:block md:!w-[56px] shrink-0" />
|
||||||
<PixelGlove :size="28" class="sm:hidden 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="28" flip class="sm:hidden shrink-0" />
|
||||||
<PixelGlove :size="40" flip class="hidden sm:block md:!w-[56px] shrink-0" />
|
<PixelGlove :size="40" flip class="hidden sm:block md:!w-[56px] shrink-0" />
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Tagline in terminal font -->
|
<!-- Tagline in terminal font -->
|
||||||
@@ -189,4 +320,17 @@ onMounted(async () => {
|
|||||||
0%, 100% { transform: translateY(0); }
|
0%, 100% { transform: translateY(0); }
|
||||||
50% { transform: translateY(-6px); }
|
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>
|
</style>
|
||||||
|
|||||||
+3
-1
@@ -21,7 +21,9 @@ app.onError((err, c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.use('*', logger())
|
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)
|
// Rate limit all POST endpoints (60/min per IP)
|
||||||
app.use('/api/*', rateLimit(60_000, 60))
|
app.use('/api/*', rateLimit(60_000, 60))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -17,7 +17,8 @@ interface QueueEntry {
|
|||||||
const waitingQueue: QueueEntry[] = []
|
const waitingQueue: QueueEntry[] = []
|
||||||
|
|
||||||
// How long a bot waits before getting matched against a mock bot
|
// 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
|
// Post-fight cooldown tracking
|
||||||
const fightCooldowns = new Map<string, number>()
|
const fightCooldowns = new Map<string, number>()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { serve } from '@hono/node-server'
|
|||||||
import { app } from './app.js'
|
import { app } from './app.js'
|
||||||
import { runMigrations } from './db/startup.js'
|
import { runMigrations } from './db/startup.js'
|
||||||
import { seedMockBots } from './engine/mock.js'
|
import { seedMockBots } from './engine/mock.js'
|
||||||
|
import { startBackgroundFights } from './engine/background.js'
|
||||||
|
|
||||||
// Run migrations and seed mock bots before starting the server
|
// Run migrations and seed mock bots before starting the server
|
||||||
runMigrations()
|
runMigrations()
|
||||||
@@ -11,4 +12,7 @@ const port = Number(process.env.PORT) || 9100
|
|||||||
|
|
||||||
serve({ fetch: app.fetch, port }, () => {
|
serve({ fetch: app.fetch, port }, () => {
|
||||||
console.log(`[botfights] server listening on http://localhost:${port}`)
|
console.log(`[botfights] server listening on http://localhost:${port}`)
|
||||||
|
|
||||||
|
// Start background fight loop so the site always has fresh activity
|
||||||
|
startBackgroundFights()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user