Files
webeditor3d/tests/serialization/project-document-json.test.ts
Victor Giers a8f89f8007 auto-git:
[change] tests/serialization/project-document-json.test.ts
2026-04-25 01:37:07 +02:00

1200 lines
36 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
createActorControlTargetRef,
createFollowActorPathControlEffect,
createLightControlTargetRef,
createPlayActorAnimationControlEffect,
createSetLightIntensityControlEffect,
createSetActorPresenceControlEffect
} from "../../src/controls/control-surface";
import {
AUTHORED_OBJECT_STATE_SCENE_DOCUMENT_VERSION,
DEFAULT_PROJECT_NAME,
DEFAULT_SCENE_EDITOR_SNAP_STEP,
NPC_COLLIDER_SCENE_DOCUMENT_VERSION,
NPC_DIALOGUE_LINE_SPEAKER_REMOVED_SCENE_DOCUMENT_VERSION,
NPC_ONLY_DIALOGUES_SCENE_DOCUMENT_VERSION,
PROJECT_DIALOGUE_LIBRARY_SCENE_DOCUMENT_VERSION,
PROJECT_SEQUENCE_EFFECTS_SCENE_DOCUMENT_VERSION,
NPC_PRESENCE_SCENE_DOCUMENT_VERSION,
PLAYER_START_PAUSE_BINDINGS_SCENE_DOCUMENT_VERSION,
PLAYER_START_GAMEPAD_CAMERA_LOOK_SCENE_DOCUMENT_VERSION,
PROJECT_TIME_DAY_NIGHT_PROFILE_SCENE_DOCUMENT_VERSION,
RUNNER_LOADING_SCREEN_SCENE_DOCUMENT_VERSION,
SCHEDULER_CONTROL_EFFECTS_SCENE_DOCUMENT_VERSION,
SCENE_DOCUMENT_VERSION,
WHITEBOX_BOX_LIGHT_VOLUME_SCENE_DOCUMENT_VERSION,
createEmptyProjectDocument,
createEmptyProjectScene
} from "../../src/document/scene-document";
import { createBoxBrush } from "../../src/document/brushes";
import { createScenePath } from "../../src/document/paths";
import { createDefaultProjectTimeSettings } from "../../src/document/project-time-settings";
import {
createDefaultWorldSettings,
createDefaultWorldTimePhaseProfile
} from "../../src/document/world-settings";
import {
createPointLightEntity,
createNpcEntity,
createNpcTimeWindowPresence,
createSceneEntryEntity,
createTriggerVolumeEntity
} from "../../src/entities/entity-instances";
import { createProjectScheduleRoutine } from "../../src/scheduler/project-scheduler";
import { createProjectSequence } from "../../src/sequencer/project-sequences";
import { createRunSequenceInteractionLink } from "../../src/interactions/interaction-links";
import {
parseProjectDocumentJson,
serializeProjectDocument
} from "../../src/serialization/scene-document-json";
describe("project document JSON", () => {
it("round-trips shader sky world settings in project scenes", () => {
const document = createEmptyProjectDocument({
name: "Shader Sky Project"
});
const activeScene = document.scenes[document.activeSceneId];
if (activeScene === undefined) {
throw new Error("Expected an active scene in the project document.");
}
activeScene.world.background = {
mode: "shader"
};
activeScene.world.showCelestialBodies = true;
activeScene.world.celestialOrbits.sun.azimuthDegrees = 132;
activeScene.world.celestialOrbits.moon.azimuthDegrees = 204;
activeScene.world.shaderSky.dayTopColorHex = "#4a76bb";
activeScene.world.shaderSky.dayBottomColorHex = "#d6efff";
activeScene.world.shaderSky.horizonHeight = -0.06;
activeScene.world.shaderSky.stars.density = 0.72;
activeScene.world.shaderSky.stars.horizonFadeOffset = 0.09;
activeScene.world.shaderSky.clouds.coverage = 0.63;
activeScene.world.shaderSky.clouds.tintHex = "#ece7df";
activeScene.world.shaderSky.aurora.enabled = true;
activeScene.world.shaderSky.aurora.intensity = 1.5;
activeScene.world.shaderSky.aurora.height = 0.74;
activeScene.world.shaderSky.aurora.primaryColorHex = "#8affd4";
activeScene.world.shaderSky.aurora.secondaryColorHex = "#6f90ff";
expect(
parseProjectDocumentJson(serializeProjectDocument(document))
).toEqual(document);
});
it("round-trips scene transition sequence effects", () => {
const document = createEmptyProjectDocument({
name: "Scene Transition Sequence Project"
});
const targetScene = createEmptyProjectScene({
id: "scene-house",
name: "House"
});
const houseEntry = createSceneEntryEntity({
id: "entity-scene-entry-house-front",
position: {
x: 0,
y: 0,
z: 0
}
});
targetScene.entities[houseEntry.id] = houseEntry;
document.scenes[targetScene.id] = targetScene;
document.sequences.sequences["sequence-enter-house"] =
createProjectSequence({
id: "sequence-enter-house",
title: "Enter House",
effects: [
{
stepClass: "impulse",
type: "startSceneTransition",
targetSceneId: targetScene.id,
targetEntryEntityId: houseEntry.id
}
]
});
expect(
parseProjectDocumentJson(serializeProjectDocument(document))
).toEqual(document);
});
it("round-trips NPC dialogue references in project scenes", () => {
const document = createEmptyProjectDocument({
name: "NPC Dialogue Project"
});
const npc = createNpcEntity({
id: "entity-npc-merchant",
actorId: "actor-merchant",
dialogues: [
{
id: "dialogue-market",
title: "Market",
lines: [
{
id: "dialogue-line-market-1",
text: "Fresh bread."
}
]
}
],
defaultDialogueId: "dialogue-market"
});
document.scenes[document.activeSceneId]!.entities[npc.id] = npc;
expect(
parseProjectDocumentJson(serializeProjectDocument(document))
).toEqual(document);
});
it("migrates legacy NPC dialogue speaker fields by deriving names from actor ids instead", () => {
const document = createEmptyProjectDocument({
name: "Legacy NPC Speaker Project"
});
const npc = createNpcEntity({
id: "entity-npc-merchant",
actorId: "actor-merchant",
dialogues: [
{
id: "dialogue-market",
title: "Market",
lines: [
{
id: "dialogue-line-market-1",
text: "Fresh bread."
}
]
}
],
defaultDialogueId: "dialogue-market"
});
document.scenes[document.activeSceneId]!.entities[npc.id] = npc;
const legacyDocument = JSON.parse(
serializeProjectDocument(document)
) as Record<string, unknown>;
legacyDocument.version = NPC_ONLY_DIALOGUES_SCENE_DOCUMENT_VERSION;
(
(
(legacyDocument.scenes as Record<string, unknown>)[
document.activeSceneId
] as {
entities: Record<string, unknown>;
}
).entities[npc.id] as {
dialogues: Array<{
lines: Array<Record<string, unknown>>;
}>;
}
).dialogues[0]!.lines[0]!.speakerName = "Merchant";
const migratedDocument = parseProjectDocumentJson(
JSON.stringify(legacyDocument)
);
const migratedNpc =
migratedDocument.scenes[migratedDocument.activeSceneId]!.entities[npc.id];
expect(migratedNpc).toEqual(
expect.objectContaining({
kind: "npc",
actorId: "actor-merchant",
dialogues: [
{
id: "dialogue-market",
title: "Market",
lines: [
{
id: "dialogue-line-market-1",
text: "Fresh bread."
}
]
}
]
})
);
expect(
"speakerName" in (migratedNpc as typeof npc).dialogues[0]!.lines[0]!
).toBe(false);
});
it("migrates legacy actor follow-path effects to smooth paths by default", () => {
const document = createEmptyProjectDocument({
name: "Legacy Smooth Path Project"
});
const npc = createNpcEntity({
id: "entity-npc-guard",
actorId: "actor-guard"
});
document.scenes[document.activeSceneId]!.entities[npc.id] = npc;
document.scenes[document.activeSceneId]!.paths["path-guard"] =
createScenePath({
id: "path-guard"
});
document.sequences.sequences["sequence-patrol"] = createProjectSequence({
id: "sequence-patrol",
title: "Patrol",
effects: [
{
stepClass: "held",
type: "controlEffect",
effect: createFollowActorPathControlEffect({
target: createActorControlTargetRef("actor-guard"),
pathId: "path-guard",
speed: 1.5,
loop: false
})
}
]
});
const legacyDocument = JSON.parse(
serializeProjectDocument(document)
) as Record<string, unknown>;
legacyDocument.version =
NPC_DIALOGUE_LINE_SPEAKER_REMOVED_SCENE_DOCUMENT_VERSION;
delete (
(
legacyDocument.sequences as {
sequences: Record<
string,
{ effects: Array<{ effect?: Record<string, unknown> }> }
>;
}
).sequences["sequence-patrol"]!.effects[0]!.effect as Record<
string,
unknown
>
).smoothPath;
const migratedDocument = parseProjectDocumentJson(
JSON.stringify(legacyDocument)
);
const migratedEffect =
migratedDocument.sequences.sequences["sequence-patrol"]!.effects[0]!;
expect(migratedEffect).toEqual(
expect.objectContaining({
type: "controlEffect",
effect: expect.objectContaining({
type: "followActorPath",
smoothPath: true
})
})
);
});
it("migrates project NPCs without dialogue references from v50 to null NPC dialogue defaults", () => {
const document = createEmptyProjectDocument({
name: "Legacy NPC Dialogue Project"
});
const npc = createNpcEntity({
id: "entity-npc-guide",
actorId: "actor-guide"
});
document.scenes[document.activeSceneId]!.entities[npc.id] = npc;
const legacyDocument = JSON.parse(
serializeProjectDocument(document)
) as Record<string, unknown>;
legacyDocument.version = PROJECT_DIALOGUE_LIBRARY_SCENE_DOCUMENT_VERSION;
delete (legacyDocument.scenes as Record<string, unknown>)[
document.activeSceneId
];
legacyDocument.scenes = {
[document.activeSceneId]: {
...document.scenes[document.activeSceneId],
entities: {
[npc.id]: {
...document.scenes[document.activeSceneId]!.entities[npc.id],
dialogueId: undefined
}
}
}
};
const migratedDocument = parseProjectDocumentJson(
JSON.stringify(legacyDocument)
);
const migratedNpc =
migratedDocument.scenes[migratedDocument.activeSceneId]!.entities[npc.id];
expect(migratedNpc).toEqual(
expect.objectContaining({
kind: "npc",
dialogues: [],
defaultDialogueId: null
})
);
});
it("round-trips project sequences referenced by routines and interaction links", () => {
const document = createEmptyProjectDocument({
name: "Sequence Project"
});
const npc = createNpcEntity({
id: "entity-npc-vendor-sequence",
actorId: "actor-vendor"
});
document.scenes[document.activeSceneId]!.entities[npc.id] = createNpcEntity(
{
...npc,
dialogues: [
{
id: "dialogue-market",
title: "Market Greeting",
lines: [
{
id: "dialogue-line-market-1",
text: "Fresh fruit."
}
]
}
],
defaultDialogueId: "dialogue-market"
}
);
document.sequences.sequences["sequence-market-dialogue"] =
createProjectSequence({
id: "sequence-market-dialogue",
title: "Market Greeting Sequence",
steps: [
{
stepClass: "impulse",
type: "makeNpcTalk",
npcEntityId: npc.id,
dialogueId: "dialogue-market"
}
]
});
document.sequences.sequences["sequence-vendor-open"] =
createProjectSequence({
id: "sequence-vendor-open",
title: "Vendor Open",
steps: [
{
stepClass: "held",
type: "controlEffect",
effect: createSetActorPresenceControlEffect({
target: createActorControlTargetRef("actor-vendor"),
active: true
})
}
]
});
document.scheduler.routines["routine-vendor-open"] =
createProjectScheduleRoutine({
id: "routine-vendor-open",
title: "Vendor Open",
target: createActorControlTargetRef("actor-vendor"),
sequenceId: "sequence-vendor-open",
effect: createSetActorPresenceControlEffect({
target: createActorControlTargetRef("actor-vendor"),
active: false
})
});
document.scenes[document.activeSceneId]!.entities[
"entity-trigger-sequence"
] = createTriggerVolumeEntity({
id: "entity-trigger-sequence"
});
document.scenes[document.activeSceneId]!.interactionLinks["link-sequence"] =
createRunSequenceInteractionLink({
id: "link-sequence",
sourceEntityId: "entity-trigger-sequence",
sequenceId: "sequence-market-dialogue"
});
expect(
parseProjectDocumentJson(serializeProjectDocument(document))
).toEqual(document);
});
it("migrates v52 project documents without sequences to an empty project sequence library", () => {
const document = createEmptyProjectDocument({
name: "Legacy Sequence Project"
});
const legacyDocument = JSON.parse(
serializeProjectDocument(document)
) as Record<string, unknown>;
legacyDocument.version = PLAYER_START_PAUSE_BINDINGS_SCENE_DOCUMENT_VERSION;
delete legacyDocument.sequences;
const migratedDocument = parseProjectDocumentJson(
JSON.stringify(legacyDocument)
);
expect(migratedDocument.sequences).toEqual({
sequences: {}
});
});
it("migrates legacy toggle visibility sequence effects to unified visibility effects", () => {
const document = createEmptyProjectDocument({
name: "Legacy Visibility Sequence Project"
});
document.sequences.sequences["sequence-legacy-visibility"] =
createProjectSequence({
id: "sequence-legacy-visibility",
title: "Legacy Visibility Sequence",
effects: [
{
stepClass: "impulse",
type: "setVisibility",
target: {
kind: "brush",
brushId: "brush-legacy-wall"
},
mode: "hide"
}
]
});
document.scenes[document.activeSceneId]!.brushes["brush-legacy-wall"] =
createBoxBrush({
id: "brush-legacy-wall",
name: "Legacy Wall"
});
const legacyDocument = JSON.parse(
serializeProjectDocument(document)
) as Record<string, unknown>;
legacyDocument.version = PROJECT_SEQUENCE_EFFECTS_SCENE_DOCUMENT_VERSION;
const legacySequence = (
legacyDocument.sequences as { sequences: Record<string, unknown> }
).sequences["sequence-legacy-visibility"] as {
effects: Array<Record<string, unknown>>;
};
legacySequence.effects = [
{
stepClass: "impulse",
type: "toggleVisibility",
targetBrushId: "brush-legacy-wall",
visible: false
}
];
const migratedDocument = parseProjectDocumentJson(
JSON.stringify(legacyDocument)
);
expect(
migratedDocument.sequences.sequences["sequence-legacy-visibility"]
).toEqual(
expect.objectContaining({
effects: [
{
stepClass: "impulse",
type: "setVisibility",
target: {
kind: "brush",
brushId: "brush-legacy-wall"
},
mode: "hide"
}
]
})
);
});
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 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-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.scenes["scene-cellar"].world.showCelestialBodies = true;
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: {
background: {
mode: "verticalGradient",
topColorHex: "#6680bc",
bottomColorHex: "#f3b07a"
},
skyTopColorHex: "#6680bc",
skyBottomColorHex: "#f3b07a",
ambientColorHex: "#ffe0ba",
ambientIntensityFactor: 0.78,
lightColorHex: "#ffd29d",
lightIntensityFactor: 0.82
},
dusk: {
background: {
mode: "verticalGradient",
topColorHex: "#313d70",
bottomColorHex: "#e27b5e"
},
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
}
};
document.scenes["scene-cellar"].paths["path-cellar-patrol"] =
createScenePath({
id: "path-cellar-patrol",
name: "Cellar Patrol",
loop: true,
points: [
{
id: "path-point-a",
position: {
x: -2,
y: 0,
z: 1
}
},
{
id: "path-point-b",
position: {
x: 0,
y: 0,
z: 3
}
},
{
id: "path-point-c",
position: {
x: 2,
y: 0,
z: 1
}
}
]
});
const serializedDocument = serializeProjectDocument(document);
expect(parseProjectDocumentJson(serializedDocument)).toEqual(document);
});
it("round-trips project scheduler routines bound to actor presence control", () => {
const document = createEmptyProjectDocument({
name: "Routine Project"
});
const npc = createNpcEntity({
id: "entity-npc-vendor",
actorId: "actor-vendor"
});
document.scenes[document.activeSceneId].entities[npc.id] = npc;
document.scheduler.routines["routine-vendor-open"] =
createProjectScheduleRoutine({
id: "routine-vendor-open",
title: "Vendor Open",
target: createActorControlTargetRef(npc.actorId),
startHour: 8,
endHour: 16,
priority: 2,
effect: createSetActorPresenceControlEffect({
target: createActorControlTargetRef(npc.actorId),
active: true
})
});
expect(
parseProjectDocumentJson(serializeProjectDocument(document))
).toEqual(document);
});
it("round-trips actor scheduler routines with animation and follow-path effects", () => {
const document = createEmptyProjectDocument({
name: "Routine Project"
});
const npcModelAsset = {
id: "asset-model-patroller",
kind: "model" as const,
sourceName: "patroller.glb",
mimeType: "model/gltf-binary",
storageKey: "asset:model:patroller",
byteLength: 1024,
metadata: {
kind: "model" as const,
format: "glb" as const,
sceneName: null,
nodeCount: 1,
meshCount: 1,
materialNames: [],
textureNames: [],
animationNames: ["Walk"],
boundingBox: null,
warnings: []
}
};
const npc = createNpcEntity({
id: "entity-npc-patroller",
actorId: "actor-patroller",
modelAssetId: npcModelAsset.id
});
const path = createScenePath({
id: "path-patrol"
});
document.assets[npcModelAsset.id] = npcModelAsset;
document.scenes[document.activeSceneId].entities[npc.id] = npc;
document.scenes[document.activeSceneId].paths[path.id] = path;
document.scheduler.routines["routine-patrol"] =
createProjectScheduleRoutine({
id: "routine-patrol",
title: "Patrolling",
target: createActorControlTargetRef(npc.actorId),
startHour: 9,
endHour: 13,
effects: [
createSetActorPresenceControlEffect({
target: createActorControlTargetRef(npc.actorId),
active: true
}),
createPlayActorAnimationControlEffect({
target: createActorControlTargetRef(npc.actorId),
clipName: "Walk",
loop: true
}),
createFollowActorPathControlEffect({
target: createActorControlTargetRef(npc.actorId),
pathId: path.id,
speed: 2,
loop: false,
progressMode: "deriveFromTime"
})
]
});
expect(
parseProjectDocumentJson(serializeProjectDocument(document))
).toEqual(document);
});
it("round-trips project scheduler routines bound to typed light control effects", () => {
const document = createEmptyProjectDocument({
name: "Lighting Project"
});
const pointLight = createPointLightEntity({
id: "entity-point-light-main",
intensity: 1.25
});
document.scenes[document.activeSceneId].entities[pointLight.id] =
pointLight;
document.scheduler.routines["routine-night-light"] =
createProjectScheduleRoutine({
id: "routine-night-light",
title: "Night Light",
target: createLightControlTargetRef("pointLight", pointLight.id),
startHour: 18,
endHour: 6,
effect: createSetLightIntensityControlEffect({
target: createLightControlTargetRef("pointLight", pointLight.id),
intensity: 0.35
})
});
expect(
parseProjectDocumentJson(serializeProjectDocument(document))
).toEqual(document);
});
it("migrates v48 project documents with legacy single-effect scheduler routines to the current version", () => {
const pointLight = createPointLightEntity({
id: "entity-point-light-main",
intensity: 1.25
});
const migratedDocument = parseProjectDocumentJson(
JSON.stringify({
...createEmptyProjectDocument({
name: "Legacy Scheduler Project"
}),
version: SCHEDULER_CONTROL_EFFECTS_SCENE_DOCUMENT_VERSION,
scenes: {
"scene-main": {
...createEmptyProjectScene({
id: "scene-main",
name: "Legacy Scene"
}),
entities: {
[pointLight.id]: pointLight
}
}
},
scheduler: {
routines: {
"routine-night-light": {
id: "routine-night-light",
title: "Night Light",
enabled: true,
target: {
kind: "entity",
entityKind: "pointLight",
entityId: pointLight.id
},
days: {
mode: "everyDay"
},
startHour: 18,
endHour: 6,
priority: 0,
effect: {
type: "setLightIntensity",
target: {
kind: "entity",
entityKind: "pointLight",
entityId: pointLight.id
},
intensity: 0.35
}
}
}
}
})
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.scheduler.routines["routine-night-light"]).toEqual(
createProjectScheduleRoutine({
id: "routine-night-light",
title: "Night Light",
target: createLightControlTargetRef("pointLight", pointLight.id),
startHour: 18,
endHour: 6,
effect: createSetLightIntensityControlEffect({
target: createLightControlTargetRef("pointLight", pointLight.id),
intensity: 0.35
})
})
);
});
it("migrates pre-path project documents to empty scene path registries", () => {
const migratedDocument = parseProjectDocumentJson(
JSON.stringify({
version: NPC_COLLIDER_SCENE_DOCUMENT_VERSION,
name: "Legacy Pathless Project",
activeSceneId: "scene-main",
scenes: {
"scene-main": {
...createEmptyProjectScene({
id: "scene-main",
name: "Legacy Scene"
}),
paths: undefined
}
},
materials: createEmptyProjectDocument().materials,
textures: {},
assets: {},
time: createDefaultProjectTimeSettings()
})
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.scenes["scene-main"]?.paths).toEqual({});
});
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("defaults legacy scene world celestial body overlays to disabled", () => {
const document = createEmptyProjectDocument({
name: "Celestial Overlay Project"
});
const activeScene = document.scenes[document.activeSceneId];
if (activeScene === undefined) {
throw new Error("Expected an active scene in the project document.");
}
const migratedDocument = parseProjectDocumentJson(
JSON.stringify({
...document,
version: WHITEBOX_BOX_LIGHT_VOLUME_SCENE_DOCUMENT_VERSION,
scenes: {
[activeScene.id]: {
...activeScene,
world: {
...activeScene.world,
showCelestialBodies: undefined
}
}
}
})
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(
migratedDocument.scenes[migratedDocument.activeSceneId]?.world
.showCelestialBodies
).toBe(false);
});
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 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("migrates legacy NPC time-window presence into project scheduler routines", () => {
const legacyNpc = createNpcEntity({
id: "entity-npc-night-guard",
actorId: "actor-night-guard",
presence: createNpcTimeWindowPresence({
startHour: 20,
endHour: 4
})
});
const migratedDocument = parseProjectDocumentJson(
JSON.stringify({
version: NPC_PRESENCE_SCENE_DOCUMENT_VERSION,
name: "Legacy Presence Project",
time: createDefaultProjectTimeSettings(),
activeSceneId: "scene-main",
scenes: {
"scene-main": {
...createEmptyProjectScene({
id: "scene-main",
name: "Main"
}),
entities: {
[legacyNpc.id]: legacyNpc
}
}
},
materials: createEmptyProjectDocument().materials,
textures: {},
assets: {}
})
);
expect(
migratedDocument.scenes["scene-main"]?.entities[legacyNpc.id]
).toEqual(
createNpcEntity({
...legacyNpc,
presence: {
mode: "always"
}
})
);
expect(migratedDocument.scheduler.routines).toEqual({
"schedule-routine-scene-main-entity-npc-night-guard":
expect.objectContaining({
id: "schedule-routine-scene-main-entity-npc-night-guard",
title: legacyNpc.actorId,
startHour: 20,
endHour: 4,
target: {
kind: "actor",
actorId: legacyNpc.actorId
},
effects: [
{
type: "setActorPresence",
target: {
kind: "actor",
actorId: legacyNpc.actorId
},
active: true
}
]
})
});
});
it("rejects scene transition sequence 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.sequences.sequences["sequence-enter-house"] =
createProjectSequence({
id: "sequence-enter-house",
title: "Enter House",
effects: [
{
stepClass: "impulse",
type: "startSceneTransition",
targetSceneId: targetScene.id,
targetEntryEntityId: "missing-entry"
}
]
});
expect(() => parseProjectDocumentJson(JSON.stringify(document))).toThrow(
"target entry"
);
});
});