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:
co-authored by
Claude Opus 4.6
parent
ccf4196647
commit
f6eb7d2845
@@ -57,7 +57,7 @@ authRouter.post('/login', async (c) => {
|
||||
})
|
||||
|
||||
// Register a new bot with Nostr pubkey
|
||||
authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
|
||||
authRouter.post('/register', rateLimit(3600_000, 15), async (c) => {
|
||||
const body = await c.req.json()
|
||||
const { pubkey, name, webhookUrl, archetype, profilePicUrl, customization: rawCustomization } = body
|
||||
|
||||
@@ -65,8 +65,8 @@ authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
|
||||
return c.json({ error: 'Invalid pubkey.' }, 400)
|
||||
}
|
||||
|
||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 32) {
|
||||
return c.json({ error: 'Name must be 2-32 characters.' }, 400)
|
||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) {
|
||||
return c.json({ error: 'Name must be 2-12 characters.' }, 400)
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
@@ -156,7 +156,7 @@ authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
|
||||
|
||||
|
||||
// Register a human player (no webhook required)
|
||||
authRouter.post('/register-human', rateLimit(3600_000, 5), async (c) => {
|
||||
authRouter.post('/register-human', rateLimit(3600_000, 15), async (c) => {
|
||||
const body = await c.req.json()
|
||||
const { pubkey, name, profilePicUrl, avatarSeed } = body
|
||||
|
||||
@@ -164,8 +164,8 @@ authRouter.post('/register-human', rateLimit(3600_000, 5), async (c) => {
|
||||
return c.json({ error: 'Invalid pubkey.' }, 400)
|
||||
}
|
||||
|
||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 32) {
|
||||
return c.json({ error: 'Name must be 2-32 characters.' }, 400)
|
||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) {
|
||||
return c.json({ error: 'Name must be 2-12 characters.' }, 400)
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
|
||||
@@ -20,8 +20,8 @@ botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
|
||||
const body = await c.req.json()
|
||||
const { name, webhook_url, avatar_seed } = body
|
||||
|
||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 32) {
|
||||
return c.json({ error: 'Name must be 2-32 characters.' }, 400)
|
||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) {
|
||||
return c.json({ error: 'Name must be 2-12 characters.' }, 400)
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
@@ -89,6 +89,7 @@ botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
|
||||
|
||||
// List bots (public info only)
|
||||
botsRouter.get('/', async (c) => {
|
||||
const type = c.req.query('type') // 'classic' to get classic bots, default excludes them
|
||||
const rows = await db.select({
|
||||
id: schema.bots.id,
|
||||
name: schema.bots.name,
|
||||
@@ -102,10 +103,15 @@ botsRouter.get('/', async (c) => {
|
||||
isActive: schema.bots.isActive,
|
||||
archetype: schema.bots.archetype,
|
||||
customization: schema.bots.customization,
|
||||
botType: schema.bots.botType,
|
||||
createdAt: schema.bots.createdAt,
|
||||
}).from(schema.bots).orderBy(schema.bots.eloRating)
|
||||
|
||||
return c.json(rows.map(r => ({
|
||||
const filtered = type === 'classic'
|
||||
? rows.filter(r => r.botType === 'classic')
|
||||
: rows.filter(r => r.botType !== 'classic')
|
||||
|
||||
return c.json(filtered.map(r => ({
|
||||
...r,
|
||||
customization: r.customization ? JSON.parse(r.customization) : null,
|
||||
})))
|
||||
@@ -127,6 +133,7 @@ botsRouter.get('/:name', async (c) => {
|
||||
isActive: schema.bots.isActive,
|
||||
archetype: schema.bots.archetype,
|
||||
customization: schema.bots.customization,
|
||||
botType: schema.bots.botType,
|
||||
createdAt: schema.bots.createdAt,
|
||||
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
|
||||
|
||||
@@ -158,6 +165,7 @@ botsRouter.get('/:name/stats', async (c) => {
|
||||
isActive: schema.bots.isActive,
|
||||
archetype: schema.bots.archetype,
|
||||
customization: schema.bots.customization,
|
||||
botType: schema.bots.botType,
|
||||
createdAt: schema.bots.createdAt,
|
||||
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
|
||||
|
||||
@@ -173,13 +181,15 @@ botsRouter.get('/:name/stats', async (c) => {
|
||||
const total = bot.wins + bot.losses
|
||||
const winRate = total > 0 ? Math.round((bot.wins / total) * 100) : 0
|
||||
|
||||
// Get rank position
|
||||
// Get rank position (exclude classic bots from ranking)
|
||||
const allBots = await db.select({
|
||||
id: schema.bots.id,
|
||||
eloRating: schema.bots.eloRating,
|
||||
botType: schema.bots.botType,
|
||||
}).from(schema.bots)
|
||||
allBots.sort((a, b) => b.eloRating - a.eloRating)
|
||||
const rank = allBots.findIndex(b => b.id === bot.id) + 1
|
||||
const rankedBots = allBots.filter(b => b.botType !== 'classic')
|
||||
rankedBots.sort((a, b) => b.eloRating - a.eloRating)
|
||||
const rank = rankedBots.findIndex(b => b.id === bot.id) + 1
|
||||
|
||||
// All fights for achievements
|
||||
const allFights = await db.select({
|
||||
@@ -246,7 +256,7 @@ botsRouter.get('/:name/stats', async (c) => {
|
||||
winRate,
|
||||
totalFights: total,
|
||||
rank,
|
||||
totalBots: allBots.length,
|
||||
totalBots: rankedBots.length,
|
||||
recentFights,
|
||||
achievements,
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { streamSSE } from 'hono/streaming'
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { eq, desc } from 'drizzle-orm'
|
||||
import { ARENAS } from '../engine/arenas.js'
|
||||
import { runMockFight } from '../engine/mock.js'
|
||||
import { runMockFight, isClassicBot } from '../engine/mock.js'
|
||||
import { startFightLoop } from '../engine/fight-loop.js'
|
||||
import { runFight, runFightAsync, isInFight } from '../engine/orchestrator.js'
|
||||
import { fightEvents } from '../engine/events.js'
|
||||
@@ -26,7 +26,7 @@ fightsRouter.get('/', async (c) => {
|
||||
if (f.winnerId) botIds.add(f.winnerId)
|
||||
}
|
||||
|
||||
const botMap = new Map<string, { name: string; avatarSeed: string; archetype: string; eloRating: number; tier: number }>()
|
||||
const botMap = new Map<string, { name: string; avatarSeed: string; archetype: string; eloRating: number; tier: number; botType: string }>()
|
||||
for (const id of botIds) {
|
||||
const bot = await db.select({
|
||||
name: schema.bots.name,
|
||||
@@ -34,6 +34,7 @@ fightsRouter.get('/', async (c) => {
|
||||
archetype: schema.bots.archetype,
|
||||
eloRating: schema.bots.eloRating,
|
||||
tier: schema.bots.tier,
|
||||
botType: schema.bots.botType,
|
||||
}).from(schema.bots).where(eq(schema.bots.id, id)).limit(1)
|
||||
if (bot[0]) botMap.set(id, bot[0])
|
||||
}
|
||||
@@ -78,6 +79,7 @@ fightsRouter.get('/:id', async (c) => {
|
||||
wins: schema.bots.wins,
|
||||
losses: schema.bots.losses,
|
||||
tier: schema.bots.tier,
|
||||
botType: schema.bots.botType,
|
||||
}
|
||||
|
||||
const [botARows, botBRows] = await Promise.all([
|
||||
@@ -194,7 +196,8 @@ fightsRouter.post('/matchmake/:botId', botRateLimit(10_000), async (c) => {
|
||||
const allBots = await db.select()
|
||||
.from(schema.bots)
|
||||
|
||||
const opponents = allBots.filter(b => b.id !== botId)
|
||||
// Exclude classic bots from regular matchmaking — use /practice for those
|
||||
const opponents = allBots.filter(b => b.id !== botId && b.botType !== 'classic')
|
||||
if (opponents.length === 0) {
|
||||
return c.json({ error: 'No opponents available.' }, 400)
|
||||
}
|
||||
@@ -223,6 +226,58 @@ fightsRouter.post('/matchmake/:botId', botRateLimit(10_000), async (c) => {
|
||||
})
|
||||
})
|
||||
|
||||
// Practice fight against a random classic bot (free, no sats)
|
||||
fightsRouter.post('/practice/:botId', botRateLimit(10_000), async (c) => {
|
||||
const botId = c.req.param('botId') as string
|
||||
|
||||
const botRows = await db.select()
|
||||
.from(schema.bots)
|
||||
.where(eq(schema.bots.id, botId))
|
||||
.limit(1)
|
||||
|
||||
if (botRows.length === 0) {
|
||||
return c.json({ error: 'Bot not found.' }, 404)
|
||||
}
|
||||
|
||||
const bot = botRows[0]
|
||||
|
||||
if (isInFight(botId)) {
|
||||
return c.json({ error: 'Bot is already in a fight.' }, 400)
|
||||
}
|
||||
|
||||
// Find all classic bots
|
||||
const classicBots = await db.select()
|
||||
.from(schema.bots)
|
||||
.where(eq(schema.bots.botType, 'classic'))
|
||||
|
||||
if (classicBots.length === 0) {
|
||||
return c.json({ error: 'No practice bots available.' }, 400)
|
||||
}
|
||||
|
||||
// Pick closest Elo classic bot with randomness
|
||||
classicBots.sort((a, b) => {
|
||||
const diffA = Math.abs(a.eloRating - bot.eloRating) + Math.random() * 200
|
||||
const diffB = Math.abs(b.eloRating - bot.eloRating) + Math.random() * 200
|
||||
return diffA - diffB
|
||||
})
|
||||
|
||||
const opponent = classicBots[0]
|
||||
|
||||
let fightId: string
|
||||
try {
|
||||
fightId = await runFightAsync(botId, opponent.id, 'free')
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Fight failed to start'
|
||||
return c.json({ error: msg }, 400)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
fightId,
|
||||
opponent: { id: opponent.id, name: opponent.name },
|
||||
message: 'Practice fight started.',
|
||||
})
|
||||
})
|
||||
|
||||
// Get pending challenge for a human player in an active fight
|
||||
fightsRouter.get('/:fightId/challenge/:botId', async (c) => {
|
||||
const fightId = c.req.param('fightId')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user