diff --git a/docs/planning/EXPLORE_GATE_IOS_AUDIO_HOTFIX_2026-09-24.md b/docs/planning/EXPLORE_GATE_IOS_AUDIO_HOTFIX_2026-09-24.md new file mode 100644 index 00000000..89c7cb45 --- /dev/null +++ b/docs/planning/EXPLORE_GATE_IOS_AUDIO_HOTFIX_2026-09-24.md @@ -0,0 +1,99 @@ +# Explore gate and iOS audio emergency repair + +Status: private candidate, isolated from unfinished development. No backend, +provider, migration, save-schema, creature-art or reward changes. + +## Confirmed failures + +1. `GameScene.enterHubWorld()` set a one-second scene-clock cooldown, then left + the scene after a 500ms camera fade. Scene shutdown removed that timer. Phaser + reuses the scene instance, and `init()` never cleared the flag. The gate could + remain blocked on the second and third visits. The regression test reproduced + one successful entry out of three before this repair. +2. `AudioManager` used one-shot unlock listeners and treated a resolved resume + promise as success without checking the context state. Muted gestures consumed + listeners; a still-interrupted context could be incorrectly marked unlocked. + Recovery covered the procedural context but not Phaser's separate recorded + music context. The added tests reproduced these gaps before repair. +3. Protected main still lacked the earlier private loading-overlay lifecycle + repair. `showLoading()` dereferenced a potentially missing overlay. The + `beforeunload` handler also destroyed UI on cancelled navigation or before a + back/forward-cache return. Only this loading/page-lifecycle portion of private + commit `3f0a496a` was retained; its final-boss changes were NOT included. + +Public read-only verification on 24 September found these old implementations in +`/assets/gameplay-Cg5wdlmV.js` and `/assets/core-BDTYa9MM.js`, referenced by the live +`/play/` entry `/assets/index-CUGK000k.js`. Branch base: +`e802c7b4b758fa904f6085fdf0de733a08c537ff`. + +## Changes + +- Own each Sanctuary-to-Hub transition explicitly. Reset on scene initialization + and shutdown, deduplicate taps, cancel stale callbacks, and use an independent + fade watchdog. A failed load restores the view and allows retry. +- Restore missing loading UI idempotently, do not accumulate empty focus traps, + ignore stale Hub loading callbacks, and preserve UI on cancelled/cached exits. +- Keep trusted-gesture recovery available across interruptions. Resume both audio + contexts inside that gesture, inspect their actual states, contain rejections, + and synchronize Phaser mute with the player's existing preference. +- Suspend both contexts when hidden/page-hidden. Returning does not force a new + unmute; muted players stay muted. Remove owned listeners during destruction. +- No microphone permission, recording, new sound asset or audio provider added. + +## Verification + +- Focused initial repair: 5 suites / 64 tests passed. +- Final complete Jest run: 265 suites / 2,580 tests passed. +- Direct production Vite build passed. Existing large-chunk advisory remains. +- `git diff --check` passed. +- Built-game browser candidate passed at 390x844 and 1280x720. Each performs four + real Explore entries: Hub/back, Forest/return, Forest/return, Caves/return. + The final entry deliberately removes the loading overlay to verify recovery. +- Real pointer/keyboard controls are used, with correct camera projection and a + 100ms keyboard press. Prior-progress and player positioning are explicit local + fixtures, not claims of full campaign completion or new-player usability. +- Both real Web Audio contexts recover from separate and simultaneous suspensions; + both clocks advance. The actual intro soundtrack decodes (284.72s at 48kHz) and + its silent playback advances. This does not certify physical iPhone audibility. +- Passing phone/desktop cases have zero console/page errors, HTTP errors and + outside requests. No hosted generation or production data writes. + +All Chromium launches use `--mute-audio`. Game journeys remain muted; the audio +probe additionally sets all procedural volumes to zero and keeps Phaser muted. +Every run closes its browser and preview server in `finally`, including failures. + +Before logs: `/private/tmp/mythical-hotfix-before.log` (includes the initial test +fixture correction) and `/private/tmp/mythical-hotfix-gate-before.log`. +Final tests/build: `/private/tmp/mythical-hotfix-final-tests.log` and +`/private/tmp/mythical-hotfix-final-build.log`. + +Browser diagnostics remain under `.visual-review/explore-audio*` in this worktree. +The first failed browser run used unprojected coordinates; the next desktop input +was too short for frame-polled Space; a later film probe mistakenly used the local +preview route that intentionally skips the home screen. These harness failures +were corrected, not hidden or treated as product approval. + +Repeatable commands (from this worktree): + +```sh +npx jest --runInBand --silent +npx vite build +MYTHICAL_VOID_AUTOMATION_AUDIO=0 SMOKE_HARDWARE_ACCELERATED_CAPTURE=1 HOTFIX_EVIDENCE=.visual-review/explore-audio-final node scripts/smoke-explore-audio-hotfix.cjs +``` + +## Device and release boundary + +After an authorized deployment, Kevin should test the actual iPhone browser or +Home Screen app: enter Explore, return, repeat three times, enter an unlocked +later level, then background/lock and return. With Sound enabled and volume above +zero, tap once and check both music and effects. Also check Sound off remains off. +Do not clear his save or request a new game to test the repair. + +iOS can interrupt Web Audio and require a resume. This is documented by +[MDN](https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/state). +The implementation uses the installed Phaser 3.90 sound manager's context and +unlock lifecycle, not a new audio engine. Chromium emulation cannot prove iOS +hardware routing, silent mode/Bluetooth behavior, or subjective sound quality. + +The broader private closeout and pending story-film changes stay on +`codex/regional-victory-summary`; they are not part of this emergency candidate. diff --git a/scripts/smoke-explore-audio-hotfix.cjs b/scripts/smoke-explore-audio-hotfix.cjs new file mode 100644 index 00000000..575343ec --- /dev/null +++ b/scripts/smoke-explore-audio-hotfix.cjs @@ -0,0 +1,200 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const { createHash } = require('node:crypto'); +const { chromium } = require('playwright'); +const { smokeRendererArgs } = require('./lib/smoke-renderer-policy.cjs'); +const root = path.resolve(__dirname, '..'); +const output = path.resolve(root, process.env.HOTFIX_EVIDENCE || '.visual-review/explore-audio'); +const profile = JSON.parse(fs.readFileSync(path.join(root, 'public/press/gameplay/real-creature-showcase/source-profiles.json'))).profiles[1]; +const git = args => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); +const report = { source: git(['rev-parse', 'HEAD']), dirty: !!git(['status', '--porcelain']), + fixture: 'Local saved-creature/prior-progress fixture and actor positioning. Real touch/keyboard gate input, actual scene loading/return. Chromium mobile emulation, not physical iOS. No provider calls.', + audioSafety: 'Chromium --mute-audio throughout; game muted during journeys; recovery probe additionally uses zero master/music/SFX and muted Phaser output.', cases: [] }; +let browser, server, activePage; +async function cleanup() { + try { await browser?.close(); } finally { await new Promise(resolve => server?.httpServer ? server.httpServer.close(resolve) : resolve()); } +} +for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, async () => { await cleanup(); process.exit(1); }); +async function main() { + fs.mkdirSync(output, { recursive: true }); + const html = fs.readFileSync(path.join(root, 'dist/index.html'), 'utf8'); + const entry = html.match(/src="([^"]*\/assets\/index-[^"]+\.js)"/)?.[1]; + assert(entry, 'Built entry missing'); + report.entry = entry; + report.entrySHA256 = createHash('sha256').update(fs.readFileSync(path.join(root, 'dist', entry))).digest('hex'); + const { preview } = await import('vite'); + server = await preview({ root, preview: { host: '127.0.0.1', port: 0, open: false } }); + const base = `http://127.0.0.1:${server.httpServer.address().port}`; + browser = await chromium.launch({ channel: 'chrome', headless: true, + args: [...smokeRendererArgs(process.env), '--mute-audio', '--enable-webgl', '--ignore-gpu-blocklist'] }); + for (const [name, width, height] of [['phone', 390, 844], ['desktop', 1280, 720]]) { + const context = await browser.newContext({ viewport: { width, height }, hasTouch: name === 'phone', serviceWorkers: 'block' }); + const page = await context.newPage(); + activePage = page; + const result = { name, errors: [], outside: [], httpErrors: [], visits: [] }; report.cases.push(result); + page.on('pageerror', error => result.errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') result.errors.push(message.text()); }); + page.on('response', response => { if (response.status() >= 400) result.httpErrors.push([response.status(), response.url()]); }); + await page.route('**/*', route => { + const url = route.request().url(); + if (/^https?:/.test(url) && !url.startsWith(base + '/')) { result.outside.push(url); return route.abort(); } + return route.continue(); + }); + await page.addInitScript(() => { + localStorage.setItem('audioMuted', 'true'); + localStorage.setItem('mythical_void_age_confirmed', 'true'); + localStorage.setItem('mythical_void_age_group', 'age_under_13'); + Object.defineProperty(window, 'APIConfig', { configurable: true, set(value) { + value.isEnabled = () => false; value.isVideoEnabled = () => false; + Object.defineProperty(window, 'APIConfig', { configurable: true, writable: true, value }); + } }); + }); + await page.goto(base + '/play/'); + await page.waitForFunction(() => window.mythicalGame?.isBooted && window.SceneLoader && window.AudioManager?.phaserSound, null, { timeout: 60000 }); + // Verify actual audio-clock recovery without sending audible output to the host. + result.audio = await page.evaluate(() => { + const audio = window.AudioManager; + audio.muted = true; + audio.masterVolume = 0; audio.musicVolume = 0; audio.sfxVolume = 0; + audio.applyMusicGain(); + mythicalGame.sound.setMute(true); + return { attachedToRealPhaser: audio.phaserSound === mythicalGame.sound, + separateContexts: audio.audioContext !== mythicalGame.sound.context, recoveries: [] }; + }); + for (const kind of ['procedural', 'recorded', 'both']) { + await page.evaluate(async kind => { + const audio = window.AudioManager; + const contexts = kind === 'procedural' ? [audio.audioContext] : kind === 'recorded' ? [mythicalGame.sound.context] : audio.getAudioContexts(); + await Promise.all(contexts.map(c => c.suspend())); + audio.muted = false; // Zero gains + Phaser mute + browser mute remain in force. + }, kind); + if (name === 'phone') await page.touchscreen.tap(5, height / 2); + else await page.mouse.click(5, height / 2); + await page.waitForFunction(() => AudioManager.audioUnlocked && AudioManager.getAudioContexts().every(c => c.state === 'running')); + const before = await page.evaluate(() => AudioManager.getAudioContexts().map(c => c.currentTime)); + await page.waitForTimeout(120); + const after = await page.evaluate(() => AudioManager.getAudioContexts().map(c => c.currentTime)); + assert(after.every((time, index) => time > before[index]), 'An audio clock stayed frozen'); + result.audio.recoveries.push({ kind, bothClocksAdvancing: true }); + } + await page.waitForFunction(() => mythicalGame.cache.audio.exists('themeMusic'), null, { timeout: 20000 }); + result.audio.theme = await page.evaluate(() => { + const buffer = mythicalGame.cache.audio.get('themeMusic'); + window.hotfixThemeProbe = mythicalGame.sound.add('themeMusic', { volume: 0, mute: true }); + hotfixThemeProbe.play(); + AudioManager.playSound('coin_collect', 0); + return { decodedSeconds: buffer.duration, sampleRate: buffer.sampleRate }; + }); + await page.waitForFunction(() => hotfixThemeProbe.isPlaying && hotfixThemeProbe.seek > 0.1); + await page.evaluate(() => { hotfixThemeProbe.stop(); hotfixThemeProbe.destroy(); delete window.hotfixThemeProbe; }); + result.audio.theme.zeroOutputPlaybackAdvanced = true; + await page.evaluate(() => { AudioManager.muted = true; mythicalGame.sound.setMute(true); }); + await page.evaluate(async profile => { + const state = window.GameState, game = window.mythicalGame; + state.saveKey = 'gate_hotfix_fixture'; state.saveBackupKeyPrefix = 'gate_hotfix_backup_'; state.saveBackupIndexKey = 'gate_hotfix_backups'; + const creature = { ...state.get('creature'), id: profile.genes.id, name: 'Aster', genes: profile.genes, dna: profile.dna, + hatched: true, named: true, lifecycle: { ...state.get('creature.lifecycle'), stage: 'juvenile' } }; + state.set('creature', creature); state.set('creatures', [creature]); state.set('activeCreatureIndex', 0); + for (const [key, value] of Object.entries({ 'settings.audioMuted': true, 'session.gameStarted': true, + 'tutorial.livingFormSeen': true, 'tutorial.livingFormPending': false, 'tutorial.crashStorySeen': true, + 'tutorial.controlsSeen': true, 'tutorial.villageHeartArrivalSeen': true, + 'story.projectBeacon.fieldKit.recovered': true, 'story.projectBeacon.pendingDebriefs': [], + 'story.projectBeacon.firstExpeditionDrill': { completed: true }, + 'story.projectBeacon.firstForestCinematicVersion': 3, 'hubWorld.shipCompletionCutsceneShown': true, + 'levels.mythicalForest.completed': true, 'hubWorld.gates.crystal_caves.unlocked': true + })) state.set(key, value); + for (const key of ['GameScene', 'HubWorldScene']) assertLoaded(await SceneLoader.loadScene(game, key), key); + function assertLoaded(ok, key) { if (!ok) throw Error('Could not load ' + key); } + for (const s of game.scene.getScenes(false)) if (s.sys.isActive() || s.sys.isPaused()) game.scene.stop(s.sys.settings.key); + game.scene.start('GameScene', { forceMobileControls: innerWidth < 600 }); + window.hotfixScreenPoint = (scene, object) => { + const bounds = object.getBounds(); + const camera = scene.cameras.main; + const point = camera.matrix.transformPoint( + bounds.centerX - camera.scrollX * object.scrollFactorX, + bounds.centerY - camera.scrollY * object.scrollFactorY + ); + return { x: point.x, y: point.y }; + }; + }, profile); + const tap = async point => { + const rect = await page.locator('canvas').first().boundingBox(); + const size = await page.evaluate(() => ({ width: mythicalGame.scale.width, height: mythicalGame.scale.height })); + const x = rect.x + point.x * rect.width / size.width, y = rect.y + point.y * rect.height / size.height; + if (name === 'phone') await page.touchscreen.tap(x, y); else await page.mouse.click(x, y); + }; + for (const [visit, target] of ['back', 'mythical_forest', 'mythical_forest', 'crystal_caves'].entries()) { + console.log(`[hotfix] ${name} visit ${visit + 1} -> ${target}`); + await page.waitForFunction(() => mythicalGame.scene.isActive('GameScene') && mythicalGame.scene.keys.GameScene.player?.body); + await page.waitForTimeout(1200); + // Close ordinary check-in UI through its existing button, if present. + const greeting = await page.evaluate(() => { + const s = mythicalGame.scene.keys.GameScene; + const button = s.greetingElements?.find(o => o.input?.enabled); + return button ? hotfixScreenPoint(s, button) : null; + }); + if (greeting) await tap(greeting); + const position = await page.evaluate(() => { + const s = mythicalGame.scene.keys.GameScene; + s.player.body.reset(s.hubPortal.x, s.hubPortal.y + 45); + s.player.body.setVelocity(0, 0); + s.cameras.main.centerOn(s.player.x, s.player.y); + s.sanctuaryInteractionDirector?.update({ force: true }); + const b = s.mobileControls?.actionButtons?.interact; + return { cooldown: s.hubEntryCooldown, button: b ? hotfixScreenPoint(s, b.zone) : null }; + }); + assert.equal(position.cooldown, false, 'Gate cooldown survived a return'); + if (visit === 3) await page.evaluate(() => document.getElementById('loading-overlay')?.remove()); + await page.waitForTimeout(200); + if (name === 'phone') { assert(position.button, 'Touch Explore control missing'); await tap(position.button); } + else await page.keyboard.press('Space', { delay: 100 }); + await page.waitForFunction(() => mythicalGame.scene.isActive('HubWorldScene') && mythicalGame.scene.keys.HubWorldScene.actionLabel, null, { timeout: 20000 }); + await page.screenshot({ path: path.join(output, `${name}-hub-visit-${visit + 1}.png`) }); + if (target === 'back') { + const back = await page.evaluate(() => { + const s = mythicalGame.scene.keys.HubWorldScene; + const b = s.children.list.find(o => o.text === '\u2190 Back'); + return hotfixScreenPoint(s, b); + }); + await tap(back); + } else { + const action = await page.evaluate(target => { + const s = mythicalGame.scene.keys.HubWorldScene; + s.selectGate(s.gates.findIndex(g => g.id === target)); + return hotfixScreenPoint(s, s.actionLabel); + }, target); + await tap(action); + const key = target === 'mythical_forest' ? 'MythicalForestLevel' : 'CrystalCavesLevel'; + await page.waitForFunction(key => mythicalGame.scene.isActive(key) && mythicalGame.scene.keys[key].player?.body, key, { timeout: 25000 }); + await page.screenshot({ path: path.join(output, `${name}-level-visit-${visit + 1}.png`) }); + await page.evaluate(key => mythicalGame.scene.keys[key].returnToSanctuary(), key); + } + result.visits.push({ visit: visit + 1, target, entered: true, missingOverlayRecovered: visit === 3 }); + } + assert.deepEqual(result.errors, []); assert.deepEqual(result.outside, []); assert.deepEqual(result.httpErrors, []); + await context.close(); + } + report.passed = true; +} +main().catch(async error => { + report.error = error.stack; process.exitCode = 1; + if (activePage && !activePage.isClosed()) { + await activePage.screenshot({ path: path.join(output, 'failure.png') }).catch(() => {}); + report.diagnostic = await activePage.evaluate(() => { + const s = mythicalGame.scene.keys.GameScene; + return { scenes: mythicalGame.scene.getScenes(true).map(s => s.sys.settings.key), + cooldown: s.hubEntryCooldown, transition: !!s.hubEntryTransition, nearHub: s.nearHubPortal, + player: { x: s.player?.x, y: s.player?.y }, portal: { x: s.hubPortal?.x, y: s.hubPortal?.y }, + zoom: s.cameras?.main?.zoom, focus: s.sanctuaryFocusModeActive, + tutorial: s.controlsTutorial?.isVisible, onboarding: window.OnboardingManager?.currentStep, + texts: s.children?.list?.filter(o => o.text && o.visible).map(o => o.text).slice(-35) }; + }).catch(() => null); + } +}).finally(async () => { + await cleanup(); + fs.mkdirSync(output, { recursive: true }); + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(report, null, 2)); + console.log(JSON.stringify(report, null, 2)); +}); diff --git a/src/__tests__/AudioPlaybackLifecycle.test.js b/src/__tests__/AudioPlaybackLifecycle.test.js index 08783af9..a87981c2 100644 --- a/src/__tests__/AudioPlaybackLifecycle.test.js +++ b/src/__tests__/AudioPlaybackLifecycle.test.js @@ -7,16 +7,18 @@ const declaration = parse(source, { sourceType: 'module' }).program.body .find(node => node.type === 'ClassDeclaration' && node.id.name === 'AudioManager'); function fixture() { - const document = { hidden: false, visibilityState: 'visible', addEventListener: jest.fn(), removeEventListener: jest.fn() }; + const document = new EventTarget(); + document.hidden = false; document.visibilityState = 'visible'; + jest.spyOn(document, 'addEventListener'); jest.spyOn(document, 'removeEventListener'); const timers = []; const Manager = vm.runInNewContext(`(${source.slice(declaration.start, declaration.end)})`, { - document, window: { addEventListener: jest.fn() }, + document, window: { addEventListener: jest.fn(), removeEventListener: jest.fn() }, console: { log() {}, warn() {} }, setTimeout: callback => timers.push(callback), clearInterval: jest.fn() }); const manager = new Manager(); manager.initialized = true; const context = { state: 'running', currentTime: 0, destination: {}, suspend: jest.fn(async () => { context.state = 'suspended'; }), - resume: jest.fn(async () => { context.state = 'running'; }) }; + resume: jest.fn(async () => { context.state = 'running'; }), close: jest.fn() }; context.createGain = () => ({ gain: { value: 0, linearRampToValueAtTime: jest.fn() }, connect: jest.fn(), disconnect: jest.fn() }); manager.audioContext = context; manager.createMusicLayer = jest.fn(); @@ -99,3 +101,68 @@ test('a rejected mobile resume is contained and retriable, not an unhandled reje await Promise.resolve(); await Promise.resolve(); }); + +test('muted gestures do not consume the later audio unlock', async () => { + const { manager, context, document } = fixture(); + context.state = 'suspended'; manager.muted = true; + manager.setupMobileAudioUnlock(); + document.dispatchEvent(new Event('touchend')); + expect(context.resume).not.toHaveBeenCalled(); + manager.muted = false; + document.dispatchEvent(new Event('touchend')); + await Promise.resolve(); + expect(context.resume).toHaveBeenCalledTimes(1); +}); + +test('a resolved but still interrupted context retries on the next gesture', async () => { + const { manager, context, document } = fixture(); + context.state = 'interrupted'; + context.resume.mockImplementationOnce(async () => {}); + manager.setupMobileAudioUnlock(); + document.dispatchEvent(new Event('touchend')); + await Promise.resolve(); await Promise.resolve(); + expect(manager.audioUnlocked).toBe(false); + document.dispatchEvent(new Event('touchend')); + await Promise.resolve(); await Promise.resolve(); + expect(context.resume).toHaveBeenCalledTimes(2); + expect(context.state).toBe('running'); +}); + +test('recorded Phaser music and procedural sound both resume in the same gesture', async () => { + const { manager, context, document } = fixture(); + const recorded = { state: 'suspended', resume: jest.fn(async () => { recorded.state = 'running'; }), suspend: jest.fn() }; + const sound = { context: recorded, locked: true, setMute: jest.fn() }; + manager.attachPhaserSound?.(sound); + manager.setupMobileAudioUnlock(); + document.dispatchEvent(new Event('touchend')); + expect(recorded.resume).toHaveBeenCalledTimes(1); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(context.state).toBe('running'); + expect(sound.unlocked).toBe(true); + manager.toggleMute(); + expect(sound.setMute).toHaveBeenLastCalledWith(true); +}); + +test('backgrounding suspends both contexts; return waits for interaction and destroy removes recovery', async () => { + const { manager, context, document } = fixture(); + const recorded = { state: 'running', resume: jest.fn(async () => { recorded.state = 'running'; }), + suspend: jest.fn(async () => { recorded.state = 'suspended'; }) }; + manager.attachPhaserSound?.({ context: recorded, setMute: jest.fn() }); + manager.setupMobileAudioUnlock(); manager.setupAudioLifecycleRecovery(); + document.hidden = true; document.visibilityState = 'hidden'; + manager.audioVisibilityHandler(); + expect(recorded.suspend).toHaveBeenCalledTimes(1); + document.dispatchEvent(new Event('touchend')); + expect(recorded.resume).not.toHaveBeenCalled(); + document.hidden = false; document.visibilityState = 'visible'; + manager.audioVisibilityHandler(); + expect(recorded.resume).not.toHaveBeenCalled(); + document.dispatchEvent(new Event('touchend')); + await Promise.resolve(); await Promise.resolve(); + expect(recorded.resume).toHaveBeenCalledTimes(1); + manager.destroy(); + context.resume.mockClear(); recorded.resume.mockClear(); + document.dispatchEvent(new Event('touchend')); + expect(context.resume).not.toHaveBeenCalled(); + expect(recorded.resume).not.toHaveBeenCalled(); +}); diff --git a/src/__tests__/LoadingOverlayLifecycle.test.js b/src/__tests__/LoadingOverlayLifecycle.test.js new file mode 100644 index 00000000..7395c99f --- /dev/null +++ b/src/__tests__/LoadingOverlayLifecycle.test.js @@ -0,0 +1,81 @@ +const fs = require('fs'); +const path = require('path'); + +const source = fs.readFileSync(path.join(__dirname, '../systems/UXEnhancements.js'), 'utf8'); +const createUX = () => new Function(`${source}\nreturn window.UXEnhancements;`)(); + +describe('loading presentation must not block scene entry', () => { + let ux; + beforeEach(() => { document.body.innerHTML = ''; ux = createUX(); }); + afterEach(() => { ux.destroy(); delete window.UXEnhancements; }); + + test('can show and hide before optional UX initialization', () => { + expect(() => ux.hideLoading()).not.toThrow(); + expect(ux.showLoading('Entering the Final Void...')).toBe(true); + const overlay = document.getElementById('loading-overlay'); + expect(overlay.querySelector('.loading-text').textContent).toBe('Entering the Final Void...'); + expect(overlay.getAttribute('aria-busy')).toBe('true'); + ux.hideLoading(); + expect(overlay.classList.contains('hidden')).toBe(true); + expect(overlay.getAttribute('aria-busy')).toBe('false'); + }); + + test('recreates a removed overlay and repairs missing contents without duplicate styles', () => { + ux.showLoading('One'); + document.getElementById('loading-overlay').remove(); + expect(() => ux.hideLoading()).not.toThrow(); + ux.showLoading('Two'); + document.querySelector('.loading-text').remove(); + ux.showLoading('Three'); + ux.setupLoadingStates(); + expect(document.querySelectorAll('#loading-overlay')).toHaveLength(1); + expect(document.querySelectorAll('#ux-loading-styles')).toHaveLength(1); + expect(document.querySelector('.loading-text').textContent).toBe('Three'); + expect(document.querySelector('#loading-overlay').classList.contains('hidden')).toBe(false); + }); + + test('repeated scene-loading updates do not accumulate focus handlers or steal focus on hide', () => { + const button = document.createElement('button'); + document.body.appendChild(button); + button.focus(); + for (let i = 0; i < 20; i++) { ux.showLoading('Loading'); ux.hideLoading(); } + expect(ux.manualEvents).toHaveLength(0); + expect(document.activeElement).toBe(button); + }); + + test('late loading callbacks after teardown recover without null references', () => { + ux.setupLoadingStates(); + ux.destroy(); + expect(() => ux.hideLoading()).not.toThrow(); + expect(ux.showLoading('Reconnecting...')).toBe(true); + ux.hideLoading(); + expect(document.querySelectorAll('#loading-overlay')).toHaveLength(1); + }); +}); + +describe('page lifecycle keeps a cancelled or cached game usable', () => { + test('beforeunload saves only; confirmed non-cached exit releases resources', () => { + const game = fs.readFileSync(path.join(__dirname, '../game.js'), 'utf8'); + const listeners = {}; + const state = { save: jest.fn() }; + const cloud = { flush: jest.fn().mockResolvedValue() }; + const responsive = { destroy: jest.fn() }; + const ux = { destroy: jest.fn() }; + const visibility = { detach: jest.fn() }; + const memory = { performCleanup: jest.fn() }; + const lifecycle = game.slice(game.indexOf('// Handle page unload'), game.indexOf('// Pause only scenes')); + new Function('window', 'GameState', 'cloudSaveManager', 'responsiveManager', 'uxEnhancements', + 'pageVisibilityController', lifecycle)({ addEventListener: (key, callback) => { listeners[key] = callback; }, + memoryManager: memory }, state, cloud, responsive, ux, visibility); + listeners.beforeunload(); + expect(state.save).toHaveBeenCalledTimes(1); + listeners.pagehide({ persisted: true }); + for (const fn of [memory.performCleanup, visibility.detach, responsive.destroy, ux.destroy]) { + expect(fn).not.toHaveBeenCalled(); + } + listeners.pagehide({ persisted: false }); + for (const fn of [memory.performCleanup, visibility.detach, responsive.destroy, ux.destroy]) { + expect(fn).toHaveBeenCalledTimes(1); + } + }); +}); diff --git a/src/__tests__/SanctuaryGateReentry.test.js b/src/__tests__/SanctuaryGateReentry.test.js new file mode 100644 index 00000000..0e276e7b --- /dev/null +++ b/src/__tests__/SanctuaryGateReentry.test.js @@ -0,0 +1,77 @@ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const { EventEmitter } = require('events'); +const { parse } = require('@babel/parser'); +const source = fs.readFileSync(path.join(__dirname, '../scenes/GameScene.js'), 'utf8'); +const declaration = parse(source, { sourceType: 'module' }).program.body + .find(node => node.type === 'ClassDeclaration' && node.id.name === 'GameScene'); + +function fixture() { + const camera = new EventEmitter(); + camera.fadeOut = jest.fn(); camera.fadeIn = jest.fn(); camera.resetFX = jest.fn(); + const scene = { + player: { x: 100, y: 200 }, hubPortal: {}, + cameras: { main: camera }, time: { delayedCall: jest.fn() }, + sceneRouter: { playSound: jest.fn(), showLoading: jest.fn(), startScene: jest.fn(async () => true) }, + getInteractionDistance: () => ({ enter: 170 }), isPlayerAtInteractionDistance: () => true, + showInteractionHint: jest.fn(), cancelCompletionHandoffs: jest.fn() + }; + const state = { get: jest.fn(), set: jest.fn() }; + const globals = { window: { UXEnhancements: { hideLoading: jest.fn() } }, + console: { log() {}, warn() {} }, getGameState: () => state, + FEND_COMMONS_PRIORITIES: [], GUARDIAN_RESIDENT_DEFINITIONS: [], setTimeout, clearTimeout }; + for (const name of ['init', 'enterHubWorld', 'cancelHubEntryTransition']) { + const node = declaration.body.body.find(m => m.key?.name === name); + if (node) scene[name] = vm.runInNewContext(`({${source.slice(node.start, node.end)}}).${name}`, globals); + } + return { scene, camera }; +} + +beforeEach(() => jest.useFakeTimers()); +afterEach(() => jest.useRealTimers()); + +test('three visits can enter the gate without relying on the departed scene clock', async () => { + const { scene, camera } = fixture(); + for (let visit = 0; visit < 3; visit++) { + scene.init({}); + scene.enterHubWorld(); + camera.emit('camerafadeoutcomplete'); + await Promise.resolve(); + // Phaser removes scene-clock timers on departure, before the old 1s cooldown. + } + expect(scene.sceneRouter.startScene).toHaveBeenCalledTimes(3); + scene.cancelHubEntryTransition?.(); +}); + +test('a missed fade completion still enters once and ignores repeated taps', async () => { + const { scene, camera } = fixture(); + scene.init({}); scene.enterHubWorld(); scene.enterHubWorld(); + await jest.advanceTimersByTimeAsync(1500); + expect(scene.sceneRouter.startScene).toHaveBeenCalledTimes(1); + camera.emit('camerafadeoutcomplete'); + expect(scene.sceneRouter.startScene).toHaveBeenCalledTimes(1); + scene.cancelHubEntryTransition?.(); +}); + +test('failed scene loading restores the camera and allows another attempt', async () => { + const { scene, camera } = fixture(); + scene.sceneRouter.startScene.mockResolvedValueOnce(false); + scene.init({}); scene.enterHubWorld(); camera.emit('camerafadeoutcomplete'); + await jest.advanceTimersByTimeAsync(0); + expect(scene.hubEntryCooldown).toBe(false); + expect(camera.fadeIn).toHaveBeenCalled(); + scene.enterHubWorld(); camera.emit('camerafadeoutcomplete'); + expect(scene.sceneRouter.startScene).toHaveBeenCalledTimes(2); + scene.cancelHubEntryTransition?.(); +}); + +test('a cancelled transition cannot launch from its old fade or watchdog', async () => { + const { scene, camera } = fixture(); + scene.init({}); scene.enterHubWorld(); + scene.cancelHubEntryTransition?.(); + camera.emit('camerafadeoutcomplete'); + await jest.advanceTimersByTimeAsync(1500); + expect(scene.sceneRouter.startScene).not.toHaveBeenCalled(); + expect(scene.hubEntryCooldown).toBe(false); +}); diff --git a/src/game.js b/src/game.js index 919d420b..fd5a0ef8 100644 --- a/src/game.js +++ b/src/game.js @@ -811,6 +811,7 @@ async function initializeGame() { // Add Phaser's built-in error handling callbacks: { postBoot: function (game) { + window.AudioManager?.attachPhaserSound?.(game.sound); console.log('🎮 Phaser game booted successfully'); try { @@ -928,20 +929,6 @@ async function initializeGame() { }); } - // Resume audio context on first user interaction (browser requirement) - const resumeAudio = () => { - if (window.AudioManager) { - window.AudioManager.resume(); - // Remove listener after first interaction - document.removeEventListener('click', resumeAudio); - document.removeEventListener('touchstart', resumeAudio); - document.removeEventListener('keydown', resumeAudio); - } - }; - document.addEventListener('click', resumeAudio); - document.addEventListener('touchstart', resumeAudio); - document.addEventListener('keydown', resumeAudio); - // Set up scene transition cleanup game.scene.scenes.forEach(scene => { if (scene.events) { @@ -2697,11 +2684,6 @@ async function initializeGame() { // Handle page unload - save game state with error handling window.addEventListener('beforeunload', () => { try { - // Clean up resources - if (window.memoryManager) { - window.memoryManager.performCleanup(); - } - // Save game state if (GameState && typeof GameState.save === 'function') { GameState.save(); @@ -2710,7 +2692,16 @@ async function initializeGame() { cloudSaveManager?.flush().catch(() => { // The local save has already completed; retry cloud sync next launch. }); + } catch (saveError) { + console.error('💾❌ Final save failed:', saveError); + } + }); + // Cancelled navigation and back/forward cache retain this running game. + window.addEventListener('pagehide', event => { + if (event.persisted) return; + try { + window.memoryManager?.performCleanup(); pageVisibilityController?.detach(); pageVisibilityController = null; @@ -2723,9 +2714,8 @@ async function initializeGame() { if (uxEnhancements) { uxEnhancements.destroy(); } - } catch (saveError) { - console.error('💾❌ Final save failed:', saveError); - // Don't show error message here as page is unloading + } catch (cleanupError) { + console.warn('[Main] Page exit cleanup failed:', cleanupError); } }); diff --git a/src/scenes/GameScene.js b/src/scenes/GameScene.js index 8e36a4e5..c645d5d4 100644 --- a/src/scenes/GameScene.js +++ b/src/scenes/GameScene.js @@ -522,6 +522,7 @@ class GameScene extends Phaser.Scene { init(data) { // Reset shutdown flag for fresh scene this._isShuttingDown = false; + this.cancelHubEntryTransition(); this.forceMobileControls = data?.forceMobileControls === true; this.fieldKitPreview = data?.fieldKitPreview === true; this.fieldKitPreviewSize = data?.fieldKitPreviewSize || null; @@ -12600,9 +12601,6 @@ class GameScene extends Phaser.Scene { } this.hubEntryCooldown = true; - this.time.delayedCall(1000, () => { - this.hubEntryCooldown = false; - }); this.nearHubPortal = false; window.QuestManager?.trackProgress('landmark_visit', { landmark: 'hub_gate' }); @@ -12620,22 +12618,47 @@ class GameScene extends Phaser.Scene { // Screen effect - magical transition window.FeedbackManager?.cameraFlash?.(this, 300, 147, 112, 219); // Purple flash - // Fade out and transition to hub - this.cameras.main.fadeOut(500, 0, 0, 0); - - this.cameras.main.once('camerafadeoutcomplete', () => { - // Get the current creature texture for the hub - const creatureTexture = this.creatureTextureName || getGameState().get('creature.textureName'); + const camera = this.cameras.main; + const transition = { camera, started: false, timer: null, onComplete: null }; + this.hubEntryTransition = transition; + const data = { + creatureTexture: this.creatureTextureName || getGameState().get('creature.textureName'), + returnPosition: { x: this.player.x, y: this.player.y } + }; + const recover = () => { + if (this.hubEntryTransition !== transition || this._isShuttingDown) return; + this.cancelHubEntryTransition(); + window.UXEnhancements?.hideLoading?.(); + camera.resetFX?.(); + camera.fadeIn(180, 0, 0, 0); + this.showInteractionHint('The gate could not open. Tap Explore to try again.'); + }; + transition.onComplete = () => { + if (this.hubEntryTransition !== transition || transition.started || this._isShuttingDown) return; + transition.started = true; + clearTimeout(transition.timer); + camera.off?.('camerafadeoutcomplete', transition.onComplete); + try { + Promise.resolve(this.sceneRouter.startScene('HubWorldScene', data, { sound: null })) + .then(started => { if (started === false) recover(); }, recover); + } catch { + recover(); + } + }; + // Scene-clock timers vanish on departure; own and cancel this handoff explicitly. + camera.once('camerafadeoutcomplete', transition.onComplete); + transition.timer = setTimeout(transition.onComplete, 1200); + camera.fadeOut(500, 0, 0, 0); + } - // Start hub world scene - this.sceneRouter.startScene('HubWorldScene', { - creatureTexture: creatureTexture, - returnPosition: { - x: this.player.x, - y: this.player.y - } - }, { sound: null }); - }); + cancelHubEntryTransition() { + const transition = this.hubEntryTransition; + if (transition) { + clearTimeout(transition.timer); + transition.camera?.off?.('camerafadeoutcomplete', transition.onComplete); + } + this.hubEntryTransition = null; + this.hubEntryCooldown = false; } /** @@ -18285,6 +18308,7 @@ class GameScene extends Phaser.Scene { return; } this._isShuttingDown = true; + this.cancelHubEntryTransition(); this.cancelCompletionHandoffs(); this.villageCommandPreviewState = null; console.log('[GameScene] Shutting down - cleaning up event listeners'); diff --git a/src/scenes/HubWorldScene.js b/src/scenes/HubWorldScene.js index 4649c234..99f2c80e 100644 --- a/src/scenes/HubWorldScene.js +++ b/src/scenes/HubWorldScene.js @@ -2095,6 +2095,7 @@ export default class HubWorldScene extends Phaser.Scene { // Show loading (will appear after transition) if (window.UXEnhancements) { this.time.delayedCall(800, () => { + if (this._isShuttingDown || !this.isTransitioning || this.gateTransitionStarted) return; window.UXEnhancements.showLoading( resume ? `Reconnecting at ${resume.label}...` diff --git a/src/systems/AudioManager.js b/src/systems/AudioManager.js index 0ba90c4a..55b74ed2 100644 --- a/src/systems/AudioManager.js +++ b/src/systems/AudioManager.js @@ -26,6 +26,8 @@ class AudioManager { this.unlockHandler = null; this.audioVisibilityHandler = null; this.audioPageShowHandler = null; + this.audioPageHideHandler = null; + this.phaserSound = null; } /** @@ -103,34 +105,22 @@ class AudioManager { * Mobile browsers require user interaction before audio can play */ setupMobileAudioUnlock() { - if (!this.audioContext) return; + if (!this.getAudioContexts().length || typeof document === 'undefined') return; this.removeUnlockListeners(); - this.audioUnlocked = this.audioContext.state === 'running'; + this.audioUnlocked = this.getAudioContexts().every(context => context.state === 'running'); - // Create unlock handler that resumes audio context on first interaction + // A later interruption must be recoverable by another trusted interaction. this.unlockHandler = () => { if (this.muted || (typeof document !== 'undefined' && document.hidden)) return; - if (this.audioUnlocked && this.audioContext?.state === 'running') return; - - if (this.audioContext && ['suspended', 'interrupted'].includes(this.audioContext.state)) { - this.audioContext.resume().then(() => { - console.log('[AudioManager] 🔊 Audio unlocked on mobile'); - this.audioUnlocked = true; - this.removeUnlockListeners(); - }).catch(() => { - this.audioUnlocked = false; - }); - } else { - this.audioUnlocked = true; - this.removeUnlockListeners(); - } + void this.resume(); }; - // Listen for first user interaction (touch or click) - const events = ['touchstart', 'touchend', 'mousedown', 'click', 'keydown']; + // Capture also reaches taps handled by overlays that stop propagation. + const events = ['touchstart', 'touchend', 'pointerup', 'mousedown', 'click', 'keydown']; events.forEach(event => { - document.addEventListener(event, this.unlockHandler, { once: true, passive: true }); + // Keep recovery available after muted taps, rejected unlocks and iOS interruptions. + document.addEventListener(event, this.unlockHandler, { capture: true, passive: true }); }); console.log('[AudioManager] Mobile audio unlock listeners added'); @@ -142,33 +132,52 @@ class AudioManager { this.audioVisibilityHandler = () => { if (document.hidden) { this.audioUnlocked = false; - this.audioContext?.suspend?.().catch(() => {}); + this.suspendAudioContexts(); return; } if (document.visibilityState !== 'visible') return; this.rearmAudioAfterInterruption(); }; this.audioPageShowHandler = () => this.rearmAudioAfterInterruption(); + this.audioPageHideHandler = () => this.suspendAudioContexts(); document.addEventListener('visibilitychange', this.audioVisibilityHandler); window.addEventListener?.('pageshow', this.audioPageShowHandler); + window.addEventListener?.('pagehide', this.audioPageHideHandler); } rearmAudioAfterInterruption() { - if (!this.audioContext || this.audioContext.state === 'running') return; + if (!this.getAudioContexts().length || this.getAudioContexts().every(context => context.state === 'running')) return; this.audioUnlocked = false; this.setupMobileAudioUnlock(); } + getAudioContexts() { + return [...new Set([this.audioContext, this.phaserSound?.context].filter(Boolean))]; + } + + attachPhaserSound(sound) { + this.phaserSound = sound; + sound?.setMute?.(this.muted); + this.setupMobileAudioUnlock(); + } + + suspendAudioContexts() { + this.audioUnlocked = false; + this.getAudioContexts().forEach(context => { + try { Promise.resolve(context.suspend?.()).catch(() => {}); } catch { /* Already closed. */ } + }); + } + /** * Remove audio unlock event listeners */ removeUnlockListeners() { if (!this.unlockHandler) return; - const events = ['touchstart', 'touchend', 'mousedown', 'click', 'keydown']; + const events = ['touchstart', 'touchend', 'pointerup', 'mousedown', 'click', 'keydown']; events.forEach(event => { - document.removeEventListener(event, this.unlockHandler); + document.removeEventListener(event, this.unlockHandler, true); }); this.unlockHandler = null; @@ -2277,6 +2286,7 @@ class AudioManager { } this.applyMusicGain(); + this.phaserSound?.setMute?.(this.muted); if (!this.muted) void this.resume(); if (!this.muted && !this.musicPlaying && this.requestedArea) { this.playAreaMusic(this.requestedArea); @@ -2378,17 +2388,28 @@ class AudioManager { * Resume audio context (needed for user interaction requirement) */ resume() { - const context = this.audioContext; - if (!context || this.muted || (typeof document !== 'undefined' && document.hidden)) return Promise.resolve(false); - if (context.state === 'running') return Promise.resolve(true); - if (!['suspended', 'interrupted'].includes(context.state)) return Promise.resolve(false); - return context.resume().then(() => { - this.audioUnlocked = context === this.audioContext && context.state === 'running'; + const contexts = this.getAudioContexts(); + if (!contexts.length || this.muted || (typeof document !== 'undefined' && document.hidden)) return Promise.resolve(false); + // Both resume calls must happen synchronously inside the same trusted gesture. + const attempts = contexts.map(context => { + if (context.state === 'running') return Promise.resolve(true); + if (!['suspended', 'interrupted'].includes(context.state)) return Promise.resolve(false); + try { + return Promise.resolve(context.resume()).then(() => context.state === 'running', () => false); + } catch { return Promise.resolve(false); } + }); + return Promise.all(attempts).then(() => { + if (this.muted || (typeof document !== 'undefined' && document.hidden)) { + this.suspendAudioContexts(); + return false; + } + this.audioUnlocked = contexts.every(context => this.getAudioContexts().includes(context) && context.state === 'running'); + if (this.phaserSound?.context?.state === 'running' && this.phaserSound.locked) { + // Phaser's update emits UNLOCKED and releases sounds queued before the gesture. + this.phaserSound.unlocked = true; + } + if (!this.audioUnlocked) this.rearmAudioAfterInterruption(); return this.audioUnlocked; - }).catch(() => { - this.audioUnlocked = false; - this.rearmAudioAfterInterruption(); - return false; }); } @@ -3418,6 +3439,11 @@ class AudioManager { window.removeEventListener?.('pageshow', this.audioPageShowHandler); this.audioPageShowHandler = null; } + if (this.audioPageHideHandler) { + window.removeEventListener?.('pagehide', this.audioPageHideHandler); + this.audioPageHideHandler = null; + } + this.phaserSound = null; // Clear music nodes this.musicNodes = null; diff --git a/src/systems/UXEnhancements.js b/src/systems/UXEnhancements.js index a0e68bb8..bfffe787 100644 --- a/src/systems/UXEnhancements.js +++ b/src/systems/UXEnhancements.js @@ -7,6 +7,7 @@ class UXEnhancements { constructor() { this.initialized = false; this.focusableElements = []; + this.focusHistory = []; this.currentFocusIndex = 0; this.announcementQueue = []; this.tooltips = new Map(); @@ -529,20 +530,26 @@ class UXEnhancements { * Set up loading states for async operations */ setupLoadingStates() { - // Create loading overlay - const loadingOverlay = document.createElement('div'); - loadingOverlay.id = 'loading-overlay'; - loadingOverlay.className = 'loading-overlay hidden'; - loadingOverlay.innerHTML = ` + if (!document.body || !document.head) return null; + let loadingOverlay = document.getElementById('loading-overlay'); + if (!loadingOverlay) { + loadingOverlay = document.createElement('div'); + loadingOverlay.id = 'loading-overlay'; + loadingOverlay.className = 'loading-overlay hidden'; + document.body.appendChild(loadingOverlay); + } + loadingOverlay.setAttribute('role', 'status'); + loadingOverlay.setAttribute('aria-live', 'polite'); + if (!loadingOverlay.querySelector('.loading-text')) loadingOverlay.innerHTML = `
-
+

