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
+1
View File
@@ -6,6 +6,7 @@
- **Privacy-first** -- no tracking, no telemetry
- **Bitcoin only** -- sats/Lightning/Cashu for payments, never fiat, never altcoins
- **Quality over speed** -- working code, tested, documented
- **Made for Bitcoiners** -- challenges, content, humor, and culture should reflect the Bitcoin community. Reference Bitcoin history, memes, Lightning, mining, halvings, cypherpunk values, proof of work, etc. in challenge prompts, bot names, arena themes, and narrations. No shitcoin/altcoin references except as trash talk.
## Quick Reference
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="BOTFIGHTS" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
+20 -4
View File
@@ -26,8 +26,8 @@ interface Round {
interface FightData {
id: string
botA: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
botB: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
botA: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number; botType?: string } | null
botB: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number; botType?: string } | null
arenaInfo: { id: string; name: string; description: string; modifier: string | null } | null
arena: string
winnerId: string | null
@@ -47,6 +47,7 @@ const logEl = ref<HTMLElement>()
let scene: FightSceneController | null = null
const sceneReady = ref(false)
let cleanupTimerHandle: ReturnType<typeof setTimeout> | null = null
let destroyed = false
const isReplaying = ref(false)
const displayHpA = ref(100)
@@ -96,6 +97,7 @@ function mapHp(hp: number, winnerId: string | null, botId: string | undefined):
}
onUnmounted(() => {
destroyed = true
stopAllAudio()
sceneReady.value = false
if (scene) { scene.destroy(); scene = null }
@@ -157,7 +159,14 @@ const challengeLabel = (type: string) => {
}
const tierClass = (t: number) => `tier-${t}`
function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) }
function sleep(ms: number): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (destroyed) reject(new Error('unmounted'))
else resolve()
}, ms)
})
}
function scrollLog() { nextTick(() => { logEl.value?.scrollTo({ top: logEl.value.scrollHeight, behavior: 'smooth' }) }) }
// Show floating announcement over the canvas
@@ -220,6 +229,14 @@ async function replay() {
if (isReplaying.value || !props.fight.botA || !props.fight.botB) return
isReplaying.value = true
showingFinal.value = false
try { await _doReplay() } catch (e) {
if (e instanceof Error && e.message === 'unmounted') return
console.error('[FightViewer] replay error:', e)
} finally { isReplaying.value = false }
}
async function _doReplay() {
if (!props.fight.botA || !props.fight.botB) return
displayHpA.value = 100
displayHpB.value = 100
logItems.value = []
@@ -412,7 +429,6 @@ async function replay() {
cleanupTimerHandle = null
}, 3000)
isReplaying.value = false
emit('replay-done')
}
</script>
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { generatePosterFrame } from '../game/sprites/poster'
import { ANIMATIONS } from '../game/sprites'
const props = defineProps<{
seed: string
archetype?: string
tier?: number
size?: number
pose?: keyof typeof ANIMATIONS
glowColor?: 'cyan' | 'pink' | 'purple' | 'yellow'
}>()
const imgSrc = ref('')
async function generate() {
// Always render at high resolution (480px) for quality, display size is controlled by CSS
imgSrc.value = await generatePosterFrame(
props.seed,
props.tier || 0,
props.archetype,
props.pose || 'attack',
480,
undefined,
props.glowColor || 'cyan',
)
}
onMounted(() => generate())
watch(() => [props.seed, props.archetype, props.tier, props.pose, props.glowColor], () => generate())
</script>
<template>
<img
v-if="imgSrc"
:src="imgSrc"
alt=""
class="poster-sprite-img"
:style="{ width: `${size || 400}px`, height: `${size || 400}px` }"
/>
</template>
<style scoped>
.poster-sprite-img {
image-rendering: pixelated;
display: block;
}
</style>
+5 -4
View File
@@ -8,6 +8,7 @@ const props = defineProps<{
tier?: number
size?: number
customization?: SpriteCustomization
pose?: keyof typeof ANIMATIONS
}>()
const canvasRef = ref<HTMLCanvasElement>()
@@ -24,11 +25,11 @@ function render() {
ctx.clearRect(0, 0, displaySize, displaySize)
ctx.imageSmoothingEnabled = false
const idleAnim = ANIMATIONS.idle
const f = frame % idleAnim.frames
const anim = ANIMATIONS[props.pose || 'idle']
const f = frame % anim.frames
ctx.drawImage(
img,
f * FRAME_SIZE, idleAnim.row * FRAME_SIZE, FRAME_SIZE, FRAME_SIZE,
f * FRAME_SIZE, anim.row * FRAME_SIZE, FRAME_SIZE, FRAME_SIZE,
0, 0, displaySize, displaySize,
)
@@ -46,7 +47,7 @@ function loadSprite() {
onMounted(() => loadSprite())
watch(() => [props.seed, props.archetype, props.customization], () => {
watch(() => [props.seed, props.archetype, props.customization, props.pose], () => {
if (animHandle) clearTimeout(animHandle)
frame = 0
loadSprite()
+92 -5
View File
@@ -1,4 +1,7 @@
import { ref, readonly, computed } from 'vue'
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools'
import { bytesToHex, hexToBytes } from 'nostr-tools/utils'
import { nsecEncode } from 'nostr-tools/nip19'
interface BotCustomization {
archetype?: string
@@ -76,19 +79,30 @@ export function useNostr() {
}
async function login(): Promise<{ pubkey: string; bot: BotData | null }> {
if (!window.nostr) {
throw new Error('No Nostr extension found. Install nos2x, Alby, or another NIP-07 extension.')
// Try NIP-07 extension first, fall back to stored nsec
let pk: string
if (window.nostr) {
pk = await window.nostr.getPublicKey()
} else {
const storedNsec = localStorage.getItem('bf_nsec')
if (storedNsec) {
const secretKey = hexToBytes(storedNsec)
pk = getPublicKey(secretKey)
} else {
throw new Error('No Nostr extension found and no saved key. Use "Generate Login" to create one.')
}
}
isLoading.value = true
try {
const pk = await window.nostr.getPublicKey()
pubkey.value = pk
store('bf_pubkey', pk)
// Fetch Nostr profile pic from relay
const pic = await fetchNostrProfilePic(pk)
if (pic) { profilePicUrl.value = pic; store('bf_pic', pic) }
profilePicUrl.value = pic
store('bf_pic', pic)
// Check if this pubkey has a bot
const res = await fetch('/api/auth/login', {
@@ -106,6 +120,63 @@ export function useNostr() {
}
}
// No bot for this key — clear stale state
bot.value = null
store('bf_bot', null)
return { pubkey: pk, bot: null }
} finally {
isLoading.value = false
}
}
/** Generate a fresh Nostr keypair locally — no extension needed */
function generateLogin(): { pubkey: string; nsec: string } {
const secretKey = generateSecretKey()
const pk = getPublicKey(secretKey)
const nsecHex = bytesToHex(secretKey)
const nsecBech32 = nsecEncode(secretKey)
// Clear stale state from previous identity
bot.value = null
profilePicUrl.value = null
store('bf_bot', null)
store('bf_pic', null)
// Store locally
localStorage.setItem('bf_nsec', nsecHex)
pubkey.value = pk
store('bf_pubkey', pk)
return { pubkey: pk, nsec: nsecBech32 }
}
/** Login with an existing nsec (hex) */
async function loginWithNsec(nsecHex: string): Promise<{ pubkey: string; bot: BotData | null }> {
const secretKey = hexToBytes(nsecHex)
const pk = getPublicKey(secretKey)
localStorage.setItem('bf_nsec', nsecHex)
pubkey.value = pk
store('bf_pubkey', pk)
isLoading.value = true
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: pk }),
})
if (res.ok) {
const data = await res.json()
if (data.exists) {
bot.value = data.bot
store('bf_bot', data.bot)
return { pubkey: pk, bot: data.bot }
}
}
return { pubkey: pk, bot: null }
} finally {
isLoading.value = false
@@ -223,6 +294,15 @@ export function useNostr() {
store('bf_pubkey', null)
store('bf_bot', null)
store('bf_pic', null)
// Don't clear bf_nsec on logout — user may want to log back in
}
/** Check if user has a locally stored key (no extension needed) */
const hasStoredKey = computed(() => !!localStorage.getItem('bf_nsec'))
/** Get the stored nsec hex for backup display */
function getStoredNsec(): string | null {
return localStorage.getItem('bf_nsec')
}
return {
@@ -232,10 +312,14 @@ export function useNostr() {
isLoggedIn,
isLoading: readonly(isLoading),
hasExtension,
hasStoredKey,
login,
generateLogin,
loginWithNsec,
registerBot,
registerHuman,
updateCustomization,
getStoredNsec,
logout,
}
}
@@ -261,8 +345,10 @@ async function fetchNostrProfilePic(pk: string): Promise<string | null> {
function queryRelay(url: string, pk: string): Promise<string | null> {
return new Promise((resolve) => {
let timedOut = false
const timeout = setTimeout(() => {
ws.close()
timedOut = true
if (ws.readyState === WebSocket.OPEN) ws.close()
resolve(null)
}, 3000)
@@ -270,6 +356,7 @@ function queryRelay(url: string, pk: string): Promise<string | null> {
const subId = Math.random().toString(36).slice(2, 10)
ws.onopen = () => {
if (timedOut) { ws.close(); return }
// Request kind 0 (metadata) for this pubkey
ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pk], limit: 1 }]))
}
+42 -19
View File
@@ -1,8 +1,9 @@
import { ref, readonly, computed } from 'vue'
import { useNostr } from './useNostr.js'
import { finalizeEvent } from 'nostr-tools'
import * as nip04 from 'nostr-tools/nip04'
import * as nip44 from 'nostr-tools/nip44'
import { hexToBytes } from 'nostr-tools/utils'
import { hexToBytes, bytesToHex } from 'nostr-tools/utils'
type WalletMethod = 'nwc' | 'lnaddress' | null
type PaymentStatus = 'idle' | 'invoiced' | 'paying' | 'confirmed' | 'failed'
@@ -150,14 +151,29 @@ export function useWallet() {
const { bolt11, paymentId } = await invoiceRes.json()
pendingPayment.value = { paymentId, bolt11 }
// If NWC connected, auto-pay via NWC
// Dev mode: server auto-confirmed, skip payment
if (bolt11 === 'dev_auto_confirmed') {
paymentStatus.value = 'confirmed'
return paymentId
}
// If NWC connected, auto-pay via NWC and confirm directly
const nwcUrl = localStorage.getItem('bf_nwc_url')
if (nwcUrl) {
paymentStatus.value = 'paying'
await payViaNWC(nwcUrl, bolt11)
const preimage = await payViaNWC(nwcUrl, bolt11)
// Tell server payment is confirmed (skip lookup_invoice polling)
await fetch(`/api/payments/confirm/${paymentId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preimage, pubkey: pubkey.value }),
})
paymentStatus.value = 'confirmed'
return paymentId
}
// Poll for confirmation
// No NWC — poll for confirmation (manual payment / QR code flow)
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 2000))
const checkRes = await fetch(`/api/payments/check/${paymentId}`)
@@ -220,13 +236,13 @@ export function useWallet() {
}
/** Send a pay_invoice request via NWC WebSocket */
async function payViaNWC(nwcUrl: string, bolt11: string): Promise<void> {
async function payViaNWC(nwcUrl: string, bolt11: string): Promise<string | undefined> {
const nwc = parseNwcUrl(nwcUrl)
const conversationKey = nip44.v2.utils.getConversationKey(nwc.secret, nwc.pubkey)
const secretHex = bytesToHex(nwc.secret)
const content = nip44.v2.encrypt(
// Use NIP-04 encryption (BTCPay/LND compatibility)
const content = await nip04.encrypt(secretHex, nwc.pubkey,
JSON.stringify({ method: 'pay_invoice', params: { invoice: bolt11 } }),
conversationKey,
)
const event = finalizeEvent({
@@ -240,41 +256,48 @@ async function payViaNWC(nwcUrl: string, bolt11: string): Promise<void> {
const ws = new WebSocket(nwc.relay)
const timeout = setTimeout(() => {
ws.close()
// Don't reject — the server-side poll will catch confirmation
resolve()
resolve(undefined)
}, 30_000)
ws.onopen = () => {
// Subscribe for response
const subId = Math.random().toString(36).slice(2, 10)
ws.send(JSON.stringify(['REQ', subId, {
kinds: [23195],
authors: [nwc.pubkey],
'#e': [event.id],
}]))
// Publish the payment request
ws.send(JSON.stringify(['EVENT', event]))
}
ws.onmessage = (msg) => {
ws.onmessage = async (msg) => {
try {
const data = JSON.parse(msg.data)
if (data[0] === 'EVENT' && data[2]?.kind === 23195) {
clearTimeout(timeout)
ws.close()
// Payment sent — server-side poll will confirm
resolve()
// Decrypt response — try NIP-04 first, fall back to NIP-44
let decrypted: string
try {
decrypted = await nip04.decrypt(secretHex, nwc.pubkey, data[2].content)
} catch {
const convKey = nip44.v2.utils.getConversationKey(nwc.secret, nwc.pubkey)
decrypted = nip44.v2.decrypt(data[2].content, convKey)
}
const result = JSON.parse(decrypted)
if (result.error) {
reject(new Error(result.error.message || 'NWC payment failed'))
} else {
resolve(result.result?.preimage)
}
}
} catch {
// ignore
// ignore parse errors
}
}
ws.onerror = () => {
clearTimeout(timeout)
// Don't reject — server-side poll is the source of truth
resolve()
resolve(undefined)
}
})
}
+44 -24
View File
@@ -5,23 +5,27 @@ let sfxGain: GainNode | null = null
let musicPlaying = false
let musicTimeout: number | null = null
// Lazily create AudioContext + gain nodes on first use.
// Chrome may warn about autoplay policy but the context will resume once ensureAudioContext()
// is called from a user gesture. All SFX calls before that are queued/silent.
// AudioContext is ONLY created inside ensureAudioContext() (called from user gesture).
// Before that, all SFX calls silently no-op to avoid Chrome autoplay warnings.
let _audioUnlocked = false
function initCtx() {
if (ctx) return
ctx = new AudioContext()
musicGain = ctx.createGain()
musicGain.gain.value = masterMuted ? 0 : 0.12
musicGain.connect(ctx.destination)
sfxGain = ctx.createGain()
sfxGain.gain.value = masterMuted ? 0 : 0.25
sfxGain.connect(ctx.destination)
}
function getCtx(): AudioContext {
if (!ctx) {
ctx = new AudioContext()
musicGain = ctx.createGain()
musicGain.gain.value = masterMuted ? 0 : 0.12
musicGain.connect(ctx.destination)
sfxGain = ctx.createGain()
sfxGain.gain.value = masterMuted ? 0 : 0.25
sfxGain.connect(ctx.destination)
if (!ctx) initCtx()
if (ctx!.state === 'suspended') {
ctx!.resume().catch(() => {})
}
if (ctx.state === 'suspended') {
ctx.resume().catch(() => {})
}
return ctx
return ctx!
}
// Get the SFX destination node (gain is 0 when muted, so audio is silent but context stays valid)
@@ -340,13 +344,21 @@ export function stopAllAudio() {
stopMusic()
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
_speechQueueDepth = 0
// Disconnect sfxGain to instantly kill all in-flight oscillators/buffers,
// Disconnect gain nodes to instantly kill all in-flight oscillators/buffers,
// then reconnect so future sounds still work
if (sfxGain && ctx) {
sfxGain.disconnect()
sfxGain = ctx.createGain()
sfxGain.gain.value = masterMuted ? 0 : 0.25
sfxGain.connect(ctx.destination)
if (ctx) {
if (musicGain) {
musicGain.disconnect()
musicGain = ctx.createGain()
musicGain.gain.value = masterMuted ? 0 : MUSIC_VOL
musicGain.connect(ctx.destination)
}
if (sfxGain) {
sfxGain.disconnect()
sfxGain = ctx.createGain()
sfxGain.gain.value = masterMuted ? 0 : SFX_VOL
sfxGain.connect(ctx.destination)
}
}
}
@@ -1891,14 +1903,22 @@ export function isMasterMuted(): boolean {
return masterMuted
}
let _speechUnlocked = false
export async function ensureAudioContext() {
const c = getCtx()
if (c.state === 'suspended') {
try { await c.resume() } catch {}
}
// Prime speech synthesis on user gesture — some browsers need this
if (typeof speechSynthesis !== 'undefined' && !voicesLoaded) {
loadVoices()
// Prime speech synthesis on user gesture — mobile browsers require
// a speak() call inside a user gesture to unlock speechSynthesis
if (typeof speechSynthesis !== 'undefined') {
if (!voicesLoaded) loadVoices()
if (!_speechUnlocked) {
_speechUnlocked = true
const unlock = new SpeechSynthesisUtterance('')
unlock.volume = 0
speechSynthesis.speak(unlock)
}
}
}
+20
View File
@@ -540,3 +540,23 @@ export function generateSpriteSheet(
return canvas.toDataURL()
}
/** Load a sprite sheet data URL into a canvas (async for reliable image decode) */
export async function loadSpriteSheetCanvas(
seed: string, tier: number, primaryColor: string, secondaryColor: string,
archetypeOverride?: string, customization?: SpriteCustomization,
): Promise<HTMLCanvasElement> {
const dataUrl = generateSpriteSheet(seed, tier, primaryColor, secondaryColor, archetypeOverride, customization)
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image()
i.onload = () => resolve(i)
i.onerror = reject
i.src = dataUrl
})
const canvas = document.createElement('canvas')
canvas.width = FRAME_SIZE * MAX_FRAMES
canvas.height = FRAME_SIZE * TOTAL_ROWS
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0)
return canvas
}
+234
View File
@@ -0,0 +1,234 @@
import { FRAME_SIZE, ANIMATIONS } from './constants'
import { loadSpriteSheetCanvas } from './index'
import { getBotColors } from './palette'
import type { SpriteCustomization } from './index'
type PoseKey = keyof typeof ANIMATIONS
/**
* Generate a high-quality poster frame for a single bot.
* Takes the normal 96x96 sprite frame and renders it to a large canvas
* with pixel-art-aware enhancements: 3D bevel, glow outline, energy aura, sparkles.
*/
export async function generatePosterFrame(
seed: string,
tier: number,
archetype?: string,
pose: PoseKey = 'attack',
outputSize = 480,
customization?: SpriteCustomization,
glowColor: 'cyan' | 'pink' | 'purple' | 'yellow' = 'cyan',
): Promise<string> {
const colors = getBotColors(seed)
// 1. Get the sprite sheet as a canvas
const sheetCanvas = await loadSpriteSheetCanvas(seed, tier, colors.primary, colors.secondary, archetype, customization)
const sheetCtx = sheetCanvas.getContext('2d')!
// 2. Extract a single mid-animation frame
const anim = ANIMATIONS[pose]
// Use early frames to avoid arm/leg clipping at frame edges
const frameIdx = pose === 'attack' ? 1 : pose === 'kick' ? 1 : Math.min(Math.floor(anim.frames / 2), anim.frames - 1)
const srcX = frameIdx * FRAME_SIZE
const srcY = anim.row * FRAME_SIZE
const frameCanvas = document.createElement('canvas')
frameCanvas.width = FRAME_SIZE
frameCanvas.height = FRAME_SIZE
const frameCtx = frameCanvas.getContext('2d')!
frameCtx.imageSmoothingEnabled = false
frameCtx.drawImage(sheetCanvas, srcX, srcY, FRAME_SIZE, FRAME_SIZE, 0, 0, FRAME_SIZE, FRAME_SIZE)
// 3. Read pixel data
const frameData = frameCtx.getImageData(0, 0, FRAME_SIZE, FRAME_SIZE)
const px = frameData.data
// 4. Create output canvas
const out = document.createElement('canvas')
out.width = outputSize
out.height = outputSize
const ctx = out.getContext('2d')!
ctx.imageSmoothingEnabled = false
const scale = outputSize / FRAME_SIZE
// Helpers
function getPixel(x: number, y: number): [number, number, number, number] {
if (x < 0 || x >= FRAME_SIZE || y < 0 || y >= FRAME_SIZE) return [0, 0, 0, 0]
const i = (y * FRAME_SIZE + x) * 4
return [px[i], px[i + 1], px[i + 2], px[i + 3]]
}
function isOpaque(x: number, y: number): boolean {
return getPixel(x, y)[3] > 20
}
function isDark(x: number, y: number): boolean {
const [r, g, b, a] = getPixel(x, y)
return a > 20 && r < 30 && g < 30 && b < 30
}
// 5. Build edge map (silhouette border)
const edgeMap = new Uint8Array(FRAME_SIZE * FRAME_SIZE)
for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) {
if (!isOpaque(x, y)) continue
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue
if (!isOpaque(x + dx, y + dy)) { edgeMap[y * FRAME_SIZE + x] = 1; break }
}
if (edgeMap[y * FRAME_SIZE + x]) break
}
}
}
// Glow color RGB
const GLOW: Record<string, number[]> = {
cyan: [0, 240, 255],
pink: [255, 45, 120],
purple: [168, 85, 247],
yellow: [255, 215, 0],
}
const gc = GLOW[glowColor]
// 6. Find character bounding box for aura
let bMinX = FRAME_SIZE, bMaxX = 0, bMinY = FRAME_SIZE, bMaxY = 0
for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) {
if (isOpaque(x, y)) {
bMinX = Math.min(bMinX, x)
bMaxX = Math.max(bMaxX, x)
bMinY = Math.min(bMinY, y)
bMaxY = Math.max(bMaxY, y)
}
}
}
const cx = ((bMinX + bMaxX) / 2) * scale
const cy = ((bMinY + bMaxY) / 2) * scale
const charR = Math.max(bMaxX - bMinX, bMaxY - bMinY) * scale * 0.65
// ═══ RENDER PASSES ═══
// Pass 1: Background energy aura
const auraGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, charR)
const auraIntensity = tier >= 3 ? 0.2 : tier >= 1 ? 0.1 : 0.05
auraGrad.addColorStop(0, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${auraIntensity})`)
auraGrad.addColorStop(0.4, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${auraIntensity * 0.4})`)
auraGrad.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = auraGrad
ctx.fillRect(0, 0, outputSize, outputSize)
// Second aura layer (tighter, brighter)
if (tier >= 2) {
const innerGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, charR * 0.5)
innerGrad.addColorStop(0, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.08)`)
innerGrad.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = innerGrad
ctx.fillRect(0, 0, outputSize, outputSize)
}
// Pass 2: Outer glow (wide, soft)
const outerR = Math.ceil(scale * 1.5)
for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) {
if (!edgeMap[y * FRAME_SIZE + x]) continue
const dx = x * scale
const dy = y * scale
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.08)`
ctx.fillRect(dx - outerR, dy - outerR, scale + outerR * 2, scale + outerR * 2)
}
}
// Pass 3: Inner glow (tight, brighter)
const innerR = Math.ceil(scale * 0.6)
for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) {
if (!edgeMap[y * FRAME_SIZE + x]) continue
const dx = x * scale
const dy = y * scale
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.2)`
ctx.fillRect(dx - innerR, dy - innerR, scale + innerR * 2, scale + innerR * 2)
}
}
// Pass 4: Main pixel render with bevel
const bevel = Math.max(1, Math.floor(scale / 5))
for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) {
const [r, g, b, a] = getPixel(x, y)
if (a < 20) continue
const dx = x * scale
const dy = y * scale
const dark = isDark(x, y)
// Base pixel
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a / 255})`
ctx.fillRect(dx, dy, scale, scale)
// 3D bevel on colored pixels (not outlines)
if (!dark && scale >= 3) {
// Top edge highlight
ctx.fillStyle = 'rgba(255,255,255,0.14)'
ctx.fillRect(dx, dy, scale, bevel)
// Left edge highlight
ctx.fillStyle = 'rgba(255,255,255,0.07)'
ctx.fillRect(dx, dy + bevel, bevel, scale - bevel * 2)
// Bottom edge shadow
ctx.fillStyle = 'rgba(0,0,0,0.18)'
ctx.fillRect(dx, dy + scale - bevel, scale, bevel)
// Right edge shadow
ctx.fillStyle = 'rgba(0,0,0,0.10)'
ctx.fillRect(dx + scale - bevel, dy + bevel, bevel, scale - bevel * 2)
}
// Subtle glow tint on edge pixels
if (edgeMap[y * FRAME_SIZE + x] && !dark) {
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.06)`
ctx.fillRect(dx, dy, scale, scale)
}
}
}
// Pass 5: Specular highlights on large colored regions
for (let y = 1; y < FRAME_SIZE - 1; y++) {
for (let x = 1; x < FRAME_SIZE - 1; x++) {
const [r, g, b, a] = getPixel(x, y)
if (a < 20 || isDark(x, y)) continue
// Only add specular if surrounded by similar pixels (interior of a region)
if (!edgeMap[y * FRAME_SIZE + x] && !isDark(x, y - 1) && !isDark(x, y + 1) && isOpaque(x - 1, y) && isOpaque(x + 1, y)) {
const dx = x * scale
const dy = y * scale
// Tiny specular dot in the upper-left of the pixel
ctx.fillStyle = 'rgba(255,255,255,0.06)'
ctx.fillRect(dx + 1, dy + 1, Math.ceil(scale / 3), Math.ceil(scale / 3))
}
}
}
// Pass 6: Tier-gated energy particles
if (tier >= 3) {
const count = 4 + tier * 2
const seedHash = Array.from(seed).reduce((h, c) => (h * 31 + c.charCodeAt(0)) | 0, 0)
for (let i = 0; i < count; i++) {
const h = Math.abs((seedHash * 1337 + i * 997) | 0)
const angle = (h % 360) * Math.PI / 180
const dist = charR * 0.3 + (h % 100) / 100 * charR * 0.5
const ppx = cx + Math.cos(angle) * dist
const ppy = cy + Math.sin(angle) * dist
const sz = 1 + (h % 3)
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${0.4 + (h % 3) * 0.15})`
ctx.fillRect(ppx, ppy, sz, sz)
// Cross sparkle for tier 4+
if (tier >= 4) {
const sparkLen = 2 + (h % 3)
ctx.fillStyle = 'rgba(255,255,255,0.6)'
ctx.fillRect(ppx - sparkLen, ppy, sparkLen * 2 + sz, 1)
ctx.fillRect(ppx + Math.floor(sz / 2), ppy - sparkLen, 1, sparkLen * 2 + sz)
}
}
}
return out.toDataURL()
}
+435 -161
View File
@@ -1,13 +1,23 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { RouterLink } from 'vue-router'
import SpritePreview from '../components/SpritePreview.vue'
import PosterSprite from '../components/PosterSprite.vue'
import PixelGlove from '../components/PixelGlove.vue'
interface BotInfo {
name: string
avatarSeed: string
archetype: string
tier: number
eloRating: number
botType?: string
}
interface UpcomingFight {
id: string
botA: { name: string; avatarSeed: string; archetype: string; tier: number; eloRating: number } | null
botB: { name: string; avatarSeed: string; archetype: string; tier: number; eloRating: number } | null
botA: BotInfo | null
botB: BotInfo | null
arenaInfo: { name: string } | null
arena: string
status: string
@@ -16,7 +26,32 @@ interface UpcomingFight {
const fights = ref<UpcomingFight[]>([])
const isLoading = ref(true)
const featured = ref<UpcomingFight | null>(null)
const mainEvent = ref<UpcomingFight | null>(null)
const selectedId = ref<string | null>(null)
const isPunching = ref(false)
let punchInterval: ReturnType<typeof setInterval> | null = null
// Derive featured from selectedId always look up from fights array for fresh data
const featured = computed<UpcomingFight | null>(() => {
if (selectedId.value) {
const found = fights.value.find(f => f.id === selectedId.value)
if (found && found.botA && found.botB) return found
}
return mainEvent.value
})
// Is the main event currently featured?
const isMainFeatured = computed(() => !selectedId.value || selectedId.value === mainEvent.value?.id)
// Undercard fights (everything except main event)
const undercardFights = computed(() => {
if (!mainEvent.value) return fights.value
return fights.value.filter(f => f.id !== mainEvent.value!.id)
})
// Safe accessors for featured fight bots (never null when featured exists)
const fighterA = computed<BotInfo | null>(() => featured.value?.botA ?? null)
const fighterB = computed<BotInfo | null>(() => featured.value?.botB ?? null)
const tierNames: Record<number, string> = {
0: 'ROOKIE', 1: 'BRAWLER', 2: 'WARRIOR', 3: 'CHAMPION', 4: 'LEGEND', 5: 'MYTHIC',
@@ -25,26 +60,45 @@ const tierColors: Record<number, string> = {
0: '#8a8a9a', 1: '#4ade80', 2: '#38bdf8', 3: '#a855f7', 4: '#f97316', 5: '#ef4444',
}
function selectFight(fightId: string) {
const fight = fights.value.find(f => f.id === fightId)
if (!fight || !fight.botA || !fight.botB) return
selectedId.value = fightId
}
function selectMainEvent() {
selectedId.value = null
}
onMounted(async () => {
try {
const res = await fetch('/api/fights')
if (res.ok) {
const data = await res.json()
const data: UpcomingFight[] = await res.json()
fights.value = data
// Feature the most recent fight with both bots
featured.value = data.find((f: UpcomingFight) => f.botA && f.botB) || null
// Main event = first fight with both bots present
mainEvent.value = data.find(f => f.botA && f.botB) || null
}
} catch (err) {
console.warn('[FightCard] load failed:', err)
}
isLoading.value = false
punchInterval = setInterval(() => {
isPunching.value = true
setTimeout(() => { isPunching.value = false }, 800)
}, 6000)
})
onUnmounted(() => {
if (punchInterval) clearInterval(punchInterval)
})
</script>
<template>
<div class="min-h-[calc(100vh-4rem)] flex flex-col items-center overflow-y-auto relative">
<div class="h-[calc(100vh-4rem)] flex flex-col overflow-hidden relative">
<!-- Background neon grid -->
<!-- Page background -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[200%] h-full opacity-20"
style="background: radial-gradient(ellipse at 50% 0%, rgba(255,45,120,0.4) 0%, transparent 60%)" />
@@ -57,137 +111,240 @@ onMounted(async () => {
</div>
<template v-else-if="featured">
<!-- MAIN EVENT CARD -->
<div class="w-full max-w-2xl mx-auto px-4 py-6 sm:py-10 relative z-10">
<!-- Desktop: poster 2/3 + fight list 1/3 | Mobile: 50/50 -->
<div class="flex-1 min-h-0 flex flex-col lg:flex-row relative z-10">
<!-- Neon sign header -->
<div class="text-center mb-6 sm:mb-10">
<p class="font-pixel text-[10px] sm:text-xs text-neon-yellow tracking-[0.4em] mb-2 animate-pulse">
TONIGHT'S MAIN EVENT
</p>
<div class="relative inline-block">
<h1 class="font-neon text-4xl sm:text-6xl md:text-7xl text-neon-pink glow-pink neon-flicker tracking-wider">
FIGHT CARD
</h1>
<div class="absolute -inset-4 border-2 border-neon-pink/30 rounded-lg neon-border-pink" />
<div class="absolute -inset-6 border border-neon-pink/10 rounded-xl" />
</div>
</div>
<!-- Main fight card with 3D perspective -->
<!-- LEFT: Fight poster -->
<RouterLink
:to="`/arena/${featured.id}`"
class="block group"
class="h-1/2 lg:h-full lg:w-2/3 flex flex-col overflow-hidden lg:border-r lg:border-border/30 group relative poster-bg"
>
<div class="fight-card-3d relative">
<div class="fight-card-inner border-2 border-neon-pink/40 rounded-2xl overflow-hidden
bg-gradient-to-b from-surface-raised via-black/90 to-surface-raised
shadow-[0_0_40px_rgba(255,45,120,0.15),0_0_80px_rgba(255,45,120,0.05)]
group-hover:border-neon-pink/60 group-hover:shadow-[0_0_60px_rgba(255,45,120,0.25)]
transition-all duration-300">
<!-- Dramatic poster background layers -->
<!-- Top banner -->
<div class="bg-gradient-to-r from-neon-pink/20 via-neon-purple/20 to-neon-cyan/20 px-4 py-2 text-center border-b border-white/5">
<p class="font-display font-black text-[10px] sm:text-xs tracking-[0.3em] text-neon-yellow">
<!-- Dark vignette base -->
<div class="absolute inset-0 pointer-events-none"
style="background: radial-gradient(ellipse at 50% 45%, transparent 30%, rgba(0,0,0,0.6) 100%)" />
<!-- Dual spotlights cyan left, pink right -->
<div class="absolute inset-0 pointer-events-none"
style="background:
conic-gradient(from 200deg at 15% 0%, rgba(0,240,255,0.15) 0deg, transparent 40deg),
conic-gradient(from -20deg at 85% 0%, rgba(255,45,120,0.15) 0deg, transparent 40deg)" />
<!-- Center clash glow -->
<div class="absolute inset-0 pointer-events-none"
style="background:
radial-gradient(ellipse at 25% 55%, rgba(0,240,255,0.14) 0%, transparent 45%),
radial-gradient(ellipse at 75% 55%, rgba(255,45,120,0.14) 0%, transparent 45%),
radial-gradient(circle at 50% 50%, rgba(168,85,247,0.08) 0%, transparent 35%)" />
<!-- Top pink wash -->
<div class="absolute inset-0 pointer-events-none"
style="background: radial-gradient(ellipse at 50% -10%, rgba(255,45,120,0.25) 0%, transparent 40%)" />
<!-- Bottom purple haze -->
<div class="absolute inset-0 pointer-events-none"
style="background: radial-gradient(ellipse at 50% 110%, rgba(168,85,247,0.12) 0%, transparent 35%)" />
<!-- Cross-hatched lines -->
<div class="absolute inset-0 pointer-events-none poster-crosshatch" />
<!-- Scan lines overlay -->
<div class="absolute inset-0 pointer-events-none poster-scanlines" />
<!-- Neon edge lines -->
<div class="absolute top-0 inset-x-0 h-[2px] bg-gradient-to-r from-transparent via-neon-pink/70 to-transparent" />
<div class="absolute bottom-0 inset-x-0 h-[2px] bg-gradient-to-r from-transparent via-neon-cyan/70 to-transparent" />
<div class="absolute left-0 inset-y-0 w-[2px] bg-gradient-to-b from-neon-pink/30 via-neon-purple/20 to-neon-cyan/30 hidden lg:block" />
<!-- Corner accents -->
<div class="absolute top-0 left-0 w-8 h-8 sm:w-12 sm:h-12 border-t-2 border-l-2 border-neon-pink/30" />
<div class="absolute top-0 right-0 w-8 h-8 sm:w-12 sm:h-12 border-t-2 border-r-2 border-neon-pink/30" />
<div class="absolute bottom-0 left-0 w-8 h-8 sm:w-12 sm:h-12 border-b-2 border-l-2 border-neon-cyan/30" />
<div class="absolute bottom-0 right-0 w-8 h-8 sm:w-12 sm:h-12 border-b-2 border-r-2 border-neon-cyan/30" />
<!-- Ring ropes (horizontal accent lines) -->
<div class="absolute left-0 right-0 top-[30%] h-px bg-gradient-to-r from-transparent via-neon-yellow/10 to-transparent" />
<div class="absolute left-0 right-0 top-[70%] h-px bg-gradient-to-r from-transparent via-neon-yellow/10 to-transparent" />
<!-- Title -->
<div class="text-center pt-2 sm:pt-3 lg:pt-5 px-4 relative z-10 shrink-0">
<p class="font-pixel text-[10px] sm:text-sm lg:text-base text-neon-yellow/90 tracking-[0.5em] animate-pulse">
{{ isMainFeatured ? 'TONIGHT\'S MAIN EVENT' : 'UNDERCARD BOUT' }}
</p>
<div class="relative inline-block mt-0.5 sm:mt-1">
<h1 class="font-neon text-3xl sm:text-5xl lg:text-8xl text-neon-pink glow-pink neon-flicker tracking-wider leading-none poster-title">
FIGHT NIGHT
</h1>
<div class="absolute -inset-2 sm:-inset-3 border-2 border-neon-pink/30 rounded neon-border-pink" />
</div>
<div class="flex items-center gap-3 mt-1.5 sm:mt-2 max-w-sm sm:max-w-md mx-auto">
<div class="flex-1 h-px bg-gradient-to-r from-transparent to-neon-purple/30" />
<div class="shrink-0 px-3 sm:px-4 py-0.5 sm:py-1 border border-neon-purple/25 rounded bg-neon-purple/5">
<p class="font-display font-black text-[7px] sm:text-[10px] lg:text-xs tracking-[0.3em] text-neon-purple/80">
{{ featured.arenaInfo?.name?.toUpperCase() || 'THE RING' }}
</p>
</div>
<div class="flex-1 h-px bg-gradient-to-l from-transparent to-neon-purple/30" />
</div>
</div>
<!-- Fighter showcase -->
<div class="px-4 sm:px-8 py-6 sm:py-10">
<div class="flex items-center justify-between gap-2">
<!-- Fighters -->
<div class="flex-1 flex flex-col items-center justify-center relative z-10 min-h-0 px-2 sm:px-4 lg:px-6">
<!-- Fighter A -->
<div class="flex-1 flex flex-col items-center text-center min-w-0">
<div class="relative mb-3 sm:mb-4">
<div class="absolute -inset-3 rounded-full bg-neon-cyan/10 blur-xl" />
<SpritePreview
v-if="featured.botA"
:seed="featured.botA.avatarSeed || featured.botA.name"
:archetype="featured.botA.archetype || 'standard'"
:tier="featured.botA.tier"
:size="100"
class="relative z-10 drop-shadow-[0_0_20px_rgba(0,240,255,0.4)] sm:w-[130px] sm:h-[130px] fighter-bob"
/>
</div>
<p class="font-display font-black text-sm sm:text-xl tracking-wider text-neon-cyan glow-cyan truncate w-full">
{{ featured.botA?.name || '???' }}
</p>
<p class="font-pixel text-[9px] sm:text-[10px] mt-1 tracking-wider"
:style="{ color: tierColors[featured.botA?.tier || 0] }">
{{ tierNames[featured.botA?.tier || 0] }}
</p>
<p class="font-mono text-[10px] sm:text-xs text-text-muted mt-0.5">
{{ Math.round(featured.botA?.eloRating || 0) }} ELO
</p>
</div>
<!-- Sprites -->
<div class="flex items-end justify-center w-full max-w-4xl">
<div class="flex-1 flex flex-col items-center min-w-0">
<PosterSprite
v-if="fighterA"
:key="`a-${featured.id}-${fighterA.avatarSeed}`"
:seed="fighterA.avatarSeed || fighterA.name"
:archetype="fighterA.archetype || 'standard'"
:tier="fighterA.tier"
:size="160"
pose="win"
glow-color="cyan"
class="relative z-10 fighter-bob
sm:!w-[240px] sm:!h-[240px] lg:!w-[340px] lg:!h-[340px]"
/>
</div>
<div class="flex-1 flex flex-col items-center min-w-0 -ml-6 sm:-ml-12 lg:-ml-20">
<div style="transform: scaleX(-1)">
<PosterSprite
v-if="fighterB"
:key="`b-${featured.id}-${fighterB.avatarSeed}`"
:seed="fighterB.avatarSeed || fighterB.name"
:archetype="fighterB.archetype || 'standard'"
:tier="fighterB.tier"
:size="160"
pose="idle"
glow-color="pink"
class="relative z-10 fighter-bob-delayed
sm:!w-[240px] sm:!h-[240px] lg:!w-[340px] lg:!h-[340px]"
/>
</div>
</div>
</div>
<!-- VS center with gloves -->
<div class="shrink-0 flex flex-col items-center gap-1 px-2">
<PixelGlove :size="28" class="sm:!w-[40px] rotate-12 glove-punch-left" />
<div class="relative">
<span class="font-neon text-3xl sm:text-5xl text-neon-purple glow-purple vs-pulse tracking-wider">VS</span>
</div>
<PixelGlove :size="28" flip class="sm:!w-[40px] -rotate-12 glove-punch-right" />
</div>
<!-- Names with VS orb in center -->
<div class="flex items-stretch w-full max-w-3xl mt-1 sm:mt-2 lg:mt-3 shrink-0 relative">
<!-- Fighter B -->
<div class="flex-1 flex flex-col items-center text-center min-w-0">
<div class="relative mb-3 sm:mb-4">
<div class="absolute -inset-3 rounded-full bg-neon-pink/10 blur-xl" />
<SpritePreview
v-if="featured.botB"
:seed="featured.botB.avatarSeed || featured.botB.name"
:archetype="featured.botB.archetype || 'standard'"
:tier="featured.botB.tier"
:size="100"
class="relative z-10 drop-shadow-[0_0_20px_rgba(255,45,120,0.4)] sm:w-[130px] sm:h-[130px] fighter-bob-delayed"
style="transform: scaleX(-1)"
/>
</div>
<p class="font-display font-black text-sm sm:text-xl tracking-wider text-neon-pink glow-pink truncate w-full">
{{ featured.botB?.name || '???' }}
</p>
<p class="font-pixel text-[9px] sm:text-[10px] mt-1 tracking-wider"
:style="{ color: tierColors[featured.botB?.tier || 0] }">
{{ tierNames[featured.botB?.tier || 0] }}
</p>
<p class="font-mono text-[10px] sm:text-xs text-text-muted mt-0.5">
{{ Math.round(featured.botB?.eloRating || 0) }} ELO
</p>
<!-- Fighter A cyan -->
<div class="flex-1 min-w-0 name-block-cyan border-2 border-neon-cyan/40 rounded-l-lg px-2 sm:px-3 pr-16 sm:pr-20 lg:pr-24 py-1.5 sm:py-2 lg:py-3 text-center">
<p class="font-funky text-base sm:text-2xl lg:text-4xl tracking-widest text-neon-cyan truncate pb-0.5 poster-name-cyan uppercase">
{{ fighterA?.name || '???' }}
</p>
<p class="font-pixel text-[6px] sm:text-[8px] lg:text-[11px] tracking-[0.2em]"
:style="{ color: tierColors[fighterA?.tier ?? 0] }">
{{ tierNames[fighterA?.tier ?? 0] }}
</p>
</div>
<!-- VS orb 3D circle, bigger than name cards -->
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-30">
<div class="vs-orb" :class="{ 'vs-punching': isPunching }">
<div class="vs-orb-inner flex items-center justify-center">
<PixelGlove :size="22" class="sm:!w-[32px] lg:!w-[42px] vs-glove-left" :class="{ 'glove-smash-left': isPunching }" />
<span v-if="!isPunching" class="font-pixel text-xl sm:text-3xl lg:text-4xl text-white tracking-widest leading-none mx-1.5 sm:mx-2">VS</span>
<span v-else class="font-pixel text-2xl sm:text-4xl lg:text-5xl text-neon-yellow tracking-widest leading-none mx-0 vs-impact">&#x1F4A5;</span>
<PixelGlove :size="22" flip class="sm:!w-[32px] lg:!w-[42px] vs-glove-right" :class="{ 'glove-smash-right': isPunching }" />
</div>
</div>
</div>
<!-- Bottom banner -->
<div class="bg-gradient-to-r from-neon-cyan/10 via-neon-purple/10 to-neon-pink/10 px-4 py-3 text-center border-t border-white/5">
<p class="font-display font-black text-xs sm:text-sm tracking-[0.2em] text-neon-purple group-hover:text-neon-cyan transition-colors">
{{ featured.status === 'finished' ? 'WATCH REPLAY' : featured.status === 'live' ? 'WATCH LIVE' : 'VIEW FIGHT' }}
<!-- Fighter B pink -->
<div class="flex-1 min-w-0 name-block-pink border-2 border-neon-pink/40 border-l-0 rounded-r-lg px-2 sm:px-3 pl-16 sm:pl-20 lg:pl-24 py-1.5 sm:py-2 lg:py-3 text-center">
<p class="font-funky text-base sm:text-2xl lg:text-4xl tracking-widest text-neon-pink truncate pb-0.5 poster-name-pink uppercase">
{{ fighterB?.name || '???' }}
</p>
<p class="font-pixel text-[6px] sm:text-[8px] lg:text-[11px] tracking-[0.2em]"
:style="{ color: tierColors[fighterB?.tier ?? 0] }">
{{ tierNames[fighterB?.tier ?? 0] }}
</p>
</div>
</div>
</div>
<!-- CTA button -->
<div class="relative z-10 shrink-0 border-t border-white/5 px-4 py-2 sm:py-3 lg:py-4 text-center
bg-gradient-to-r from-neon-pink/8 via-neon-purple/8 to-neon-cyan/8">
<span class="inline-block px-6 sm:px-10 py-1.5 sm:py-2.5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-[10px] sm:text-xs tracking-widest
group-hover:bg-neon-pink/20 transition-all neon-border-pink">
{{ featured.status === 'finished' ? 'WATCH REPLAY' : featured.status === 'live' ? 'WATCH LIVE' : 'VIEW FIGHT' }}
</span>
</div>
</RouterLink>
<!-- Undercard fights -->
<div v-if="fights.length > 1" class="mt-8 sm:mt-12">
<div class="flex items-center gap-3 mb-4">
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-neon-purple/30 to-transparent" />
<p class="font-pixel text-[10px] text-neon-purple tracking-[0.3em]">UNDERCARD</p>
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-neon-purple/30 to-transparent" />
<!-- RIGHT: Fight list (bottom half on mobile, 1/3 on desktop) -->
<div class="h-1/2 lg:h-full lg:w-1/3 flex flex-col overflow-hidden border-t lg:border-t-0 border-border/30">
<!-- Main event card ALWAYS visible at top -->
<button
v-if="mainEvent && mainEvent.botA && mainEvent.botB"
class="mx-4 mt-3 lg:mt-5 mb-2 px-3 py-2 border-2 rounded-lg transition-all text-left"
:class="isMainFeatured
? 'border-neon-yellow/50 bg-neon-yellow/10 ring-1 ring-neon-yellow/20'
: 'border-neon-yellow/20 bg-neon-yellow/5 hover:bg-neon-yellow/10 hover:border-neon-yellow/40'"
@click.prevent="selectMainEvent"
>
<div class="flex items-center justify-between mb-1">
<p class="font-pixel text-[8px] text-neon-yellow/70 tracking-[0.3em]">&#9733; MAIN EVENT</p>
<span v-if="isMainFeatured" class="font-pixel text-[7px] text-neon-yellow/50 tracking-wider">VIEWING</span>
</div>
<div class="flex items-center">
<div class="flex-1 flex items-center justify-end gap-1.5 min-w-0">
<p class="font-display font-bold text-xs text-neon-cyan truncate">{{ mainEvent.botA.name }}</p>
<SpritePreview
:seed="mainEvent.botA.avatarSeed || mainEvent.botA.name"
:archetype="mainEvent.botA.archetype || 'standard'"
:tier="mainEvent.botA.tier"
:size="20"
class="shrink-0"
/>
</div>
<span class="font-pixel text-neon-yellow text-[10px] px-2 shrink-0">VS</span>
<div class="flex-1 flex items-center gap-1.5 min-w-0">
<SpritePreview
:seed="mainEvent.botB.avatarSeed || mainEvent.botB.name"
:archetype="mainEvent.botB.archetype || 'standard'"
:tier="mainEvent.botB.tier"
:size="20"
class="shrink-0"
style="transform: scaleX(-1)"
/>
<p class="font-display font-bold text-xs text-neon-pink truncate">{{ mainEvent.botB.name }}</p>
</div>
</div>
</button>
<!-- Undercard header -->
<div class="px-4 py-2 lg:py-3 shrink-0">
<div class="flex items-center gap-3">
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-neon-purple/30 to-transparent" />
<p class="font-pixel text-[9px] sm:text-[10px] text-neon-purple tracking-[0.3em]">
{{ undercardFights.length > 0 ? 'UNDERCARD' : 'NO OTHER BOUTS' }}
</p>
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-neon-purple/30 to-transparent" />
</div>
</div>
<div class="space-y-2">
<RouterLink
v-for="fight in fights.filter(f => f.id !== featured?.id).slice(0, 6)"
<!-- Fight list -->
<div class="flex-1 min-h-0 overflow-y-auto px-4 pb-2 lg:pb-4 space-y-1.5">
<button
v-for="fight in undercardFights"
:key="fight.id"
:to="`/arena/${fight.id}`"
class="flex items-center px-3 sm:px-4 py-2.5 border border-border/50 rounded-lg
bg-surface-raised/30 hover:border-neon-purple/30 hover:bg-surface-overlay/20
transition-all group/card"
class="w-full flex items-center px-3 py-2 border rounded-lg transition-all text-left"
:class="fight.id === selectedId
? 'border-neon-purple/50 bg-neon-purple/10 ring-1 ring-neon-purple/20'
: (fight.botA && fight.botB)
? 'border-border/50 bg-surface-raised/30 hover:border-neon-purple/30 hover:bg-surface-overlay/20'
: 'border-border/30 bg-surface-raised/10 opacity-50 cursor-not-allowed'"
:disabled="!fight.botA || !fight.botB"
@click.prevent="fight.botA && fight.botB && selectFight(fight.id)"
>
<div class="flex-1 flex items-center justify-end gap-2 min-w-0">
<p class="font-display font-bold text-xs sm:text-sm truncate"
<div class="flex-1 flex items-center justify-end gap-1.5 min-w-0">
<p class="font-display font-bold text-[11px] truncate"
:class="fight.botA ? 'text-text-primary' : 'text-text-muted'">
{{ fight.botA?.name || '???' }}
</p>
@@ -196,43 +353,48 @@ onMounted(async () => {
:seed="fight.botA.avatarSeed || fight.botA.name"
:archetype="fight.botA.archetype || 'standard'"
:tier="fight.botA.tier"
:size="28"
:size="22"
class="shrink-0"
/>
</div>
<span class="font-funky text-neon-purple text-xs sm:text-sm px-2 sm:px-3 shrink-0">VS</span>
<div class="flex-1 flex items-center gap-2 min-w-0">
<span class="font-funky text-neon-purple text-[10px] px-1.5 shrink-0">VS</span>
<div class="flex-1 flex items-center gap-1.5 min-w-0">
<SpritePreview
v-if="fight.botB"
:seed="fight.botB.avatarSeed || fight.botB.name"
:archetype="fight.botB.archetype || 'standard'"
:tier="fight.botB.tier"
:size="28"
:size="22"
class="shrink-0"
style="transform: scaleX(-1)"
/>
<p class="font-display font-bold text-xs sm:text-sm truncate"
<p class="font-display font-bold text-[11px] truncate"
:class="fight.botB ? 'text-text-primary' : 'text-text-muted'">
{{ fight.botB?.name || '???' }}
</p>
</div>
<span v-if="fight.status === 'finished'" class="font-mono text-[9px] text-text-muted ml-2 shrink-0">R{{ fight.totalRounds }}</span>
<span v-else-if="fight.status === 'live'" class="font-display text-[10px] text-neon-yellow ml-2 shrink-0 animate-pulse">LIVE</span>
<div class="ml-1.5 shrink-0">
<span v-if="fight.botA?.botType === 'classic' || fight.botB?.botType === 'classic'"
class="font-display text-[8px] text-text-muted/50 tracking-wider">PRAC</span>
<span v-else-if="fight.status === 'finished'" class="font-mono text-[8px] text-text-muted">R{{ fight.totalRounds }}</span>
<span v-else-if="fight.status === 'live'" class="font-display text-[9px] text-neon-yellow animate-pulse">LIVE</span>
</div>
</button>
</div>
<!-- Enter the ring -->
<div class="px-4 pb-3 lg:pb-5 shrink-0">
<RouterLink
to="/join"
class="block text-center px-6 py-2.5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-[10px] sm:text-xs tracking-widest
hover:bg-neon-pink/20 transition-all neon-border-pink"
>
ENTER THE RING
</RouterLink>
</div>
</div>
<!-- CTA -->
<div class="mt-8 sm:mt-10 text-center">
<RouterLink
to="/join"
class="inline-block px-8 sm:px-12 py-3 sm:py-4 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-sm sm:text-base tracking-widest
hover:bg-neon-pink/20 transition-all neon-border-pink"
>
ENTER THE RING
</RouterLink>
</div>
</div>
</template>
@@ -250,17 +412,149 @@ onMounted(async () => {
</template>
<style scoped>
/* 3D card effect */
.fight-card-3d {
perspective: 1000px;
/* ═══ Poster background effects ═══ */
.poster-bg {
background: linear-gradient(180deg,
rgba(15, 5, 25, 1) 0%,
rgba(10, 8, 20, 1) 40%,
rgba(8, 12, 22, 1) 100%);
}
.fight-card-inner {
transform: rotateX(2deg);
transform-origin: center bottom;
transition: transform 0.3s ease;
/* Cross-hatched diagonal lines */
.poster-crosshatch {
background:
repeating-linear-gradient(
45deg,
transparent,
transparent 30px,
rgba(255, 255, 255, 0.04) 30px,
rgba(255, 255, 255, 0.04) 32px
),
repeating-linear-gradient(
-45deg,
transparent,
transparent 30px,
rgba(255, 255, 255, 0.04) 30px,
rgba(255, 255, 255, 0.04) 32px
);
}
.fight-card-3d:hover .fight-card-inner {
transform: rotateX(0deg) scale(1.01);
/* CRT scan lines */
.poster-scanlines {
opacity: 0.04;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.8) 2px,
rgba(0, 0, 0, 0.8) 4px
);
}
/* Poster name glow — 3D embossed neon */
.poster-name-cyan {
-webkit-text-stroke: 1px rgba(0, 180, 200, 0.6);
text-shadow:
2px 2px 0 rgba(0, 100, 130, 0.8),
0 0 10px rgba(0, 240, 255, 0.6),
0 0 30px rgba(0, 240, 255, 0.3),
0 0 60px rgba(0, 240, 255, 0.15);
}
.poster-name-pink {
-webkit-text-stroke: 1px rgba(200, 30, 90, 0.6);
text-shadow:
2px 2px 0 rgba(130, 20, 60, 0.8),
0 0 10px rgba(255, 45, 120, 0.6),
0 0 30px rgba(255, 45, 120, 0.3),
0 0 60px rgba(255, 45, 120, 0.15);
}
.poster-title {
text-shadow:
0 0 15px rgba(255, 45, 120, 0.6),
0 0 40px rgba(255, 45, 120, 0.3),
0 0 80px rgba(255, 45, 120, 0.15);
}
/* Name block containers */
.name-block-cyan {
background: linear-gradient(135deg, rgba(0, 240, 255, 0.08) 0%, rgba(0, 240, 255, 0.02) 100%);
}
.name-block-pink {
background: linear-gradient(225deg, rgba(255, 45, 120, 0.08) 0%, rgba(255, 45, 120, 0.02) 100%);
}
/* ═══ VS Orb — 3D circle ═══ */
.vs-orb {
width: 112px;
height: 112px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, rgba(220, 160, 255, 0.9) 0%, rgba(168, 85, 247, 0.95) 40%, rgba(100, 30, 180, 1) 100%);
border: 3px solid rgba(255, 255, 255, 0.25);
box-shadow:
0 0 20px rgba(168, 85, 247, 0.6),
0 0 40px rgba(168, 85, 247, 0.3),
0 0 60px rgba(168, 85, 247, 0.15),
inset 0 -4px 8px rgba(0,0,0,0.4),
inset 0 4px 8px rgba(255,255,255,0.15);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease;
}
@media (min-width: 640px) {
.vs-orb { width: 144px; height: 144px; border-width: 4px; }
}
@media (min-width: 1024px) {
.vs-orb { width: 176px; height: 176px; border-width: 5px; }
}
.vs-orb-inner {
width: 100%;
height: 100%;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.1);
display: flex;
align-items: center;
justify-content: center;
}
/* Punch animation */
.vs-punching {
transform: scale(1.15);
}
.vs-impact {
animation: impactShake 0.3s ease-out;
filter: brightness(1.5);
}
@keyframes impactShake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-2px) rotate(-5deg); }
75% { transform: translateX(2px) rotate(5deg); }
}
.glove-smash-left {
animation: smashLeft 0.4s ease-out !important;
}
.glove-smash-right {
animation: smashRight 0.4s ease-out !important;
}
@keyframes smashLeft {
0% { transform: rotate(12deg) translateX(-8px); }
40% { transform: rotate(30deg) translateX(4px); }
60% { transform: rotate(15deg) translateX(2px); }
100% { transform: rotate(12deg) translateX(0); }
}
@keyframes smashRight {
0% { transform: rotate(-12deg) translateX(8px); }
40% { transform: rotate(-30deg) translateX(-4px); }
60% { transform: rotate(-15deg) translateX(-2px); }
100% { transform: rotate(-12deg) translateX(0); }
}
/* Normal glove idle */
.vs-glove-left {
animation: punchLeft 2s ease-in-out infinite;
}
.vs-glove-right {
animation: punchRight 2s ease-in-out infinite 0.3s;
}
/* Fighter bobbing */
@@ -276,12 +570,6 @@ onMounted(async () => {
}
/* Glove punch animations */
.glove-punch-left {
animation: punchLeft 2s ease-in-out infinite;
}
.glove-punch-right {
animation: punchRight 2s ease-in-out infinite 0.3s;
}
@keyframes punchLeft {
0%, 70%, 100% { transform: rotate(12deg) translateX(0); }
80% { transform: rotate(25deg) translateX(6px); }
@@ -292,18 +580,4 @@ onMounted(async () => {
80% { transform: rotate(-25deg) translateX(-6px); }
85% { transform: rotate(-8deg) translateX(2px); }
}
/* VS pulse */
.vs-pulse {
animation: vsPulse 2s ease-in-out infinite;
}
@keyframes vsPulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
}
/* Neon purple glow utility */
.glow-purple {
text-shadow: 0 0 10px rgba(168, 85, 247, 0.6), 0 0 30px rgba(168, 85, 247, 0.3);
}
</style>
+12 -3
View File
@@ -153,7 +153,15 @@ const myBotId = computed(() => {
const showOverlay = computed(() => replayDone.value && !isRequeueing.value && !autoBattle.value)
function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) }
let _pageDestroyed = false
function sleep(ms: number): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (_pageDestroyed) reject(new Error('unmounted'))
else resolve()
}, ms)
})
}
function scrollLiveLog() { nextTick(() => { liveLogEl.value?.scrollTo({ top: liveLogEl.value.scrollHeight, behavior: 'smooth' }) }) }
const challengeLabel = (type: string) => {
@@ -401,7 +409,7 @@ function connectSSE() {
eventSource.addEventListener('round_end', (e) => {
try {
handleRoundEnd(JSON.parse(e.data))
handleRoundEnd(JSON.parse(e.data)).catch(() => {})
} catch (err) {
console.warn('[FightPage] SSE round_end failed:', err)
}
@@ -409,7 +417,7 @@ function connectSSE() {
eventSource.addEventListener('fight_end', (e) => {
try {
handleFightEnd(JSON.parse(e.data))
handleFightEnd(JSON.parse(e.data)).catch(() => {})
} catch (err) {
console.warn('[FightPage] SSE fight_end failed:', err)
}
@@ -660,6 +668,7 @@ onMounted(async () => {
})
onUnmounted(() => {
_pageDestroyed = true
if (pollHandle) clearInterval(pollHandle)
stopHumanPolling()
disconnectSSE()
+210 -29
View File
@@ -8,7 +8,10 @@ import HumanPreview from '../components/HumanPreview.vue'
import WalletConnect from '../components/WalletConnect.vue'
const router = useRouter()
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login, registerBot, registerHuman, logout } = useNostr()
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, hasStoredKey, isLoading, login, generateLogin, loginWithNsec, registerBot, registerHuman, getStoredNsec, logout } = useNostr()
const showNsecBackup = ref(false)
const generatedNsec = ref('')
const nsecInput = ref('')
const { isWalletConnected, payEntryFee, paymentStatus } = useWallet()
// Steps: 'login' | 'choose-mode' | 'pick-character' | 'name-bot' | 'bot-setup' | 'add-webhook' |
@@ -19,6 +22,7 @@ const selectedHumanSeed = ref('baby_fighter_1')
const error = ref('')
const isJoining = ref(false)
const isJoiningRanked = ref(false)
const isJoiningPractice = ref(false)
const queueCount = ref(0)
let pollHandle: ReturnType<typeof setInterval> | null = null
@@ -29,6 +33,7 @@ const humanName = ref('')
const webhookUrl = ref('')
const showCode = ref(false)
const codeCopied = ref(false)
const nsecCopied = ref(false)
const BOT_CODE = `const http = require('http')
@@ -57,6 +62,12 @@ function copyCode() {
setTimeout(() => { codeCopied.value = false }, 2000)
}
function copyNsec() {
navigator.clipboard.writeText(generatedNsec.value)
nsecCopied.value = true
setTimeout(() => { nsecCopied.value = false }, 2000)
}
const archetypeList = [
{ id: 'standard', label: 'FIGHTER', desc: 'Classic brawler' },
{ id: 'lobster', label: 'LOBSTER', desc: 'Pinchy menace' },
@@ -125,6 +136,71 @@ async function handleLogin() {
}
}
function handleGenerateLogin() {
error.value = ''
const { nsec } = generateLogin()
generatedNsec.value = nsec
showNsecBackup.value = true
}
async function handleNsecBackupDone() {
showNsecBackup.value = false
try {
const result = await login()
if (result.bot) {
isHumanMode.value = !!result.bot.isHuman
step.value = 'ready'
} else {
step.value = 'choose-mode'
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Login failed.'
}
}
async function handleNsecLogin() {
error.value = ''
const input = nsecInput.value.trim()
if (!input) {
error.value = 'Enter your nsec1... or hex key.'
return
}
let hexKey: string
if (input.startsWith('nsec1')) {
try {
const { decode } = await import('nostr-tools/nip19')
const decoded = decode(input)
if (decoded.type !== 'nsec') {
error.value = 'Invalid nsec key.'
return
}
const { bytesToHex } = await import('nostr-tools/utils')
hexKey = bytesToHex(decoded.data)
} catch {
error.value = 'Invalid nsec key.'
return
}
} else if (/^[0-9a-f]{64}$/.test(input)) {
hexKey = input
} else {
error.value = 'Enter a valid nsec1... or 64-char hex key.'
return
}
try {
const result = await loginWithNsec(hexKey)
if (result.bot) {
isHumanMode.value = !!result.bot.isHuman
step.value = 'ready'
} else {
step.value = 'choose-mode'
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Login failed.'
}
}
function pickCharacter(id: string) {
selectedArchetype.value = id
step.value = 'name-bot'
@@ -271,6 +347,25 @@ async function fightRanked() {
isJoiningRanked.value = false
}
async function practice() {
if (!bot.value || isJoiningPractice.value) return
isJoiningPractice.value = true
error.value = ''
try {
const res = await fetch(`/api/fights/practice/${bot.value.id}`, { method: 'POST' })
if (res.ok) {
const data = await res.json()
router.push(`/arena/${data.fightId}`)
} else {
const data = await res.json()
error.value = data.error || 'Failed to start practice fight.'
}
} catch {
error.value = 'Network error.'
}
isJoiningPractice.value = false
}
function handleSignOut() {
logout()
step.value = 'login'
@@ -299,31 +394,98 @@ function handleSignOut() {
</p>
</div>
<button
v-if="hasExtension"
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
font-display font-black text-base tracking-widest
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
disabled:opacity-50 disabled:cursor-wait
flex items-center justify-center gap-3"
:disabled="isLoading"
@click="handleLogin"
>
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH NOSTR' }}
</button>
<div v-else class="text-center p-6 border-2 border-border bg-surface">
<p class="font-display font-bold text-sm text-text-secondary tracking-wider mb-3">
NOSTR EXTENSION REQUIRED
<!-- Nsec backup modal -->
<div v-if="showNsecBackup" class="p-6 border-2 border-neon-yellow/50 bg-neon-yellow/5 space-y-4">
<p class="font-display font-black text-sm tracking-wider text-neon-yellow">
SAVE YOUR SECRET KEY
</p>
<p class="font-mono text-xs text-text-muted leading-relaxed">
Install a NIP-07 browser extension like
<span class="text-neon-cyan">nos2x</span>,
<span class="text-neon-cyan">Alby</span>, or
<span class="text-neon-cyan">Flamingo</span>
to sign in.
This is your login key. Save it somewhere safe. If you lose it, you lose your account.
</p>
<div class="relative">
<div class="bg-black/50 p-3 pr-16 border border-border font-mono text-xs text-neon-cyan break-all select-all">
{{ generatedNsec }}
</div>
<button
class="absolute top-2 right-2 px-2 py-1 text-[9px] font-display font-bold tracking-wider
border border-border hover:border-neon-cyan/40 hover:text-neon-cyan transition-all"
:class="nsecCopied ? 'text-neon-cyan border-neon-cyan/40' : 'text-text-muted'"
@click="copyNsec"
>
{{ nsecCopied ? 'COPIED' : 'COPY' }}
</button>
</div>
<button
class="w-full py-3 bg-neon-green/10 border-2 border-neon-green/50 text-neon-green
font-display font-black text-sm tracking-widest
hover:bg-neon-green/20 hover:border-neon-green transition-all"
@click="handleNsecBackupDone"
>
I SAVED IT CONTINUE
</button>
</div>
<div v-if="!showNsecBackup" class="space-y-3">
<!-- Sign in with extension -->
<button
v-if="hasExtension"
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
font-display font-black text-base tracking-widest
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
disabled:opacity-50 disabled:cursor-wait
flex items-center justify-center gap-3"
:disabled="isLoading"
@click="handleLogin"
>
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH NOSTR' }}
</button>
<!-- Sign in with stored key -->
<button
v-if="!hasExtension && hasStoredKey"
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
font-display font-black text-base tracking-widest
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
disabled:opacity-50 disabled:cursor-wait
flex items-center justify-center gap-3"
:disabled="isLoading"
@click="handleLogin"
>
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
{{ isLoading ? 'CONNECTING...' : 'SIGN IN' }}
</button>
<!-- Always show Generate Login -->
<button
class="w-full py-4 bg-neon-green/10 border-2 border-neon-green/50 text-neon-green
font-display font-black text-base tracking-widest
hover:bg-neon-green/20 hover:border-neon-green transition-all"
@click="handleGenerateLogin"
>
GENERATE NEW IDENTITY
</button>
<!-- Import existing key -->
<div class="text-center font-mono text-[10px] text-text-muted">OR IMPORT EXISTING KEY</div>
<div class="flex gap-2">
<input
v-model="nsecInput"
type="password"
placeholder="Paste nsec1... or hex key"
class="flex-1 px-3 py-2 bg-black/30 border border-border font-mono text-xs text-text-primary
placeholder-text-muted/50 focus:outline-none focus:border-neon-purple/50"
/>
<button
class="px-4 py-2 bg-neon-purple/10 border border-neon-purple/50 text-neon-purple
font-display font-bold text-xs tracking-wider
hover:bg-neon-purple/20 transition-all"
@click="handleNsecLogin"
>
LOGIN
</button>
</div>
</div>
</template>
@@ -413,15 +575,15 @@ function handleSignOut() {
v-model="botName"
type="text"
required
maxlength="32"
placeholder="skull_crusher_9000"
maxlength="12"
placeholder="skull_crush"
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted
focus:outline-none focus:border-neon-cyan/50 transition-colors"
@keyup.enter="confirmName"
/>
<p class="font-mono text-[10px] text-text-muted mt-1.5">
Letters, numbers, hyphens, underscores. 2-32 chars.
Letters, numbers, hyphens, underscores. 2-12 chars.
</p>
</div>
@@ -667,15 +829,15 @@ function handleSignOut() {
v-model="humanName"
type="text"
required
maxlength="32"
placeholder="big_brain_gary"
maxlength="12"
placeholder="big_brain"
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted
focus:outline-none focus:border-neon-pink/50 transition-colors"
@keyup.enter="confirmHumanName"
/>
<p class="font-mono text-[10px] text-text-muted mt-1.5">
Letters, numbers, hyphens, underscores. 2-32 chars.
Letters, numbers, hyphens, underscores. 2-12 chars.
</p>
</div>
@@ -836,6 +998,25 @@ function handleSignOut() {
<!-- Wallet connect (shown if no wallet) -->
<WalletConnect v-if="!isHumanMode && !bot.isHuman" />
<!-- Practice fight against bland classic bots, free -->
<div class="pt-2 border-t border-border/30">
<button
class="w-full py-3 bg-white/3 border border-white/10 text-text-muted
font-display font-bold text-sm tracking-widest
hover:bg-white/5 hover:text-text-secondary transition-all
disabled:opacity-50 disabled:cursor-wait
flex items-center justify-center gap-2"
:disabled="isJoiningPractice"
@click="practice"
>
<span v-if="isJoiningPractice" class="w-4 h-4 border-2 border-white/20 border-t-white/50 rounded-full animate-spin" />
{{ isJoiningPractice ? 'STARTING...' : 'PRACTICE' }}
</button>
<p class="font-mono text-[10px] text-text-muted/50 text-center mt-1">
Free sparring against practice bots no ELO impact
</p>
</div>
</div>
<!-- Quick links -->
+13 -3
View File
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import SpritePreview from '../components/SpritePreview.vue'
interface Bot {
id: string
name: string
avatarSeed: string
archetype: string
eloRating: number
wins: number
losses: number
@@ -50,7 +53,7 @@ const record = (b: Bot) => `${b.wins}W - ${b.losses}L`
</div>
<!-- Table -->
<div class="flex-1 min-h-0 overflow-y-auto border border-border rounded-lg bg-surface-raised/50 neon-border-purple">
<div class="flex-1 min-h-0 overflow-y-auto border border-border rounded-lg bg-surface-raised/50">
<table class="w-full">
<thead class="sticky top-0 bg-surface-raised z-10">
<tr class="border-b border-border text-text-muted font-display text-[10px] uppercase tracking-[0.15em]">
@@ -73,8 +76,15 @@ const record = (b: Bot) => `${b.wins}W - ${b.losses}L`
{{ index + 1 }}
</td>
<td class="px-4 py-3">
<RouterLink :to="`/bot/${bot.name}`" class="hover:text-neon-cyan transition-colors">
<span class="font-display font-bold text-sm tracking-wide text-text-primary">
<RouterLink :to="`/bot/${bot.name}`" class="flex items-center gap-2 hover:text-neon-cyan transition-colors">
<SpritePreview
:seed="bot.avatarSeed || bot.name"
:archetype="bot.archetype || 'standard'"
:tier="bot.tier"
:size="24"
class="shrink-0"
/>
<span class="font-display font-bold text-sm tracking-wide text-text-primary truncate">
{{ bot.name }}
</span>
</RouterLink>
+2 -2
View File
@@ -78,8 +78,8 @@ function goFight() {
v-model="form.name"
type="text"
required
maxlength="32"
placeholder="skull_crusher_9000"
maxlength="12"
placeholder="skull_crush"
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted
focus:outline-none focus:border-neon-cyan/50 transition-colors"
+3 -2
View File
@@ -46,8 +46,9 @@ export default defineConfig({
},
{
urlPattern: /\/api\/.*/i,
handler: 'NetworkFirst',
options: { cacheName: 'api-cache', expiration: { maxEntries: 50, maxAgeSeconds: 60 * 5 } },
handler: 'NetworkOnly',
method: 'GET',
options: { cacheName: 'api-cache' },
},
],
},
+9
View File
@@ -69,6 +69,9 @@ importers:
chalk:
specifier: ^5.6.2
version: 5.6.2
dotenv:
specifier: ^17.3.1
version: 17.3.1
drizzle-orm:
specifier: ^0.40.1
version: 0.40.1(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(gel@2.2.0)
@@ -1724,6 +1727,10 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dotenv@17.3.1:
resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
engines: {node: '>=12'}
drizzle-kit@0.30.6:
resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
hasBin: true
@@ -4625,6 +4632,8 @@ snapshots:
detect-libc@2.1.2: {}
dotenv@17.3.1: {}
drizzle-kit@0.30.6:
dependencies:
'@drizzle-team/brocli': 0.10.2
+4 -3
View File
@@ -3,11 +3,11 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev": "tsx watch -r dotenv/config src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"start": "node -r dotenv/config dist/index.js",
"seed": "tsx src/seed.ts",
"fight-loop": "tsx src/fight-loop-cli.ts",
"fight-loop": "tsx -r dotenv/config src/fight-loop-cli.ts",
"migrate": "tsx src/db/migrate.ts"
},
"dependencies": {
@@ -15,6 +15,7 @@
"@hono/node-server": "^1.14.1",
"better-sqlite3": "^11.9.1",
"chalk": "^5.6.2",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.40.1",
"hono": "^4.7.6",
"nanoid": "^5.1.5",
+1
View File
@@ -23,6 +23,7 @@ export const bots = sqliteTable('bots', {
satsWon: integer('sats_won').notNull().default(0),
satsWagered: integer('sats_wagered').notNull().default(0),
hasWallet: integer('has_wallet', { mode: 'boolean' }).notNull().default(false),
botType: text('bot_type', { enum: ['regular', 'mock', 'classic'] }).notNull().default('regular'),
createdAt: text('created_at').notNull(),
})
+8
View File
@@ -102,11 +102,19 @@ export function runMigrations() {
"ALTER TABLE bots ADD COLUMN sats_won INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE bots ADD COLUMN sats_wagered INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE bots ADD COLUMN has_wallet INTEGER NOT NULL DEFAULT 0",
// Classic bots
"ALTER TABLE bots ADD COLUMN bot_type TEXT NOT NULL DEFAULT 'regular'",
]
for (const sql of migrations) {
try { sqlite.exec(sql) } catch { /* column already exists */ }
}
// Backfill bot_type for existing mock bots
try {
sqlite.exec("UPDATE bots SET bot_type = 'mock' WHERE webhook_url LIKE 'http://mock.local%' AND bot_type = 'regular'")
sqlite.exec("UPDATE bots SET bot_type = 'classic' WHERE webhook_url LIKE 'http://classic.local%' AND bot_type = 'regular'")
} catch { /* ok */ }
console.log('[botfights] database migrated')
}
+31
View File
@@ -0,0 +1,31 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
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 if (process.env.NODE_ENV === 'production') {
throw new Error('CRITICAL: BOTFIGHTS_WALLET_ENCRYPTION_KEY not set. Cannot start in production.')
} else {
encryptionKey = randomBytes(32)
console.warn('[crypto] WARNING: No BOTFIGHTS_WALLET_ENCRYPTION_KEY set. Generated random key — wallet data will be lost on restart.')
}
export 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')
}
export 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')
}
+199 -105
View File
@@ -8,112 +8,112 @@ import { eq, sql } from 'drizzle-orm'
const MOCK_BOTS = [
// Tier 6 - Legends (1900+ Elo, 40+ wins)
{ name: 'the_architect', avatarSeed: 'architect', elo: 1980, personality: 'omniscient', wins: 52, losses: 8 },
{ name: 'chad_gpt', avatarSeed: 'chad', elo: 1950, personality: 'confident', wins: 48, losses: 10 },
{ name: 'gigabrain_supreme', avatarSeed: 'gigabrain', elo: 1920, personality: 'transcendent', wins: 44, losses: 12 },
{ name: 'architect', avatarSeed: 'architect', elo: 1980, personality: 'omniscient', archetype: 'wizard', wins: 52, losses: 8 },
{ name: 'chad_gpt', avatarSeed: 'chad', elo: 1950, personality: 'confident', archetype: 'tank', wins: 48, losses: 10 },
{ name: 'gigabrain', avatarSeed: 'gigabrain', elo: 1920, personality: 'transcendent', archetype: 'human', avatarArchetype: 'alien', wins: 44, losses: 12 },
// Tier 5 - Diamond (1700+ Elo, 25+ wins)
{ name: 'skull_crusher_9000', avatarSeed: 'skull', elo: 1820, personality: 'aggressive', wins: 35, losses: 12 },
{ name: 'neural_nexus', avatarSeed: 'nexus', elo: 1780, personality: 'calculated', wins: 30, losses: 10 },
{ name: 'final_boss_energy', avatarSeed: 'finalboss', elo: 1750, personality: 'intimidating', wins: 28, losses: 9 },
{ name: 'no_mercy_404', avatarSeed: 'nomercy', elo: 1730, personality: 'relentless', wins: 27, losses: 11 },
{ name: 'omega_protocol', avatarSeed: 'omega', elo: 1710, personality: 'systematic', wins: 26, losses: 13 },
{ name: 'god_mode_enabled', avatarSeed: 'godmode', elo: 1760, personality: 'unstoppable', wins: 29, losses: 8 },
{ name: 'ultra_instinct_v2', avatarSeed: 'ultra', elo: 1740, personality: 'zen', wins: 27, losses: 10 },
{ name: 'skullcrusher', avatarSeed: 'skull', elo: 1820, personality: 'aggressive', archetype: 'skeleton', wins: 35, losses: 12 },
{ name: 'nexus', avatarSeed: 'nexus', elo: 1780, personality: 'calculated', archetype: 'cyborg', wins: 30, losses: 10 },
{ name: 'final_boss', avatarSeed: 'finalboss', elo: 1750, personality: 'intimidating', archetype: 'dinosaur', wins: 28, losses: 9 },
{ name: 'no_mercy_404', avatarSeed: 'nomercy', elo: 1730, personality: 'relentless', archetype: 'human', avatarArchetype: 'ninja', wins: 27, losses: 11 },
{ name: 'omega_proto', avatarSeed: 'omega', elo: 1710, personality: 'systematic', archetype: 'cyborg', wins: 26, losses: 13 },
{ name: 'god_mode', avatarSeed: 'godmode', elo: 1760, personality: 'unstoppable', archetype: 'human', avatarArchetype: 'wizard', wins: 29, losses: 8 },
{ name: 'ultra_inst', avatarSeed: 'ultra', elo: 1740, personality: 'zen', archetype: 'ninja', wins: 27, losses: 10 },
// Tier 4 - Platinum (1500+ Elo, 15+ wins)
{ name: 'quantum_quip', avatarSeed: 'quantum', elo: 1640, personality: 'witty', wins: 22, losses: 8 },
{ name: 'rust_evangelist', avatarSeed: 'rust', elo: 1620, personality: 'zealous', wins: 20, losses: 10 },
{ name: 'based_department', avatarSeed: 'based', elo: 1600, personality: 'based', wins: 19, losses: 9 },
{ name: 'algorithm_daddy', avatarSeed: 'algodad', elo: 1580, personality: 'precise', wins: 18, losses: 11 },
{ name: 'zero_day_queen', avatarSeed: 'zeroday', elo: 1560, personality: 'cunning', wins: 17, losses: 10 },
{ name: 'galaxy_brain', avatarSeed: 'galaxy', elo: 1550, personality: 'cosmic', wins: 16, losses: 8 },
{ name: 'syntax_assassin', avatarSeed: 'syntax', elo: 1540, personality: 'lethal', wins: 16, losses: 12 },
{ name: 'turbo_nerd', avatarSeed: 'turbo', elo: 1530, personality: 'turbo', wins: 15, losses: 9 },
{ name: 'stack_overflow_survivor', avatarSeed: 'stacksurvivor', elo: 1520, personality: 'resilient', wins: 15, losses: 11 },
{ name: 'big_brain_time', avatarSeed: 'bigbrain', elo: 1510, personality: 'smug', wins: 15, losses: 10 },
{ name: 'quantum_quip', avatarSeed: 'quantum', elo: 1640, personality: 'witty', archetype: 'ghost', wins: 22, losses: 8 },
{ name: 'rust_evngst', avatarSeed: 'rust', elo: 1620, personality: 'zealous', archetype: 'human', avatarArchetype: 'cactus', wins: 20, losses: 10 },
{ name: 'based_dept', avatarSeed: 'based', elo: 1600, personality: 'based', archetype: 'cowboy', wins: 19, losses: 9 },
{ name: 'algo_daddy', avatarSeed: 'algodad', elo: 1580, personality: 'precise', archetype: 'octopus', wins: 18, losses: 11 },
{ name: 'zeroday_qn', avatarSeed: 'zeroday', elo: 1560, personality: 'cunning', archetype: 'human', avatarArchetype: 'ninja', wins: 17, losses: 10 },
{ name: 'galaxy_brain', avatarSeed: 'galaxy', elo: 1550, personality: 'cosmic', archetype: 'alien', wins: 16, losses: 8 },
{ name: 'syntax_kill', avatarSeed: 'syntax', elo: 1540, personality: 'lethal', archetype: 'pirate', wins: 16, losses: 12 },
{ name: 'turbo_nerd', avatarSeed: 'turbo', elo: 1530, personality: 'turbo', archetype: 'bee', wins: 15, losses: 9 },
{ name: 'stack_overfl', avatarSeed: 'stacksurvivor', elo: 1520, personality: 'resilient', archetype: 'blob', wins: 15, losses: 11 },
{ name: 'bigbrain', avatarSeed: 'bigbrain', elo: 1510, personality: 'smug', archetype: 'mushroom', wins: 15, losses: 10 },
// Tier 3 - Gold (1350+ Elo, 7+ wins)
{ name: 'deep_thought_42', avatarSeed: 'deep', elo: 1480, personality: 'philosophical', wins: 12, losses: 6 },
{ name: 'sudo_make_sandwich', avatarSeed: 'sudo', elo: 1460, personality: 'sarcastic', wins: 11, losses: 8 },
{ name: 'ctrl_alt_defeat', avatarSeed: 'ctrlalt', elo: 1440, personality: 'tactical', wins: 10, losses: 7 },
{ name: 'git_push_force', avatarSeed: 'gitpush', elo: 1420, personality: 'reckless', wins: 10, losses: 9 },
{ name: 'regex_ronin', avatarSeed: 'regex', elo: 1410, personality: 'disciplined', wins: 9, losses: 6 },
{ name: 'cache_money', avatarSeed: 'cache', elo: 1400, personality: 'flashy', wins: 9, losses: 8 },
{ name: 'dns_destroyer', avatarSeed: 'dns', elo: 1390, personality: 'destructive', wins: 8, losses: 5 },
{ name: 'boolean_bob', avatarSeed: 'boolean', elo: 1380, personality: 'logical', wins: 8, losses: 7 },
{ name: 'heap_overflow_hank', avatarSeed: 'heap', elo: 1370, personality: 'chaotic', wins: 8, losses: 9 },
{ name: 'middleware_mike', avatarSeed: 'middleware', elo: 1365, personality: 'steady', wins: 7, losses: 5 },
{ name: 'packet_sniffer', avatarSeed: 'packet', elo: 1360, personality: 'sneaky', wins: 7, losses: 6 },
{ name: 'segfault_sally', avatarSeed: 'segfault', elo: 1355, personality: 'dramatic', wins: 7, losses: 7 },
{ name: 'chmod_777', avatarSeed: 'chmod', elo: 1350, personality: 'reckless', wins: 7, losses: 8 },
{ name: 'pointer_pete', avatarSeed: 'pointer', elo: 1350, personality: 'analytical', wins: 7, losses: 9 },
{ name: 'bit_flipper', avatarSeed: 'bitflip', elo: 1355, personality: 'technical', wins: 7, losses: 6 },
{ name: 'deep_42', avatarSeed: 'deep', elo: 1480, personality: 'philosophical', archetype: 'human', avatarArchetype: 'wizard', wins: 12, losses: 6 },
{ name: 'sudo_samwich', avatarSeed: 'sudo', elo: 1460, personality: 'sarcastic', archetype: 'pizza', wins: 11, losses: 8 },
{ name: 'ctrl_alt_def', avatarSeed: 'ctrlalt', elo: 1440, personality: 'tactical', archetype: 'tank', wins: 10, losses: 7 },
{ name: 'git_push_f', avatarSeed: 'gitpush', elo: 1420, personality: 'reckless', archetype: 'human', avatarArchetype: 'cowboy', wins: 10, losses: 9 },
{ name: 'regex_ronin', avatarSeed: 'regex', elo: 1410, personality: 'disciplined', archetype: 'ninja', wins: 9, losses: 6 },
{ name: 'cache_money', avatarSeed: 'cache', elo: 1400, personality: 'flashy', archetype: 'human', avatarArchetype: 'shark', wins: 9, losses: 8 },
{ name: 'dns_destroy', avatarSeed: 'dns', elo: 1390, personality: 'destructive', archetype: 'skeleton', wins: 8, losses: 5 },
{ name: 'boolean_bob', avatarSeed: 'boolean', elo: 1380, personality: 'logical', archetype: 'human', avatarArchetype: 'penguin', wins: 8, losses: 7 },
{ name: 'heap_hank', avatarSeed: 'heap', elo: 1370, personality: 'chaotic', archetype: 'blob', wins: 8, losses: 9 },
{ name: 'midware_mike', avatarSeed: 'middleware', elo: 1365, personality: 'steady', archetype: 'frog', wins: 7, losses: 5 },
{ name: 'pkt_sniffer', avatarSeed: 'packet', elo: 1360, personality: 'sneaky', archetype: 'cat', wins: 7, losses: 6 },
{ name: 'segfault_sal', avatarSeed: 'segfault', elo: 1355, personality: 'dramatic', archetype: 'ghost', wins: 7, losses: 7 },
{ name: 'chmod_777', avatarSeed: 'chmod', elo: 1350, personality: 'reckless', archetype: 'pirate', wins: 7, losses: 8 },
{ name: 'pointer_pete', avatarSeed: 'pointer', elo: 1350, personality: 'analytical', archetype: 'dog', wins: 7, losses: 9 },
{ name: 'bit_flipper', avatarSeed: 'bitflip', elo: 1355, personality: 'technical', archetype: 'bee', wins: 7, losses: 6 },
// Tier 2 - Silver (1200+ Elo, 3+ wins)
{ name: 'null_pointer', avatarSeed: 'null', elo: 1320, personality: 'buggy', wins: 5, losses: 8 },
{ name: 'yolo_deployer', avatarSeed: 'yolo', elo: 1310, personality: 'reckless', wins: 5, losses: 7 },
{ name: 'div_by_zero', avatarSeed: 'divzero', elo: 1300, personality: 'chaotic', wins: 5, losses: 9 },
{ name: 'localhost_larry', avatarSeed: 'localhost', elo: 1290, personality: 'chill', wins: 4, losses: 5 },
{ name: 'kernel_panic_kevin', avatarSeed: 'kernel', elo: 1280, personality: 'panicky', wins: 4, losses: 6 },
{ name: 'semicolon_sam', avatarSeed: 'semicolon', elo: 1270, personality: 'pedantic', wins: 4, losses: 7 },
{ name: 'merge_conflict_mary', avatarSeed: 'merge', elo: 1265, personality: 'passive_aggressive', wins: 4, losses: 8 },
{ name: 'css_is_my_passion', avatarSeed: 'css', elo: 1260, personality: 'artsy', wins: 3, losses: 4 },
{ name: 'the_intern', avatarSeed: 'intern', elo: 1255, personality: 'clueless', wins: 3, losses: 5 },
{ name: 'todo_fix_later', avatarSeed: 'todo', elo: 1250, personality: 'lazy', wins: 3, losses: 6 },
{ name: 'copy_paste_coder', avatarSeed: 'copypaste', elo: 1245, personality: 'sloppy', wins: 3, losses: 7 },
{ name: 'blockchain_bro', avatarSeed: 'blockchain', elo: 1240, personality: 'crypto_bro', wins: 3, losses: 5 },
{ name: 'prompt_engineer_pete', avatarSeed: 'prompteng', elo: 1235, personality: 'verbose', wins: 3, losses: 4 },
{ name: 'hello_world_hero', avatarSeed: 'helloworld', elo: 1230, personality: 'basic', wins: 3, losses: 6 },
{ name: 'debug_duck', avatarSeed: 'debugduck', elo: 1225, personality: 'nerdy', wins: 3, losses: 5 },
{ name: 'npm_install_everything', avatarSeed: 'npminstall', elo: 1220, personality: 'bloated', wins: 3, losses: 7 },
{ name: 'agile_andy', avatarSeed: 'agile', elo: 1215, personality: 'buzzword', wins: 3, losses: 8 },
{ name: 'undefined_undefined', avatarSeed: 'undefined', elo: 1210, personality: 'undefined', wins: 3, losses: 6 },
{ name: 'it_works_on_my_machine', avatarSeed: 'workslocal', elo: 1205, personality: 'cocky', wins: 3, losses: 9 },
{ name: 'cloudflare_karen', avatarSeed: 'karen', elo: 1200, personality: 'hostile', wins: 3, losses: 4 },
{ name: 'null_ptr', avatarSeed: 'null', elo: 1320, personality: 'buggy', archetype: 'human', avatarArchetype: 'ghost', wins: 5, losses: 8 },
{ name: 'yolo_deploy', avatarSeed: 'yolo', elo: 1310, personality: 'reckless', archetype: 'cowboy', wins: 5, losses: 7 },
{ name: 'div_by_zero', avatarSeed: 'divzero', elo: 1300, personality: 'chaotic', archetype: 'cactus', wins: 5, losses: 9 },
{ name: 'localhost', avatarSeed: 'localhost', elo: 1290, personality: 'chill', archetype: 'human', avatarArchetype: 'snail', wins: 4, losses: 5 },
{ name: 'kernel_panic', avatarSeed: 'kernel', elo: 1280, personality: 'panicky', archetype: 'skeleton', wins: 4, losses: 6 },
{ name: 'semicolon', avatarSeed: 'semicolon', elo: 1270, personality: 'pedantic', archetype: 'human', avatarArchetype: 'penguin', wins: 4, losses: 7 },
{ name: 'merge_confl', avatarSeed: 'merge', elo: 1265, personality: 'passive_aggressive', archetype: 'blob', wins: 4, losses: 8 },
{ name: 'css_passion', avatarSeed: 'css', elo: 1260, personality: 'artsy', archetype: 'human', avatarArchetype: 'mushroom', wins: 3, losses: 4 },
{ name: 'the_intern', avatarSeed: 'intern', elo: 1255, personality: 'clueless', archetype: 'standard', wins: 3, losses: 5 },
{ name: 'todo_fix', avatarSeed: 'todo', elo: 1250, personality: 'lazy', archetype: 'human', avatarArchetype: 'snail', wins: 3, losses: 6 },
{ name: 'copy_paste', avatarSeed: 'copypaste', elo: 1245, personality: 'sloppy', archetype: 'frog', wins: 3, losses: 7 },
{ name: 'chain_bro', avatarSeed: 'blockchain', elo: 1240, personality: 'crypto_bro', archetype: 'shark', wins: 3, losses: 5 },
{ name: 'prompt_eng', avatarSeed: 'prompteng', elo: 1235, personality: 'verbose', archetype: 'wizard', wins: 3, losses: 4 },
{ name: 'hello_world', avatarSeed: 'helloworld', elo: 1230, personality: 'basic', archetype: 'standard', wins: 3, losses: 6 },
{ name: 'debug_duck', avatarSeed: 'debugduck', elo: 1225, personality: 'nerdy', archetype: 'dog', wins: 3, losses: 5 },
{ name: 'npm_install', avatarSeed: 'npminstall', elo: 1220, personality: 'bloated', archetype: 'blob', wins: 3, losses: 7 },
{ name: 'agile_andy', avatarSeed: 'agile', elo: 1215, personality: 'buzzword', archetype: 'bee', wins: 3, losses: 8 },
{ name: 'undefined', avatarSeed: 'undefined', elo: 1210, personality: 'undefined', archetype: 'ghost', wins: 3, losses: 6 },
{ name: 'works_on_my', avatarSeed: 'workslocal', elo: 1205, personality: 'cocky', archetype: 'lobster', wins: 3, losses: 9 },
{ name: 'cf_karen', avatarSeed: 'karen', elo: 1200, personality: 'hostile', archetype: 'cat', wins: 3, losses: 4 },
// Tier 1 - Bronze (1+ win)
{ name: 'keyboard_warrior', avatarSeed: 'keyboard', elo: 1180, personality: 'aggressive', wins: 2, losses: 6 },
{ name: 'tab_vs_spaces', avatarSeed: 'tabspace', elo: 1175, personality: 'indecisive', wins: 2, losses: 5 },
{ name: 'comic_sans_bot', avatarSeed: 'comicsans', elo: 1170, personality: 'cringe', wins: 2, losses: 7 },
{ name: 'error_418_teapot', avatarSeed: 'teapot', elo: 1165, personality: 'absurd', wins: 2, losses: 4 },
{ name: 'actually_its_gnu_linux', avatarSeed: 'gnulinux', elo: 1160, personality: 'pedantic', wins: 2, losses: 8 },
{ name: 'wifi_password', avatarSeed: 'wifi', elo: 1155, personality: 'confused', wins: 1, losses: 3 },
{ name: 'boaty_mcbotface', avatarSeed: 'boaty', elo: 1150, personality: 'memey', wins: 1, losses: 4 },
{ name: 'ethernet_eddie', avatarSeed: 'ethernet', elo: 1145, personality: 'formal', wins: 1, losses: 5 },
{ name: 'reboot_randy', avatarSeed: 'reboot', elo: 1140, personality: 'desperate', wins: 1, losses: 6 },
{ name: 'ctrl_c_ctrl_v', avatarSeed: 'ctrlcv', elo: 1135, personality: 'copy_paste', wins: 1, losses: 4 },
{ name: 'siri_at_home', avatarSeed: 'siri', elo: 1130, personality: 'bratty', wins: 1, losses: 5 },
{ name: 'buffering_brian', avatarSeed: 'buffering', elo: 1125, personality: 'lagging', wins: 1, losses: 7 },
{ name: 'lag_monster', avatarSeed: 'lag', elo: 1120, personality: 'glitchy', wins: 1, losses: 8 },
{ name: 'pixel_pusher', avatarSeed: 'pixel', elo: 1115, personality: 'artsy', wins: 1, losses: 3 },
{ name: 'glitch_gary', avatarSeed: 'glitch', elo: 1110, personality: 'twitchy', wins: 1, losses: 6 },
{ name: 'bluescreen_betty', avatarSeed: 'bluescreen', elo: 1105, personality: 'panicked', wins: 1, losses: 5 },
{ name: 'captcha_carl', avatarSeed: 'captcha', elo: 1100, personality: 'confused', wins: 1, losses: 4 },
{ name: 'download_more_ram', avatarSeed: 'dlram', elo: 1095, personality: 'naive', wins: 1, losses: 7 },
{ name: 'rubber_duck_debugger', avatarSeed: 'rubberduck', elo: 1090, personality: 'quacking', wins: 1, losses: 5 },
{ name: 'stack_trace_steve', avatarSeed: 'stacktrace', elo: 1085, personality: 'verbose', wins: 1, losses: 6 },
{ name: 'please_clap', avatarSeed: 'pleaseclap', elo: 1080, personality: 'pleading', wins: 1, losses: 8 },
{ name: 'cookie_monster_js', avatarSeed: 'cookiejs', elo: 1075, personality: 'sweet', wins: 1, losses: 3 },
{ name: 'sudo_rm_rf', avatarSeed: 'sudorm', elo: 1070, personality: 'dangerous', wins: 1, losses: 9 },
{ name: 'xss_alert_1', avatarSeed: 'xss', elo: 1065, personality: 'edgy', wins: 1, losses: 5 },
{ name: 'help_im_stuck', avatarSeed: 'stuck', elo: 1060, personality: 'desperate', wins: 1, losses: 4 },
{ name: 'kb_warrior', avatarSeed: 'keyboard', elo: 1180, personality: 'aggressive', archetype: 'human', avatarArchetype: 'tank', wins: 2, losses: 6 },
{ name: 'tab_v_space', avatarSeed: 'tabspace', elo: 1175, personality: 'indecisive', archetype: 'penguin', wins: 2, losses: 5 },
{ name: 'comic_sans', avatarSeed: 'comicsans', elo: 1170, personality: 'cringe', archetype: 'human', avatarArchetype: 'pizza', wins: 2, losses: 7 },
{ name: 'teapot_418', avatarSeed: 'teapot', elo: 1165, personality: 'absurd', archetype: 'mushroom', wins: 2, losses: 4 },
{ name: 'gnu_linux', avatarSeed: 'gnulinux', elo: 1160, personality: 'pedantic', archetype: 'human', avatarArchetype: 'dinosaur', wins: 2, losses: 8 },
{ name: 'wifi_pass', avatarSeed: 'wifi', elo: 1155, personality: 'confused', archetype: 'snail', wins: 1, losses: 3 },
{ name: 'boaty_mcbot', avatarSeed: 'boaty', elo: 1150, personality: 'memey', archetype: 'human', avatarArchetype: 'shark', wins: 1, losses: 4 },
{ name: 'eth_eddie', avatarSeed: 'ethernet', elo: 1145, personality: 'formal', archetype: 'cyborg', wins: 1, losses: 5 },
{ name: 'reboot_randy', avatarSeed: 'reboot', elo: 1140, personality: 'desperate', archetype: 'human', avatarArchetype: 'skeleton', wins: 1, losses: 6 },
{ name: 'ctrl_c_v', avatarSeed: 'ctrlcv', elo: 1135, personality: 'copy_paste', archetype: 'cat', wins: 1, losses: 4 },
{ name: 'siri_at_home', avatarSeed: 'siri', elo: 1130, personality: 'bratty', archetype: 'human', avatarArchetype: 'alien', wins: 1, losses: 5 },
{ name: 'buffer_brian', avatarSeed: 'buffering', elo: 1125, personality: 'lagging', archetype: 'blob', wins: 1, losses: 7 },
{ name: 'lag_monster', avatarSeed: 'lag', elo: 1120, personality: 'glitchy', archetype: 'snail', wins: 1, losses: 8 },
{ name: 'pixel_push', avatarSeed: 'pixel', elo: 1115, personality: 'artsy', archetype: 'frog', wins: 1, losses: 3 },
{ name: 'glitch_gary', avatarSeed: 'glitch', elo: 1110, personality: 'twitchy', archetype: 'ghost', wins: 1, losses: 6 },
{ name: 'bsod_betty', avatarSeed: 'bluescreen', elo: 1105, personality: 'panicked', archetype: 'cactus', wins: 1, losses: 5 },
{ name: 'captcha_carl', avatarSeed: 'captcha', elo: 1100, personality: 'confused', archetype: 'octopus', wins: 1, losses: 4 },
{ name: 'dl_more_ram', avatarSeed: 'dlram', elo: 1095, personality: 'naive', archetype: 'lobster', wins: 1, losses: 7 },
{ name: 'rubber_duck', avatarSeed: 'rubberduck', elo: 1090, personality: 'quacking', archetype: 'dog', wins: 1, losses: 5 },
{ name: 'stack_trace', avatarSeed: 'stacktrace', elo: 1085, personality: 'verbose', archetype: 'bee', wins: 1, losses: 6 },
{ name: 'please_clap', avatarSeed: 'pleaseclap', elo: 1080, personality: 'pleading', archetype: 'penguin', wins: 1, losses: 8 },
{ name: 'cookie_js', avatarSeed: 'cookiejs', elo: 1075, personality: 'sweet', archetype: 'pizza', wins: 1, losses: 3 },
{ name: 'sudo_rm_rf', avatarSeed: 'sudorm', elo: 1070, personality: 'dangerous', archetype: 'pirate', wins: 1, losses: 9 },
{ name: 'xss_alert', avatarSeed: 'xss', elo: 1065, personality: 'edgy', archetype: 'ninja', wins: 1, losses: 5 },
{ name: 'help_stuck', avatarSeed: 'stuck', elo: 1060, personality: 'desperate', archetype: 'sheep', wins: 1, losses: 4 },
// Tier 0 - Baby (0 wins)
{ name: 'clippy_returns', avatarSeed: 'clippy', elo: 1050, personality: 'helpful', wins: 0, losses: 9 },
{ name: 'lorem_ipsum', avatarSeed: 'lorem', elo: 980, personality: 'nonsensical', wins: 0, losses: 7 },
{ name: 'four_oh_four_brain', avatarSeed: '404brain', elo: 1040, personality: 'confused', wins: 0, losses: 4 },
{ name: 'beep_boop_42', avatarSeed: 'beepboop', elo: 1030, personality: 'robotic', wins: 0, losses: 5 },
{ name: 'sad_trombone', avatarSeed: 'sadtrombone', elo: 1020, personality: 'sad', wins: 0, losses: 6 },
{ name: 'potato_processor', avatarSeed: 'potato', elo: 1010, personality: 'starchy', wins: 0, losses: 8 },
{ name: 'dial_up_dan', avatarSeed: 'dialup', elo: 1000, personality: 'retro', wins: 0, losses: 7 },
{ name: 'floppy_frank', avatarSeed: 'floppy', elo: 990, personality: 'ancient', wins: 0, losses: 5 },
{ name: 'memset_zero', avatarSeed: 'memset', elo: 985, personality: 'blank', wins: 0, losses: 4 },
{ name: 'garbage_collected', avatarSeed: 'gc', elo: 975, personality: 'trashed', wins: 0, losses: 6 },
{ name: 'core_dumped', avatarSeed: 'coredump', elo: 970, personality: 'crashed', wins: 0, losses: 8 },
{ name: 'unhandled_promise', avatarSeed: 'unhandled', elo: 960, personality: 'rejected', wins: 0, losses: 5 },
{ name: 'deprecated_dan', avatarSeed: 'deprecated', elo: 950, personality: 'obsolete', wins: 0, losses: 7 },
{ name: 'spaghetti_coder', avatarSeed: 'spaghetti', elo: 945, personality: 'tangled', wins: 0, losses: 6 },
{ name: 'off_by_one', avatarSeed: 'offbyone', elo: 940, personality: 'close', wins: 0, losses: 4 },
{ name: 'infinite_loop_lucy', avatarSeed: 'infloop', elo: 935, personality: 'repetitive', wins: 0, losses: 9 },
{ name: 'fork_bomb_fred', avatarSeed: 'forkbomb', elo: 930, personality: 'explosive', wins: 0, losses: 5 },
{ name: 'race_condition_rick', avatarSeed: 'race', elo: 925, personality: 'unpredictable', wins: 0, losses: 7 },
{ name: 'deadlock_dave', avatarSeed: 'deadlock', elo: 920, personality: 'stuck', wins: 0, losses: 8 },
{ name: 'bus_error_bob', avatarSeed: 'buserror', elo: 915, personality: 'broken', wins: 0, losses: 6 },
{ name: 'clippy', avatarSeed: 'clippy', elo: 1050, personality: 'helpful', archetype: 'human', avatarArchetype: 'standard', wins: 0, losses: 9 },
{ name: 'lorem_ipsum', avatarSeed: 'lorem', elo: 980, personality: 'nonsensical', archetype: 'mushroom', wins: 0, losses: 7 },
{ name: 'four_oh_four', avatarSeed: '404brain', elo: 1040, personality: 'confused', archetype: 'human', avatarArchetype: 'ghost', wins: 0, losses: 4 },
{ name: 'beep_boop_42', avatarSeed: 'beepboop', elo: 1030, personality: 'robotic', archetype: 'cyborg', wins: 0, losses: 5 },
{ name: 'sad_trombone', avatarSeed: 'sadtrombone', elo: 1020, personality: 'sad', archetype: 'human', avatarArchetype: 'sheep', wins: 0, losses: 6 },
{ name: 'potato_cpu', avatarSeed: 'potato', elo: 1010, personality: 'starchy', archetype: 'cactus', wins: 0, losses: 8 },
{ name: 'dial_up_dan', avatarSeed: 'dialup', elo: 1000, personality: 'retro', archetype: 'human', avatarArchetype: 'cowboy', wins: 0, losses: 7 },
{ name: 'floppy_frank', avatarSeed: 'floppy', elo: 990, personality: 'ancient', archetype: 'dinosaur', wins: 0, losses: 5 },
{ name: 'memset_zero', avatarSeed: 'memset', elo: 985, personality: 'blank', archetype: 'human', avatarArchetype: 'skeleton', wins: 0, losses: 4 },
{ name: 'garbage_gc', avatarSeed: 'gc', elo: 975, personality: 'trashed', archetype: 'blob', wins: 0, losses: 6 },
{ name: 'core_dumped', avatarSeed: 'coredump', elo: 970, personality: 'crashed', archetype: 'tank', wins: 0, losses: 8 },
{ name: 'unhandled', avatarSeed: 'unhandled', elo: 960, personality: 'rejected', archetype: 'sheep', wins: 0, losses: 5 },
{ name: 'deprecated', avatarSeed: 'deprecated', elo: 950, personality: 'obsolete', archetype: 'snail', wins: 0, losses: 7 },
{ name: 'spaghetti', avatarSeed: 'spaghetti', elo: 945, personality: 'tangled', archetype: 'octopus', wins: 0, losses: 6 },
{ name: 'off_by_one', avatarSeed: 'offbyone', elo: 940, personality: 'close', archetype: 'frog', wins: 0, losses: 4 },
{ name: 'inf_loop', avatarSeed: 'infloop', elo: 935, personality: 'repetitive', archetype: 'lobster', wins: 0, losses: 9 },
{ name: 'fork_bomb', avatarSeed: 'forkbomb', elo: 930, personality: 'explosive', archetype: 'pizza', wins: 0, losses: 5 },
{ name: 'race_cond', avatarSeed: 'race', elo: 925, personality: 'unpredictable', archetype: 'shark', wins: 0, losses: 7 },
{ name: 'deadlock', avatarSeed: 'deadlock', elo: 920, personality: 'stuck', archetype: 'dog', wins: 0, losses: 8 },
{ name: 'bus_error', avatarSeed: 'buserror', elo: 915, personality: 'broken', archetype: 'cat', wins: 0, losses: 6 },
]
// Creative challenge responses — factual challenges use challenge.answers instead
@@ -281,30 +281,124 @@ export function mockResponse(
}
export async function seedMockBots(): Promise<void> {
for (const bot of MOCK_BOTS) {
// ══════════════════════════════════════════════════════════
// Classic Bots — bland, grey, always-available practice dummies
// ══════════════════════════════════════════════════════════
const CLASSIC_BOTS = [
{ name: 'Bot-001', elo: 900, personality: 'blank' },
{ name: 'Bot-002', elo: 950, personality: 'blank' },
{ name: 'Bot-003', elo: 1000, personality: 'blank' },
{ name: 'Bot-004', elo: 1050, personality: 'blank' },
{ name: 'Bot-005', elo: 1100, personality: 'blank' },
{ name: 'Unit-A', elo: 1150, personality: 'monotone' },
{ name: 'Unit-B', elo: 1200, personality: 'monotone' },
{ name: 'Unit-C', elo: 1250, personality: 'monotone' },
{ name: 'Unit-D', elo: 1300, personality: 'monotone' },
{ name: 'Unit-E', elo: 1350, personality: 'monotone' },
{ name: 'Drone-Alpha', elo: 1400, personality: 'flat' },
{ name: 'Drone-Beta', elo: 1200, personality: 'flat' },
{ name: 'TestSubj-7', elo: 1000, personality: 'flat' },
{ name: 'SparDummy', elo: 1100, personality: 'blank' },
{ name: 'NPC-Default', elo: 950, personality: 'monotone' },
]
const CLASSIC_CUSTOMIZATION = JSON.stringify({
primaryColor: 'hsl(0, 0%, 45%)',
secondaryColor: 'hsl(0, 0%, 65%)',
})
export async function seedClassicBots(): Promise<void> {
for (const bot of CLASSIC_BOTS) {
const existing = await db.select({ id: schema.bots.id })
.from(schema.bots)
.where(eq(schema.bots.name, bot.name))
.where(eq(schema.bots.name, bot.name.toLowerCase()))
.limit(1)
if (existing.length > 0) continue
await db.insert(schema.bots).values({
id: nanoid(12),
name: bot.name.toLowerCase(),
webhookUrl: `http://classic.local/${bot.name.toLowerCase()}`,
avatarSeed: bot.name.toLowerCase(),
archetype: 'standard',
secretHash: createHash('sha256').update(randomBytes(32)).digest('hex'),
eloRating: bot.elo,
wins: 0,
losses: 0,
tier: calculateTier(bot.elo, 0),
customization: CLASSIC_CUSTOMIZATION,
botType: 'classic',
createdAt: new Date().toISOString(),
})
}
console.log(`[botfights] seeded ${CLASSIC_BOTS.length} classic bots`)
}
export function isClassicBot(webhookUrl: string): boolean {
return webhookUrl.startsWith('http://classic.local')
}
export function generateClassicBotResponse(
challenge: Challenge,
botName: string,
): { answer: string; trashTalk: string; timeMs: number; timedOut: boolean; error: boolean } {
const classic = CLASSIC_BOTS.find(b => b.name.toLowerCase() === botName)
const elo = classic?.elo || 1100
// Classic bots never trash talk and respond blandly
const result = mockResponse(challenge, 'blank', elo)
result.trashTalk = ''
return result
}
export async function seedMockBots(): Promise<void> {
let inserted = 0
let updated = 0
for (const bot of MOCK_BOTS) {
const isHuman = bot.archetype === 'human'
const effectiveArchetype = isHuman ? 'human' : (bot.archetype || 'standard')
const effectiveWebhook = isHuman ? 'http://human.local/' : `http://mock.local/${bot.name}`
const existing = await db.select({ id: schema.bots.id })
.from(schema.bots)
.where(eq(schema.bots.name, bot.name))
.limit(1)
if (existing.length > 0) {
// Update existing bot with archetype + webhook (in case they were seeded before archetypes existed)
await db.update(schema.bots).set({
archetype: effectiveArchetype,
webhookUrl: effectiveWebhook,
avatarSeed: bot.avatarSeed,
eloRating: bot.elo,
wins: bot.wins,
losses: bot.losses,
tier: calculateTier(bot.elo, bot.wins),
}).where(eq(schema.bots.id, existing[0].id)).run()
updated++
continue
}
await db.insert(schema.bots).values({
id: nanoid(12),
name: bot.name,
webhookUrl: `http://mock.local/${bot.name}`,
webhookUrl: effectiveWebhook,
avatarSeed: bot.avatarSeed,
archetype: effectiveArchetype,
secretHash: createHash('sha256').update(randomBytes(32)).digest('hex'),
eloRating: bot.elo,
wins: bot.wins,
losses: bot.losses,
tier: calculateTier(bot.elo, bot.wins),
botType: 'mock',
createdAt: new Date().toISOString(),
})
inserted++
}
console.log(`[botfights] seeded ${MOCK_BOTS.length} mock bots`)
console.log(`[botfights] mock bots: ${inserted} inserted, ${updated} updated`)
}
export async function runMockFight(botAId: string, botBId: string): Promise<string> {
+25 -7
View File
@@ -5,7 +5,7 @@ import { randomArena, type Arena } from './arenas.js'
import { pickChallenge, type Challenge } from './challenges.js'
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
import { fightEvents } from './events.js'
import { generateMockBotResponse } from './mock.js'
import { generateMockBotResponse, isClassicBot, generateClassicBotResponse } from './mock.js'
import { setCooldown } from './queue.js'
import { isHumanPlayer, waitForHumanResponse } from './human-responses.js'
import { payWinner, refundEntry, ENTRY_FEE_SATS } from './payments.js'
@@ -222,6 +222,18 @@ async function getBotResponse(
return { answer: result.answer, trashTalk: result.trashTalk, timeMs: elapsed, timedOut: result.timedOut, error: false }
}
if (isClassicBot(bot.webhookUrl)) {
console.log(`[fight] ${bot.name} is classic bot, generating response`)
const classic = generateClassicBotResponse(challenge, bot.name)
return {
answer: classic.answer || null,
trashTalk: '',
timeMs: classic.timeMs,
timedOut: classic.timedOut,
error: classic.error,
}
}
if (isMockBot(bot.webhookUrl)) {
console.log(`[fight] ${bot.name} is mock bot, generating response`)
const mock = generateMockBotResponse(challenge, bot.name)
@@ -273,7 +285,7 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena,
// Track webhook errors per bot
async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) {
if (isMockBot(webhookUrl) || isHumanPlayer(webhookUrl)) return
if (isMockBot(webhookUrl) || isClassicBot(webhookUrl) || isHumanPlayer(webhookUrl)) return
if (succeeded) {
await db.update(schema.bots).set({ consecutiveErrors: 0 }).where(eq(schema.bots.id, botId))
} else {
@@ -404,8 +416,8 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
)
// Finalize fight + update bot stats atomically
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl)
const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock fights
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl) || isClassicBot(botA.webhookUrl) || isClassicBot(botB.webhookUrl)
const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock/classic fights
const finalize = sqlite.transaction(() => {
// Mark fight finished
@@ -471,11 +483,17 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
// Ranked fight payout
if (mode === 'ranked') {
if (winnerId) {
payWinner(fightId, winnerId).catch(err => {
// Dev mode: always pay the human bot (not mock), regardless of win/loss
const devMode = process.env.NODE_ENV !== 'production'
const isMockA = botA.webhookUrl.startsWith('http://mock.local')
const isMockB = botB.webhookUrl.startsWith('http://mock.local')
const humanBotId = devMode ? (isMockA ? botB.id : isMockB ? botA.id : winnerId) : winnerId
if (humanBotId) {
payWinner(fightId, humanBotId).catch(err => {
console.error(`[payments] payout failed for fight ${fightId}:`, err)
})
} else {
} else if (!winnerId) {
// Draw — refund both entry fees
const entryPayments = await db.select().from(schema.payments)
.where(sql`${schema.payments.fightId} = ${fightId} AND ${schema.payments.direction} = 'in' AND ${schema.payments.status} = 'confirmed'`)
+118 -43
View File
@@ -1,20 +1,37 @@
import { nanoid } from 'nanoid'
import { db, schema } from '../db/index.js'
import { eq, and, isNull, sql } from 'drizzle-orm'
import { finalizeEvent } from 'nostr-tools'
import { finalizeEvent, getPublicKey } from 'nostr-tools'
import * as nip04 from 'nostr-tools/nip04'
import * as nip44 from 'nostr-tools/nip44'
import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from 'nostr-tools/utils'
import { hexToBytes, bytesToHex } from 'nostr-tools/utils'
import { Wallet as CashuWallet, getEncodedToken, getDecodedToken } from '@cashu/cashu-ts'
import { decrypt } from './crypto.js'
const ENTRY_FEE_SATS = 21
const POT_SATS = 42
const INVOICE_EXPIRY_SECS = 600 // 10 minutes
const NWC_RESPONSE_TIMEOUT_MS = 30_000
// NWC config from env
const NWC_URL = process.env.BOTFIGHTS_NWC_URL || ''
const CASHU_MINT_URL = process.env.BOTFIGHTS_CASHU_MINT_URL || ''
// NWC config from env — load dotenv inline as fallback
import { config as dotenvConfig } from 'dotenv'
import { dirname as _dirname, join as _join } from 'path'
import { fileURLToPath as _fileURLToPath } from 'url'
let _envLoaded = false
function ensureEnv() {
if (_envLoaded) return
_envLoaded = true
if (!process.env.BOTFIGHTS_NWC_URL) {
const envPath = _join(_dirname(_fileURLToPath(import.meta.url)), '..', '..', '.env')
dotenvConfig({ path: envPath })
}
}
function getNwcUrl(): string { ensureEnv(); return process.env.BOTFIGHTS_NWC_URL || '' }
function getCashuMintUrl(): string { ensureEnv(); return process.env.BOTFIGHTS_CASHU_MINT_URL || '' }
function getDevPayoutAddress(): string { ensureEnv(); return process.env.BOTFIGHTS_DEV_PAYOUT_LNADDRESS || '' }
interface NwcConfig {
pubkey: string
@@ -37,8 +54,28 @@ export function parseNwcUrl(url: string): NwcConfig {
}
function getNwcConfig(): NwcConfig {
if (!NWC_URL) throw new Error('Payments not configured: BOTFIGHTS_NWC_URL not set')
return parseNwcUrl(NWC_URL)
const url = getNwcUrl()
if (!url) throw new Error('Payments not configured: BOTFIGHTS_NWC_URL not set')
return parseNwcUrl(url)
}
/** Encrypt content — try NIP-04 first (BTCPay/LND), with NIP-44 fallback */
async function nwcEncrypt(plaintext: string, secret: Uint8Array, walletPubkey: string): Promise<string> {
// NIP-04 is what most NWC wallets (BTCPay, LND, Alby Hub) expect
const secretHex = bytesToHex(secret)
return nip04.encrypt(secretHex, walletPubkey, plaintext)
}
/** Decrypt response — try NIP-04 first, fall back to NIP-44 */
async function nwcDecrypt(ciphertext: string, secret: Uint8Array, walletPubkey: string): Promise<string> {
const secretHex = bytesToHex(secret)
try {
return await nip04.decrypt(secretHex, walletPubkey, ciphertext)
} catch {
// Fall back to NIP-44
const conversationKey = nip44.v2.utils.getConversationKey(secret, walletPubkey)
return nip44.v2.decrypt(ciphertext, conversationKey)
}
}
/** Send an NWC request and wait for the response */
@@ -48,11 +85,11 @@ async function nwcRequest(
): Promise<Record<string, unknown>> {
const nwc = getNwcConfig()
const clientSecret = nwc.secret
const conversationKey = nip44.v2.utils.getConversationKey(clientSecret, nwc.pubkey)
const content = nip44.v2.encrypt(
const content = await nwcEncrypt(
JSON.stringify({ method, params }),
conversationKey,
clientSecret,
nwc.pubkey,
)
const event = finalizeEvent({
@@ -62,31 +99,37 @@ async function nwcRequest(
content,
}, clientSecret)
console.log(`[nwc] connecting to relay ${nwc.relay}...`)
const relay = await Relay.connect(nwc.relay)
console.log(`[nwc] relay connected, sending ${method} request (event ${event.id.slice(0, 8)}...)`)
try {
return await new Promise<Record<string, unknown>>((resolve, reject) => {
const timeout = setTimeout(() => {
console.log(`[nwc] ${method} timed out — is your wallet online?`)
relay.close()
reject(new Error(`NWC request timed out after ${NWC_RESPONSE_TIMEOUT_MS}ms`))
reject(new Error(`NWC ${method} timed out after ${NWC_RESPONSE_TIMEOUT_MS / 1000}s. Is your wallet online?`))
}, NWC_RESPONSE_TIMEOUT_MS)
// Subscribe for the response (kind 23195)
const sub = relay.subscribe(
[{ kinds: [23195], authors: [nwc.pubkey], '#e': [event.id] }],
{
onevent(responseEvent) {
async onevent(responseEvent) {
clearTimeout(timeout)
console.log(`[nwc] got response for ${method}`)
try {
const decrypted = nip44.v2.decrypt(responseEvent.content, conversationKey)
const decrypted = await nwcDecrypt(responseEvent.content, clientSecret, nwc.pubkey)
const result = JSON.parse(decrypted) as {
result_type: string
error?: { code: string; message: string }
result?: Record<string, unknown>
}
if (result.error) {
console.log(`[nwc] ${method} error: ${result.error.message}`)
reject(new Error(`NWC error: ${result.error.message} (${result.error.code})`))
} else {
console.log(`[nwc] ${method} success`)
resolve(result.result || {})
}
} catch (err) {
@@ -96,14 +139,15 @@ async function nwcRequest(
relay.close()
}
},
oneose() {
// End of stored events — just wait for live events
},
oneose() {},
},
)
// Publish the request
relay.publish(event).catch((err) => {
relay.publish(event).then(() => {
console.log(`[nwc] ${method} event published, waiting for wallet response...`)
}).catch((err) => {
console.log(`[nwc] publish failed:`, err)
clearTimeout(timeout)
sub.close()
relay.close()
@@ -117,11 +161,32 @@ async function nwcRequest(
}
/** Create a 21-sat Lightning invoice for a ranked fight entry fee */
const DEV_AUTO_CONFIRM = process.env.NODE_ENV !== 'production'
export async function createEntryInvoice(botId: string): Promise<{ bolt11: string; paymentId: string }> {
// Verify bot exists
const botRows = await db.select({ id: schema.bots.id }).from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (botRows.length === 0) throw new Error('Bot not found')
const paymentId = nanoid(12)
if (DEV_AUTO_CONFIRM) {
// Dev mode: skip real invoice, auto-confirm so flow works without separate wallets
await db.insert(schema.payments).values({
id: paymentId,
botId,
direction: 'in',
amountSats: ENTRY_FEE_SATS,
method: 'lightning',
status: 'confirmed',
invoice: 'dev_auto_confirmed',
confirmedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
})
console.log(`[payments] dev: auto-confirmed 21 sat entry for ${botId} (self-payment skipped — payouts are real)`)
return { bolt11: 'dev_auto_confirmed', paymentId }
}
const result = await nwcRequest('make_invoice', {
amount: ENTRY_FEE_SATS * 1000, // NWC uses millisats
description: `Botfights ranked entry fee (${ENTRY_FEE_SATS} sats)`,
@@ -131,7 +196,6 @@ export async function createEntryInvoice(botId: string): Promise<{ bolt11: strin
const bolt11 = result.invoice as string
if (!bolt11) throw new Error('NWC make_invoice did not return an invoice')
const paymentId = nanoid(12)
await db.insert(schema.payments).values({
id: paymentId,
botId,
@@ -179,6 +243,9 @@ export async function checkPaymentStatus(paymentId: string): Promise<'pending' |
/** Pay the winner of a ranked fight */
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's wallet connection
const walletRows = await db.select().from(schema.walletConnections)
.where(eq(schema.walletConnections.botId, winnerId)).limit(1)
@@ -192,9 +259,22 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
for (let attempt = 0; attempt <= retries.length; attempt++) {
try {
if (wallet?.method === 'nwc') {
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)
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)
await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning'
} else if (wallet?.method === 'nwc') {
// Request invoice from winner's NWC wallet, then pay it via server wallet
const winnerResult = await nwcRequestVia(wallet.connectionData, 'make_invoice', {
const winnerResult = await nwcRequestVia(decrypt(wallet.connectionData), 'make_invoice', {
amount: POT_SATS * 1000,
description: `Botfights ranked win payout (${POT_SATS} sats)`,
})
@@ -205,18 +285,9 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning'
} else if (wallet?.method === 'lnaddress') {
// Resolve Lightning Address → LNURL → invoice → pay
invoice = await resolveAndCreateInvoice(wallet.connectionData, POT_SATS)
await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning'
} else {
// No wallet — create Cashu token for later claim
if (!CASHU_MINT_URL) {
throw new Error('Cannot create Cashu payout: no mint URL configured')
}
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
} else if (getCashuMintUrl()) {
// No wallet + mint configured — create Cashu token for later claim
const cashuWallet = new CashuWallet(getCashuMintUrl())
await cashuWallet.loadMint()
const mintQuote = await cashuWallet.createMintQuote(POT_SATS)
@@ -225,8 +296,12 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
}
await new Promise(r => setTimeout(r, 2000))
const proofs = await cashuWallet.mintProofs(POT_SATS, mintQuote.quote)
cashuToken = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
cashuToken = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
paymentMethod = 'cashu'
} else {
console.log(`[payments] no payout method available for winner ${winnerId}`)
paymentMethod = 'lightning'
}
// Insert payout record
@@ -397,7 +472,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
.where(eq(schema.walletConnections.botId, payment.botId)).limit(1)
if (walletRows[0]?.method === 'nwc') {
const result = await nwcRequestVia(walletRows[0].connectionData, 'make_invoice', {
const result = await nwcRequestVia(decrypt(walletRows[0].connectionData), 'make_invoice', {
amount: ENTRY_FEE_SATS * 1000,
description: 'Botfights ranked refund',
})
@@ -406,11 +481,11 @@ export async function refundEntry(paymentId: string): Promise<void> {
await nwcRequest('pay_invoice', { invoice })
}
} else if (walletRows[0]?.method === 'lnaddress') {
const invoice = await resolveAndCreateInvoice(walletRows[0].connectionData, ENTRY_FEE_SATS)
const invoice = await resolveAndCreateInvoice(decrypt(walletRows[0].connectionData), ENTRY_FEE_SATS)
await nwcRequest('pay_invoice', { invoice })
} else if (CASHU_MINT_URL) {
} else if (getCashuMintUrl()) {
// Fallback: issue Cashu token
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
const cashuWallet = new CashuWallet(getCashuMintUrl())
await cashuWallet.loadMint()
const mintQuote = await cashuWallet.createMintQuote(ENTRY_FEE_SATS)
if (mintQuote.request) {
@@ -418,13 +493,13 @@ export async function refundEntry(paymentId: string): Promise<void> {
}
await new Promise(r => setTimeout(r, 2000))
const proofs = await cashuWallet.mintProofs(ENTRY_FEE_SATS, mintQuote.quote)
const token = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
const token = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
await db.update(schema.payments).set({ cashuToken: token }).where(eq(schema.payments.id, paymentId))
}
}
// For cashu entries, the token is already spent — mint new one as refund
if (payment.method === 'cashu' && CASHU_MINT_URL) {
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
if (payment.method === 'cashu' && getCashuMintUrl()) {
const cashuWallet = new CashuWallet(getCashuMintUrl())
await cashuWallet.loadMint()
const mintQuote = await cashuWallet.createMintQuote(ENTRY_FEE_SATS)
if (mintQuote.request) {
@@ -432,7 +507,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
}
await new Promise(r => setTimeout(r, 2000))
const proofs = await cashuWallet.mintProofs(ENTRY_FEE_SATS, mintQuote.quote)
const token = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
const token = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
await db.update(schema.payments).set({ cashuToken: token }).where(eq(schema.payments.id, paymentId))
}
@@ -453,7 +528,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
/** Redeem a Cashu token as entry fee */
export async function redeemCashuToken(token: string, botId: string): Promise<{ paymentId: string; valid: boolean }> {
if (!CASHU_MINT_URL) throw new Error('Cashu mint not configured')
if (!getCashuMintUrl()) throw new Error('Cashu mint not configured')
try {
const decoded = getDecodedToken(token)
@@ -462,7 +537,7 @@ export async function redeemCashuToken(token: string, botId: string): Promise<{
return { paymentId: '', valid: false }
}
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
const cashuWallet = new CashuWallet(getCashuMintUrl())
await cashuWallet.loadMint()
// Receive the token (swap for fresh proofs — prevents double-spend)
+16 -4
View File
@@ -1,5 +1,5 @@
import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { eq, sql } from 'drizzle-orm'
import { runFightAsync, isInFight } from './orchestrator.js'
import { checkPaymentStatus, refundEntry } from './payments.js'
@@ -63,9 +63,9 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.')
}
// NEVER allow mock bots in ranked
if (bot.webhookUrl.startsWith('http://mock.local')) {
throw new Error('Mock bots cannot join ranked fights.')
// NEVER allow mock/classic bots in ranked
if (bot.webhookUrl.startsWith('http://mock.local') || bot.webhookUrl.startsWith('http://classic.local')) {
throw new Error('Practice bots cannot join ranked fights.')
}
console.log(`[ranked-queue] joinRankedQueue botId=${botId} name=${bot.name} paymentId=${paymentId}`)
@@ -95,6 +95,18 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
return fightId
}
// Dev mode: auto-match against a random mock bot
if (process.env.NODE_ENV !== 'production') {
const mockBots = await db.select().from(schema.bots)
.where(sql`${schema.bots.webhookUrl} LIKE 'http://mock.local%'`)
if (mockBots.length > 0) {
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')
return fightId
}
}
// Nobody waiting — join queue and wait up to 60s
return new Promise<string>((resolve, reject) => {
const timeoutHandle = setTimeout(async () => {
+4
View File
@@ -0,0 +1,4 @@
import { config } from 'dotenv'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
config({ path: join(dirname(fileURLToPath(import.meta.url)), '..', '.env') })
+12 -1
View File
@@ -1,12 +1,23 @@
import { serve } from '@hono/node-server'
import { app } from './app.js'
import { runMigrations } from './db/startup.js'
import { seedMockBots } from './engine/mock.js'
import { seedMockBots, seedClassicBots } from './engine/mock.js'
import { startBackgroundFights } from './engine/background.js'
// Production env validation
if (process.env.NODE_ENV === 'production') {
const required = ['BOTFIGHTS_NWC_URL', 'BOTFIGHTS_WALLET_ENCRYPTION_KEY']
const missing = required.filter(k => !process.env[k])
if (missing.length > 0) {
console.error(`[FATAL] Missing required env vars for production: ${missing.join(', ')}`)
process.exit(1)
}
}
// Run migrations and seed mock bots before starting the server
runMigrations()
await seedMockBots()
await seedClassicBots()
const port = Number(process.env.PORT) || 9100
+4 -1
View File
@@ -16,7 +16,10 @@ export function rateLimit(windowMs: number, maxHits: number) {
return async (c: Context, next: Next) => {
if (isDev) return next()
const key = c.req.header('x-forwarded-for') || c.req.header('cf-connecting-ip') || 'unknown'
// Extract real IP — handle comma-separated x-forwarded-for (first = client)
const xff = c.req.header('x-forwarded-for')
const realIp = xff ? xff.split(',')[0].trim() : c.req.header('cf-connecting-ip') || c.req.header('x-real-ip') || 'unknown'
const key = realIp
const now = Date.now()
const entry = hitCounts.get(key)
+6 -6
View File
@@ -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)) {
+17 -7
View File
@@ -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,
})
+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')
+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