diff --git a/.plans/projectspec-canonical-yaml-adapter-plan.md b/.plans/projectspec-canonical-yaml-adapter-plan.md
deleted file mode 100644
index abf799fb..00000000
--- a/.plans/projectspec-canonical-yaml-adapter-plan.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# ProjectSpec Canonical, YAML Adapter Plan
-
-Status: proposed plan; implementation not started.
-
-## Durable Rule
-
-`ProjectSpec` is the canonical project model for editor state, persistence, publish, and runtime compilation. YAML remains a supported human-readable import/export and compatibility adapter, but should not be required for internal persistence or publishing.
-
-## Goals
-
-- Publish games directly from validated `ProjectSpec` / editor state without serializing through YAML.
-- Keep YAML import/export available for human-readable backups, bug reports, examples, and compatibility tests.
-- Make structured project snapshots the authority for local/cloud persistence where available.
-- Preserve backward compatibility with existing YAML projects and legacy cached YAML records.
-
-## Non-Goals
-
-- Removing YAML import/export.
-- Breaking existing project files or tests that intentionally exercise YAML compatibility.
-- Replacing `ProjectSpec` with a second runtime-only schema in this increment.
-
-## Phase 1 — Inventory Current YAML Coupling
-
-- [ ] Identify every path that calls `serializeProjectToYaml` or `parseProjectYaml`.
-- [ ] Classify each usage as:
- - user-facing import/export;
- - persistence/cache compatibility;
- - publish/runtime build;
- - tests/fixtures/debug tooling.
-- [ ] Record which paths must remain YAML-based and which should switch to structured `ProjectSpec`.
-
-## Phase 2 — Define Canonical Snapshot Contract
-
-- [ ] Add or document a versioned structured project snapshot format based on `ProjectSpec`.
-- [ ] Ensure validation and canonicalization can run on structured snapshots without YAML parse/stringify.
-- [ ] Define migration order when both structured project data and YAML are present: latest valid structured `ProjectSpec` wins, YAML is fallback/import compatibility.
-- [ ] Add tests proving structured snapshots preserve optional scene appearance, entity tint, scatter layout params, event blocks, attachment value sources, assets, audio, input maps, collections, counters, and patterns.
-
-## Phase 3 — Publish Without YAML
-
-- [ ] Find the publish path that currently depends on YAML text.
-- [ ] Refactor publish to compile/package directly from validated `ProjectSpec`.
-- [ ] Keep YAML export as a separate user action and optional debugging artifact.
-- [ ] Add publish tests proving YAML serialization is not called on the primary publish path.
-
-## Phase 4 — Persistence Without YAML as Authority
-
-- [ ] Prefer structured `ProjectSpec` records for IndexedDB/local persistence writes.
-- [ ] Prefer structured `ProjectSpec` records for cloud persistence writes where the API supports it.
-- [ ] Keep YAML reads as fallback for legacy records and explicit import flows.
-- [ ] Add reload/reopen tests for structured persistence precedence over stale YAML/cache data.
-
-## Phase 5 — Keep YAML Honest as an Adapter
-
-- [ ] Keep YAML round-trip tests for human-readable export/import compatibility.
-- [ ] Keep migration tests for legacy YAML formats.
-- [ ] Add a small docs note explaining YAML's role: portable import/export, not the canonical internal transport.
-- [ ] Make test names and helper names avoid implying YAML is the project authority except in YAML-specific suites.
-
-## Verification
-
-- [ ] Unit tests for structured snapshot validation/canonicalization.
-- [ ] Persistence unit tests and reload/reopen E2E.
-- [ ] Publish tests proving direct `ProjectSpec` compilation/package.
-- [ ] Existing YAML import/export tests still pass.
-- [ ] Chromium smoke if editor/publish UI changes are touched.
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 3f69382b..28fffe27 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -24,6 +24,7 @@ export default defineConfig({
text: 'Reference',
items: [
{ text: 'Editor Workflows', link: '/reference/editor-workflows' },
+ { text: 'Project Data', link: '/reference/project-data' },
{ text: 'Workflow Glossary', link: '/reference/workflow-glossary' },
],
},
diff --git a/docs/reference/project-data.md b/docs/reference/project-data.md
new file mode 100644
index 00000000..73eadf52
--- /dev/null
+++ b/docs/reference/project-data.md
@@ -0,0 +1,5 @@
+# Project Data
+
+`ProjectSpec` is PhaserForge's canonical project model. The editor state, cloud save API, local structured snapshots, publish packaging, and runtime loading all use validated `ProjectSpec` data.
+
+YAML remains supported as a portable import/export format for backups, examples, bug reports, and compatibility testing. Opening YAML imports it into `ProjectSpec`; saving YAML exports the current `ProjectSpec`. YAML is not the canonical internal transport for persistence or publishing.
diff --git a/server/src/server/services/publishService.ts b/server/src/server/services/publishService.ts
index 21177a43..38398feb 100644
--- a/server/src/server/services/publishService.ts
+++ b/server/src/server/services/publishService.ts
@@ -1,6 +1,6 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
-import { serializeProjectToYaml } from '../../../../src/model/serialization';
+import { serializeProjectSnapshot } from '../../../../src/model/serialization';
import type { AssetFileSource, ProjectSpec } from '../../../../src/model/types';
import type { Repositories } from '../types';
@@ -311,8 +311,8 @@ async function readPublishableDistFiles(): Promise`;
const publishMeta = ``;
- const yamlUrl = `./game.yaml?pf_publish=${encodeURIComponent(publishMarker)}`;
- const boot = ``;
+ const projectUrl = `./game.json?pf_publish=${encodeURIComponent(publishMarker)}`;
+ const boot = ``;
const title = `
${escapeHtml(gameTitle)}`;
if (distIndexHtml.includes('name="phaserforge-mode"')) return distIndexHtml;
if (distIndexHtml.includes('')) {
@@ -718,11 +718,11 @@ export async function publishGameToGithubPages(
const playIndex = buildPlayIndexHtml(Buffer.from(indexEntry.bytes).toString('utf8'), publishMarker, game.title);
const publishableProject = await materializeProjectForPublish(repositories, userId, game.project, publishMarker);
if (!publishableProject) return { ok: false, error: 'cloud_asset_missing' };
- const yamlNormalized = serializeProjectToYaml(publishableProject.project);
+ const projectSnapshotJson = serializeProjectSnapshot(publishableProject.project);
const files: Array<{ path: string; bytes: Uint8Array }> = [
{ path: PAGES_WORKFLOW_PATH, bytes: Buffer.from(buildPagesWorkflowYaml(), 'utf8') },
{ path: 'index.html', bytes: Buffer.from(playIndex, 'utf8') },
- { path: 'game.yaml', bytes: Buffer.from(yamlNormalized, 'utf8') },
+ { path: 'game.json', bytes: Buffer.from(projectSnapshotJson, 'utf8') },
{ path: PAGES_PUBLISH_PROBE_PATH, bytes: Buffer.from(buildPublishProbeJson(publishMarker), 'utf8') },
];
diff --git a/src/PlayApp.tsx b/src/PlayApp.tsx
index 4a92b201..44c478f9 100644
--- a/src/PlayApp.tsx
+++ b/src/PlayApp.tsx
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PhaserGame } from './phaser/PhaserHost';
import { EventBus, getActiveScene } from './phaser/EventBus';
import { getGame } from './cloud/api';
-import { parseProjectYaml } from './model/serialization';
+import { parseProjectSnapshot, parseProjectYaml } from './model/serialization';
import { getSceneWorld } from './editor/sceneWorld';
import type { ProjectSpec } from './model/types';
import { AudioDebugOverlay } from './AudioDebugOverlay';
@@ -24,6 +24,15 @@ function readYamlUrl(): string | null {
return typeof globalVal === 'string' && globalVal.trim().length > 0 ? globalVal.trim() : null;
}
+function readProjectUrl(): string | null {
+ if (typeof window === 'undefined') return null;
+ const url = new URL(window.location.href);
+ const raw = url.searchParams.get('projectUrl');
+ if (raw && raw.trim().length > 0) return raw.trim();
+ const globalVal = (window as any).__PHASER_FORGE_PLAY_PROJECT_URL;
+ return typeof globalVal === 'string' && globalVal.trim().length > 0 ? globalVal.trim() : null;
+}
+
function unlockAudioFromStartGesture(): void {
const game = (window as any).__phaserGame;
const sound = game?.sound;
@@ -42,6 +51,7 @@ function unlockAudioFromStartGesture(): void {
export default function PlayApp() {
const gameId = useMemo(() => readPlayGameId(), []);
const yamlUrl = useMemo(() => readYamlUrl(), []);
+ const projectUrl = useMemo(() => readProjectUrl(), []);
const [error, setError] = useState(null);
const [project, setProject] = useState(null);
const [sceneId, setSceneId] = useState(null);
@@ -53,7 +63,15 @@ export default function PlayApp() {
let cancelled = false;
const load = async () => {
try {
- if (yamlUrl) {
+ if (projectUrl) {
+ const res = await fetch(projectUrl, { credentials: 'omit', cache: 'no-store' });
+ if (!res.ok) throw new Error(`http_${res.status}`);
+ const snapshot = await res.json();
+ const parsed = parseProjectSnapshot(snapshot);
+ if (cancelled) return;
+ setProject(parsed);
+ setSceneId(parsed.initialSceneId);
+ } else if (yamlUrl) {
const res = await fetch(yamlUrl, { credentials: 'omit', cache: 'no-store' });
if (!res.ok) throw new Error(`http_${res.status}`);
const yamlText = await res.text();
@@ -78,7 +96,7 @@ export default function PlayApp() {
return () => {
cancelled = true;
};
- }, [gameId, yamlUrl]);
+ }, [gameId, projectUrl, yamlUrl]);
const world = useMemo(() => {
if (!project || !sceneId) return null;
diff --git a/src/editor/projectPersistence.ts b/src/editor/projectPersistence.ts
index bbb87be5..e277e4f5 100644
--- a/src/editor/projectPersistence.ts
+++ b/src/editor/projectPersistence.ts
@@ -1,4 +1,10 @@
-import { parseProjectYaml, serializeProjectToYaml } from '../model/serialization';
+import {
+ createProjectSnapshot,
+ parseProjectSnapshot,
+ parseProjectYaml,
+ serializeProjectToYaml,
+ type ProjectSpecSnapshot,
+} from '../model/serialization';
import { createEmptyProject } from '../model/emptyProject';
import { projectsSemanticallyEqual } from '../model/projectCanonical';
import type { ProjectSpec, StartupMode } from '../model/types';
@@ -75,7 +81,9 @@ type LatestActiveProjectSnapshotRecord = {
savedAt: string;
};
-type PersistedProjectRecord = Omit;
+type PersistedProjectRecord = Omit & {
+ projectSnapshot?: ProjectSpecSnapshot;
+};
export type PreferencesRecord = {
startupMode: StartupMode;
@@ -273,7 +281,10 @@ function parsePreferencesRecord(raw: unknown, context: string): PreferencesRecor
};
}
-function buildStoredProjectRecordBase(raw: unknown, context: string): Omit & { project?: ProjectSpec } {
+function buildStoredProjectRecordBase(
+ raw: unknown,
+ context: string,
+): Omit & { project?: ProjectSpec; projectSnapshot?: ProjectSpecSnapshot } {
assertStoredProjectRecord(raw && typeof raw === 'object', `${context}: stored project record must be an object, got ${describeRuntimeValue(raw)}`);
const candidate = raw as Record;
assertStoredProjectRecord(typeof candidate.id === 'string' && candidate.id.length > 0, `${context}: id must be a non-empty string`);
@@ -309,6 +320,7 @@ function buildStoredProjectRecordBase(raw: unknown, context: string): Omit & { project?: ProjectSpec },
+ record: Omit & { project?: ProjectSpec; projectSnapshot?: ProjectSpecSnapshot },
context: string,
): StoredProjectRecord {
const validProject = getValidProjectOrNull(record.project);
@@ -515,6 +527,25 @@ function hydrateStoredProjectRecord(
archivedHistoryEvents: record.archivedHistoryEvents ?? [],
};
}
+ if (record.projectSnapshot) {
+ try {
+ const project = parseProjectSnapshot(record.projectSnapshot);
+ validateProjectSpec(project);
+ return {
+ ...record,
+ project,
+ revisions: normalizeStoredProjectRevisions(project, record.revisions),
+ archivedRevisions: normalizeStoredProjectRevisions(project, record.archivedRevisions, {
+ fallbackToCurrentProject: false,
+ enforceLatestMatchesProject: false,
+ }),
+ historyEvents: record.historyEvents ?? [],
+ archivedHistoryEvents: record.archivedHistoryEvents ?? [],
+ };
+ } catch {
+ // Fall through to revision/YAML compatibility paths for older or damaged records.
+ }
+ }
if (Array.isArray(record.revisions) && record.revisions.length > 0) {
try {
const latestProject = materializeProjectRevision(record.revisions, record.revisions[0].id);
@@ -571,7 +602,10 @@ function parseStoredProjectRecord(raw: unknown, context: string): StoredProjectR
function dehydrateStoredProjectRecord(record: StoredProjectRecord): PersistedProjectRecord {
const { project: _project, ...persisted } = structuredClone(record);
- return persisted;
+ return {
+ ...persisted,
+ projectSnapshot: createProjectSnapshot(record.project),
+ };
}
function validateStoredProjectRecordForPersistence(record: StoredProjectRecord): void {
diff --git a/src/model/serialization.ts b/src/model/serialization.ts
index 8dadc15a..fe2fe228 100644
--- a/src/model/serialization.ts
+++ b/src/model/serialization.ts
@@ -3,6 +3,13 @@ import { normalizeProjectPixelsPerUnit, normalizeProjectRenderMode } from './pro
import { CollisionRuleSpec, GameSceneSpec, ProjectSpec, TriggerZoneSpec } from './types';
import { migrateSceneSpec } from './migrateScene';
+export const PROJECT_SPEC_SNAPSHOT_VERSION = 1;
+
+export type ProjectSpecSnapshot = {
+ version: typeof PROJECT_SPEC_SNAPSHOT_VERSION;
+ project: ProjectSpec;
+};
+
function coerceRecord(value: unknown): Record {
if (!value || typeof value !== 'object') return {};
return value as Record;
@@ -213,13 +220,12 @@ export function serializeProjectToYaml(project: ProjectSpec): string {
});
}
-export function parseProjectYaml(text: string): ProjectSpec {
- const parsed = parse(text);
- if (!parsed || typeof parsed !== 'object') {
- throw new Error('Invalid YAML project');
+export function canonicalizeProjectSpec(rawProject: unknown): ProjectSpec {
+ if (!rawProject || typeof rawProject !== 'object') {
+ throw new Error('Invalid project');
}
- const raw = parsed as any;
+ const raw = rawProject as any;
const scenesRaw = coerceRecord(raw.scenes);
const sceneEntries = Object.entries(scenesRaw);
if (sceneEntries.length === 0) {
@@ -294,3 +300,33 @@ export function parseProjectYaml(text: string): ProjectSpec {
...(patterns !== undefined ? { patterns } : {}),
};
}
+
+export function createProjectSnapshot(project: ProjectSpec): ProjectSpecSnapshot {
+ return {
+ version: PROJECT_SPEC_SNAPSHOT_VERSION,
+ project: canonicalizeProjectSpec(structuredClone(project)),
+ };
+}
+
+export function parseProjectSnapshot(snapshot: unknown): ProjectSpec {
+ if (!snapshot || typeof snapshot !== 'object') {
+ throw new Error('Invalid project snapshot');
+ }
+ const raw = snapshot as any;
+ if (raw.version !== PROJECT_SPEC_SNAPSHOT_VERSION) {
+ throw new Error(`Unsupported project snapshot version ${String(raw.version)}`);
+ }
+ return canonicalizeProjectSpec(raw.project);
+}
+
+export function serializeProjectSnapshot(project: ProjectSpec): string {
+ return JSON.stringify(createProjectSnapshot(project));
+}
+
+export function parseProjectYaml(text: string): ProjectSpec {
+ const parsed = parse(text);
+ if (!parsed || typeof parsed !== 'object') {
+ throw new Error('Invalid YAML project');
+ }
+ return canonicalizeProjectSpec(parsed);
+}
diff --git a/tests/e2e/cloud-reload-preserves-latest-head.spec.ts b/tests/e2e/cloud-reload-preserves-latest-head.spec.ts
index 1befada5..b5ab3ac6 100644
--- a/tests/e2e/cloud-reload-preserves-latest-head.spec.ts
+++ b/tests/e2e/cloud-reload-preserves-latest-head.spec.ts
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import { sampleProject } from '../../src/model/sampleProject';
import { buildStoredProjectRecord } from '../../src/editor/projectPersistence';
import { appendProjectRevision, createProjectRevision } from '../../src/editor/projectTreeHistory';
+import { createProjectSnapshot, serializeProjectToYaml } from '../../src/model/serialization';
import { enablePersistenceDebug, expectPersistenceDebugEvents, expectProjectRestoreState, getState, gotoStudio } from './helpers';
test('cloud-backed active project reload restores the latest IndexedDB head and history without legacy localStorage project state @regression', async ({ page }) => {
@@ -45,10 +46,13 @@ test('cloud-backed active project reload restores the latest IndexedDB head and
syncStatus: 'cloud',
cloudProjectId: 'g1',
revisions: appendProjectRevision([staleRevision], latestRevision),
+ yaml: serializeProjectToYaml(sampleProject),
}),
id: 'cloud:g1',
projectId: latestProject.id,
- };
+ projectSnapshot: createProjectSnapshot(latestProject),
+ } as any;
+ delete latestRecord.project;
const olderProject = structuredClone(sampleProject);
olderProject.title = 'Older Cloud Game';
const olderRecord = {
diff --git a/tests/editor/projectPersistence.test.ts b/tests/editor/projectPersistence.test.ts
index 83a48d4a..99972ac2 100644
--- a/tests/editor/projectPersistence.test.ts
+++ b/tests/editor/projectPersistence.test.ts
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest';
import { createEmptyProject } from '../../src/model/emptyProject';
-import { serializeProjectToYaml } from '../../src/model/serialization';
+import { createProjectSnapshot, serializeProjectToYaml } from '../../src/model/serialization';
import type { ProjectHistoryEvent } from '../../src/editor/projectHistoryEvents';
import {
__pauseActiveProjectRecordPersistenceForTests,
@@ -502,6 +502,7 @@ describe('projectPersistence steady-state storage', () => {
const stored = await readStoredProjectRecord(project.id);
expect(stored?.project).toBeUndefined();
+ expect(stored?.projectSnapshot).toEqual(createProjectSnapshot(project));
expect(stored?.revisions).toHaveLength(1);
const restored = await projectPersistence.loadProjectById(project.id);
@@ -509,6 +510,44 @@ describe('projectPersistence steady-state storage', () => {
expect(Object.keys(restored?.project.scenes?.[project.initialSceneId]?.entities ?? {})).toEqual(['player']);
});
+ it('prefers a valid structured project snapshot over stale YAML fallback data', async () => {
+ const structuredProject = createEmptyProject();
+ structuredProject.id = 'project-structured';
+ structuredProject.title = 'Structured Wins';
+ structuredProject.scenes[structuredProject.initialSceneId].entities.player = {
+ id: 'player',
+ x: 64,
+ y: 64,
+ width: 16,
+ height: 16,
+ } as any;
+
+ const staleYamlProject = createEmptyProject();
+ staleYamlProject.id = structuredProject.id;
+ staleYamlProject.title = 'Stale YAML';
+ staleYamlProject.scenes[staleYamlProject.initialSceneId].entities.stale = {
+ id: 'stale',
+ x: 0,
+ y: 0,
+ width: 8,
+ height: 8,
+ } as any;
+
+ const record = buildStoredProjectRecord(structuredProject, {
+ id: structuredProject.id,
+ yaml: serializeProjectToYaml(staleYamlProject),
+ revisions: [],
+ }) as any;
+ delete record.project;
+ record.projectSnapshot = createProjectSnapshot(structuredProject);
+
+ await seedPersistenceRecords({ projectRecord: record });
+
+ const restored = await projectPersistence.loadProjectById(structuredProject.id);
+ expect(restored?.project.title).toBe('Structured Wins');
+ expect(Object.keys(restored?.project.scenes?.[structuredProject.initialSceneId]?.entities ?? {})).toEqual(['player']);
+ });
+
it('can still retain an explicit YAML payload for import-export compatibility', () => {
const project = createEmptyProject();
diff --git a/tests/model/project-serialization.test.ts b/tests/model/project-serialization.test.ts
index 78940bd3..4160ed9f 100644
--- a/tests/model/project-serialization.test.ts
+++ b/tests/model/project-serialization.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { parseProjectYaml, serializeProjectToYaml } from '../../src/model/serialization';
+import { createProjectSnapshot, parseProjectSnapshot, parseProjectYaml, serializeProjectSnapshot, serializeProjectToYaml } from '../../src/model/serialization';
import { sampleScene } from '../../src/model/sampleScene';
import { stringify } from 'yaml';
@@ -416,3 +416,94 @@ describe('project YAML serialization', () => {
expect(parsed).toEqual(project);
});
});
+
+describe('project structured snapshots', () => {
+ it('canonicalizes and validates a structured ProjectSpec without YAML', () => {
+ const project = {
+ id: 'project-1',
+ title: 'Snapshot Project',
+ assets: {
+ images: {
+ enemy: {
+ id: 'enemy',
+ width: 16,
+ height: 16,
+ source: { kind: 'embedded', dataUrl: 'data:image/png;base64,AAAA', originalName: 'enemy.png', mimeType: 'image/png' },
+ },
+ },
+ spriteSheets: {},
+ fonts: {},
+ },
+ audio: {
+ sounds: {
+ theme: {
+ id: 'theme',
+ source: { kind: 'embedded', dataUrl: 'data:audio/mp3;base64,AAAA', originalName: 'theme.mp3', mimeType: 'audio/mpeg' },
+ },
+ },
+ },
+ inputMaps: {
+ arrows: { actions: { moveLeft: [{ device: 'keyboard', key: 'ArrowLeft', event: 'held' }] } },
+ },
+ defaultInputMapId: 'arrows',
+ collections: { gems: { id: 'gems', name: 'Gems', entityIds: ['e1'] } },
+ counters: { score: { id: 'score', name: 'Score', initialValue: 0 } },
+ scenes: {
+ 'scene-1': {
+ ...sampleScene,
+ backgroundColor: 0x000000,
+ entities: {
+ ...sampleScene.entities,
+ e1: { ...sampleScene.entities.e1, tint: 0x224466 },
+ },
+ groups: {
+ ...sampleScene.groups,
+ g1: {
+ ...sampleScene.groups.g1,
+ layout: {
+ type: 'arrange',
+ arrangeKind: 'scatter',
+ params: { minX: 0, maxX: 720, minY: 5, maxY: 1285, seed: 'stars-1' },
+ },
+ },
+ },
+ eventBlocks: {
+ wrap: {
+ id: 'wrap',
+ target: { type: 'group', groupId: 'g-enemies' },
+ trigger: { type: 'bounds', boundsEvent: 'wrapped', axis: 'y', side: 'bottom' },
+ },
+ },
+ attachments: {
+ rerollX: {
+ id: 'rerollX',
+ target: { type: 'group', groupId: 'g-enemies' },
+ eventId: 'wrap',
+ targetMode: 'event-source',
+ presetId: 'SetProperty',
+ params: { property: 'x', valueSource: { kind: 'randomRange', min: 0, max: 720, seed: 'wrap-x' } },
+ },
+ },
+ },
+ },
+ initialSceneId: 'scene-1',
+ patterns: {
+ p1: {
+ id: 'p1',
+ name: 'Pattern 1',
+ params: [],
+ body: [{ presetId: 'Wait', params: { durationMs: 10 } }],
+ },
+ },
+ } as any;
+
+ const snapshot = createProjectSnapshot(project);
+ expect(snapshot.version).toBe(1);
+ expect(parseProjectSnapshot(snapshot)).toEqual(project);
+ expect(parseProjectSnapshot(JSON.parse(serializeProjectSnapshot(project)))).toEqual(project);
+ });
+
+ it('rejects unsupported structured snapshot versions', () => {
+ expect(() => parseProjectSnapshot({ version: 999, project: {} })).toThrow(/Unsupported project snapshot version/);
+ });
+});
diff --git a/tests/server/publish-github-pages.test.ts b/tests/server/publish-github-pages.test.ts
index ae97cc27..f0b57140 100644
--- a/tests/server/publish-github-pages.test.ts
+++ b/tests/server/publish-github-pages.test.ts
@@ -7,6 +7,7 @@ import * as path from 'node:path';
import { createApp } from '../../server/src/server/app';
import { createMemoryRepositories } from '../../server/src/server/repositories/memory';
import { createEmptyProject } from '../../src/model/emptyProject';
+import * as projectSerialization from '../../src/model/serialization';
function makeApp() {
const repositories = createMemoryRepositories();
@@ -289,7 +290,7 @@ describe('publish github pages', () => {
.find((content) => content.includes(''));
expect(indexHtml).toContain('Game One');
expect(indexHtml).toContain('');
- expect(indexHtml).toContain(`window.__PHASER_FORGE_PLAY_YAML_URL = ${JSON.stringify(`./game.yaml?pf_publish=${encodeURIComponent(res.body.publishMarker)}`)};`);
+ expect(indexHtml).toContain(`window.__PHASER_FORGE_PLAY_PROJECT_URL = ${JSON.stringify(`./game.json?pf_publish=${encodeURIComponent(res.body.publishMarker)}`)};`);
expect(indexHtml).toContain(`window.__PHASER_FORGE_PUBLISH_MARKER = ${JSON.stringify(res.body.publishMarker)};`);
expect(indexHtml).toContain("fetch('./phaserforge-publish.json?pf_check='");
expect(indexHtml).toContain("nextUrl.searchParams.set('pf_publish', latest.publishMarker)");
@@ -303,6 +304,7 @@ describe('publish github pages', () => {
const { userId, csrf } = await signup(agent);
await linkGithub(repositories, userId);
await addGame(repositories, userId);
+ const yamlSerializeSpy = vi.spyOn(projectSerialization, 'serializeProjectToYaml');
const treeBodies: any[] = [];
const blobBodies: Array<{ content: string; encoding: string }> = [];
@@ -378,8 +380,10 @@ describe('publish github pages', () => {
expect(treeBodies).toHaveLength(1);
expect(treeBodies[0].tree.some((entry: any) => entry.path === '.github/workflows/deploy-phaserforge-pages.yml')).toBe(true);
expect(treeBodies[0].tree.some((entry: any) => entry.path === 'index.html')).toBe(true);
- expect(treeBodies[0].tree.some((entry: any) => entry.path === 'game.yaml')).toBe(true);
+ expect(treeBodies[0].tree.some((entry: any) => entry.path === 'game.json')).toBe(true);
+ expect(treeBodies[0].tree.some((entry: any) => entry.path === 'game.yaml')).toBe(false);
expect(treeBodies[0].tree.some((entry: any) => entry.path === 'phaserforge-publish.json')).toBe(true);
+ expect(yamlSerializeSpy).not.toHaveBeenCalled();
const workflowBlob = blobBodies
.map((blob) => Buffer.from(blob.content, 'base64').toString('utf8'))
.find((content) => content.includes('Deploy PhaserForge game to GitHub Pages'));
@@ -470,7 +474,7 @@ describe('publish github pages', () => {
expect(res.body.error).toBe('github_workflow_permission_required');
});
- it('publish materializes cloud assets into repo files and rewrites game.yaml paths', async () => {
+ it('publish materializes cloud assets into repo files and rewrites game.json paths', async () => {
const { app, repositories } = makeApp();
const agent = request.agent(app);
const { userId, csrf } = await signup(agent);
@@ -561,12 +565,12 @@ describe('publish github pages', () => {
const audioBlob = decodedBlobs.find((bytes) => bytes.toString('utf8') === 'ABCD');
expect(audioBlob).toBeDefined();
- const yamlBlob = decodedBlobs
+ const projectJsonBlob = decodedBlobs
.map((bytes) => bytes.toString('utf8'))
- .find((content) => content.includes('initialSceneId:') && !content.includes(''));
- expect(yamlBlob).toContain('kind: path');
- expect(yamlBlob).toContain(`path: assets/cloud/theme.mp3?pf_publish=${encodeURIComponent(publishRes.body.publishMarker)}`);
- expect(yamlBlob).not.toContain('kind: cloud');
+ .find((content) => content.includes('"version":1') && content.includes('"initialSceneId"'));
+ expect(projectJsonBlob).toContain('"kind":"path"');
+ expect(projectJsonBlob).toContain(`"path":"assets/cloud/theme.mp3?pf_publish=${encodeURIComponent(publishRes.body.publishMarker)}"`);
+ expect(projectJsonBlob).not.toContain('"kind":"cloud"');
});
it('publish includes repo path-backed demo-pack audio files in the published repo', async () => {
@@ -656,10 +660,10 @@ describe('publish github pages', () => {
.map((blob) => Buffer.from(blob.content, 'base64'))
.find((bytes) => bytes.toString('utf8') === 'DEMOAUDIO');
expect(audioBlob).toBeDefined();
- const yamlBlob = blobBodies
+ const projectJsonBlob = blobBodies
.map((blob) => Buffer.from(blob.content, 'base64').toString('utf8'))
- .find((content) => content.includes('initialSceneId'));
- expect(yamlBlob).toContain('path: assets/demo-pack/audio/Simulacra-chosic.com_.mp3');
+ .find((content) => content.includes('"version":1') && content.includes('"initialSceneId"'));
+ expect(projectJsonBlob).toContain('"path":"assets/demo-pack/audio/Simulacra-chosic.com_.mp3?pf_publish=');
});
it('publish fetches repo path-backed demo-pack audio when the local file is only a Git LFS pointer', async () => {
diff --git a/tests/ui/play-app-start-gate.test.tsx b/tests/ui/play-app-start-gate.test.tsx
index d86871d4..31954654 100644
--- a/tests/ui/play-app-start-gate.test.tsx
+++ b/tests/ui/play-app-start-gate.test.tsx
@@ -53,6 +53,7 @@ vi.mock('../../src/cloud/api', () => ({
vi.mock('../../src/model/serialization', () => ({
parseProjectYaml: vi.fn(() => project),
+ parseProjectSnapshot: vi.fn(() => project),
}));
vi.mock('../../src/editor/sceneWorld', () => ({
@@ -88,6 +89,16 @@ describe('PlayApp start gate', () => {
expect(eventBus.emit).not.toHaveBeenCalledWith('runtime:load-project', expect.anything(), expect.anything(), 'play');
});
+ it('loads structured project snapshots when a project URL is provided', async () => {
+ window.history.replaceState({}, '', '/?projectUrl=%2Fgame.json');
+ vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ version: 1, project }) })) as any);
+
+ render();
+
+ expect(await screen.findByTestId('play-start-gate')).not.toBeNull();
+ expect(fetch).toHaveBeenCalledWith('/game.json', { credentials: 'omit', cache: 'no-store' });
+ });
+
it('starts the game only after the player clicks the gate', async () => {
render();