import { describe, expect, it } from "vitest"; import { AUTHORED_OBJECT_STATE_SCENE_DOCUMENT_VERSION, DEFAULT_PROJECT_NAME, DEFAULT_SCENE_EDITOR_SNAP_STEP, PLAYER_START_GAMEPAD_CAMERA_LOOK_SCENE_DOCUMENT_VERSION, PROJECT_TIME_DAY_NIGHT_PROFILE_SCENE_DOCUMENT_VERSION, RUNNER_LOADING_SCREEN_SCENE_DOCUMENT_VERSION, SCENE_DOCUMENT_VERSION, createEmptyProjectDocument, createEmptyProjectScene } from "../../src/document/scene-document"; import { createDefaultProjectTimeSettings } from "../../src/document/project-time-settings"; import { createDefaultWorldSettings, createDefaultWorldTimePhaseProfile } from "../../src/document/world-settings"; import { createSceneEntryEntity, createSceneExitEntity } from "../../src/entities/entity-instances"; import { parseProjectDocumentJson, serializeProjectDocument } from "../../src/serialization/scene-document-json"; describe("project document JSON", () => { it("round-trips the project name and authored scene loading overlay settings", () => { const cellarEntry = createSceneEntryEntity({ id: "entity-scene-entry-cellar-stairs", position: { x: 1, y: 0, z: -2 }, yawDegrees: 180 }); const mainExit = createSceneExitEntity({ id: "entity-scene-exit-main-hatch", position: { x: 0, y: 1, z: 3 }, targetSceneId: "scene-cellar", targetEntryEntityId: cellarEntry.id }); const document = { ...createEmptyProjectDocument({ name: "Castle Project", sceneName: "Entry" }), activeSceneId: "scene-cellar", scenes: { "scene-main": createEmptyProjectScene({ id: "scene-main", name: "Entry" }), "scene-cellar": createEmptyProjectScene({ id: "scene-cellar", name: "Cellar", loadingScreen: { colorHex: "#233041", headline: "Descending", description: "Dust and echoes settle before the next room appears." } }) } }; document.scenes["scene-main"].entities[mainExit.id] = mainExit; document.scenes["scene-cellar"].entities[cellarEntry.id] = cellarEntry; document.scenes["scene-main"].editorPreferences = { ...document.scenes["scene-main"].editorPreferences, whiteboxSelectionMode: "face", whiteboxSnapEnabled: false, whiteboxSnapStep: 0.5, viewportGridVisible: false, viewportLayoutMode: "quad", activeViewportPanelId: "bottomRight", viewportQuadSplit: { x: 0.42, y: 0.58 }, viewportPanels: { ...document.scenes["scene-main"].editorPreferences.viewportPanels, topLeft: { viewMode: "front", displayMode: "wireframe" } } }; document.scenes["scene-cellar"].editorPreferences = { ...document.scenes["scene-cellar"].editorPreferences, whiteboxSelectionMode: "vertex", whiteboxSnapStep: 2, viewportLayoutMode: "quad", activeViewportPanelId: "topRight", viewportPanels: { ...document.scenes["scene-cellar"].editorPreferences.viewportPanels, topLeft: { viewMode: "side", displayMode: "authoring" } } }; document.scenes["scene-cellar"].world.advancedRendering = { ...document.scenes["scene-cellar"].world.advancedRendering, enabled: true, whiteboxBevel: { enabled: true, edgeWidth: 0.16, normalStrength: 0.85 } }; document.scenes["scene-cellar"].world.projectTimeLightingEnabled = false; document.assets["asset-night-sky"] = { id: "asset-night-sky", kind: "image", sourceName: "night-sky.png", mimeType: "image/png", storageKey: "project-asset:asset-night-sky", byteLength: 2048, metadata: { kind: "image", width: 1024, height: 512, hasAlpha: false, warnings: [] } }; document.time = { startDayNumber: 3, startTimeOfDayHours: 18.5, dayLengthMinutes: 16, sunriseTimeOfDayHours: 5.5, sunsetTimeOfDayHours: 19.75, dawnDurationHours: 1.25, duskDurationHours: 1.75 }; document.scenes["scene-cellar"].world.timeOfDay = { dawn: { skyTopColorHex: "#6680bc", skyBottomColorHex: "#f3b07a", ambientColorHex: "#ffe0ba", ambientIntensityFactor: 0.78, lightColorHex: "#ffd29d", lightIntensityFactor: 0.82 }, dusk: { skyTopColorHex: "#313d70", skyBottomColorHex: "#e27b5e", ambientColorHex: "#f0bf9f", ambientIntensityFactor: 0.58, lightColorHex: "#ff9d79", lightIntensityFactor: 0.61 }, night: { background: { mode: "image", assetId: "asset-night-sky", environmentIntensity: 0.42 }, ambientColorHex: "#1a2941", ambientIntensityFactor: 0.22, lightColorHex: "#95b0ff", lightIntensityFactor: 0.19 } }; const serializedDocument = serializeProjectDocument(document); expect(parseProjectDocumentJson(serializedDocument)).toEqual(document); }); it("migrates pre-project-time multi-scene documents to default project time settings", () => { const migratedDocument = parseProjectDocumentJson( JSON.stringify({ version: AUTHORED_OBJECT_STATE_SCENE_DOCUMENT_VERSION, name: "Legacy Project", activeSceneId: "scene-main", scenes: { "scene-main": createEmptyProjectScene({ id: "scene-main", name: "Legacy Entry" }) }, materials: createEmptyProjectDocument().materials, textures: {}, assets: {} }) ); expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); expect(migratedDocument.time).toEqual(createDefaultProjectTimeSettings()); }); it("migrates legacy project time environment profiles into scene world settings", () => { const legacyProject = createEmptyProjectDocument({ name: "Legacy Time Project", sceneName: "Atrium" }); const legacyScene = legacyProject.scenes[legacyProject.activeSceneId]; if (legacyScene === undefined) { throw new Error("Expected the legacy project to contain an active scene."); } const migratedDocument = parseProjectDocumentJson( JSON.stringify({ version: PROJECT_TIME_DAY_NIGHT_PROFILE_SCENE_DOCUMENT_VERSION, name: legacyProject.name, activeSceneId: legacyProject.activeSceneId, scenes: { [legacyScene.id]: { ...legacyScene, world: { ...legacyScene.world, projectTimeLightingEnabled: undefined, timeOfDay: undefined } } }, materials: legacyProject.materials, textures: legacyProject.textures, assets: legacyProject.assets, time: { startDayNumber: 2, startTimeOfDayHours: 17.5, dayLengthMinutes: 20, sunriseTimeOfDayHours: 6.25, sunsetTimeOfDayHours: 19.5, dawnDurationHours: 1.25, duskDurationHours: 1.75, dawn: { ...createDefaultWorldSettings().timeOfDay.dawn, ambientIntensityFactor: 0.74 }, dusk: { ...createDefaultWorldSettings().timeOfDay.dusk, lightIntensityFactor: 0.63 }, night: { ...createDefaultWorldTimePhaseProfile("night"), lightIntensityFactor: 0.21 } } }) ); expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); expect(migratedDocument.time.startDayNumber).toBe(2); expect(migratedDocument.time.startTimeOfDayHours).toBe(17.5); expect(migratedDocument.time.dayLengthMinutes).toBe(20); expect(migratedDocument.time.sunriseTimeOfDayHours).toBe(6.25); expect( migratedDocument.scenes[migratedDocument.activeSceneId]?.world.timeOfDay .dawn.ambientIntensityFactor ).toBe(0.74); expect( migratedDocument.scenes[migratedDocument.activeSceneId]?.world.timeOfDay .dusk.lightIntensityFactor ).toBe(0.63); expect( migratedDocument.scenes[migratedDocument.activeSceneId]?.world.timeOfDay .night.lightIntensityFactor ).toBe(0.21); expect( migratedDocument.scenes[migratedDocument.activeSceneId]?.world.timeOfDay .night.background ).toEqual( createDefaultWorldSettings().timeOfDay.night.background ); expect( migratedDocument.scenes[migratedDocument.activeSceneId]?.world .projectTimeLightingEnabled ).toBe(true); }); it("migrates pre-project-name multi-scene documents to Untitled Project", () => { const migratedDocument = parseProjectDocumentJson( JSON.stringify({ version: PLAYER_START_GAMEPAD_CAMERA_LOOK_SCENE_DOCUMENT_VERSION, activeSceneId: "scene-main", scenes: { "scene-main": (() => { const legacyScene = createEmptyProjectScene({ id: "scene-main", name: "Legacy Entry" }); return { ...legacyScene, editorPreferences: undefined }; })() }, materials: createEmptyProjectDocument().materials, textures: {}, assets: {} }) ); expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); expect(migratedDocument.name).toBe(DEFAULT_PROJECT_NAME); expect(migratedDocument.scenes["scene-main"]?.name).toBe("Legacy Entry"); expect(migratedDocument.scenes["scene-main"]?.editorPreferences).toMatchObject( { whiteboxSelectionMode: "object", whiteboxSnapEnabled: true, whiteboxSnapStep: DEFAULT_SCENE_EDITOR_SNAP_STEP, viewportGridVisible: true, viewportLayoutMode: "single", activeViewportPanelId: "topLeft" } ); }); it("migrates v23 project documents without Scene Entry and Scene Exit entities", () => { const legacyScene = createEmptyProjectScene({ id: "scene-main", name: "Legacy Entry" }); const migratedDocument = parseProjectDocumentJson( JSON.stringify({ version: RUNNER_LOADING_SCREEN_SCENE_DOCUMENT_VERSION, activeSceneId: "scene-main", scenes: { "scene-main": legacyScene }, materials: createEmptyProjectDocument().materials, textures: {}, assets: {} }) ); expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); expect(migratedDocument.scenes["scene-main"]?.loadingScreen).toEqual( legacyScene.loadingScreen ); }); it("rejects Scene Exit targets that point at a missing Scene Entry", () => { const document = createEmptyProjectDocument({ sceneName: "Outside" }); const targetScene = createEmptyProjectScene({ id: "scene-house", name: "House" }); document.scenes[targetScene.id] = targetScene; document.scenes["scene-main"].entities["entity-scene-exit-door"] = createSceneExitEntity({ id: "entity-scene-exit-door", targetSceneId: targetScene.id, targetEntryEntityId: "missing-entry" }); expect(() => parseProjectDocumentJson(JSON.stringify(document)) ).toThrow("target entry"); }); });