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:
co-authored by
Claude Opus 4.6
parent
63cc00fcb6
commit
150ce7447d
@@ -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,7 +618,6 @@ 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
|
||||||
@@ -630,6 +631,8 @@ async function _doReplay() {
|
|||||||
await showOverlay('FINISH IT!', '#ff2d2d', 900)
|
await showOverlay('FINISH IT!', '#ff2d2d', 900)
|
||||||
await sleep(150)
|
await sleep(150)
|
||||||
|
|
||||||
|
if (scene) {
|
||||||
|
try {
|
||||||
if (isPerfect) {
|
if (isPerfect) {
|
||||||
await scene.playPerfect(winningSide, winnerName)
|
await scene.playPerfect(winningSide, winnerName)
|
||||||
announceFlawlessVictory()
|
announceFlawlessVictory()
|
||||||
@@ -644,6 +647,18 @@ async function _doReplay() {
|
|||||||
const loserElo = winningSide === 'a' ? (props.fight.botB?.eloRating ?? 1200) : (props.fight.botA?.eloRating ?? 1200)
|
const loserElo = winningSide === 'a' ? (props.fight.botB?.eloRating ?? 1200) : (props.fight.botA?.eloRating ?? 1200)
|
||||||
const isUpset = winnerElo < loserElo - 100
|
const isUpset = winnerElo < loserElo - 100
|
||||||
await scene.playVictoryCelebration(winningSide, isUpset)
|
await scene.playVictoryCelebration(winningSide, isUpset)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[FightViewer] KO animation failed:', err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 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
|
glitching.value = true
|
||||||
sfxApplause()
|
sfxApplause()
|
||||||
@@ -660,7 +675,9 @@ async function _doReplay() {
|
|||||||
scrollLog()
|
scrollLog()
|
||||||
} else {
|
} else {
|
||||||
// Draws get a dramatic double-KO
|
// Draws get a dramatic double-KO
|
||||||
await scene.playKO('a', 'NOBODY')
|
if (scene) {
|
||||||
|
try { await scene.playKO('a', 'NOBODY') } catch { /* continue */ }
|
||||||
|
}
|
||||||
await showOverlay('DOUBLE K.O.!', '#ff2d2d', 1500)
|
await showOverlay('DOUBLE K.O.!', '#ff2d2d', 1500)
|
||||||
logItems.value.push(
|
logItems.value.push(
|
||||||
{ type: 'divider', round: 99, text: '', color: '' },
|
{ type: 'divider', round: 99, text: '', color: '' },
|
||||||
@@ -668,7 +685,6 @@ async function _doReplay() {
|
|||||||
)
|
)
|
||||||
scrollLog()
|
scrollLog()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Kill any remaining queued speech — fight is over
|
// Kill any remaining queued speech — fight is over
|
||||||
if (cleanupTimerHandle) clearTimeout(cleanupTimerHandle)
|
if (cleanupTimerHandle) clearTimeout(cleanupTimerHandle)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,12 +335,22 @@ async function handleFightEnd(data: any) {
|
|||||||
announceFinishHim()
|
announceFinishHim()
|
||||||
await showLiveOverlay('FINISH IT!', '#ff2d2d', 900)
|
await showLiveOverlay('FINISH IT!', '#ff2d2d', 900)
|
||||||
|
|
||||||
|
if (liveScene) {
|
||||||
|
try {
|
||||||
if (data.isPerfect) {
|
if (data.isPerfect) {
|
||||||
await liveScene.playPerfect(winningSide, data.winnerName)
|
await liveScene.playPerfect(winningSide, data.winnerName)
|
||||||
announceFlawlessVictory()
|
announceFlawlessVictory()
|
||||||
} else {
|
} else {
|
||||||
await liveScene.playKO(winningSide, data.winnerName)
|
await liveScene.playKO(winningSide, data.winnerName)
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[FightPage] KO animation failed:', err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Scene unavailable — still announce vocally
|
||||||
|
if (data.isPerfect) announceFlawlessVictory()
|
||||||
|
await sleep(800)
|
||||||
|
}
|
||||||
|
|
||||||
sfxApplause()
|
sfxApplause()
|
||||||
sfxCrowdCheer()
|
sfxCrowdCheer()
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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'))
|
||||||
|
|||||||
Reference in New Issue
Block a user