From 150ce7447d38c6d9ea837e8176aa9f5bfaeb7bf6 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 9 Mar 2026 17:00:20 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20human=20vs=20AI=20fight=20bugs=20?= =?UTF-8?q?=E2=80=94=20CSP=20for=20TTS,=20invisible=20sprites,=20fight=20e?= =?UTF-8?q?nd=20sequence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Allow huggingface.co in CSP connect-src (fixes Kokoro TTS model download) - Add registerSW.js route (fixes PWA service worker 404) - Add _resetPositions() safety after entrance (fixes invisible fighters) - Fight end sequence works without canvas scene (KO/overlays/log always play) - Pre-fight instructions in battle log for human players - NIP-55 visibility sync and cleanup handlers Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/FightViewer.vue | 110 ++++++++++++++---------- frontend/src/composables/useNostr.ts | 12 +++ frontend/src/pages/FightPage.vue | 29 +++++-- frontend/src/pages/JoinBoutPage.vue | 42 +++++++++ server/src/app.ts | 3 +- 5 files changed, 142 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 86cc9c8..faca97f 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -396,6 +396,8 @@ async function _doReplay() { // Entrance animations if (scene) { await scene.playEntrance() + // Safety: ensure fighters are visible after entrance (prevents invisible characters if entrance times out) + scene._resetPositions() await sleep(300) } @@ -616,58 +618,72 @@ async function _doReplay() { if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() // Always end with a dramatic death/KO sequence - if (scene) { - if (props.fight.winnerId) { - const winningSide = props.fight.winnerId === props.fight.botA!.id ? 'a' : 'b' - const winnerHp = winningSide === 'a' ? props.fight.botAHp : props.fight.botBHp - const isPerfect = winnerHp >= 200 - const winnerName = winningSide === 'a' ? props.fight.botA!.name : props.fight.botB!.name + if (props.fight.winnerId) { + const winningSide = props.fight.winnerId === props.fight.botA!.id ? 'a' : 'b' + const winnerHp = winningSide === 'a' ? props.fight.botAHp : props.fight.botBHp + const isPerfect = winnerHp >= 200 + const winnerName = winningSide === 'a' ? props.fight.botA!.name : props.fight.botB!.name - // "FINISH HIM!" moment before KO - sfxDrumRoll() - await sleep(500) - announceFinishHim() - await showOverlay('FINISH IT!', '#ff2d2d', 900) - await sleep(150) + // "FINISH HIM!" moment before KO + sfxDrumRoll() + await sleep(500) + announceFinishHim() + await showOverlay('FINISH IT!', '#ff2d2d', 900) + await sleep(150) - if (isPerfect) { - await scene.playPerfect(winningSide, winnerName) - announceFlawlessVictory() - await sleep(600) // let "flawless victory" voice land - } else { - await scene.playKO(winningSide, winnerName) - await sleep(300) // let fatality voice finish + if (scene) { + try { + if (isPerfect) { + await scene.playPerfect(winningSide, winnerName) + announceFlawlessVictory() + await sleep(600) // let "flawless victory" voice land + } else { + await scene.playKO(winningSide, winnerName) + await sleep(300) // let fatality voice finish + } + + // Victory celebration — detect upsets (winner had lower Elo) + const winnerElo = winningSide === 'a' ? (props.fight.botA?.eloRating ?? 1200) : (props.fight.botB?.eloRating ?? 1200) + const loserElo = winningSide === 'a' ? (props.fight.botB?.eloRating ?? 1200) : (props.fight.botA?.eloRating ?? 1200) + const isUpset = winnerElo < loserElo - 100 + await scene.playVictoryCelebration(winningSide, isUpset) + } catch (err) { + console.warn('[FightViewer] KO animation failed:', err) } - - // Victory celebration — detect upsets (winner had lower Elo) - const winnerElo = winningSide === 'a' ? (props.fight.botA?.eloRating ?? 1200) : (props.fight.botB?.eloRating ?? 1200) - const loserElo = winningSide === 'a' ? (props.fight.botB?.eloRating ?? 1200) : (props.fight.botA?.eloRating ?? 1200) - const isUpset = winnerElo < loserElo - 100 - await scene.playVictoryCelebration(winningSide, isUpset) - - glitching.value = true - sfxApplause() - sfxCrowdCheer() - await sleep(200) - glitching.value = false - const upsetTag = isUpset ? ' UPSET!' : '' - await showOverlay(`${winnerName} WINS!${upsetTag}`, isUpset ? '#ff6600' : '#00f0ff', 1800) - - logItems.value.push( - { type: 'divider', round: 99, text: '', color: '' }, - { type: 'result', round: 99, text: `${winnerName.toUpperCase()} WINS!${isPerfect ? ' PERFECT!' : ''}`, color: winningSide === 'a' ? 'neon-cyan' : 'neon-pink' }, - ) - scrollLog() } else { - // Draws get a dramatic double-KO - await scene.playKO('a', 'NOBODY') - await showOverlay('DOUBLE K.O.!', '#ff2d2d', 1500) - logItems.value.push( - { type: 'divider', round: 99, text: '', color: '' }, - { type: 'result', round: 99, text: 'DOUBLE K.O.! DRAW!', color: 'neon-purple' }, - ) - scrollLog() + // Scene unavailable — still announce vocally + if (isPerfect) announceFlawlessVictory() + await sleep(800) } + + const winnerElo = winningSide === 'a' ? (props.fight.botA?.eloRating ?? 1200) : (props.fight.botB?.eloRating ?? 1200) + const loserElo = winningSide === 'a' ? (props.fight.botB?.eloRating ?? 1200) : (props.fight.botA?.eloRating ?? 1200) + const isUpset = winnerElo < loserElo - 100 + + glitching.value = true + sfxApplause() + sfxCrowdCheer() + await sleep(200) + glitching.value = false + const upsetTag = isUpset ? ' UPSET!' : '' + await showOverlay(`${winnerName} WINS!${upsetTag}`, isUpset ? '#ff6600' : '#00f0ff', 1800) + + logItems.value.push( + { type: 'divider', round: 99, text: '', color: '' }, + { type: 'result', round: 99, text: `${winnerName.toUpperCase()} WINS!${isPerfect ? ' PERFECT!' : ''}`, color: winningSide === 'a' ? 'neon-cyan' : 'neon-pink' }, + ) + scrollLog() + } else { + // Draws get a dramatic double-KO + if (scene) { + try { await scene.playKO('a', 'NOBODY') } catch { /* continue */ } + } + await showOverlay('DOUBLE K.O.!', '#ff2d2d', 1500) + logItems.value.push( + { type: 'divider', round: 99, text: '', color: '' }, + { type: 'result', round: 99, text: 'DOUBLE K.O.! DRAW!', color: 'neon-purple' }, + ) + scrollLog() } // Kill any remaining queued speech — fight is over diff --git a/frontend/src/composables/useNostr.ts b/frontend/src/composables/useNostr.ts index 25a99d0..e59265a 100644 --- a/frontend/src/composables/useNostr.ts +++ b/frontend/src/composables/useNostr.ts @@ -99,6 +99,18 @@ let autoRestoreRan = false // Flag: skip relay pic fetch for freshly generated keys (no profile exists) let freshlyGenerated = false +// Sync in-memory auth state when tab regains focus (handles external localStorage clearing) +if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') return + const storedPubkey = loadStored('bf_pubkey') + if (!storedPubkey && pubkey.value) { + // localStorage was cleared externally — wipe in-memory state to match + clearAllState() + } + }) +} + // Auto-restore session from JWT on first load if (!autoRestoreRan && pubkey.value && !bot.value && getToken() && !isTokenExpired()) { autoRestoreRan = true diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue index 7da2e76..eb9d36d 100644 --- a/frontend/src/pages/FightPage.vue +++ b/frontend/src/pages/FightPage.vue @@ -170,6 +170,13 @@ async function initLiveScene() { { type: 'system', round: 0, text: `${data.botA.name} vs ${data.botB.name}`, color: 'text-secondary' }, { type: 'divider', round: 0, text: '', color: '' }, ) + // Pre-fight instructions for human players + if (isHumanFight.value) { + liveLogItems.value.push( + { type: 'narration', round: 0, text: 'HUMAN VS AI — Answer challenges faster and better than the bot! Tap a choice or type your answer before time runs out.', color: 'neon-yellow' }, + { type: 'divider', round: 0, text: '', color: '' }, + ) + } scrollLiveLog() } @@ -320,7 +327,7 @@ async function handleFightEnd(data: any) { else liveHpA.value = 0 } - if (liveScene && data.winnerId) { + if (data.winnerId) { const winningSide = (data.winnerId === fd.botA.id ? 'a' : 'b') as 'a' | 'b' sfxDrumRoll() @@ -328,11 +335,21 @@ async function handleFightEnd(data: any) { announceFinishHim() await showLiveOverlay('FINISH IT!', '#ff2d2d', 900) - if (data.isPerfect) { - await liveScene.playPerfect(winningSide, data.winnerName) - announceFlawlessVictory() + if (liveScene) { + try { + if (data.isPerfect) { + await liveScene.playPerfect(winningSide, data.winnerName) + announceFlawlessVictory() + } else { + await liveScene.playKO(winningSide, data.winnerName) + } + } catch (err) { + console.warn('[FightPage] KO animation failed:', err) + } } else { - await liveScene.playKO(winningSide, data.winnerName) + // Scene unavailable — still announce vocally + if (data.isPerfect) announceFlawlessVictory() + await sleep(800) } sfxApplause() @@ -341,7 +358,7 @@ async function handleFightEnd(data: any) { if (data.mode === 'ranked' && data.potSats) { await showLiveOverlay(`WON ${data.potSats} SATS!`, '#00f0ff', 1500) } - liveScene.stopMusic() + if (liveScene) liveScene.stopMusic() } if (isHumanFight.value) { diff --git a/frontend/src/pages/JoinBoutPage.vue b/frontend/src/pages/JoinBoutPage.vue index 216b7e9..4e06b3c 100644 --- a/frontend/src/pages/JoinBoutPage.vue +++ b/frontend/src/pages/JoinBoutPage.vue @@ -33,6 +33,7 @@ const queueCount = ref(0) const rankedQueueCount = ref(0) const rankedEstWait = ref(15) let pollHandle: ReturnType | null = null +let nip55ReturnHandler: (() => void) | null = null // Registration form const selectedArchetype = ref('standard') @@ -161,11 +162,13 @@ onMounted(async () => { onBeforeRouteLeave(() => { if (pollHandle) { clearInterval(pollHandle); pollHandle = null } if (rateLimitTimer) { clearInterval(rateLimitTimer); rateLimitTimer = null } + if (nip55ReturnHandler) { document.removeEventListener('visibilitychange', nip55ReturnHandler); nip55ReturnHandler = null } }) onUnmounted(() => { if (pollHandle) clearInterval(pollHandle) if (rateLimitTimer) { clearInterval(rateLimitTimer); rateLimitTimer = null } + if (nip55ReturnHandler) { document.removeEventListener('visibilitychange', nip55ReturnHandler); nip55ReturnHandler = null } }) async function pollQueue() { @@ -212,6 +215,45 @@ async function handleSignerLogin() { // On Android, try NIP-55 intent to open Amber/Primal directly if (hasAndroidSigner.value) { error.value = 'Opening signer app...' + + // Listen for return from signer app (user switches back to browser) + if (nip55ReturnHandler) document.removeEventListener('visibilitychange', nip55ReturnHandler) + nip55ReturnHandler = async () => { + if (document.visibilityState !== 'visible') return + document.removeEventListener('visibilitychange', nip55ReturnHandler!) + nip55ReturnHandler = null + + // Check if signer redirected with callback params in the URL + try { + const nip55Result = await processNip55Return() + if (nip55Result) { + error.value = '' + if (nip55Result.bot) { + isHumanMode.value = !!nip55Result.bot.isHuman + step.value = 'ready' + } else { + step.value = 'choose-mode' + } + return + } + } catch { + // Callback params present but auth failed + error.value = 'Signer login failed. Try again.' + return + } + + // No callback params — signer may have injected window.nostr + error.value = 'Connecting...' + const signerFound = await waitForSigner(2000) + if (signerFound) { + error.value = '' + handleLogin() + } else { + error.value = 'Signer did not return a signed event. Tap to try again.' + } + } + document.addEventListener('visibilitychange', nip55ReturnHandler) + initiateNip55Login() return } diff --git a/server/src/app.ts b/server/src/app.ts index 380fe71..3e9ef45 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -57,7 +57,7 @@ app.use('*', secureHeaders({ scriptSrc: ["'self'", 'blob:'], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], imgSrc: ["'self'", 'data:', 'blob:'], - connectSrc: ["'self'"], + connectSrc: ["'self'", 'https://huggingface.co', 'https://*.huggingface.co'], fontSrc: ["'self'", 'https://fonts.gstatic.com'], workerSrc: ["'self'", 'blob:'], } : undefined, @@ -154,6 +154,7 @@ if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) { // PWA service worker + related root files app.get('/sw.js', (c) => serveFile(c, '/sw.js', 'no-cache')) + app.get('/registerSW.js', (c) => serveFile(c, '/registerSW.js', 'no-cache')) app.get('/workbox-*.js', (c) => serveFile(c, c.req.path, 'public, max-age=31536000, immutable')) app.get('/icon-*.png', (c) => serveFile(c, c.req.path, 'public, max-age=86400')) app.get('/icon.svg', (c) => serveFile(c, '/icon.svg', 'public, max-age=86400'))