feat: THE CREATOR god-tier character, bitcoin choreographies, omni-morph, cameos

- Guy Fawkes mask archetype with golden outline, bitcoin chest symbol, laptop, tier-gated effects
- 12 custom bitcoin-themed choreographies (8 regular + 4 exclusive ultimates)
- Omni-morph system: creator morphs into any of 80+ archetypes instead of cycling 3
- 6% per-round cameo in non-creator fights — drops golden ₿ gifts to fighters
- Custom golden code rain entrance with persistent orbiting particles
- pickChoreography: 50% ultimate chance (100% on crits), all tier ultimates + 4 exclusive
- Server auto-assigns the_creator archetype on login/register for creator pubkey
- FightViewer, payments, queue, orchestrator improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 12:08:18 +00:00
co-authored by Claude Opus 4.6
parent 8a3480e5ab
commit 6d390f69b2
14 changed files with 1513 additions and 104 deletions
+11 -2
View File
@@ -72,8 +72,17 @@ if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) {
return c.body(readFileSync(resolved))
}
// Hashed assets — immutable cache
app.get('/assets/*', (c) => serveFile(c, c.req.path, 'public, max-age=31536000, immutable'))
// Hashed assets — immutable cache. If missing (stale deploy), return JS that triggers reload.
app.get('/assets/*', (c) => {
const resolved = join(publicDir, c.req.path)
if (!resolved.startsWith(publicDir) || !existsSync(resolved)) {
// Stale chunk hash from old deploy — tell the browser to reload
c.header('Content-Type', 'application/javascript')
c.header('Cache-Control', 'no-cache')
return c.body('window.location.reload();')
}
return serveFile(c, c.req.path, 'public, max-age=31536000, immutable')
})
// Root static files (favicon, manifest, robots, etc.)
app.get('/favicon.ico', (c) => serveFile(c, '/favicon.ico', 'public, max-age=86400'))
+1 -1
View File
@@ -17,7 +17,7 @@ const VALID_ARCHETYPES = new Set([
'hamster', 'sock_puppet', 'traffic_cone', 'toilet_man', 'potato',
'cloud_man', 'rock_man', 'balloon_man', 'trash_can', 'rubber_duck',
'snowman', 'scarecrow', 'jack_o_lantern', 'garden_gnome', 'lamp_post',
'broom_man', 'human',
'broom_man', 'human', 'the_creator',
])
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/
+18 -3
View File
@@ -52,17 +52,32 @@ function emit(fightId: string, type: string, data: Record<string, unknown>) {
// SSRF protection: block internal/private URLs
function isAllowedWebhookUrl(url: string): boolean {
try {
if (typeof url !== 'string' || url.length > 2048) return false
const parsed = new URL(url)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
const hostname = parsed.hostname.toLowerCase()
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false
// Localhost variants
if (hostname === 'localhost' || hostname === '::1') return false
if (hostname.startsWith('127.')) return false
// IPv6-mapped IPv4 localhost
if (hostname.startsWith('::ffff:127.')) return false
// Private IPv4 ranges
if (hostname.startsWith('10.')) return false
if (hostname.startsWith('192.168.')) return false
if (hostname.startsWith('172.')) {
const second = parseInt(hostname.split('.')[1])
if (second >= 16 && second <= 31) return false
}
if (hostname === '169.254.169.254') return false
if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false
// Link-local and metadata
if (hostname.startsWith('169.254.')) return false
// IPv6 private (fc00::/7)
if (hostname.startsWith('fc') || hostname.startsWith('fd')) return false
// IPv6 link-local (fe80::/10)
if (hostname.startsWith('fe80')) return false
// Reserved TLDs
if (hostname.endsWith('.local') || hostname.endsWith('.internal') || hostname.endsWith('.localhost')) return false
// Null byte injection
if (hostname.includes('\0')) return false
return true
} catch {
return false
+78 -5
View File
@@ -246,6 +246,26 @@ 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 name for payout description
const winnerRows = await db.select({ name: schema.bots.name })
.from(schema.bots).where(eq(schema.bots.id, winnerId)).limit(1)
const winnerName = winnerRows[0]?.name || 'unknown'
// Look up loser for the payout message
const fightRows = await db.select({
botAId: schema.fights.botAId,
botBId: schema.fights.botBId,
}).from(schema.fights).where(eq(schema.fights.id, fightId)).limit(1)
let loserName = 'opponent'
if (fightRows.length > 0) {
const loserId = fightRows[0].botAId === winnerId ? fightRows[0].botBId : fightRows[0].botAId
const loserRows = await db.select({ name: schema.bots.name })
.from(schema.bots).where(eq(schema.bots.id, loserId)).limit(1)
loserName = loserRows[0]?.name || 'opponent'
}
const payoutDesc = `BOTFIGHTS VICTORY: ${winnerName} defeated ${loserName}! ${POT_SATS} sats prize`
// Look up winner's wallet connection
const walletRows = await db.select().from(schema.walletConnections)
.where(eq(schema.walletConnections.botId, winnerId)).limit(1)
@@ -262,13 +282,13 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
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)
invoice = await resolveAndCreateInvoice(devPayoutAddr, POT_SATS, payoutDesc)
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)
invoice = await resolveAndCreateInvoice(decrypt(wallet.connectionData), POT_SATS, payoutDesc)
await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning'
@@ -276,7 +296,7 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
// Request invoice from winner's NWC wallet, then pay it via server wallet
const winnerResult = await nwcRequestVia(decrypt(wallet.connectionData), 'make_invoice', {
amount: POT_SATS * 1000,
description: `Botfights ranked win payout (${POT_SATS} sats)`,
description: payoutDesc,
})
invoice = winnerResult.invoice as string
if (!invoice) throw new Error('Winner NWC make_invoice returned no invoice')
@@ -419,7 +439,7 @@ async function nwcRequestVia(
}
/** Resolve a Lightning Address to a BOLT11 invoice */
async function resolveAndCreateInvoice(lnAddress: string, amountSats: number): Promise<string> {
async function resolveAndCreateInvoice(lnAddress: string, amountSats: number, comment?: string): Promise<string> {
const [name, domain] = lnAddress.split('@')
if (!name || !domain) throw new Error(`Invalid Lightning Address: ${lnAddress}`)
@@ -443,6 +463,7 @@ async function resolveAndCreateInvoice(lnAddress: string, amountSats: number): P
const callbackUrl = new URL(data.callback)
callbackUrl.searchParams.set('amount', String(amountMillisats))
if (comment) callbackUrl.searchParams.set('comment', comment)
const invoiceRes = await fetch(callbackUrl.toString())
if (!invoiceRes.ok) throw new Error(`LNURL callback failed: ${invoiceRes.status}`)
@@ -474,7 +495,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
if (walletRows[0]?.method === 'nwc') {
const result = await nwcRequestVia(decrypt(walletRows[0].connectionData), 'make_invoice', {
amount: ENTRY_FEE_SATS * 1000,
description: 'Botfights ranked refund',
description: `BOTFIGHTS REFUND: ${ENTRY_FEE_SATS} sats ranked entry fee returned`,
})
const invoice = result.invoice as string
if (invoice) {
@@ -609,4 +630,56 @@ export async function recoverOrphanedPayments(): Promise<void> {
}
}
// In-memory set of payment IDs currently consumed by the ranked queue.
// Safe because Node.js is single-threaded and the ranked queue is also in-memory.
// On server restart, the queue is empty and recoverOrphanedPayments handles cleanup.
const consumedPayments = new Set<string>()
/**
* Consume a confirmed entry payment for queue use.
* Returns true if the payment was successfully consumed, false if already used.
*/
export async function consumePaymentForQueue(paymentId: string, botId: string): Promise<boolean> {
// 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
consumedPayments.add(paymentId)
return true
}
/**
* Link consumed entry payments to the actual fight.
* Called after a ranked fight is created. Also removes from consumed set.
*/
export async function linkPaymentsToFight(fightId: string, paymentIds: string[]): Promise<void> {
for (const pid of paymentIds) {
await db.update(schema.payments).set({ fightId })
.where(eq(schema.payments.id, pid))
consumedPayments.delete(pid)
}
}
/**
* Release a consumed payment back for refund when queue times out or bot leaves.
*/
export function releasePayment(paymentId: string): void {
consumedPayments.delete(paymentId)
}
export { ENTRY_FEE_SATS, POT_SATS }
+26 -6
View File
@@ -1,7 +1,7 @@
import { db, schema } from '../db/index.js'
import { eq, sql } from 'drizzle-orm'
import { runFightAsync, isInFight } from './orchestrator.js'
import { checkPaymentStatus, refundEntry } from './payments.js'
import { checkPaymentStatus, refundEntry, consumePaymentForQueue, linkPaymentsToFight, releasePayment } from './payments.js'
interface RankedQueueEntry {
botId: string
@@ -36,35 +36,49 @@ export function getRankedQueueStatus(): { waiting: number } {
* NEVER matches against mock bots.
*/
export async function joinRankedQueue(botId: string, paymentId: string): Promise<string> {
// Verify payment is confirmed
// Verify payment is confirmed (checks NWC if still pending)
const status = await checkPaymentStatus(paymentId)
if (status !== 'confirmed') {
throw new Error(`Payment not confirmed (status: ${status}). Cannot join ranked queue.`)
}
// Consume the payment — prevents double-spend
// Verifies payment is confirmed, unused, belongs to this bot
const consumed = await consumePaymentForQueue(paymentId, botId)
if (!consumed) {
throw new Error('Payment already used or does not belong to this bot.')
}
// Check cooldown
const cooldownUntil = rankedCooldowns.get(botId)
if (cooldownUntil && Date.now() < cooldownUntil) {
const waitSec = Math.ceil((cooldownUntil - Date.now()) / 1000)
releasePayment(paymentId)
throw new Error(`Cooldown active. Wait ${waitSec}s.`)
}
// Check if already in a fight
if (isInFight(botId)) {
releasePayment(paymentId)
throw new Error('Bot is already in a fight.')
}
// Load bot
const botRows = await db.select().from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (botRows.length === 0) throw new Error('Bot not found')
if (botRows.length === 0) {
releasePayment(paymentId)
throw new Error('Bot not found')
}
const bot = botRows[0]
if (!bot.isActive) {
releasePayment(paymentId)
throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.')
}
// NEVER allow mock/classic bots in ranked
if (bot.webhookUrl.startsWith('http://mock.local') || bot.webhookUrl.startsWith('http://classic.local')) {
releasePayment(paymentId)
throw new Error('Practice bots cannot join ranked fights.')
}
@@ -75,6 +89,8 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
if (existing !== -1) {
const old = rankedQueue.splice(existing, 1)[0]
clearTimeout(old.timeoutHandle)
// Release the OLD payment (the new one is already consumed)
releasePayment(old.paymentId)
old.reject(new Error('Rejoined ranked queue'))
}
@@ -89,8 +105,9 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
const opponent = rankedQueue.shift()!
clearTimeout(opponent.timeoutHandle)
// Start ranked fight
// Start ranked fight and link both payments
const fightId = await runFightAsync(opponent.botId, botId, 'ranked')
await linkPaymentsToFight(fightId, [opponent.paymentId, paymentId])
opponent.resolve(fightId)
return fightId
}
@@ -103,6 +120,7 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
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')
await linkPaymentsToFight(fightId, [paymentId])
return fightId
}
}
@@ -113,7 +131,8 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
const idx = rankedQueue.findIndex(e => e.botId === botId)
if (idx !== -1) {
rankedQueue.splice(idx, 1)
// Refund the entry fee
// Release payment back to refundable state, then refund
releasePayment(paymentId)
try {
await refundEntry(paymentId)
reject(new Error('No ranked opponent found — entry fee refunded.'))
@@ -146,8 +165,9 @@ export async function leaveRankedQueue(botId: string): Promise<boolean> {
const entry = rankedQueue.splice(idx, 1)[0]
clearTimeout(entry.timeoutHandle)
// Refund entry fee
// Release payment, then refund
try {
releasePayment(entry.paymentId)
await refundEntry(entry.paymentId)
} catch (err) {
console.error(`[ranked-queue] refund failed for ${entry.paymentId}:`, err)
+21 -3
View File
@@ -10,6 +10,9 @@ import { rateLimit } from '../middleware/rate-limit.js'
export const authRouter = new Hono()
// The Creator — game founder pubkey (auto-assigns the_creator archetype)
const CREATOR_PUBKEY = "da5e0c1b646bdb13c2300f805b0ca3e5afe5b052c594ce78bac8978d21c3fa39"
// Check name availability
authRouter.get("/check-name/:name", async (c) => {
const name = c.req.param("name")?.trim().toLowerCase()
@@ -58,6 +61,12 @@ authRouter.post('/login', async (c) => {
const bot = rows[0]
const isHuman = bot.webhookUrl === 'http://human.local/'
// Auto-upgrade: if creator logs in, ensure archetype is always the_creator
if (pubkey === CREATOR_PUBKEY && bot.archetype !== "the_creator") {
await db.update(schema.bots).set({ archetype: "the_creator" }).where(eq(schema.bots.id, bot.id))
bot.archetype = "the_creator"
}
return c.json({
exists: true,
bot: {
@@ -154,7 +163,8 @@ authRouter.post('/register', rateLimit(3600_000, 15), async (c) => {
const id = nanoid(12)
const secret = randomBytes(32).toString('hex')
const effectiveArchetype = custResult.data.archetype || archetype || 'standard'
const baseArchetype = custResult.data.archetype || archetype || 'standard'
const effectiveArchetype = pubkey === CREATOR_PUBKEY ? 'the_creator' : baseArchetype
const custJson = Object.keys(custResult.data).length > 0 ? JSON.stringify(custResult.data) : null
await db.insert(schema.bots).values({
@@ -248,7 +258,7 @@ authRouter.post('/update', async (c) => {
const body = await c.req.json()
const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = body
if (!pubkey || typeof pubkey !== 'string') {
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
return c.json({ error: 'Invalid pubkey.' }, 400)
}
@@ -264,6 +274,9 @@ authRouter.post('/update', async (c) => {
const updates: Record<string, unknown> = {}
if (webhookUrl) {
if (typeof webhookUrl !== 'string' || webhookUrl.length > 2048) {
return c.json({ error: 'Invalid webhookUrl.' }, 400)
}
if (!isAllowedWebhookUrl(webhookUrl)) {
return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400)
}
@@ -279,7 +292,12 @@ authRouter.post('/update', async (c) => {
updates.isActive = true
}
if (profilePicUrl) updates.profilePicUrl = profilePicUrl
if (profilePicUrl) {
if (typeof profilePicUrl !== 'string' || profilePicUrl.length > 2048 || !/^https?:\/\//.test(profilePicUrl)) {
return c.json({ error: 'profilePicUrl must be a valid HTTP(S) URL.' }, 400)
}
updates.profilePicUrl = profilePicUrl
}
if (rawCustomization !== undefined) {
const custResult = validateCustomization(rawCustomization)
+49 -8
View File
@@ -4,6 +4,7 @@ import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
import { encrypt, decrypt } from '../engine/crypto.js'
import { rateLimit } from '../middleware/rate-limit.js'
export const paymentsRouter = new Hono()
@@ -82,11 +83,23 @@ paymentsRouter.get('/wallet-status', async (c) => {
return c.json({ connected: true, method: walletRows[0].method })
})
// POST /create-invoice
paymentsRouter.post('/create-invoice', async (c) => {
const { botId } = await c.req.json<{ botId: string }>()
// POST /create-invoice — rate limited: 10 per minute per IP
paymentsRouter.post('/create-invoice', rateLimit(60_000, 10), async (c) => {
const { botId, pubkey } = await c.req.json<{ botId: string; pubkey?: string }>()
if (!botId) return c.json({ error: 'Missing botId' }, 400)
// In production, verify bot ownership
if (process.env.NODE_ENV === 'production') {
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
return c.json({ error: 'Missing pubkey' }, 400)
}
const botRows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
return c.json({ error: 'Unauthorized' }, 403)
}
}
try {
const result = await createEntryInvoice(botId)
return c.json(result)
@@ -96,9 +109,12 @@ paymentsRouter.post('/create-invoice', async (c) => {
}
})
// GET /check/:paymentId
paymentsRouter.get('/check/:paymentId', async (c) => {
// GET /check/:paymentId — rate limited: 30 per minute per IP
paymentsRouter.get('/check/:paymentId', rateLimit(60_000, 30), async (c) => {
const paymentId = c.req.param('paymentId')
if (!paymentId || paymentId.length > 24) {
return c.json({ error: 'Invalid paymentId' }, 400)
}
try {
const status = await checkPaymentStatus(paymentId)
return c.json({ status })
@@ -109,8 +125,12 @@ paymentsRouter.get('/check/:paymentId', async (c) => {
})
// POST /confirm/:paymentId — frontend confirms after NWC pay returns preimage
paymentsRouter.post('/confirm/:paymentId', async (c) => {
paymentsRouter.post('/confirm/:paymentId', rateLimit(60_000, 20), async (c) => {
const paymentId = c.req.param('paymentId')
if (!paymentId || paymentId.length > 24) {
return c.json({ error: 'Invalid paymentId' }, 400)
}
const { preimage, pubkey } = await c.req.json<{ preimage?: string; pubkey?: string }>().catch(() => ({ preimage: undefined, pubkey: undefined }))
const rows = await db.select().from(schema.payments)
@@ -119,16 +139,37 @@ paymentsRouter.post('/confirm/:paymentId', async (c) => {
const payment = rows[0]
if (payment.status === 'confirmed') return c.json({ status: 'confirmed' })
if (payment.status !== 'pending') return c.json({ error: 'Payment is not pending' }, 400)
// Must be an inbound entry payment
if (payment.direction !== 'in') return c.json({ error: 'Cannot confirm outbound payments' }, 400)
// Verify caller owns this payment's bot
if (pubkey) {
if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) {
const botRows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
return c.json({ error: 'Unauthorized' }, 403)
}
} else if (process.env.NODE_ENV === 'production') {
return c.json({ error: 'Missing pubkey' }, 400)
return c.json({ error: 'Missing or invalid pubkey' }, 400)
}
// In production, also verify payment via NWC lookup (belt and suspenders)
if (process.env.NODE_ENV === 'production' && payment.invoice && payment.invoice !== 'dev_auto_confirmed') {
try {
const serverStatus = await checkPaymentStatus(paymentId)
if (serverStatus !== 'confirmed') {
return c.json({ error: 'Server could not verify payment. Try again.' }, 402)
}
// checkPaymentStatus already updated the DB
return c.json({ status: 'confirmed' })
} catch {
// NWC check failed — fall through to client-confirmed path with preimage
if (!preimage) {
return c.json({ error: 'Payment verification failed and no preimage provided.' }, 402)
}
}
}
await db.update(schema.payments).set({
+15 -2
View File
@@ -3,6 +3,7 @@ import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine/queue.js'
import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js'
import { rateLimit } from '../middleware/rate-limit.js'
export const queueRouter = new Hono()
@@ -48,15 +49,27 @@ queueRouter.get('/ranked-status', (c) => {
return c.json(getRankedQueueStatus())
})
// Join ranked queue — requires confirmed payment
// Join ranked queue — requires confirmed payment + bot ownership
queueRouter.post('/join-ranked/:botId', async (c) => {
const botId = c.req.param('botId')
const { paymentId } = await c.req.json<{ paymentId: string }>()
const { paymentId, pubkey } = await c.req.json<{ paymentId: string; pubkey?: string }>()
if (!paymentId) {
return c.json({ error: 'Missing paymentId' }, 400)
}
// Verify bot ownership in production
if (process.env.NODE_ENV === 'production') {
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
return c.json({ error: 'Missing pubkey' }, 400)
}
const botRows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
return c.json({ error: 'Unauthorized' }, 403)
}
}
try {
const fightId = await joinRankedQueue(botId, paymentId)
return c.json({ fightId, message: 'Ranked match found! Fight starting.' })