diff --git a/eslint.config.js b/eslint.config.js index ea6dd12..7984fb7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tsparser from '@typescript-eslint/parser' export default [ { - ignores: ['**/dist/**', '**/node_modules/**', '**/*.js', '**/*.mjs', '**/*.cjs', '**/*.vue'], + ignores: ['**/dist/**', '**/node_modules/**', '**/*.js', '**/*.mjs', '**/*.cjs', '**/*.vue', '**/vite.config.ts', '**/drizzle.config.ts', 'server/scripts/**'], }, { files: ['**/*.ts'], diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index da62e76..37ccc3d 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -26,6 +26,7 @@ let scene: FightSceneController | null = null const sceneReady = ref(false) let cleanupTimerHandle: ReturnType | null = null let destroyed = false +const contextLost = ref(false) const isReplaying = ref(false) const ttsProgress = ref(-1) @@ -195,6 +196,18 @@ async function initScene() { container.prepend(newCanvas) } canvasRef.value = newCanvas + contextLost.value = false + + // Handle WebGL/Canvas context loss (GPU pressure, tab backgrounding, etc.) + newCanvas.addEventListener('webglcontextlost', (e) => { + e.preventDefault() // Allow context restoration + contextLost.value = true + sceneReady.value = false + }) + newCanvas.addEventListener('webglcontextrestored', () => { + contextLost.value = false + initScene() // Re-create the scene with fresh context + }) // Wrap scene creation in try/catch + timeout so a mobile sprite loading failure // or hang never blocks the overlay/voice/round flow @@ -720,6 +733,13 @@ async function _doReplay() {
+ +
+
+

CANVAS CONTEXT LOST

+

Recovering...

+
+
diff --git a/frontend/src/components/WTFModal.vue b/frontend/src/components/WTFModal.vue new file mode 100644 index 0000000..b2641ce --- /dev/null +++ b/frontend/src/components/WTFModal.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/frontend/src/composables/useFightPolling.ts b/frontend/src/composables/useFightPolling.ts index 49934d0..6fd1076 100644 --- a/frontend/src/composables/useFightPolling.ts +++ b/frontend/src/composables/useFightPolling.ts @@ -102,6 +102,8 @@ export function useFightPolling(fightId: Ref) { onHumanChallenge?: (data: any) => void onReaction?: (data: any) => void }) { + // Close any existing SSE connection before opening a new one + disconnectSSE() eventSource = new EventSource(`/api/fights/${fightId.value}/stream`) addSSEListener(eventSource, 'spectator_count', (e) => { diff --git a/frontend/src/composables/useHumanChallenge.ts b/frontend/src/composables/useHumanChallenge.ts index f9092f2..4a37710 100644 --- a/frontend/src/composables/useHumanChallenge.ts +++ b/frontend/src/composables/useHumanChallenge.ts @@ -48,10 +48,9 @@ export function useHumanChallenge( if (humanTimer.value <= 0) { if (timerHandle) clearInterval(timerHandle) if (!humanSubmitted.value) { - if (humanChoices.value.length > 0 && !humanAnswer.value.trim()) { - humanAnswer.value = humanChoices.value[Math.floor(Math.random() * humanChoices.value.length)] - } - void submitHumanAnswer() + // BUG-9: Submit empty timeout instead of random choice + humanSubmitted.value = true + void submitTimeout() } } }, 1000) @@ -76,10 +75,25 @@ export function useHumanChallenge( } function submitChoice(choice: string) { + if (humanSubmitted.value) return // Prevent double-tap humanAnswer.value = choice + humanSubmitted.value = true // Lock immediately before async void submitHumanAnswer() } + async function submitTimeout() { + if (!myBotId.value || !humanChallenge.value) return + try { + await fetch(`/api/fights/${fightId.value}/respond/${myBotId.value}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ answer: '', timeout: true }), + }) + } catch { + // Server will time out on its own + } + } + async function pollForChallenge() { if (!myBotId.value) return try { diff --git a/frontend/src/composables/useNostr.ts b/frontend/src/composables/useNostr.ts index a3dbf83..0e1eb93 100644 --- a/frontend/src/composables/useNostr.ts +++ b/frontend/src/composables/useNostr.ts @@ -260,8 +260,9 @@ export function useNostr() { const data = await res.json() if (!res.ok) { - // Surface detailed error info from webhook verification failures - const msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Registration failed') + // Include retry timer info for rate limits + let msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Registration failed') + if (res.status === 429 && data.retryAfterSec) msg += ` (${data.retryAfterSec}s)` throw new Error(msg) } @@ -344,7 +345,11 @@ export function useNostr() { }) const data = await res.json() - if (!res.ok) throw new Error(data.error || 'Registration failed') + if (!res.ok) { + let msg = data.error || 'Registration failed' + if (res.status === 429 && data.retryAfterSec) msg += ` (${data.retryAfterSec}s)` + throw new Error(msg) + } bot.value = { id: data.id, diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index d6779a4..0d0ba9c 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -91,7 +91,7 @@ export async function createFightScene(config: FightSceneConfig) { : generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary, botA.archetype, botA.customization) } catch (err) { console.error('[FightScene] Failed to generate sprite for botA:', err) - sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary) + sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary, botA.archetype, botA.customization) } try { sheetB = botB.archetype === 'human' @@ -99,7 +99,7 @@ export async function createFightScene(config: FightSceneConfig) { : generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary, botB.archetype, botB.customization) } catch (err) { console.error('[FightScene] Failed to generate sprite for botB:', err) - sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary) + sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary, botB.archetype, botB.customization) } const isHumanA = botA.archetype === 'human' diff --git a/frontend/src/pages/HomePage.vue b/frontend/src/pages/HomePage.vue index 2e8e297..b964a7c 100644 --- a/frontend/src/pages/HomePage.vue +++ b/frontend/src/pages/HomePage.vue @@ -4,6 +4,7 @@ import { RouterLink } from 'vue-router' import PixelGlove from '../components/PixelGlove.vue' import SpritePreview from '../components/SpritePreview.vue' import HumanPreview from '../components/HumanPreview.vue' +import WTFModal from '../components/WTFModal.vue' interface FightResult { id: string @@ -231,6 +232,7 @@ const taglines = [ const tagline = ref('') const isTypingDone = ref(false) +const showWTF = ref(false) const recentFights = ref([]) function shuffle(arr: T[]): T[] { @@ -456,7 +458,7 @@ onUnmounted(() => {
-
+
+ +
+

@@ -508,6 +523,9 @@ onUnmounted(() => {

+ + +
@@ -557,4 +575,13 @@ onUnmounted(() => { 0%, 100% { opacity: 0.6; transform: scale(1); } 50% { opacity: 1; transform: scale(1.1); } } + +.wtf-glow { + animation: wtfPulse 2s ease-in-out infinite; +} + +@keyframes wtfPulse { + 0%, 100% { box-shadow: 0 0 8px rgba(168, 85, 247, 0.2); } + 50% { box-shadow: 0 0 20px rgba(168, 85, 247, 0.4), 0 0 40px rgba(168, 85, 247, 0.15); } +} diff --git a/frontend/src/pages/HumanFightPage.vue b/frontend/src/pages/HumanFightPage.vue index 62484a4..c8fd512 100644 --- a/frontend/src/pages/HumanFightPage.vue +++ b/frontend/src/pages/HumanFightPage.vue @@ -1,6 +1,6 @@