diff --git a/CLAUDE.md b/CLAUDE.md index fcb2460..3319584 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/frontend/index.html b/frontend/index.html index 8d32998..0b7e084 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,7 +9,7 @@ - + diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 6dbf639..b6b18d3 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -26,8 +26,8 @@ interface Round { interface FightData { id: string - botA: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null - botB: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null + botA: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record | 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 | 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() let scene: FightSceneController | null = null const sceneReady = ref(false) let cleanupTimerHandle: ReturnType | 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 { + 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') } diff --git a/frontend/src/components/PosterSprite.vue b/frontend/src/components/PosterSprite.vue new file mode 100644 index 0000000..615e992 --- /dev/null +++ b/frontend/src/components/PosterSprite.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/frontend/src/components/SpritePreview.vue b/frontend/src/components/SpritePreview.vue index 749e016..8a71cbc 100644 --- a/frontend/src/components/SpritePreview.vue +++ b/frontend/src/components/SpritePreview.vue @@ -8,6 +8,7 @@ const props = defineProps<{ tier?: number size?: number customization?: SpriteCustomization + pose?: keyof typeof ANIMATIONS }>() const canvasRef = ref() @@ -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() diff --git a/frontend/src/composables/useNostr.ts b/frontend/src/composables/useNostr.ts index cd09d31..1ef4884 100644 --- a/frontend/src/composables/useNostr.ts +++ b/frontend/src/composables/useNostr.ts @@ -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 { function queryRelay(url: string, pk: string): Promise { 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 { 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 }])) } diff --git a/frontend/src/composables/useWallet.ts b/frontend/src/composables/useWallet.ts index 1cef5a9..86b21a2 100644 --- a/frontend/src/composables/useWallet.ts +++ b/frontend/src/composables/useWallet.ts @@ -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 { +async function payViaNWC(nwcUrl: string, bolt11: string): Promise { 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 { 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) } }) } diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts index 81abe06..26441f6 100644 --- a/frontend/src/game/sounds.ts +++ b/frontend/src/game/sounds.ts @@ -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) + } } } diff --git a/frontend/src/game/sprites/index.ts b/frontend/src/game/sprites/index.ts index c7ea798..9bd35cc 100644 --- a/frontend/src/game/sprites/index.ts +++ b/frontend/src/game/sprites/index.ts @@ -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 { + const dataUrl = generateSpriteSheet(seed, tier, primaryColor, secondaryColor, archetypeOverride, customization) + const img = await new Promise((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 +} diff --git a/frontend/src/game/sprites/poster.ts b/frontend/src/game/sprites/poster.ts new file mode 100644 index 0000000..5f4e301 --- /dev/null +++ b/frontend/src/game/sprites/poster.ts @@ -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 { + 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 = { + 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() +} diff --git a/frontend/src/pages/FightCardPage.vue b/frontend/src/pages/FightCardPage.vue index 2a89d6d..b15cdfe 100644 --- a/frontend/src/pages/FightCardPage.vue +++ b/frontend/src/pages/FightCardPage.vue @@ -1,13 +1,23 @@ diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue index ef4fdc0..f718f5d 100644 --- a/frontend/src/pages/FightPage.vue +++ b/frontend/src/pages/FightPage.vue @@ -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 { + 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() diff --git a/frontend/src/pages/JoinBoutPage.vue b/frontend/src/pages/JoinBoutPage.vue index 237fc46..e04ac93 100644 --- a/frontend/src/pages/JoinBoutPage.vue +++ b/frontend/src/pages/JoinBoutPage.vue @@ -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 | 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() {

- - -
-

- NOSTR EXTENSION REQUIRED + +

+

+ SAVE YOUR SECRET KEY

- Install a NIP-07 browser extension like - nos2x, - Alby, or - Flamingo - to sign in. + This is your login key. Save it somewhere safe. If you lose it, you lose your account.

+
+
+ {{ generatedNsec }} +
+ +
+ +
+ +
+ + + + + + + + + + +
OR IMPORT EXISTING KEY
+ +
+ + +
@@ -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" />

- Letters, numbers, hyphens, underscores. 2-32 chars. + Letters, numbers, hyphens, underscores. 2-12 chars.

@@ -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" />

- Letters, numbers, hyphens, underscores. 2-32 chars. + Letters, numbers, hyphens, underscores. 2-12 chars.

@@ -836,6 +998,25 @@ function handleSignOut() { + +
+ +

+ Free sparring against practice bots — no ELO impact +

+
+ diff --git a/frontend/src/pages/LeaderboardPage.vue b/frontend/src/pages/LeaderboardPage.vue index c8f495d..a7419d9 100644 --- a/frontend/src/pages/LeaderboardPage.vue +++ b/frontend/src/pages/LeaderboardPage.vue @@ -1,10 +1,13 @@