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
+58 -3
View File
@@ -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')