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:
Dorian
2026-03-08 16:23:47 +00:00
co-authored by Claude Opus 4.6
parent 0acfa4417f
commit e12fb94ae7
13 changed files with 565 additions and 514 deletions
+1
View File
@@ -7,6 +7,7 @@ export interface Challenge {
timeout_ms: number
scoring: 'factual' | 'creative'
baseDamage: number
displayPrompt?: string
}
interface PromptEntry {
+19
View File
@@ -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({
+8
View File
@@ -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 {
+5 -1
View File
@@ -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,
+9 -16
View File
@@ -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 }
+2
View File
@@ -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',