Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/gameplay-contracts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions docs/planning/HELD_TOUCH_AUDIO_INCIDENT_2026-09-24.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Held joystick and missing music incident

Baseline investigated: production/main `d6a8c168f9b2ae869731cdd24fc885b6fc6beb4c`.
Scope: three reproduced input/audio/animation 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.

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.
- 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.
- 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.

## 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.

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.

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).

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.
77 changes: 77 additions & 0 deletions scripts/lib/held-touch-regression.cjs
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading