diff --git a/.github/workflows/e2e-nightly-full-matrix.yml b/.github/workflows/e2e-nightly-full-matrix.yml
index 901ee6f4..16156777 100644
--- a/.github/workflows/e2e-nightly-full-matrix.yml
+++ b/.github/workflows/e2e-nightly-full-matrix.yml
@@ -28,8 +28,15 @@ jobs:
shard: [1, 2, 3, 4, 5, 6, 7, 8]
shards: [8]
steps:
+ - name: Install Git LFS
+ run: |
+ apt-get update
+ apt-get install -y git-lfs
+
- name: Checkout
uses: actions/checkout@v7
+ with:
+ lfs: true
- name: Setup Node
uses: actions/setup-node@v6
diff --git a/.github/workflows/e2e-pr.yml b/.github/workflows/e2e-pr.yml
index 426d74ce..22341f27 100644
--- a/.github/workflows/e2e-pr.yml
+++ b/.github/workflows/e2e-pr.yml
@@ -24,8 +24,15 @@ jobs:
shard: [1, 2]
shards: [2]
steps:
+ - name: Install Git LFS
+ run: |
+ apt-get update
+ apt-get install -y git-lfs
+
- name: Checkout
uses: actions/checkout@v7
+ with:
+ lfs: true
- name: Setup Node
uses: actions/setup-node@v6
diff --git a/package.json b/package.json
index 4d80e56a..a4631d38 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,8 @@
"docs:screenshots:app": "node --import tsx scripts/docs/capture-docs-screenshots.mjs",
"docs:screenshots": "npm run docs:screenshots:storybook && npm run docs:screenshots:app",
"docs:stage": "node scripts/docs/stage-docs-for-pages.mjs",
+ "prebuild": "node scripts/ensure-lfs-assets.mjs",
+ "prebuild-nolog": "node scripts/ensure-lfs-assets.mjs",
"server:dev": "node --import tsx server/src/index.ts",
"server:start": "node --import tsx server/src/index.ts",
"prisma:generate": "prisma generate",
diff --git a/scripts/e2e-external-webserver.cjs b/scripts/e2e-external-webserver.cjs
index d61d99e5..45cde1ef 100644
--- a/scripts/e2e-external-webserver.cjs
+++ b/scripts/e2e-external-webserver.cjs
@@ -167,7 +167,22 @@ function createLifecycleTracker({ command, host, port, logDir }) {
}
function createManagedServerError(message, logDir) {
- return new Error(`${message}. Managed server logs: ${logDir}`);
+ const readTail = (name) => {
+ try {
+ const text = fs.readFileSync(path.join(logDir, name), 'utf8');
+ const lines = text.trimEnd().split(/\r?\n/);
+ return lines.slice(-80).join('\n');
+ } catch {
+ return '';
+ }
+ };
+ const stdoutTail = readTail('stdout.log');
+ const stderrTail = readTail('stderr.log');
+ const detail = [
+ stdoutTail ? `\n--- managed server stdout tail ---\n${stdoutTail}` : '',
+ stderrTail ? `\n--- managed server stderr tail ---\n${stderrTail}` : '',
+ ].join('');
+ return new Error(`${message}. Managed server logs: ${logDir}${detail}`);
}
async function startManagedExternalWebServer({
diff --git a/scripts/ensure-lfs-assets.mjs b/scripts/ensure-lfs-assets.mjs
new file mode 100644
index 00000000..2ede21bd
--- /dev/null
+++ b/scripts/ensure-lfs-assets.mjs
@@ -0,0 +1,31 @@
+import { readFileSync } from 'node:fs';
+
+const LFS_POINTER_PREFIX = 'version https://git-lfs.github.com/spec/v1';
+const REQUIRED_ASSETS = [
+ 'assets/demo-pack/audio/Simulacra-chosic.com_.mp3',
+ 'assets/demo-pack/audio/punch-deck-the-soul-crushing-monotony-of-isolation-instrumental-mix(chosic.com).mp3',
+ 'assets/demo-pack/audio/sb_indreams(chosic.com).mp3',
+];
+
+function isPointer(path) {
+ try {
+ return readFileSync(path, 'utf8').slice(0, LFS_POINTER_PREFIX.length) === LFS_POINTER_PREFIX;
+ } catch {
+ return false;
+ }
+}
+
+function pointerAssets() {
+ return REQUIRED_ASSETS.filter((assetPath) => isPointer(assetPath));
+}
+
+const pointers = pointerAssets();
+if (pointers.length > 0) {
+ throw new Error(
+ [
+ '[phaserforge] Git LFS assets are pointer files, not playable audio bytes.',
+ `Run \`git lfs pull --include="${REQUIRED_ASSETS.join(',')}"\` before building.`,
+ `Pointer assets: ${pointers.join(', ')}`,
+ ].join('\n'),
+ );
+}
diff --git a/server/src/server/services/publishService.ts b/server/src/server/services/publishService.ts
index 1ef4ad7a..e049a912 100644
--- a/server/src/server/services/publishService.ts
+++ b/server/src/server/services/publishService.ts
@@ -144,6 +144,12 @@ function decodeEmbeddedDataUrl(dataUrl: string): Uint8Array | null {
}
}
+function isGitLfsPointer(bytes: Uint8Array): boolean {
+ const prefix = 'version https://git-lfs.github.com/spec/v1';
+ if (bytes.length < prefix.length) return false;
+ return Buffer.from(bytes.subarray(0, prefix.length)).toString('utf8') === prefix;
+}
+
async function materializeProjectForPublish(
repositories: Repositories,
userId: string,
@@ -179,6 +185,7 @@ async function materializeProjectForPublish(
if (!copiedPathSources.has(relPath)) {
try {
const bytes = new Uint8Array(await fs.readFile(absPath));
+ if (isGitLfsPointer(bytes)) return null;
files.push({ path: relPath, bytes });
copiedPathSources.add(relPath);
} catch {
@@ -190,12 +197,14 @@ async function materializeProjectForPublish(
if (source.kind === 'embedded') {
const bytes = decodeEmbeddedDataUrl(source.dataUrl);
if (!bytes) return null;
+ if (isGitLfsPointer(bytes)) return null;
const relPath = allocatePath(source, fallbackBase);
files.push({ path: relPath, bytes });
return { kind: 'path', path: relPath, ...(source.originalName ? { originalName: source.originalName } : {}), ...(source.mimeType ? { mimeType: source.mimeType } : {}) };
}
const asset = await repositories.assets.findByIdForUser(source.assetId, userId);
if (!asset) return null;
+ if (isGitLfsPointer(asset.bytes)) return null;
const relPath = allocatePath(
{ kind: 'cloud', assetId: source.assetId, originalName: source.originalName ?? asset.originalName ?? undefined, mimeType: source.mimeType ?? asset.mimeType ?? undefined },
fallbackBase,
diff --git a/src/PlayApp.tsx b/src/PlayApp.tsx
index 7e04b397..8b547dfd 100644
--- a/src/PlayApp.tsx
+++ b/src/PlayApp.tsx
@@ -24,6 +24,21 @@ function readYamlUrl(): string | null {
return typeof globalVal === 'string' && globalVal.trim().length > 0 ? globalVal.trim() : null;
}
+function unlockAudioFromStartGesture(): void {
+ const game = (window as any).__phaserGame;
+ const sound = game?.sound;
+ try {
+ sound?.unlock?.();
+ } catch {
+ // ignore
+ }
+ try {
+ void sound?.context?.resume?.();
+ } catch {
+ // ignore
+ }
+}
+
export default function PlayApp() {
const gameId = useMemo(() => readPlayGameId(), []);
const yamlUrl = useMemo(() => readYamlUrl(), []);
@@ -112,13 +127,16 @@ export default function PlayApp() {
- {hasStarted ?
setSceneReady(true)} /> : null}
+ setSceneReady(true)} />
{!hasStarted ? (
diff --git a/tests/e2e/audio-runtime.spec.ts b/tests/e2e/audio-runtime.spec.ts
index 97170d98..1ac41d07 100644
--- a/tests/e2e/audio-runtime.spec.ts
+++ b/tests/e2e/audio-runtime.spec.ts
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import { dismissViewHint, getSceneSnapshot, getState, openSceneScope, seedProject } from './helpers';
import { sampleProject } from '../../src/model/sampleProject';
import { createEmptyProject } from '../../src/model/emptyProject';
+import { serializeProjectToYaml } from '../../src/model/serialization';
test.setTimeout(120000);
@@ -101,6 +102,69 @@ test('entering play mode eventually starts delayed path-backed demo-pack music @
}, { timeout: 15000 }).toBeGreaterThan(0);
});
+test('published play page starts path-backed demo-pack music @slow', async ({ page }) => {
+ const sceneId = sampleProject.initialSceneId;
+ const project = {
+ ...sampleProject,
+ audio: {
+ sounds: {
+ theme: {
+ id: 'theme',
+ source: {
+ kind: 'path',
+ path: 'assets/demo-pack/audio/Simulacra-chosic.com_.mp3',
+ originalName: 'Simulacra-chosic.com_.mp3',
+ mimeType: 'audio/mpeg',
+ },
+ },
+ },
+ },
+ scenes: {
+ ...sampleProject.scenes,
+ [sceneId]: {
+ ...sampleProject.scenes[sceneId],
+ music: { assetId: 'theme', loop: true, volume: 0.65, fadeMs: 250 },
+ },
+ },
+ };
+
+ await page.route('**/game.yaml', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/x-yaml',
+ body: serializeProjectToYaml(project as any),
+ });
+ });
+
+ await page.goto('./?yamlUrl=./game.yaml&audioDebug=1');
+ await expect(page.getByTestId('play-start-gate')).toBeVisible();
+ await page.getByTestId('play-start-gate').click();
+
+ await expect.poll(async () => {
+ const snap = await getSceneSnapshot<{
+ audio?: { musicAssetId?: string };
+ audioPlayback?: { musicIsPlaying?: boolean };
+ audioDebug?: { contextState?: string; outputRange?: number; usingWebAudio?: boolean; currentMusicResolvedUrl?: string };
+ }>(page);
+ return {
+ music: snap?.audio?.musicAssetId,
+ isPlaying: Boolean(snap?.audioPlayback?.musicIsPlaying),
+ contextState: snap?.audioDebug?.contextState ?? null,
+ usingWebAudio: Boolean(snap?.audioDebug?.usingWebAudio),
+ };
+ }, { timeout: 15000 }).toEqual({
+ music: 'theme',
+ isPlaying: true,
+ contextState: 'running',
+ usingWebAudio: true,
+ });
+
+ await expect.poll(async () => {
+ const snap = await getSceneSnapshot<{ audioDebug?: { outputRange?: number } }>(page);
+ return Number(snap?.audioDebug?.outputRange ?? 0);
+ }, { timeout: 15000 }).toBeGreaterThan(0);
+});
+
[
{
assetId: 'simulacra-chosic-com',
diff --git a/tests/server/publish-github-pages.test.ts b/tests/server/publish-github-pages.test.ts
index 39d5e889..66bc8a0c 100644
--- a/tests/server/publish-github-pages.test.ts
+++ b/tests/server/publish-github-pages.test.ts
@@ -657,4 +657,66 @@ describe('publish github pages', () => {
.find((content) => content.includes('initialSceneId'));
expect(yamlBlob).toContain('path: assets/demo-pack/audio/Simulacra-chosic.com_.mp3');
});
+
+ it('publish rejects repo path-backed audio when the file is only a Git LFS pointer', async () => {
+ const { app, repositories } = makeApp();
+ const agent = request.agent(app);
+ const { userId, csrf } = await signup(agent);
+ await linkGithub(repositories, userId);
+ await addGame(repositories, userId);
+
+ const game = await repositories.games.findByIdForUser('g1', userId);
+ if (!game) throw new Error('expected game');
+ game.project.audio.sounds.theme = {
+ id: 'theme',
+ source: {
+ kind: 'path',
+ path: 'assets/demo-pack/audio/Simulacra-chosic.com_.mp3',
+ originalName: 'Simulacra-chosic.com_.mp3',
+ mimeType: 'audio/mpeg',
+ },
+ } as any;
+ game.project.scenes.s1.music = { assetId: 'theme', loop: true, volume: 0.8 };
+ await repositories.games.updateForUser('g1', userId, {
+ project: game.project,
+ updatedAt: '2026-06-04T01:00:00.000Z',
+ });
+
+ await fs.mkdir(path.join(tempDir, 'assets', 'demo-pack', 'audio'), { recursive: true });
+ await fs.writeFile(
+ path.join(tempDir, 'assets', 'demo-pack', 'audio', 'Simulacra-chosic.com_.mp3'),
+ [
+ 'version https://git-lfs.github.com/spec/v1',
+ 'oid sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ 'size 12345',
+ '',
+ ].join('\n'),
+ );
+
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url === 'https://api.github.com/repos/alice/zoof') {
+ if (!init?.method || init.method === 'GET') return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404 });
+ }
+ if (url === 'https://api.github.com/user/repos') {
+ return new Response(JSON.stringify({ name: 'zoof', full_name: 'alice/zoof', default_branch: 'main' }), { status: 201 });
+ }
+ if (url === 'https://api.github.com/repos/alice/zoof/pages') {
+ if (!init?.method || init.method === 'GET') return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404 });
+ return new Response(JSON.stringify({ html_url: 'https://alice.github.io/zoof' }), { status: 201 });
+ }
+ throw new Error(`Unhandled fetch ${url}`);
+ }) as any,
+ );
+
+ const res = await agent
+ .post('/api/v1/publish/github-pages')
+ .set('x-csrf-token', csrf)
+ .send({ gameId: 'g1', repo: 'zoof' })
+ .expect(400);
+
+ expect(res.body).toEqual({ error: 'cloud_asset_missing' });
+ });
});
diff --git a/tests/ui/play-app-start-gate.test.tsx b/tests/ui/play-app-start-gate.test.tsx
index 5903ee9c..39e0ab22 100644
--- a/tests/ui/play-app-start-gate.test.tsx
+++ b/tests/ui/play-app-start-gate.test.tsx
@@ -75,6 +75,7 @@ describe('PlayApp start gate', () => {
afterEach(() => {
cleanup();
+ delete (window as any).__phaserGame;
vi.unstubAllGlobals();
});
@@ -82,7 +83,7 @@ describe('PlayApp start gate', () => {
render();
expect((await screen.findByTestId('play-start-gate')).textContent).toContain('Click to Start');
- expect(screen.queryByTestId('mock-phaser-game')).toBeNull();
+ expect(await screen.findByTestId('mock-phaser-game')).not.toBeNull();
expect(eventBus.emit).not.toHaveBeenCalledWith('runtime:load-project', expect.anything(), expect.anything(), 'play');
});
@@ -99,4 +100,21 @@ describe('PlayApp start gate', () => {
expect(screen.queryByTestId('play-start-gate')).toBeNull();
});
});
+
+ it('uses the start click to unlock the mounted Phaser audio context', async () => {
+ const unlock = vi.fn();
+ const resume = vi.fn();
+ (window as any).__phaserGame = {
+ sound: {
+ unlock,
+ context: { resume },
+ },
+ };
+ render();
+
+ fireEvent.click(await screen.findByTestId('play-start-gate'));
+
+ expect(unlock).toHaveBeenCalledTimes(1);
+ expect(resume).toHaveBeenCalledTimes(1);
+ });
});