Loading...

`; - document.body.appendChild(loadingOverlay); - - // Add styles + if (document.getElementById('ux-loading-styles')) return loadingOverlay; + const style = document.createElement('style'); + style.id = 'ux-loading-styles'; style.textContent = ` .loading-overlay { position: fixed; @@ -587,6 +594,7 @@ class UXEnhancements { } `; document.head.appendChild(style); + return loadingOverlay; } /** @@ -719,12 +727,14 @@ class UXEnhancements { * Trap focus within element (for modals) */ trapFocus(element) { + if (!element || element._focusTrapHandler) return; const focusableElements = element.querySelectorAll( 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' ); const firstFocusable = focusableElements[0]; const lastFocusable = focusableElements[focusableElements.length - 1]; + if (!firstFocusable) return; // Focus first element if (firstFocusable) { @@ -755,6 +765,7 @@ class UXEnhancements { * Release focus trap */ releaseFocusTrap(element) { + if (!element?._focusTrapHandler) return; if (element._focusTrapHandler) { element.removeEventListener('keydown', element._focusTrapHandler); delete element._focusTrapHandler; @@ -847,17 +858,21 @@ class UXEnhancements { * Show loading state */ showLoading(message = 'Loading...') { - const overlay = document.getElementById('loading-overlay'); + // Scene entry can outlive a DOM teardown or run before full UX setup. + const overlay = this.setupLoadingStates(); + if (!overlay) return false; const text = overlay.querySelector('.loading-text'); text.textContent = message; overlay.classList.remove('hidden'); + overlay.setAttribute('aria-busy', 'true'); // Announce to screen reader this.announce(`Loading: ${message}`); // Trap focus in loading overlay this.trapFocus(overlay); + return true; } /** @@ -865,7 +880,9 @@ class UXEnhancements { */ hideLoading() { const overlay = document.getElementById('loading-overlay'); + if (!overlay || overlay.classList.contains('hidden')) return; overlay.classList.add('hidden'); + overlay.setAttribute('aria-busy', 'false'); // Release focus trap this.releaseFocusTrap(overlay); @@ -1160,6 +1177,7 @@ class UXEnhancements { 'game-announcer', 'game-instructions', 'loading-overlay', + 'ux-loading-styles', 'tooltip-container', 'ux-visual-feedback', 'ux-mobile-enhancements'