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
+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)
}
})
}