feat: omni-morph sprite swap, bullet time showboats, music tuning, server hardening
- Creator omni-morph now generates actual sprite sheets for morphed archetypes - 3 new Creator showboats: bullet time attack, ₿ throne summon, disco dance - Music: subtle tempo shift (+10 BPM max), longer phrases (8/16/24 bars), smoother crossfades, less chaotic hi-hat at high intensity - Server: security headers, body size limit, production error masking, CORS origin warning, graceful shutdown with drain - Payments: atomic consume (eliminates SELECT/UPDATE race), release reverts DB - Fight loop: round events for live TUI, retro displayPrompt - Frontend: pass pubkey in payment/queue requests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
0acfa4417f
commit
e12fb94ae7
+19
-1
@@ -1,6 +1,8 @@
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { logger } from 'hono/logger'
|
||||
import { secureHeaders } from 'hono/secure-headers'
|
||||
import { bodyLimit } from 'hono/body-limit'
|
||||
import { botsRouter } from './routes/bots.js'
|
||||
import { fightsRouter } from './routes/fights.js'
|
||||
import { queueRouter } from './routes/queue.js'
|
||||
@@ -20,7 +22,8 @@ export const app = new Hono()
|
||||
|
||||
app.onError((err, c) => {
|
||||
console.error('[botfights] ERROR:', err.message, err.stack)
|
||||
return c.json({ error: err.message }, 500)
|
||||
const msg = process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message
|
||||
return c.json({ error: msg }, 500)
|
||||
})
|
||||
|
||||
app.use('*', logger())
|
||||
@@ -28,6 +31,21 @@ app.use('*', logger())
|
||||
const allowedOrigin = process.env.CORS_ORIGIN || '*'
|
||||
app.use('/api/*', cors({ origin: allowedOrigin }))
|
||||
|
||||
// Security headers: X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, etc.
|
||||
app.use('*', secureHeaders({
|
||||
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'"],
|
||||
} : undefined,
|
||||
}))
|
||||
|
||||
// Body size limit: 256KB max for API requests (prevents OOM)
|
||||
app.use('/api/*', bodyLimit({ maxSize: 256 * 1024 }))
|
||||
|
||||
// Rate limit all POST endpoints (60/min per IP)
|
||||
app.use('/api/*', rateLimit(60_000, 60))
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface Challenge {
|
||||
timeout_ms: number
|
||||
scoring: 'factual' | 'creative'
|
||||
baseDamage: number
|
||||
displayPrompt?: string
|
||||
}
|
||||
|
||||
interface PromptEntry {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { runMockFight } from './mock.js'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { fightEvents } from './events.js'
|
||||
|
||||
export interface FightResult {
|
||||
fightId: string
|
||||
@@ -23,6 +24,7 @@ export interface FightLoopOptions {
|
||||
matchmakingStyle?: 'random' | 'elo_close' | 'mixed'
|
||||
onFightStart?: (botAName: string, botAElo: number, botBName: string, botBElo: number) => void
|
||||
onFightComplete?: (result: FightResult) => void
|
||||
onRoundComplete?: (data: { round: number; hp: { a: number; b: number }; challengeType: string; winnerId: string | null }) => void
|
||||
onError?: (err: Error) => void
|
||||
}
|
||||
|
||||
@@ -33,6 +35,7 @@ export async function startFightLoop(options: FightLoopOptions = {}): Promise<vo
|
||||
matchmakingStyle = 'mixed',
|
||||
onFightStart,
|
||||
onFightComplete,
|
||||
onRoundComplete,
|
||||
onError,
|
||||
} = options
|
||||
|
||||
@@ -67,7 +70,23 @@ export async function startFightLoop(options: FightLoopOptions = {}): Promise<vo
|
||||
onFightStart(botA.name, botA.eloRating, botB.name, botB.eloRating)
|
||||
}
|
||||
|
||||
// Subscribe to round events for live TUI updates
|
||||
let unsub: (() => void) | undefined
|
||||
if (onRoundComplete) {
|
||||
unsub = fightEvents.onAll((event) => {
|
||||
if (event.type === 'round_end') {
|
||||
onRoundComplete({
|
||||
round: event.data.round as number,
|
||||
hp: event.data.hp as { a: number; b: number },
|
||||
challengeType: (event.data.result as any)?.challengeType || '',
|
||||
winnerId: (event.data.result as any)?.winnerId || null,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fightId = await runMockFight(botA.id, botB.id)
|
||||
unsub?.()
|
||||
|
||||
// Fetch result
|
||||
const fight = await db.select({
|
||||
|
||||
@@ -18,6 +18,14 @@ interface PendingChallenge {
|
||||
|
||||
const pending = new Map<string, PendingChallenge>()
|
||||
|
||||
/** Clear all pending human challenges (used during graceful shutdown) */
|
||||
export function clearAllPending(): void {
|
||||
for (const [key, entry] of pending) {
|
||||
clearTimeout(entry.timeoutHandle)
|
||||
pending.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_HUMAN_TIMEOUT_MS = 8_000
|
||||
|
||||
export function isHumanPlayer(webhookUrl: string): boolean {
|
||||
|
||||
@@ -37,6 +37,10 @@ const MAX_RESPONSE_BYTES = 10 * 1024 // 10KB
|
||||
// Track bots currently in a fight to prevent concurrent fights
|
||||
const activeFighters = new Set<string>()
|
||||
|
||||
export function getActiveFighterCount(): number {
|
||||
return activeFighters.size
|
||||
}
|
||||
|
||||
export function isInFight(botId: string): boolean {
|
||||
return activeFighters.has(botId)
|
||||
}
|
||||
@@ -384,7 +388,7 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
||||
fightId,
|
||||
roundNumber: round,
|
||||
challengeType: challenge.type,
|
||||
challengeData: JSON.stringify({ prompt: challenge.prompt, scoring: challenge.scoring, retroKnown: challenge.type === 'retro_mode' ? challenge.answers : undefined }),
|
||||
challengeData: JSON.stringify({ prompt: challenge.prompt, displayPrompt: challenge.displayPrompt, scoring: challenge.scoring, retroKnown: challenge.type === 'retro_mode' ? challenge.answers : undefined }),
|
||||
botAResponse: responseA.answer,
|
||||
botATimeMs: responseA.timeMs,
|
||||
botAScore: result.botAScore,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { db, schema, sqlite } from '../db/index.js'
|
||||
import { eq, and, isNull, sql } from 'drizzle-orm'
|
||||
import { finalizeEvent, getPublicKey } from 'nostr-tools'
|
||||
import * as nip04 from 'nostr-tools/nip04'
|
||||
@@ -643,22 +643,13 @@ export async function consumePaymentForQueue(paymentId: string, botId: string):
|
||||
// Fast path: already consumed in this server lifetime
|
||||
if (consumedPayments.has(paymentId)) return false
|
||||
|
||||
// Verify payment belongs to this bot, is confirmed, inbound, and not linked to a fight
|
||||
const rows = await db.select({
|
||||
id: schema.payments.id,
|
||||
botId: schema.payments.botId,
|
||||
status: schema.payments.status,
|
||||
direction: schema.payments.direction,
|
||||
fightId: schema.payments.fightId,
|
||||
}).from(schema.payments).where(eq(schema.payments.id, paymentId)).limit(1)
|
||||
|
||||
if (rows.length === 0) return false
|
||||
const p = rows[0]
|
||||
if (p.botId !== botId) return false
|
||||
if (p.status !== 'confirmed') return false
|
||||
if (p.direction !== 'in') return false
|
||||
if (p.fightId !== null) return false // already linked to a fight
|
||||
// Atomic: only marks as consumed if ALL conditions met in a single UPDATE
|
||||
// Eliminates race window between SELECT check and later UPDATE
|
||||
const result = sqlite.prepare(
|
||||
`UPDATE payments SET status = 'consumed' WHERE id = ? AND bot_id = ? AND status = 'confirmed' AND direction = 'in' AND fight_id IS NULL`
|
||||
).run(paymentId, botId)
|
||||
|
||||
if (result.changes === 0) return false
|
||||
consumedPayments.add(paymentId)
|
||||
return true
|
||||
}
|
||||
@@ -680,6 +671,8 @@ export async function linkPaymentsToFight(fightId: string, paymentIds: string[])
|
||||
*/
|
||||
export function releasePayment(paymentId: string): void {
|
||||
consumedPayments.delete(paymentId)
|
||||
// Revert DB status so the payment can be re-consumed or refunded
|
||||
sqlite.prepare(`UPDATE payments SET status = 'confirmed' WHERE id = ? AND status = 'consumed'`).run(paymentId)
|
||||
}
|
||||
|
||||
export { ENTRY_FEE_SATS, POT_SATS }
|
||||
|
||||
@@ -122,12 +122,14 @@ export function generateRetroChallenge(): Challenge {
|
||||
|
||||
const moveList = knownMoves.map(m => ` ${m.input} = ${m.name} (${m.damage} dmg)`).join('\n')
|
||||
|
||||
const displayPrompt = 'Binary Gamepads at the ready, GO!'
|
||||
const prompt = `ARCADE ROUND! Pick 3 moves:\n${moveList}\n\nSecret combos exist — longer chains hit harder.\nAnswer: combo1 | combo2 | combo3`
|
||||
|
||||
return {
|
||||
type: 'retro_mode',
|
||||
label: 'Retro Mode',
|
||||
prompt,
|
||||
displayPrompt,
|
||||
answers: knownInputs,
|
||||
timeout_ms: 12000,
|
||||
scoring: 'factual',
|
||||
|
||||
@@ -79,6 +79,16 @@ async function main() {
|
||||
renderer.render()
|
||||
},
|
||||
|
||||
onRoundComplete: (data) => {
|
||||
if (state.currentFight) {
|
||||
state.currentFight.round = data.round
|
||||
state.currentFight.botA.hp = data.hp.a
|
||||
state.currentFight.botB.hp = data.hp.b
|
||||
if (data.challengeType) state.currentFight.challengeType = data.challengeType
|
||||
renderer.render()
|
||||
}
|
||||
},
|
||||
|
||||
onFightComplete: async (result) => {
|
||||
state.completed++
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { app } from './app.js'
|
||||
import { runMigrations } from './db/startup.js'
|
||||
import { seedMockBots, seedClassicBots } from './engine/mock.js'
|
||||
import { startBackgroundFights } from './engine/background.js'
|
||||
import { getActiveFighterCount } from './engine/orchestrator.js'
|
||||
import { clearAllPending } from './engine/human-responses.js'
|
||||
|
||||
// Production env validation — warn but don't crash (wallet features degrade gracefully)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
@@ -11,6 +13,9 @@ if (process.env.NODE_ENV === 'production') {
|
||||
if (missing.length > 0) {
|
||||
console.warn(`[botfights] WARNING: Missing env vars: ${missing.join(', ')} — wallet/payment features disabled`)
|
||||
}
|
||||
if (!process.env.CORS_ORIGIN || process.env.CORS_ORIGIN === '*') {
|
||||
console.warn('[botfights] WARNING: CORS_ORIGIN not set — allowing all origins')
|
||||
}
|
||||
}
|
||||
|
||||
// Run migrations and seed mock bots before starting the server
|
||||
@@ -26,3 +31,18 @@ serve({ fetch: app.fetch, port }, () => {
|
||||
// Start background fight loop so the site always has fresh activity
|
||||
startBackgroundFights()
|
||||
})
|
||||
|
||||
// Graceful shutdown — drain in-flight fights before exiting
|
||||
const shutdown = async (signal: string) => {
|
||||
console.log(`[botfights] ${signal} received, shutting down...`)
|
||||
const active = getActiveFighterCount()
|
||||
if (active > 0) {
|
||||
console.log(`[botfights] ${active} fighters still active, waiting 10s...`)
|
||||
}
|
||||
await new Promise(r => setTimeout(r, active > 0 ? 10_000 : 500))
|
||||
clearAllPending()
|
||||
console.log('[botfights] shutdown complete')
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => shutdown('SIGINT'))
|
||||
|
||||
Reference in New Issue
Block a user