From 123e6e381212e3f66110a9d5051c6fe21c7658e3 Mon Sep 17 00:00:00 2001 From: TechEvolveAI Date: Thu, 24 Sep 2026 16:24:28 +0100 Subject: [PATCH 1/3] Preserve held mobile input during viewport refreshes --- .github/workflows/gameplay-contracts.yml | 23 ++++++ .../HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md | 81 +++++++++++++++++++ scripts/lib/held-touch-regression.cjs | 77 ++++++++++++++++++ scripts/smoke-explore-audio-hotfix.cjs | 48 +++++++++-- src/__tests__/AudioPlaybackLifecycle.test.js | 37 +++++++++ .../MobileControlPointerRegression.test.js | 64 +++++++++++++++ src/__tests__/MobileInputLifecycle.test.js | 25 ++++++ src/systems/AudioManager.js | 5 ++ src/systems/MobileControls.js | 30 +++++++ 9 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 docs/planning/HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md create mode 100644 scripts/lib/held-touch-regression.cjs diff --git a/.github/workflows/gameplay-contracts.yml b/.github/workflows/gameplay-contracts.yml index 205e7b3a..99164e97 100644 --- a/.github/workflows/gameplay-contracts.yml +++ b/.github/workflows/gameplay-contracts.yml @@ -13,6 +13,29 @@ permissions: contents: read jobs: + mobile-input-audio: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci --ignore-scripts + - run: npm run build:onboarding-ci + - name: Verify held mobile input, audio recovery and repeated Explore entry + env: + MYTHICAL_VOID_AUTOMATION_AUDIO: '0' + HOTFIX_EVIDENCE: /tmp/mythical-mobile-lifecycle + SMOKE_NATIVE_OPENGL: '1' + LIBGL_ALWAYS_SOFTWARE: '1' + run: xvfb-run -a -s '-screen 0 1280x1024x24' node scripts/smoke-explore-audio-hotfix.cjs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: mobile-input-audio-evidence + path: /tmp/mythical-mobile-lifecycle gameplay-contracts: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/docs/planning/HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md b/docs/planning/HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md new file mode 100644 index 00000000..356d32db --- /dev/null +++ b/docs/planning/HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md @@ -0,0 +1,81 @@ +# Held joystick and missing music incident + +Baseline investigated: production/main `d6a8c168f9b2ae869731cdd24fc885b6fc6beb4c`. +Scope: two reproduced lifecycle defects; no save, backend, level, artwork, +economy, generation or broader unfinished feature changes. + +## What is established + +`MobileControls.handleResize()` rebuilt the entire control dock for every Phaser +resize notification. `hide()` called `cleanupEventHandlers()`, which called +`resetJoystick(true)`. That released ownership and sent a zero movement vector +while the player was still holding their finger down. Subsequent moves correctly +do not acquire a new finger, so motion could not restart until another touch. + +Phaser 3.90 `ScaleManager.refresh()` emits its resize event even when width and +height have not changed. In the built game, a held native touch followed by one +refresh reproduced snap-back in all four directions on both the iOS-specific +Touch Events path and the Android Pointer Events path. Changing the viewport +height also reproduced it. Stable holds without either event worked. + +The unconditional control rebuild predates the last gate/audio release. The +previous gate smoke used touch taps without an iPhone user agent or a held drag +crossing a viewport event. It therefore could not establish uninterrupted iPhone +movement. Passing those checks was insufficient release coverage. + +This proves a failure mechanism, NOT the exact event that occurred on Kevin's +physical phone. An attempted page-offset-only reproduction did not fail and is +not claimed as a cause. Chromium with an iPhone user agent exercises our iOS +handler but is not WebKit or a physical iPhone test. + +Separately, `AudioManager.playAreaMusic()` remembers the requested area but does +not create music nodes while the page is hidden. `resume()` restarted audio +contexts without starting that deferred soundtrack. A deterministic regression +failed before repair: the audio context was running, but music was not playing. +The sound preference being on is not evidence that a soundtrack is playing. +This does not yet establish the physical phone's entire no-sound cause. + +## Narrow repair + +- Ignore duplicate layout notifications, including unchanged safe-area insets. +- Keep an active drag through same-width, same-orientation height changes; reflow + after release. Width/orientation changes, cancel, blur, hide and scene teardown + still release input, preventing an involuntary stuck direction. +- After audio recovery, start a current deferred soundtrack exactly once. Do not + restart a playing track, resurrect a departed scene, or override mute. +- Add a separate CI job for held touch, audio recovery and repeated Explore + journeys. Existing release checks remain unchanged. + +## Evidence and limits + +Private before-repair traces: `.visual-review/held-touch-refresh.json` and +`.visual-review/held-touch-height.json`. Focused red tests: +`/private/tmp/mythical-held-touch-red.log`. + +The expanded smoke uses real browser touch/pointer input, tests refresh and +height changes in four directions, verifies actual player displacement and zero +movement on release, and repeats Explore/return four times for each mobile path +and desktop. Actor positions and prior progress are explicit local fixtures. +It checks two real audio clocks, decoded intro music with zero-output playback, +and deferred music after simulated hidden state. No generation providers or +production saves are contacted. + +An initial combined run encountered the saved Forest victory's normal resident +arrival cinematic. Its trace showed `playRescuedResidentArrival -> suspend`, an +intentional input pause. The harness now waits for that real scene to finish; +the production cinematic was not disabled or changed. + +Repeat from the exact candidate checkout: + +```sh +npx jest --runInBand --silent +npx vite build +MYTHICAL_VOID_AUTOMATION_AUDIO=0 SMOKE_HARDWARE_ACCELERATED_CAPTURE=1 HOTFIX_EVIDENCE=.visual-review/held-touch-final node scripts/smoke-explore-audio-hotfix.cjs +``` + +All automation is host-muted and closes its owned browser/server in `finally`. +Release must retain exact source/build evidence and passing existing gates. +Do not call physical iPhone audio or the reported incident resolved solely from +these checks. On-device acceptance: hold each direction, move while browser bars +change, release, open Explore and return three times, then background/return and +verify both music and an effect with Sound enabled. Keep the existing save. diff --git a/scripts/lib/held-touch-regression.cjs b/scripts/lib/held-touch-regression.cjs new file mode 100644 index 00000000..e09d5b3f --- /dev/null +++ b/scripts/lib/held-touch-regression.cjs @@ -0,0 +1,77 @@ +const assert = require('node:assert/strict'); + +// Exercise real native pointer/touch ownership, not synthetic Phaser drag events. +// The caller owns a muted Chromium browser and closes it in finally. +async function verifyHeldTouch(page, context, { full = true } = {}) { + const cdp = await context.newCDPSession(page); + const viewport = page.viewportSize(); + const cases = []; + try { + for (const mode of full ? ['refresh', 'height'] : ['refresh']) { + for (const [direction, dx, dy] of full + ? [['right', 38, 0], ['left', -38, 0], ['down', 0, 38], ['up', 0, -38]] + : [['right', 38, 0]]) { + console.log(`[held-touch] ${mode} ${direction}`); + const start = await page.evaluate(() => { + const s = mythicalGame.scene.keys.GameScene; + s.player.body.reset(1100, 900); + const c = s.mobileControls, rect = mythicalGame.canvas.getBoundingClientRect(); + window.heldTouchTrace = []; + if (!c.heldTouchTraced) { + c.heldTouchTraced = true; + for (const name of ['resetJoystick', 'handleResize', 'hide', 'show', 'suspend', 'refresh', 'finishJoystickInput']) { + const original = c[name]; + c[name] = function (...args) { + heldTouchTrace.push({ name, active: this.joystickActive, previous: this.controlViewport, + next: this.getControlViewport(), stack: new Error().stack }); + return original.apply(this, args); + }; + } + } + return { + x: rect.x + c.joystickCenterX * rect.width / mythicalGame.scale.width, + y: rect.y + c.joystickCenterY * rect.height / mythicalGame.scale.height + }; + }); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ ...start, id: 1 }] }); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: start.x + dx, y: start.y + dy, id: 1 }] }); + if (mode === 'refresh') { + for (let i = 0; i < 4; i += 1) { + await page.evaluate(() => mythicalGame.scale.refresh()); + await page.waitForTimeout(100); + } + } else { + await page.setViewportSize({ width: viewport.width, height: viewport.height - 44 }); + } + await page.waitForTimeout(750); + const held = await page.evaluate(() => { + const s = mythicalGame.scene.keys.GameScene, c = s.mobileControls; + return { active: c.joystickActive, source: c.joystickInputSource, + vector: [s.joystickX, s.joystickY], player: { x: s.player.x, y: s.player.y }, trace: heldTouchTrace }; + }); + if (!held.active) console.error(JSON.stringify(held, null, 2)); + assert(held.active, `${mode}: ${direction} lost the held finger`); + const axis = dx ? 0 : 1; + const sign = Math.sign(dx || dy); + assert(held.vector[axis] * sign > 0.7, `${mode}: ${direction} movement reset`); + const movement = dx ? held.player.x - 1100 : held.player.y - 900; + assert(movement * sign > 40, `${mode}: ${direction} did not move the player`); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await page.waitForFunction(() => { + const s = mythicalGame.scene.keys.GameScene; + return !s.mobileControls.joystickActive && s.joystickX === 0 && s.joystickY === 0; + }, null, { timeout: 8000 }); + cases.push({ mode, direction, ...held, releaseStopped: true }); + await page.setViewportSize(viewport); + await page.waitForTimeout(250); + } + } + return cases; + } finally { + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }).catch(() => {}); + await cdp.detach(); + await page.setViewportSize(viewport); + } +} + +module.exports = { verifyHeldTouch }; diff --git a/scripts/smoke-explore-audio-hotfix.cjs b/scripts/smoke-explore-audio-hotfix.cjs index 575343ec..0aefb702 100644 --- a/scripts/smoke-explore-audio-hotfix.cjs +++ b/scripts/smoke-explore-audio-hotfix.cjs @@ -5,6 +5,7 @@ const { execFileSync } = require('node:child_process'); const { createHash } = require('node:crypto'); const { chromium } = require('playwright'); const { smokeRendererArgs } = require('./lib/smoke-renderer-policy.cjs'); +const { verifyHeldTouch } = require('./lib/held-touch-regression.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]; @@ -29,9 +30,15 @@ async function main() { 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' }); + for (const [name, width, height, userAgent] of [ + ['phone', 390, 844, 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.7339.122 Mobile/15E148 Safari/604.1'], + ['android', 390, 844, 'Mozilla/5.0 (Linux; Android 14; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.7339.122 Mobile Safari/537.36'], + ['desktop', 1280, 720] + ]) { + const phone = name !== 'desktop'; + const context = await browser.newContext({ viewport: { width, height }, hasTouch: phone, isMobile: phone, ...(userAgent ? { userAgent } : {}), serviceWorkers: 'block' }); const page = await context.newPage(); + page.setDefaultTimeout(15000); activePage = page; const result = { name, errors: [], outside: [], httpErrors: [], visits: [] }; report.cases.push(result); page.on('pageerror', error => result.errors.push(error.message)); @@ -70,7 +77,7 @@ async function main() { 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); + if (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)); @@ -90,6 +97,18 @@ async function main() { 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(async () => { + AudioManager.stopMusic(false); + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + AudioManager.playAreaMusic('sanctuary'); + if (AudioManager.musicPlaying) throw Error('Music started in hidden page'); + await AudioManager.audioContext.suspend(); + delete document.hidden; + }); + if (phone) await page.touchscreen.tap(5, height / 2); + else await page.mouse.click(5, height / 2); + await page.waitForFunction(() => AudioManager.musicPlaying && AudioManager.currentArea === 'sanctuary' && AudioManager.musicNodes?.oscillators.length > 0); + result.audio.hiddenMusicRequestRecovered = true; await page.evaluate(() => { AudioManager.muted = true; mythicalGame.sound.setMute(true); }); await page.evaluate(async profile => { const state = window.GameState, game = window.mythicalGame; @@ -123,7 +142,8 @@ async function main() { 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); + result.lastTap = { point, x, y, rect, size }; + if (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}`); @@ -136,6 +156,16 @@ async function main() { return button ? hotfixScreenPoint(s, button) : null; }); if (greeting) await tap(greeting); + if (phone) { + // A saved Forest victory schedules the normal resident-arrival + // cinematic. Its intentional input suspension must finish first. + await page.waitForFunction(() => { + const s = mythicalGame.scene.keys.GameScene; + return !s.rescuedResidentArrivalScheduleTimer && !s.rescuedResidentArrivalActive && !s.mobileControls.isSuspended; + }, null, { timeout: 15000 }); + result.heldInputs ||= []; + result.heldInputs.push({ visit: visit + 1, cases: await verifyHeldTouch(page, context, { full: visit === 0 }) }); + } const position = await page.evaluate(() => { const s = mythicalGame.scene.keys.GameScene; s.player.body.reset(s.hubPortal.x, s.hubPortal.y + 45); @@ -148,7 +178,13 @@ async function main() { 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); } + if (phone) { + const button = await page.evaluate(() => { + const s = mythicalGame.scene.keys.GameScene; + return hotfixScreenPoint(s, s.mobileControls.actionButtons.interact.zone); + }); + assert(button, 'Touch Explore control missing'); await tap(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`) }); @@ -186,6 +222,8 @@ main().catch(async error => { 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, + input: { x: s.joystickX, y: s.joystickY, active: s.mobileControls?.joystickActive, suspended: s.mobileControls?.isSuspended, + source: s.mobileControls?.joystickInputSource, trace: window.heldTouchTrace }, 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, diff --git a/src/__tests__/AudioPlaybackLifecycle.test.js b/src/__tests__/AudioPlaybackLifecycle.test.js index a87981c2..d67751c8 100644 --- a/src/__tests__/AudioPlaybackLifecycle.test.js +++ b/src/__tests__/AudioPlaybackLifecycle.test.js @@ -102,6 +102,43 @@ test('a rejected mobile resume is contained and retriable, not an unhandled reje await Promise.resolve(); }); +test('music requested while hidden starts once after foreground gesture recovery', async () => { + const { manager, context, document } = fixture(); + manager.setupMobileAudioUnlock(); + document.hidden = true; + context.state = 'suspended'; + manager.playAreaMusic('sanctuary'); + expect(manager.musicPlaying).not.toBe(true); + document.hidden = false; + document.dispatchEvent(new Event('touchend')); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(manager.musicPlaying).toBe(true); + expect(manager.currentArea).toBe('sanctuary'); + const nodes = manager.musicNodes; + await manager.resume(); + expect(manager.musicNodes).toBe(nodes); +}); + +test('a pending resume cannot restart music after leaving the scene or muting', async () => { + const { manager, context, document } = fixture(); + document.hidden = true; + manager.playAreaMusic('sanctuary'); + document.hidden = false; + let resolve; + context.state = 'suspended'; + context.resume.mockImplementation(() => new Promise(done => { resolve = done; })); + const resuming = manager.resume(); + manager.stopMusic(false); + context.state = 'running'; + resolve(); + await resuming; + expect(manager.musicPlaying).not.toBe(true); + manager.muted = true; + manager.playAreaMusic('sanctuary'); + await manager.resume(); + expect(manager.musicPlaying).not.toBe(true); +}); + test('muted gestures do not consume the later audio unlock', async () => { const { manager, context, document } = fixture(); context.state = 'suspended'; manager.muted = true; diff --git a/src/__tests__/MobileControlPointerRegression.test.js b/src/__tests__/MobileControlPointerRegression.test.js index 658c39bf..9ef0a0c7 100644 --- a/src/__tests__/MobileControlPointerRegression.test.js +++ b/src/__tests__/MobileControlPointerRegression.test.js @@ -30,6 +30,7 @@ function loadMobileControls(environment = {}) { console: { log: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, devLog: jest.fn(), getJoystickVector: layoutSandbox.module.exports.getJoystickVector, + getSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), window: { matchMedia: jest.fn(() => ({ matches: false })), addEventListener: jest.fn(), @@ -210,6 +211,69 @@ describe('MobileControls pointer ownership', () => { expect(show).toHaveBeenCalledWith(true); }); + function visibleHeldControls() { + const MobileControls = loadMobileControls(); + const { scene } = createScene(); + const controls = new MobileControls(scene); + controls.isVisible = true; + controls.controlViewport = { width: 390, height: 844, top: 0, bottom: 0, left: 0, right: 0 }; + controls.joystickActive = true; + controls.activePointerId = 7; + controls.joystickInputSource = 'touch'; + return { controls, scene }; + } + + test('duplicate Phaser size notifications cannot cancel a held joystick', () => { + const { controls, scene } = visibleHeldControls(); + const hide = jest.spyOn(controls, 'hide').mockImplementation(() => {}); + jest.spyOn(controls, 'show').mockImplementation(() => {}); + for (let i = 0; i < 10; i += 1) controls.handleResize(); + expect(hide).not.toHaveBeenCalled(); + expect(controls.joystickActive).toBe(true); + expect(controls.activePointerId).toBe(7); + expect(scene.game.events.emit).not.toHaveBeenCalledWith('virtual-joystick', { x: 0, y: 0 }); + }); + + test('browser chrome height changes reflow only after the held finger is released', () => { + const { controls, scene } = visibleHeldControls(); + const hide = jest.spyOn(controls, 'hide').mockImplementation(() => {}); + const show = jest.spyOn(controls, 'show').mockImplementation(() => {}); + scene.scale.height = 800; + controls.handleResize(); + expect(hide).not.toHaveBeenCalled(); + expect(controls.joystickActive).toBe(true); + controls.resetJoystick(true); + expect(hide).toHaveBeenCalledTimes(1); + expect(show).toHaveBeenCalledTimes(1); + expect(controls.joystickActive).toBe(false); + expect(scene.game.events.emit).toHaveBeenCalledWith('virtual-joystick', { x: 0, y: 0 }); + }); + + test('rotation still releases input instead of preserving a stale direction', () => { + const { controls, scene } = visibleHeldControls(); + jest.spyOn(controls, 'show').mockImplementation(() => {}); + scene.scale.width = 844; + scene.scale.height = 390; + controls.handleResize(); + expect(controls.activePointerId).toBeNull(); + expect(controls.joystickActive).toBe(false); + }); + + test('safe-area changes reflow an idle dock and hiding cancels deferred reflow', () => { + const { controls, scene } = visibleHeldControls(); + const show = jest.spyOn(controls, 'show').mockImplementation(() => {}); + scene.scale.height = 800; + controls.handleResize(); + controls.hide(); + expect(show).not.toHaveBeenCalled(); + expect(controls.isVisible).toBe(false); + controls.isVisible = true; + scene.scale.height = 844; + controls.getSafeAreaInsets = () => ({ top: 0, bottom: 34, left: 0, right: 0 }); + controls.handleResize(); + expect(show).toHaveBeenCalledTimes(1); + }); + function attachControlFixtures(controls) { controls.joystickBase = { clear: jest.fn(), diff --git a/src/__tests__/MobileInputLifecycle.test.js b/src/__tests__/MobileInputLifecycle.test.js index 7b8a54bd..c46c78af 100644 --- a/src/__tests__/MobileInputLifecycle.test.js +++ b/src/__tests__/MobileInputLifecycle.test.js @@ -178,6 +178,31 @@ describe('MobileInputLifecycle behavior', () => { }); describe.each(INPUTS)('%s ownership', source => { + test('refreshes and a toolbar resize preserve the owner until a real release', () => { + const h = setup(source); + const thumb = h.controls.joystickThumb; + const originalY = h.controls.joystickCenterY; + h.start(); + h.drag(0, 35); + const held = h.vector(); + for (let i = 0; i < 8; i += 1) h.scene.scale.emit('resize'); + h.resize(390, 800); + jest.advanceTimersByTime(1000); + h.drag(0, 35); + expect(h.vector()).toEqual(held); + expect(h.controls.activePointerId).toBe(0); + expect(thumb.destroy).not.toHaveBeenCalled(); + h.send('up', 0, 0, 0); + expect(h.vector()).toEqual({ x: 0, y: 0 }); + expect(thumb.destroy).toHaveBeenCalledTimes(1); + expect(h.controls.joystickCenterY).toBeLessThan(originalY); + h.start(1); + h.drag(-35, 0, 1); + expect(h.vector().x).toBeLessThan(-0.8); + h.send('cancel', 1, 0, 0); + expect(h.vector()).toEqual({ x: 0, y: 0 }); + }); + test.each([ ['down', 0, 35, 0, 1], ['left', -35, 0, -1, 0], diff --git a/src/systems/AudioManager.js b/src/systems/AudioManager.js index 55b74ed2..9b24abde 100644 --- a/src/systems/AudioManager.js +++ b/src/systems/AudioManager.js @@ -2408,6 +2408,11 @@ class AudioManager { // Phaser's update emits UNLOCKED and releases sounds queued before the gesture. this.phaserSound.unlocked = true; } + // A scene can request its soundtrack while hidden. Unlocking the + // context alone cannot play music whose nodes were never created. + if (this.audioContext?.state === 'running' && !this.musicPlaying && this.requestedArea) { + this.playAreaMusic(this.requestedArea); + } if (!this.audioUnlocked) this.rearmAudioAfterInterruption(); return this.audioUnlocked; }); diff --git a/src/systems/MobileControls.js b/src/systems/MobileControls.js index e9a0d858..58917a47 100644 --- a/src/systems/MobileControls.js +++ b/src/systems/MobileControls.js @@ -17,6 +17,8 @@ class MobileControls { this.isVisible = false; this.isSuspended = false; this.dockBackground = null; + this.controlViewport = null; + this.pendingResize = false; // Joystick state this.joystickBase = null; @@ -240,6 +242,7 @@ class MobileControls { // Create action buttons (right side) this.createActionButtons(); + this.controlViewport = this.getControlViewport(); // Set up resize handler for screen rotation/resize this.resizeHandler = () => this.handleResize(); @@ -253,6 +256,12 @@ class MobileControls { }); } + getControlViewport() { + const { width, height } = this.scene.scale; + const { top = 0, bottom = 0, left = 0, right = 0 } = this.getSafeAreaInsets(); + return { width, height, top, bottom, left, right }; + } + /** * Handle screen resize (rotation, window resize, etc.) * Recreates controls at new scaled positions @@ -260,6 +269,22 @@ class MobileControls { handleResize() { if (!this.isVisible) return; + const viewport = this.getControlViewport(); + const previous = this.controlViewport; + // Phaser emits resize on bounds refreshes too, without changing size. + // Rebuilding here cancels the finger that is still holding the joystick. + if (previous && Object.keys(viewport).every(key => viewport[key] === previous[key])) { + this.pendingResize = false; + return; + } + const sameOrientation = previous && (previous.width > previous.height) === (viewport.width > viewport.height); + if (this.joystickActive && previous?.width === viewport.width && sameOrientation) { + // Let a browser toolbar settle without replacing controls under a finger. + // Rotation/width changes still cancel stale input immediately. + this.pendingResize = true; + return; + } + // Check if still mobile after resize this.isMobile = this.detectMobile(); const explicitlyForced = this.scene?.forceMobileControls === true; @@ -384,6 +409,7 @@ class MobileControls { * Hide mobile controls */ hide() { + this.pendingResize = false; // ALWAYS clean up event handlers first (even if not visible) // This prevents orphaned handlers when state gets out of sync this.cleanupEventHandlers(); @@ -1060,6 +1086,10 @@ class MobileControls { // Emit zero movement immediately this.scene.game.events.emit('virtual-joystick', { x: 0, y: 0 }); + if (this.pendingResize && this.isVisible) { + this.pendingResize = false; + this.handleResize(); + } } /** From b83bbde4e2e8f0b4e11643231d9b135d46e99305 Mon Sep 17 00:00:00 2001 From: TechEvolveAI Date: Thu, 24 Sep 2026 16:46:49 +0100 Subject: [PATCH 2/3] Keep cosmetic creature motion out of physics position --- .../HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md | 23 ++++++- scripts/smoke-explore-audio-hotfix.cjs | 44 ++++++++----- .../CreatureAnimationPhysicsOwnership.test.js | 47 ++++++++++++++ src/systems/CreatureAnimationController.js | 61 +++++++++++-------- 4 files changed, 135 insertions(+), 40 deletions(-) create mode 100644 src/__tests__/CreatureAnimationPhysicsOwnership.test.js diff --git a/docs/planning/HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md b/docs/planning/HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md index 356d32db..6a406c10 100644 --- a/docs/planning/HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md +++ b/docs/planning/HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md @@ -1,7 +1,7 @@ # Held joystick and missing music incident Baseline investigated: production/main `d6a8c168f9b2ae869731cdd24fc885b6fc6beb4c`. -Scope: two reproduced lifecycle defects; no save, backend, level, artwork, +Scope: three reproduced input/audio/animation defects; no save, backend, level, artwork, economy, generation or broader unfinished feature changes. ## What is established @@ -35,6 +35,15 @@ failed before repair: the audio context was running, but music was not playing. The sound preference being on is not evidence that a soundtrack is playing. This does not yet establish the physical phone's entire no-sound cause. +The extended repeated-gate journey also exposed a separate movement conflict. +`GameScene` attaches `CreatureAnimationController` to the physics-controlled +player. Its idle bounce/wiggle/sniff tweens wrote absolute world coordinates +captured before the player moved. Instrumenting the built player's position +recorded a Phaser tween moving it from y=1645 back to y=880, away from Explore. +This was not residual joystick input: its vector and body velocity were zero. +The existing controller tests used a hand-copied subset rather than its actual +animation implementation and did not cover this conflict. + ## Narrow repair - Ignore duplicate layout notifications, including unchanged safe-area insets. @@ -43,6 +52,9 @@ This does not yet establish the physical phone's entire no-sound cause. still release input, preventing an involuntary stuck direction. - After audio recovery, start a current deferred soundtrack exactly once. Do not restart a playing track, resurrect a departed scene, or override mute. +- Express cosmetic positional motion as small scale changes on physics-backed + creatures. Keep world movement for non-physics display sprites. Test the real + controller's 17 behaviors and completion callbacks against position ownership. - Add a separate CI job for held touch, audio recovery and repeated Explore journeys. Existing release checks remain unchanged. @@ -65,6 +77,15 @@ arrival cinematic. Its trace showed `playRescuedResidentArrival -> suspend`, an intentional input pause. The harness now waits for that real scene to finish; the production cinematic was not disabled or changed. +The first CI run also demonstrated that the daily greeting can arrive later than +the smoke's one-off check. The harness now observes the onboarding queue, taps +the real greeting button when present, and awaits normal gameplay. It does not +suppress or skip production state. Every gate visit explicitly runs a real +creature reaction before moving the actor, then checks that it remains nearby. + +Animation pre-fix evidence: `.visual-review/gate-physics-axes/result.json` and +`/private/tmp/mythical-animation-ownership-red.log` (nine failing behaviors). + Repeat from the exact candidate checkout: ```sh diff --git a/scripts/smoke-explore-audio-hotfix.cjs b/scripts/smoke-explore-audio-hotfix.cjs index 0aefb702..2a3b1bb2 100644 --- a/scripts/smoke-explore-audio-hotfix.cjs +++ b/scripts/smoke-explore-audio-hotfix.cjs @@ -149,25 +149,36 @@ async function main() { 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); - if (phone) { - // A saved Forest victory schedules the normal resident-arrival - // cinematic. Its intentional input suspension must finish first. - await page.waitForFunction(() => { + // Scene-clock delivery can put the daily greeting after the first + // frame. Dismiss it through real input, then await normal play. + let ready = false; + const readyDeadline = Date.now() + 25000; + while (!ready && Date.now() < readyDeadline) { + const state = await page.evaluate(() => { const s = mythicalGame.scene.keys.GameScene; - return !s.rescuedResidentArrivalScheduleTimer && !s.rescuedResidentArrivalActive && !s.mobileControls.isSuspended; - }, null, { timeout: 15000 }); + const onboarding = window.OnboardingManager; + const button = s.greetingElements?.find(o => o.input?.enabled); + return { + greeting: button ? hotfixScreenPoint(s, button) : null, + ready: onboarding?.scene === s && onboarding.flowContext && !onboarding.isProcessing && + !s.rescuedResidentArrivalScheduleTimer && !s.rescuedResidentArrivalActive && + !s.mobileControls?.isSuspended + }; + }); + if (state.greeting) await tap(state.greeting); + ready = Boolean(state.ready && !state.greeting); + if (!ready) await page.waitForTimeout(150); + } + assert(ready, 'Ordinary Sanctuary arrival did not hand back control'); + if (phone) { result.heldInputs ||= []; result.heldInputs.push({ visit: visit + 1, cases: await verifyHeldTouch(page, context, { full: visit === 0 }) }); } const position = await page.evaluate(() => { const s = mythicalGame.scene.keys.GameScene; + // Exercise the real cosmetic tween while physics takes the + // actor elsewhere. It must not restore its old world position. + s.creatureAnimationController.playReaction('feed'); 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); @@ -177,7 +188,12 @@ async function main() { }); 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); + await page.waitForTimeout(1100); + const portalDistance = await page.evaluate(() => { + const s = mythicalGame.scene.keys.GameScene; + return Math.hypot(s.player.x - s.hubPortal.x, s.player.y - s.hubPortal.y); + }); + assert(portalDistance < 120, `Idle animation displaced player from Explore: ${portalDistance}`); if (phone) { const button = await page.evaluate(() => { const s = mythicalGame.scene.keys.GameScene; diff --git a/src/__tests__/CreatureAnimationPhysicsOwnership.test.js b/src/__tests__/CreatureAnimationPhysicsOwnership.test.js new file mode 100644 index 00000000..f55f77e9 --- /dev/null +++ b/src/__tests__/CreatureAnimationPhysicsOwnership.test.js @@ -0,0 +1,47 @@ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const source = fs.readFileSync(path.join(__dirname, '../systems/CreatureAnimationController.js'), 'utf8'); +const scope = { module: { exports: {} }, window: {}, console: { log() {}, warn() {} } }; +vm.runInNewContext(source.replace('export default CreatureAnimationController;', ''), scope); +const CreatureAnimationController = scope.module.exports; + +function harness(physics = true) { + const configs = []; + const add = config => { configs.push(config); return { stop() {} }; }; + const scene = { + tweens: { add, chain: add }, + time: { addEvent: () => ({ remove() {} }) } + }; + const sprite = { x: 100, y: 200, scaleX: 1, scaleY: 1, angle: 0, body: physics ? {} : null }; + return { controller: new CreatureAnimationController(scene, sprite, {}), sprite, configs }; +} + +describe('real creature animations respect physics position ownership', () => { + test.each([ + 'yawn', 'excited_bounce', 'sad_droop', 'look_around', 'head_tilt', + 'bounce', 'spin', 'wiggle', 'slow_blink', 'stretch', 'shiver', + 'contemplate', 'nuzzle', 'sniff', 'slow_nod', 'tail_wag', 'sigh' + ])('%s cannot move a physics-controlled creature or restore stale coordinates', behavior => { + const { controller, sprite, configs } = harness(); + controller.triggerBehavior(behavior); + expect(configs.length).toBeGreaterThan(1); + for (const config of configs.flatMap(config => [config, ...(config.tweens || [])])) { + expect(config).not.toHaveProperty('x'); + expect(config).not.toHaveProperty('y'); + } + sprite.x = 1200; + sprite.y = 1645; + for (const config of configs) config.onComplete?.(); + expect({ x: sprite.x, y: sprite.y }).toEqual({ x: 1200, y: 1645 }); + controller.destroy(); + }); + + test('a non-physics presentation sprite retains its positional bounce', () => { + const { controller, configs } = harness(false); + controller.triggerBehavior('bounce'); + expect(configs.at(-1).y).toBe(188); + controller.destroy(); + }); +}); diff --git a/src/systems/CreatureAnimationController.js b/src/systems/CreatureAnimationController.js index 2293c19c..691bac6f 100644 --- a/src/systems/CreatureAnimationController.js +++ b/src/systems/CreatureAnimationController.js @@ -292,6 +292,17 @@ class CreatureAnimationController { // Animation Implementations // ========================================== + positionAnimation(axis, offset) { + // Physics owns world position. Express idle motion as tissue deformation + // on playable creatures, retaining positional motion for display sprites. + if (this.sprite?.body) { + const scale = axis === 'x' ? 'scaleX' : 'scaleY'; + const base = axis === 'x' ? this.baseScaleX : this.baseScaleY; + return { [scale]: base * (1 + offset * (axis === 'x' ? 1 : -1) / 100) }; + } + return { [axis]: (axis === 'x' ? this.baseX : this.baseY) + offset }; + } + playYawnAnimation() { if (this.isDestroyed || !this.sprite) return; @@ -334,10 +345,10 @@ class CreatureAnimationController { const tween = this.scene.tweens.chain({ targets: this.sprite, tweens: [ - { y: this.baseY - 15, scaleX: this.baseScaleX * 0.9, scaleY: this.baseScaleY * 1.1, duration: 150, ease: 'Quad.easeOut' }, - { y: this.baseY, scaleX: this.baseScaleX * 1.05, scaleY: this.baseScaleY * 0.95, duration: 150, ease: 'Bounce.easeOut' }, - { y: this.baseY - 20, scaleX: this.baseScaleX * 0.9, scaleY: this.baseScaleY * 1.1, duration: 150, ease: 'Quad.easeOut' }, - { y: this.baseY, scaleX: this.baseScaleX, scaleY: this.baseScaleY, duration: 200, ease: 'Bounce.easeOut' } + { ...this.positionAnimation('y', -15), scaleX: this.baseScaleX * 0.9, scaleY: this.baseScaleY * 1.1, duration: 150, ease: 'Quad.easeOut' }, + { ...this.positionAnimation('y', 0), scaleX: this.baseScaleX * 1.05, scaleY: this.baseScaleY * 0.95, duration: 150, ease: 'Bounce.easeOut' }, + { ...this.positionAnimation('y', -20), scaleX: this.baseScaleX * 0.9, scaleY: this.baseScaleY * 1.1, duration: 150, ease: 'Quad.easeOut' }, + { ...this.positionAnimation('y', 0), scaleX: this.baseScaleX, scaleY: this.baseScaleY, duration: 200, ease: 'Bounce.easeOut' } ], onComplete: () => { this.returnToIdle(); @@ -355,7 +366,7 @@ class CreatureAnimationController { const tween = this.scene.tweens.add({ targets: this.sprite, - y: this.baseY + 5, + ...this.positionAnimation('y', 5), scaleY: this.baseScaleY * 0.92, angle: -3, duration: 800, @@ -404,7 +415,7 @@ class CreatureAnimationController { const tween = this.scene.tweens.add({ targets: this.sprite, - y: this.baseY - 12, + ...this.positionAnimation('y', -12), duration: 200, yoyo: true, repeat: 2, @@ -437,13 +448,13 @@ class CreatureAnimationController { const tween = this.scene.tweens.add({ targets: this.sprite, - x: this.baseX + 3, + ...this.positionAnimation('x', 3), duration: 50, yoyo: true, repeat: 6, ease: 'Sine.easeInOut', onComplete: () => { - this.sprite.x = this.baseX; + if (!this.sprite.body) this.sprite.x = this.baseX; this.returnToIdle(); } }); @@ -488,14 +499,14 @@ class CreatureAnimationController { const tween = this.scene.tweens.add({ targets: this.sprite, - x: this.baseX + 2, + ...this.positionAnimation('x', 2), angle: 1, duration: 40, yoyo: true, repeat: 8, ease: 'Sine.easeInOut', onComplete: () => { - this.sprite.x = this.baseX; + if (!this.sprite.body) this.sprite.x = this.baseX; this.sprite.angle = 0; this.returnToIdle(); } @@ -510,9 +521,9 @@ class CreatureAnimationController { const tween = this.scene.tweens.chain({ targets: this.sprite, tweens: [ - { y: this.baseY - 3, duration: 800, ease: 'Sine.easeOut' }, - { y: this.baseY - 3, duration: 1500 }, // Hold - { y: this.baseY, duration: 600, ease: 'Sine.easeIn' } + { ...this.positionAnimation('y', -3), duration: 800, ease: 'Sine.easeOut' }, + { ...this.positionAnimation('y', -3), duration: 1500 }, // Hold + { ...this.positionAnimation('y', 0), duration: 600, ease: 'Sine.easeIn' } ], onComplete: () => this.returnToIdle() }); @@ -526,10 +537,10 @@ class CreatureAnimationController { const tween = this.scene.tweens.chain({ targets: this.sprite, tweens: [ - { x: this.baseX + 8, angle: 5, duration: 200 }, - { x: this.baseX - 5, angle: -3, duration: 200 }, - { x: this.baseX + 5, angle: 3, duration: 200 }, - { x: this.baseX, angle: 0, duration: 150 } + { ...this.positionAnimation('x', 8), angle: 5, duration: 200 }, + { ...this.positionAnimation('x', -5), angle: -3, duration: 200 }, + { ...this.positionAnimation('x', 5), angle: 3, duration: 200 }, + { ...this.positionAnimation('x', 0), angle: 0, duration: 150 } ], onComplete: () => this.returnToIdle() }); @@ -543,10 +554,10 @@ class CreatureAnimationController { const tween = this.scene.tweens.chain({ targets: this.sprite, tweens: [ - { y: this.baseY - 2, scaleY: this.baseScaleY * 1.02, duration: 150 }, - { y: this.baseY, scaleY: this.baseScaleY * 0.98, duration: 100 }, - { y: this.baseY - 2, scaleY: this.baseScaleY * 1.02, duration: 150 }, - { y: this.baseY, scaleY: this.baseScaleY, duration: 150 } + { ...this.positionAnimation('y', -2), scaleY: this.baseScaleY * 1.02, duration: 150 }, + { ...this.positionAnimation('y', 0), scaleY: this.baseScaleY * 0.98, duration: 100 }, + { ...this.positionAnimation('y', -2), scaleY: this.baseScaleY * 1.02, duration: 150 }, + { ...this.positionAnimation('y', 0), scaleY: this.baseScaleY, duration: 150 } ], onComplete: () => this.returnToIdle() }); @@ -560,10 +571,10 @@ class CreatureAnimationController { const tween = this.scene.tweens.chain({ targets: this.sprite, tweens: [ - { y: this.baseY + 3, duration: 400, ease: 'Sine.easeOut' }, - { y: this.baseY, duration: 400, ease: 'Sine.easeIn' }, - { y: this.baseY + 3, duration: 400, ease: 'Sine.easeOut' }, - { y: this.baseY, duration: 400, ease: 'Sine.easeIn' } + { ...this.positionAnimation('y', 3), duration: 400, ease: 'Sine.easeOut' }, + { ...this.positionAnimation('y', 0), duration: 400, ease: 'Sine.easeIn' }, + { ...this.positionAnimation('y', 3), duration: 400, ease: 'Sine.easeOut' }, + { ...this.positionAnimation('y', 0), duration: 400, ease: 'Sine.easeIn' } ], onComplete: () => this.returnToIdle() }); From 5791de0b3daf21f18dbbcc426ae04639d5fee342 Mon Sep 17 00:00:00 2001 From: TechEvolveAI Date: Thu, 24 Sep 2026 17:00:30 +0100 Subject: [PATCH 3/3] Wait for real gate acknowledgement before releasing test key --- .../HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md | 7 +++++++ scripts/smoke-explore-audio-hotfix.cjs | 14 +++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/planning/HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md b/docs/planning/HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md index 6a406c10..177910b6 100644 --- a/docs/planning/HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md +++ b/docs/planning/HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md @@ -83,6 +83,13 @@ the real greeting button when present, and awaits normal gameplay. It does not suppress or skip production state. Every gate visit explicitly runs a real creature reaction before moving the actor, then checks that it remains nearby. +A subsequent clean CI run passed all 22 mobile held-input cases and eight mobile +gate visits, but missed a 100ms desktop Space pulse. The player remained correctly +at (1200,1645), near Explore, without cooldown or overlay. Phaser clears JustDown +on key-up, so the harness now holds the native key until the real game acknowledges +the transition, with a five-second timeout and key-up in finally. No direct game +interaction method or scene start replaces the keyboard path. + Animation pre-fix evidence: `.visual-review/gate-physics-axes/result.json` and `/private/tmp/mythical-animation-ownership-red.log` (nine failing behaviors). diff --git a/scripts/smoke-explore-audio-hotfix.cjs b/scripts/smoke-explore-audio-hotfix.cjs index 2a3b1bb2..45183f3b 100644 --- a/scripts/smoke-explore-audio-hotfix.cjs +++ b/scripts/smoke-explore-audio-hotfix.cjs @@ -201,7 +201,19 @@ async function main() { }); assert(button, 'Touch Explore control missing'); await tap(button); } - else await page.keyboard.press('Space', { delay: 100 }); + else { + // JustDown is cleared by key-up. Software-rendered CI may not + // update within a 100ms pulse; hold through real acknowledgement. + await page.keyboard.down('Space'); + try { + await page.waitForFunction(() => { + const s = mythicalGame.scene.keys.GameScene; + return s.hubEntryTransition || s.hubEntryCooldown || mythicalGame.scene.isActive('HubWorldScene'); + }, null, { timeout: 5000 }); + } finally { + await page.keyboard.up('Space'); + } + } 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') {