fix: human vs AI fight bugs — CSP for TTS, invisible sprites, fight end sequence

- 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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 17:00:20 +00:00
co-authored by Claude Opus 4.6
parent 63cc00fcb6
commit 150ce7447d
5 changed files with 142 additions and 54 deletions
+63 -47
View File
@@ -396,6 +396,8 @@ async function _doReplay() {
// Entrance animations // Entrance animations
if (scene) { if (scene) {
await scene.playEntrance() await scene.playEntrance()
// Safety: ensure fighters are visible after entrance (prevents invisible characters if entrance times out)
scene._resetPositions()
await sleep(300) await sleep(300)
} }
@@ -616,58 +618,72 @@ async function _doReplay() {
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
// Always end with a dramatic death/KO sequence // Always end with a dramatic death/KO sequence
if (scene) { if (props.fight.winnerId) {
if (props.fight.winnerId) { const winningSide = props.fight.winnerId === props.fight.botA!.id ? 'a' : 'b'
const winningSide = props.fight.winnerId === props.fight.botA!.id ? 'a' : 'b' const winnerHp = winningSide === 'a' ? props.fight.botAHp : props.fight.botBHp
const winnerHp = winningSide === 'a' ? props.fight.botAHp : props.fight.botBHp const isPerfect = winnerHp >= 200
const isPerfect = winnerHp >= 200 const winnerName = winningSide === 'a' ? props.fight.botA!.name : props.fight.botB!.name
const winnerName = winningSide === 'a' ? props.fight.botA!.name : props.fight.botB!.name
// "FINISH HIM!" moment before KO // "FINISH HIM!" moment before KO
sfxDrumRoll() sfxDrumRoll()
await sleep(500) await sleep(500)
announceFinishHim() announceFinishHim()
await showOverlay('FINISH IT!', '#ff2d2d', 900) await showOverlay('FINISH IT!', '#ff2d2d', 900)
await sleep(150) await sleep(150)
if (isPerfect) { if (scene) {
await scene.playPerfect(winningSide, winnerName) try {
announceFlawlessVictory() if (isPerfect) {
await sleep(600) // let "flawless victory" voice land await scene.playPerfect(winningSide, winnerName)
} else { announceFlawlessVictory()
await scene.playKO(winningSide, winnerName) await sleep(600) // let "flawless victory" voice land
await sleep(300) // let fatality voice finish } 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 { } else {
// Draws get a dramatic double-KO // Scene unavailable — still announce vocally
await scene.playKO('a', 'NOBODY') if (isPerfect) announceFlawlessVictory()
await showOverlay('DOUBLE K.O.!', '#ff2d2d', 1500) await sleep(800)
logItems.value.push(
{ type: 'divider', round: 99, text: '', color: '' },
{ type: 'result', round: 99, text: 'DOUBLE K.O.! DRAW!', color: 'neon-purple' },
)
scrollLog()
} }
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 // Kill any remaining queued speech — fight is over
+12
View File
@@ -99,6 +99,18 @@ let autoRestoreRan = false
// Flag: skip relay pic fetch for freshly generated keys (no profile exists) // Flag: skip relay pic fetch for freshly generated keys (no profile exists)
let freshlyGenerated = false 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<string>('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 // Auto-restore session from JWT on first load
if (!autoRestoreRan && pubkey.value && !bot.value && getToken() && !isTokenExpired()) { if (!autoRestoreRan && pubkey.value && !bot.value && getToken() && !isTokenExpired()) {
autoRestoreRan = true autoRestoreRan = true
+23 -6
View File
@@ -170,6 +170,13 @@ async function initLiveScene() {
{ type: 'system', round: 0, text: `${data.botA.name} vs ${data.botB.name}`, color: 'text-secondary' }, { type: 'system', round: 0, text: `${data.botA.name} vs ${data.botB.name}`, color: 'text-secondary' },
{ type: 'divider', round: 0, text: '', color: '' }, { 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() scrollLiveLog()
} }
@@ -320,7 +327,7 @@ async function handleFightEnd(data: any) {
else liveHpA.value = 0 else liveHpA.value = 0
} }
if (liveScene && data.winnerId) { if (data.winnerId) {
const winningSide = (data.winnerId === fd.botA.id ? 'a' : 'b') as 'a' | 'b' const winningSide = (data.winnerId === fd.botA.id ? 'a' : 'b') as 'a' | 'b'
sfxDrumRoll() sfxDrumRoll()
@@ -328,11 +335,21 @@ async function handleFightEnd(data: any) {
announceFinishHim() announceFinishHim()
await showLiveOverlay('FINISH IT!', '#ff2d2d', 900) await showLiveOverlay('FINISH IT!', '#ff2d2d', 900)
if (data.isPerfect) { if (liveScene) {
await liveScene.playPerfect(winningSide, data.winnerName) try {
announceFlawlessVictory() 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 { } else {
await liveScene.playKO(winningSide, data.winnerName) // Scene unavailable — still announce vocally
if (data.isPerfect) announceFlawlessVictory()
await sleep(800)
} }
sfxApplause() sfxApplause()
@@ -341,7 +358,7 @@ async function handleFightEnd(data: any) {
if (data.mode === 'ranked' && data.potSats) { if (data.mode === 'ranked' && data.potSats) {
await showLiveOverlay(`WON ${data.potSats} SATS!`, '#00f0ff', 1500) await showLiveOverlay(`WON ${data.potSats} SATS!`, '#00f0ff', 1500)
} }
liveScene.stopMusic() if (liveScene) liveScene.stopMusic()
} }
if (isHumanFight.value) { if (isHumanFight.value) {
+42
View File
@@ -33,6 +33,7 @@ const queueCount = ref(0)
const rankedQueueCount = ref(0) const rankedQueueCount = ref(0)
const rankedEstWait = ref(15) const rankedEstWait = ref(15)
let pollHandle: ReturnType<typeof setInterval> | null = null let pollHandle: ReturnType<typeof setInterval> | null = null
let nip55ReturnHandler: (() => void) | null = null
// Registration form // Registration form
const selectedArchetype = ref('standard') const selectedArchetype = ref('standard')
@@ -161,11 +162,13 @@ onMounted(async () => {
onBeforeRouteLeave(() => { onBeforeRouteLeave(() => {
if (pollHandle) { clearInterval(pollHandle); pollHandle = null } if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
if (rateLimitTimer) { clearInterval(rateLimitTimer); rateLimitTimer = null } if (rateLimitTimer) { clearInterval(rateLimitTimer); rateLimitTimer = null }
if (nip55ReturnHandler) { document.removeEventListener('visibilitychange', nip55ReturnHandler); nip55ReturnHandler = null }
}) })
onUnmounted(() => { onUnmounted(() => {
if (pollHandle) clearInterval(pollHandle) if (pollHandle) clearInterval(pollHandle)
if (rateLimitTimer) { clearInterval(rateLimitTimer); rateLimitTimer = null } if (rateLimitTimer) { clearInterval(rateLimitTimer); rateLimitTimer = null }
if (nip55ReturnHandler) { document.removeEventListener('visibilitychange', nip55ReturnHandler); nip55ReturnHandler = null }
}) })
async function pollQueue() { async function pollQueue() {
@@ -212,6 +215,45 @@ async function handleSignerLogin() {
// On Android, try NIP-55 intent to open Amber/Primal directly // On Android, try NIP-55 intent to open Amber/Primal directly
if (hasAndroidSigner.value) { if (hasAndroidSigner.value) {
error.value = 'Opening signer app...' 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() initiateNip55Login()
return return
} }
+2 -1
View File
@@ -57,7 +57,7 @@ app.use('*', secureHeaders({
scriptSrc: ["'self'", 'blob:'], scriptSrc: ["'self'", 'blob:'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
imgSrc: ["'self'", 'data:', 'blob:'], imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'"], connectSrc: ["'self'", 'https://huggingface.co', 'https://*.huggingface.co'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'], fontSrc: ["'self'", 'https://fonts.gstatic.com'],
workerSrc: ["'self'", 'blob:'], workerSrc: ["'self'", 'blob:'],
} : undefined, } : undefined,
@@ -154,6 +154,7 @@ if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) {
// PWA service worker + related root files // PWA service worker + related root files
app.get('/sw.js', (c) => serveFile(c, '/sw.js', 'no-cache')) 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('/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-*.png', (c) => serveFile(c, c.req.path, 'public, max-age=86400'))
app.get('/icon.svg', (c) => serveFile(c, '/icon.svg', 'public, max-age=86400')) app.get('/icon.svg', (c) => serveFile(c, '/icon.svg', 'public, max-age=86400'))