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
7 changes: 7 additions & 0 deletions .github/workflows/e2e-nightly-full-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/e2e-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion scripts/e2e-external-webserver.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
31 changes: 31 additions & 0 deletions scripts/ensure-lfs-assets.mjs
Original file line number Diff line number Diff line change
@@ -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'),
);
}
9 changes: 9 additions & 0 deletions server/src/server/services/publishService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
22 changes: 20 additions & 2 deletions src/PlayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(), []);
Expand Down Expand Up @@ -112,13 +127,16 @@ export default function PlayApp() {
<div className="play-root" data-testid="play-root">
<AudioDebugOverlay />
<div className="play-frame" data-testid="play-frame" style={world ? { width: world.width, height: world.height } : undefined}>
{hasStarted ? <PhaserGame currentActiveScene={() => setSceneReady(true)} /> : null}
<PhaserGame currentActiveScene={() => setSceneReady(true)} />
{!hasStarted ? (
<button
className="play-start-gate"
data-testid="play-start-gate"
type="button"
onClick={() => setHasStarted(true)}
onClick={() => {
unlockAudioFromStartGesture();
setHasStarted(true);
}}
>
Click to Start
</button>
Expand Down
64 changes: 64 additions & 0 deletions tests/e2e/audio-runtime.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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',
Expand Down
62 changes: 62 additions & 0 deletions tests/server/publish-github-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
20 changes: 19 additions & 1 deletion tests/ui/play-app-start-gate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,15 @@ describe('PlayApp start gate', () => {

afterEach(() => {
cleanup();
delete (window as any).__phaserGame;
vi.unstubAllGlobals();
});

it('shows a click-to-start gate before mounting the runtime', async () => {
render(<PlayApp />);

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');
});

Expand All @@ -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(<PlayApp />);

fireEvent.click(await screen.findByTestId('play-start-gate'));

expect(unlock).toHaveBeenCalledTimes(1);
expect(resume).toHaveBeenCalledTimes(1);
});
});
Loading