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
66 changes: 0 additions & 66 deletions .plans/projectspec-canonical-yaml-adapter-plan.md

This file was deleted.

1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
Expand Down
5 changes: 5 additions & 0 deletions docs/reference/project-data.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 5 additions & 5 deletions server/src/server/services/publishService.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -311,8 +311,8 @@ async function readPublishableDistFiles(): Promise<Array<{ relPath: string; byte
function buildPlayIndexHtml(distIndexHtml: string, publishMarker: string, gameTitle: string): string {
const meta = `<meta name="phaserforge-mode" content="play" />`;
const publishMeta = `<meta name="phaserforge-publish-marker" content="${publishMarker}" />`;
const yamlUrl = `./game.yaml?pf_publish=${encodeURIComponent(publishMarker)}`;
const boot = `<script>window.__PHASER_FORGE_PLAY_YAML_URL = ${JSON.stringify(yamlUrl)};window.__PHASER_FORGE_PUBLISH_MARKER = ${JSON.stringify(publishMarker)};(() => { const currentMarker = window.__PHASER_FORGE_PUBLISH_MARKER; fetch('./phaserforge-publish.json?pf_check=' + encodeURIComponent(String(Date.now())), { cache: 'no-store' }).then((response) => response.ok ? response.json() : null).then((latest) => { if (!latest || typeof latest.publishMarker !== 'string' || latest.publishMarker === currentMarker) return; const nextUrl = new URL(window.location.href); nextUrl.searchParams.set('pf_publish', latest.publishMarker); window.location.replace(nextUrl.toString()); }).catch(() => {}); })();</script>`;
const projectUrl = `./game.json?pf_publish=${encodeURIComponent(publishMarker)}`;
const boot = `<script>window.__PHASER_FORGE_PLAY_PROJECT_URL = ${JSON.stringify(projectUrl)};window.__PHASER_FORGE_PUBLISH_MARKER = ${JSON.stringify(publishMarker)};(() => { const currentMarker = window.__PHASER_FORGE_PUBLISH_MARKER; fetch('./phaserforge-publish.json?pf_check=' + encodeURIComponent(String(Date.now())), { cache: 'no-store' }).then((response) => response.ok ? response.json() : null).then((latest) => { if (!latest || typeof latest.publishMarker !== 'string' || latest.publishMarker === currentMarker) return; const nextUrl = new URL(window.location.href); nextUrl.searchParams.set('pf_publish', latest.publishMarker); window.location.replace(nextUrl.toString()); }).catch(() => {}); })();</script>`;
const title = `<title>${escapeHtml(gameTitle)}</title>`;
if (distIndexHtml.includes('name="phaserforge-mode"')) return distIndexHtml;
if (distIndexHtml.includes('</title>')) {
Expand Down Expand Up @@ -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') },
];

Expand Down
24 changes: 21 additions & 3 deletions src/PlayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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<string | null>(null);
const [project, setProject] = useState<ProjectSpec | null>(null);
const [sceneId, setSceneId] = useState<string | null>(null);
Expand All @@ -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();
Expand All @@ -78,7 +96,7 @@ export default function PlayApp() {
return () => {
cancelled = true;
};
}, [gameId, yamlUrl]);
}, [gameId, projectUrl, yamlUrl]);

const world = useMemo(() => {
if (!project || !sceneId) return null;
Expand Down
44 changes: 39 additions & 5 deletions src/editor/projectPersistence.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -75,7 +81,9 @@ type LatestActiveProjectSnapshotRecord = {
savedAt: string;
};

type PersistedProjectRecord = Omit<StoredProjectRecord, 'project'>;
type PersistedProjectRecord = Omit<StoredProjectRecord, 'project'> & {
projectSnapshot?: ProjectSpecSnapshot;
};

export type PreferencesRecord = {
startupMode: StartupMode;
Expand Down Expand Up @@ -273,7 +281,10 @@ function parsePreferencesRecord(raw: unknown, context: string): PreferencesRecor
};
}

function buildStoredProjectRecordBase(raw: unknown, context: string): Omit<StoredProjectRecord, 'project'> & { project?: ProjectSpec } {
function buildStoredProjectRecordBase(
raw: unknown,
context: string,
): Omit<StoredProjectRecord, 'project'> & { 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<string, unknown>;
assertStoredProjectRecord(typeof candidate.id === 'string' && candidate.id.length > 0, `${context}: id must be a non-empty string`);
Expand Down Expand Up @@ -309,6 +320,7 @@ function buildStoredProjectRecordBase(raw: unknown, context: string): Omit<Store
...(candidate.historyEvents !== undefined ? { historyEvents: candidate.historyEvents as ProjectHistoryEvent[] } : {}),
...(candidate.archivedHistoryEvents !== undefined ? { archivedHistoryEvents: candidate.archivedHistoryEvents as ProjectHistoryEvent[] } : {}),
...('project' in candidate ? { project: candidate.project as ProjectSpec | undefined } : {}),
...('projectSnapshot' in candidate ? { projectSnapshot: candidate.projectSnapshot as ProjectSpecSnapshot | undefined } : {}),
};
}

Expand Down Expand Up @@ -498,7 +510,7 @@ function getValidProjectOrNull(project: ProjectSpec | undefined): ProjectSpec |
}

function hydrateStoredProjectRecord(
record: Omit<StoredProjectRecord, 'project'> & { project?: ProjectSpec },
record: Omit<StoredProjectRecord, 'project'> & { project?: ProjectSpec; projectSnapshot?: ProjectSpecSnapshot },
context: string,
): StoredProjectRecord {
const validProject = getValidProjectOrNull(record.project);
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
46 changes: 41 additions & 5 deletions src/model/serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(value: unknown): Record<string, T> {
if (!value || typeof value !== 'object') return {};
return value as Record<string, T>;
Expand Down Expand Up @@ -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<any>(raw.scenes);
const sceneEntries = Object.entries(scenesRaw);
if (sceneEntries.length === 0) {
Expand Down Expand Up @@ -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);
}
6 changes: 5 additions & 1 deletion tests/e2e/cloud-reload-preserves-latest-head.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 { 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 }) => {
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading