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();