feat: v5 — boxing poster fight cards, 12-char names, diverse mock bots

- Fight Card page: dramatic poster background with cross-hatch, spotlights,
  vignettes, corner brackets, scan lines; 3D VS orb with punch animation;
  selectable undercard with main event always pinned at top
- PosterSprite: high-quality 480px poster frame with 6-pass renderer
  (aura, glow, bevel, specular, particles); PixelGlove component
- 12-char bot name limit across all forms and server validation
- Mock bots: all 100 now have diverse archetypes (25 types), 25% human
  fighters; seedMockBots updates existing bots on restart
- Leaderboard: inline SpritePreview next to each bot name
- Nostr auth: persistent login, nsec copy button
- Wallet: NWC + Lightning Address, ranked fight flow
- Server: payments, ranked queue, customization endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 10:33:30 +00:00
co-authored by Claude Opus 4.6
parent ccf4196647
commit f6eb7d2845
32 changed files with 1743 additions and 467 deletions
+47 -30
View File
@@ -3,39 +3,10 @@ import { nanoid } from 'nanoid'
import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'
import { encrypt, decrypt } from '../engine/crypto.js'
export const paymentsRouter = new Hono()
// Encryption for wallet connection data
const ENCRYPTION_KEY_HEX = process.env.BOTFIGHTS_WALLET_ENCRYPTION_KEY
let encryptionKey: Buffer
if (ENCRYPTION_KEY_HEX) {
encryptionKey = Buffer.from(ENCRYPTION_KEY_HEX, 'hex')
} else {
encryptionKey = randomBytes(32)
console.warn('[payments] WARNING: No BOTFIGHTS_WALLET_ENCRYPTION_KEY set. Generated random key — wallet data will be lost on restart.')
}
function encrypt(plaintext: string): string {
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', encryptionKey, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag()
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted.toString('hex')
}
function decrypt(ciphertext: string): string {
const [ivHex, authTagHex, encryptedHex] = ciphertext.split(':')
const iv = Buffer.from(ivHex, 'hex')
const authTag = Buffer.from(authTagHex, 'hex')
const encrypted = Buffer.from(encryptedHex, 'hex')
const decipher = createDecipheriv('aes-256-gcm', encryptionKey, iv)
decipher.setAuthTag(authTag)
return decipher.update(encrypted) + decipher.final('utf8')
}
// POST /connect-wallet
paymentsRouter.post('/connect-wallet', async (c) => {
const { pubkey, method, connectionData } = await c.req.json<{
@@ -137,6 +108,39 @@ paymentsRouter.get('/check/:paymentId', async (c) => {
}
})
// POST /confirm/:paymentId — frontend confirms after NWC pay returns preimage
paymentsRouter.post('/confirm/:paymentId', async (c) => {
const paymentId = c.req.param('paymentId')
const { preimage, pubkey } = await c.req.json<{ preimage?: string; pubkey?: string }>().catch(() => ({ preimage: undefined, pubkey: undefined }))
const rows = await db.select().from(schema.payments)
.where(eq(schema.payments.id, paymentId)).limit(1)
if (rows.length === 0) return c.json({ error: 'Payment not found' }, 404)
const payment = rows[0]
if (payment.status === 'confirmed') return c.json({ status: 'confirmed' })
// Verify caller owns this payment's bot
if (pubkey) {
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)
}
await db.update(schema.payments).set({
status: 'confirmed',
preimage: preimage || null,
confirmedAt: new Date().toISOString(),
}).where(eq(schema.payments.id, paymentId))
console.log(`[payments] payment ${paymentId} confirmed via client (preimage: ${preimage ? 'yes' : 'no'})`)
return c.json({ status: 'confirmed' })
})
// POST /submit-cashu
paymentsRouter.post('/submit-cashu', async (c) => {
const { botId, token } = await c.req.json<{ botId: string; token: string }>()
@@ -174,6 +178,7 @@ paymentsRouter.get('/winnings/:botId', async (c) => {
// POST /claim/:paymentId
paymentsRouter.post('/claim/:paymentId', async (c) => {
const paymentId = c.req.param('paymentId')
const { pubkey } = await c.req.json<{ pubkey?: string }>().catch(() => ({ pubkey: undefined }))
const rows = await db.select().from(schema.payments)
.where(eq(schema.payments.id, paymentId))
@@ -182,6 +187,18 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
if (rows.length === 0) return c.json({ error: 'Payment not found' }, 404)
const payment = rows[0]
// Verify caller owns this payment's bot
if (pubkey) {
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)
}
if (!payment.cashuToken) return c.json({ error: 'No Cashu token to claim' }, 400)
// Clear the token from DB after claiming