feat: v5 — boxing poster fight cards, 12-char names, diverse mock bots
- Fight Card page: dramatic poster background with cross-hatch, spotlights, vignettes, corner brackets, scan lines; 3D VS orb with punch animation; selectable undercard with main event always pinned at top - PosterSprite: high-quality 480px poster frame with 6-pass renderer (aura, glow, bevel, specular, particles); PixelGlove component - 12-char bot name limit across all forms and server validation - Mock bots: all 100 now have diverse archetypes (25 types), 25% human fighters; seedMockBots updates existing bots on restart - Leaderboard: inline SpritePreview next to each bot name - Nostr auth: persistent login, nsec copy button - Wallet: NWC + Lightning Address, ranked fight flow - Server: payments, ranked queue, customization endpoint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ccf4196647
commit
f6eb7d2845
@@ -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 }]))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user