2026-04-11 04:18:23 +02:00
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
|
|
2026-04-14 02:05:37 +02:00
|
|
|
import {
|
|
|
|
|
createActorControlTargetRef,
|
2026-04-14 13:56:33 +02:00
|
|
|
createFollowActorPathControlEffect,
|
2026-04-14 03:15:27 +02:00
|
|
|
createLightControlTargetRef,
|
2026-04-14 13:56:33 +02:00
|
|
|
createPlayActorAnimationControlEffect,
|
2026-04-14 03:15:27 +02:00
|
|
|
createSetLightIntensityControlEffect,
|
2026-04-14 02:05:37 +02:00
|
|
|
createSetActorPresenceControlEffect
|
|
|
|
|
} from "../../src/controls/control-surface";
|
2026-04-11 04:18:23 +02:00
|
|
|
import {
|
2026-04-12 04:35:13 +02:00
|
|
|
AUTHORED_OBJECT_STATE_SCENE_DOCUMENT_VERSION,
|
2026-04-11 13:26:46 +02:00
|
|
|
DEFAULT_PROJECT_NAME,
|
2026-04-11 14:29:03 +02:00
|
|
|
DEFAULT_SCENE_EDITOR_SNAP_STEP,
|
2026-04-13 21:27:29 +02:00
|
|
|
NPC_COLLIDER_SCENE_DOCUMENT_VERSION,
|
2026-04-15 11:17:07 +02:00
|
|
|
NPC_DIALOGUE_LINE_SPEAKER_REMOVED_SCENE_DOCUMENT_VERSION,
|
2026-04-15 10:15:18 +02:00
|
|
|
NPC_ONLY_DIALOGUES_SCENE_DOCUMENT_VERSION,
|
2026-04-14 20:34:42 +02:00
|
|
|
PROJECT_DIALOGUE_LIBRARY_SCENE_DOCUMENT_VERSION,
|
2026-04-15 01:43:31 +02:00
|
|
|
PROJECT_SEQUENCE_EFFECTS_SCENE_DOCUMENT_VERSION,
|
2026-04-14 02:05:37 +02:00
|
|
|
NPC_PRESENCE_SCENE_DOCUMENT_VERSION,
|
2026-04-14 23:49:55 +02:00
|
|
|
PLAYER_START_PAUSE_BINDINGS_SCENE_DOCUMENT_VERSION,
|
2026-04-11 13:26:46 +02:00
|
|
|
PLAYER_START_GAMEPAD_CAMERA_LOOK_SCENE_DOCUMENT_VERSION,
|
2026-04-12 14:35:09 +02:00
|
|
|
PROJECT_TIME_DAY_NIGHT_PROFILE_SCENE_DOCUMENT_VERSION,
|
2026-04-11 04:40:23 +02:00
|
|
|
RUNNER_LOADING_SCREEN_SCENE_DOCUMENT_VERSION,
|
2026-04-14 13:56:33 +02:00
|
|
|
SCHEDULER_CONTROL_EFFECTS_SCENE_DOCUMENT_VERSION,
|
2026-04-11 04:18:23 +02:00
|
|
|
SCENE_DOCUMENT_VERSION,
|
2026-04-22 14:03:49 +02:00
|
|
|
WHITEBOX_BOX_LIGHT_VOLUME_SCENE_DOCUMENT_VERSION,
|
2026-04-11 04:18:23 +02:00
|
|
|
createEmptyProjectDocument,
|
|
|
|
|
createEmptyProjectScene
|
|
|
|
|
} from "../../src/document/scene-document";
|
2026-04-15 01:45:51 +02:00
|
|
|
import { createBoxBrush } from "../../src/document/brushes";
|
2026-04-13 21:27:29 +02:00
|
|
|
import { createScenePath } from "../../src/document/paths";
|
2026-04-12 04:35:13 +02:00
|
|
|
import { createDefaultProjectTimeSettings } from "../../src/document/project-time-settings";
|
2026-04-13 15:17:03 +02:00
|
|
|
import {
|
|
|
|
|
createDefaultWorldSettings,
|
|
|
|
|
createDefaultWorldTimePhaseProfile
|
|
|
|
|
} from "../../src/document/world-settings";
|
2026-04-11 04:40:23 +02:00
|
|
|
import {
|
2026-04-14 03:15:27 +02:00
|
|
|
createPointLightEntity,
|
2026-04-14 02:05:37 +02:00
|
|
|
createNpcEntity,
|
|
|
|
|
createNpcTimeWindowPresence,
|
2026-04-11 04:40:23 +02:00
|
|
|
createSceneEntryEntity,
|
2026-04-14 23:49:56 +02:00
|
|
|
createTriggerVolumeEntity
|
2026-04-11 04:40:23 +02:00
|
|
|
} from "../../src/entities/entity-instances";
|
2026-04-14 02:05:37 +02:00
|
|
|
import { createProjectScheduleRoutine } from "../../src/scheduler/project-scheduler";
|
2026-04-14 23:49:42 +02:00
|
|
|
import { createProjectSequence } from "../../src/sequencer/project-sequences";
|
|
|
|
|
import { createRunSequenceInteractionLink } from "../../src/interactions/interaction-links";
|
2026-04-11 04:18:23 +02:00
|
|
|
import {
|
|
|
|
|
parseProjectDocumentJson,
|
|
|
|
|
serializeProjectDocument
|
|
|
|
|
} from "../../src/serialization/scene-document-json";
|
|
|
|
|
|
|
|
|
|
describe("project document JSON", () => {
|
2026-04-22 15:29:08 +02:00
|
|
|
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;
|
2026-04-22 16:55:23 +02:00
|
|
|
activeScene.world.celestialOrbits.sun.azimuthDegrees = 132;
|
|
|
|
|
activeScene.world.celestialOrbits.moon.azimuthDegrees = 204;
|
2026-04-22 15:29:08 +02:00
|
|
|
activeScene.world.shaderSky.dayTopColorHex = "#4a76bb";
|
|
|
|
|
activeScene.world.shaderSky.dayBottomColorHex = "#d6efff";
|
2026-04-22 16:00:18 +02:00
|
|
|
activeScene.world.shaderSky.horizonHeight = -0.06;
|
2026-04-22 15:29:08 +02:00
|
|
|
activeScene.world.shaderSky.stars.density = 0.72;
|
2026-04-22 16:23:11 +02:00
|
|
|
activeScene.world.shaderSky.stars.horizonFadeOffset = 0.09;
|
2026-04-22 15:29:08 +02:00
|
|
|
activeScene.world.shaderSky.clouds.coverage = 0.63;
|
|
|
|
|
activeScene.world.shaderSky.clouds.tintHex = "#ece7df";
|
2026-04-25 01:36:01 +02:00
|
|
|
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";
|
2026-04-22 15:29:08 +02:00
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
expect(
|
|
|
|
|
parseProjectDocumentJson(serializeProjectDocument(document))
|
|
|
|
|
).toEqual(document);
|
2026-04-22 15:29:08 +02:00
|
|
|
});
|
|
|
|
|
|
2026-04-15 03:50:49 +02:00
|
|
|
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;
|
2026-04-22 15:30:37 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
});
|
2026-04-15 03:50:49 +02:00
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
expect(
|
|
|
|
|
parseProjectDocumentJson(serializeProjectDocument(document))
|
|
|
|
|
).toEqual(document);
|
2026-04-15 03:50:49 +02:00
|
|
|
});
|
|
|
|
|
|
2026-04-14 20:34:42 +02:00
|
|
|
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",
|
2026-04-15 09:32:33 +02:00
|
|
|
dialogues: [
|
2026-04-14 20:34:42 +02:00
|
|
|
{
|
2026-04-15 09:32:33 +02:00
|
|
|
id: "dialogue-market",
|
|
|
|
|
title: "Market",
|
|
|
|
|
lines: [
|
|
|
|
|
{
|
|
|
|
|
id: "dialogue-line-market-1",
|
|
|
|
|
text: "Fresh bread."
|
|
|
|
|
}
|
|
|
|
|
]
|
2026-04-14 20:34:42 +02:00
|
|
|
}
|
2026-04-15 09:32:33 +02:00
|
|
|
],
|
|
|
|
|
defaultDialogueId: "dialogue-market"
|
|
|
|
|
});
|
2026-04-14 20:34:42 +02:00
|
|
|
document.scenes[document.activeSceneId]!.entities[npc.id] = npc;
|
|
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
expect(
|
|
|
|
|
parseProjectDocumentJson(serializeProjectDocument(document))
|
|
|
|
|
).toEqual(document);
|
2026-04-14 20:34:42 +02:00
|
|
|
});
|
|
|
|
|
|
2026-04-15 10:15:18 +02:00
|
|
|
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;
|
|
|
|
|
(
|
|
|
|
|
(
|
2026-04-22 15:30:37 +02:00
|
|
|
(legacyDocument.scenes as Record<string, unknown>)[
|
|
|
|
|
document.activeSceneId
|
|
|
|
|
] as {
|
2026-04-15 10:15:18 +02:00
|
|
|
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)
|
|
|
|
|
);
|
2026-04-22 15:30:37 +02:00
|
|
|
const migratedNpc =
|
|
|
|
|
migratedDocument.scenes[migratedDocument.activeSceneId]!.entities[npc.id];
|
2026-04-15 10:15:18 +02:00
|
|
|
|
|
|
|
|
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(
|
2026-04-22 15:30:37 +02:00
|
|
|
"speakerName" in (migratedNpc as typeof npc).dialogues[0]!.lines[0]!
|
2026-04-15 10:15:18 +02:00
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-15 11:17:07 +02:00
|
|
|
it("migrates legacy actor follow-path effects to smooth paths by default", () => {
|
|
|
|
|
const document = createEmptyProjectDocument({
|
|
|
|
|
name: "Legacy Smooth Path Project"
|
|
|
|
|
});
|
2026-04-15 11:21:51 +02:00
|
|
|
const npc = createNpcEntity({
|
|
|
|
|
id: "entity-npc-guard",
|
|
|
|
|
actorId: "actor-guard"
|
|
|
|
|
});
|
|
|
|
|
document.scenes[document.activeSceneId]!.entities[npc.id] = npc;
|
2026-04-22 15:30:37 +02:00
|
|
|
document.scenes[document.activeSceneId]!.paths["path-guard"] =
|
|
|
|
|
createScenePath({
|
|
|
|
|
id: "path-guard"
|
|
|
|
|
});
|
2026-04-15 11:17:07 +02:00
|
|
|
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>;
|
2026-04-22 15:30:37 +02:00
|
|
|
legacyDocument.version =
|
|
|
|
|
NPC_DIALOGUE_LINE_SPEAKER_REMOVED_SCENE_DOCUMENT_VERSION;
|
2026-04-15 11:17:07 +02:00
|
|
|
delete (
|
|
|
|
|
(
|
2026-04-22 15:30:37 +02:00
|
|
|
legacyDocument.sequences as {
|
|
|
|
|
sequences: Record<
|
|
|
|
|
string,
|
|
|
|
|
{ effects: Array<{ effect?: Record<string, unknown> }> }
|
|
|
|
|
>;
|
|
|
|
|
}
|
|
|
|
|
).sequences["sequence-patrol"]!.effects[0]!.effect as Record<
|
|
|
|
|
string,
|
|
|
|
|
unknown
|
|
|
|
|
>
|
|
|
|
|
).smoothPath;
|
2026-04-15 11:17:07 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-15 09:32:33 +02:00
|
|
|
it("migrates project NPCs without dialogue references from v50 to null NPC dialogue defaults", () => {
|
2026-04-14 20:34:42 +02:00
|
|
|
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)
|
|
|
|
|
);
|
2026-04-22 15:30:37 +02:00
|
|
|
const migratedNpc =
|
|
|
|
|
migratedDocument.scenes[migratedDocument.activeSceneId]!.entities[npc.id];
|
2026-04-14 20:34:42 +02:00
|
|
|
|
|
|
|
|
expect(migratedNpc).toEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
kind: "npc",
|
2026-04-15 09:32:33 +02:00
|
|
|
dialogues: [],
|
|
|
|
|
defaultDialogueId: null
|
2026-04-14 20:34:42 +02:00
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-14 23:49:42 +02:00
|
|
|
it("round-trips project sequences referenced by routines and interaction links", () => {
|
|
|
|
|
const document = createEmptyProjectDocument({
|
|
|
|
|
name: "Sequence Project"
|
|
|
|
|
});
|
2026-04-14 23:53:27 +02:00
|
|
|
const npc = createNpcEntity({
|
|
|
|
|
id: "entity-npc-vendor-sequence",
|
|
|
|
|
actorId: "actor-vendor"
|
|
|
|
|
});
|
2026-04-14 23:49:42 +02:00
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
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",
|
2026-04-14 23:49:42 +02:00
|
|
|
target: createActorControlTargetRef("actor-vendor"),
|
2026-04-22 15:30:37 +02:00
|
|
|
sequenceId: "sequence-vendor-open",
|
|
|
|
|
effect: createSetActorPresenceControlEffect({
|
|
|
|
|
target: createActorControlTargetRef("actor-vendor"),
|
|
|
|
|
active: false
|
|
|
|
|
})
|
2026-04-14 23:49:56 +02:00
|
|
|
});
|
2026-04-22 15:30:37 +02:00
|
|
|
document.scenes[document.activeSceneId]!.entities[
|
|
|
|
|
"entity-trigger-sequence"
|
|
|
|
|
] = createTriggerVolumeEntity({
|
|
|
|
|
id: "entity-trigger-sequence"
|
|
|
|
|
});
|
2026-04-14 23:49:42 +02:00
|
|
|
document.scenes[document.activeSceneId]!.interactionLinks["link-sequence"] =
|
|
|
|
|
createRunSequenceInteractionLink({
|
|
|
|
|
id: "link-sequence",
|
|
|
|
|
sourceEntityId: "entity-trigger-sequence",
|
|
|
|
|
sequenceId: "sequence-market-dialogue"
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
expect(
|
|
|
|
|
parseProjectDocumentJson(serializeProjectDocument(document))
|
|
|
|
|
).toEqual(document);
|
2026-04-14 23:49:42 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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>;
|
|
|
|
|
|
2026-04-14 23:49:55 +02:00
|
|
|
legacyDocument.version = PLAYER_START_PAUSE_BINDINGS_SCENE_DOCUMENT_VERSION;
|
2026-04-14 23:49:42 +02:00
|
|
|
delete legacyDocument.sequences;
|
|
|
|
|
|
|
|
|
|
const migratedDocument = parseProjectDocumentJson(
|
|
|
|
|
JSON.stringify(legacyDocument)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(migratedDocument.sequences).toEqual({
|
|
|
|
|
sequences: {}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-15 01:43:31 +02:00
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
});
|
2026-04-15 01:45:51 +02:00
|
|
|
document.scenes[document.activeSceneId]!.brushes["brush-legacy-wall"] =
|
|
|
|
|
createBoxBrush({
|
|
|
|
|
id: "brush-legacy-wall",
|
|
|
|
|
name: "Legacy Wall"
|
|
|
|
|
});
|
2026-04-15 01:43:31 +02:00
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-11 13:26:46 +02:00
|
|
|
it("round-trips the project name and authored scene loading overlay settings", () => {
|
2026-04-11 04:40:23 +02:00
|
|
|
const cellarEntry = createSceneEntryEntity({
|
|
|
|
|
id: "entity-scene-entry-cellar-stairs",
|
|
|
|
|
position: {
|
|
|
|
|
x: 1,
|
|
|
|
|
y: 0,
|
|
|
|
|
z: -2
|
|
|
|
|
},
|
|
|
|
|
yawDegrees: 180
|
|
|
|
|
});
|
2026-04-11 04:18:23 +02:00
|
|
|
const document = {
|
2026-04-11 13:26:46 +02:00
|
|
|
...createEmptyProjectDocument({
|
|
|
|
|
name: "Castle Project",
|
|
|
|
|
sceneName: "Entry"
|
|
|
|
|
}),
|
2026-04-11 04:18:23 +02:00
|
|
|
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."
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-04-11 04:40:23 +02:00
|
|
|
document.scenes["scene-cellar"].entities[cellarEntry.id] = cellarEntry;
|
2026-04-11 14:29:03 +02:00
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-04-12 01:05:39 +02:00
|
|
|
document.scenes["scene-cellar"].world.advancedRendering = {
|
|
|
|
|
...document.scenes["scene-cellar"].world.advancedRendering,
|
|
|
|
|
enabled: true,
|
|
|
|
|
whiteboxBevel: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
edgeWidth: 0.16,
|
|
|
|
|
normalStrength: 0.85
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-04-12 14:14:12 +02:00
|
|
|
document.scenes["scene-cellar"].world.projectTimeLightingEnabled = false;
|
2026-04-22 14:03:49 +02:00
|
|
|
document.scenes["scene-cellar"].world.showCelestialBodies = true;
|
2026-04-12 14:34:47 +02:00
|
|
|
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: []
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-04-12 04:35:13 +02:00
|
|
|
document.time = {
|
2026-04-12 14:14:12 +02:00
|
|
|
startDayNumber: 3,
|
2026-04-12 04:35:13 +02:00
|
|
|
startTimeOfDayHours: 18.5,
|
2026-04-12 14:14:12 +02:00
|
|
|
dayLengthMinutes: 16,
|
|
|
|
|
sunriseTimeOfDayHours: 5.5,
|
|
|
|
|
sunsetTimeOfDayHours: 19.75,
|
|
|
|
|
dawnDurationHours: 1.25,
|
2026-04-13 15:11:15 +02:00
|
|
|
duskDurationHours: 1.75
|
|
|
|
|
};
|
|
|
|
|
document.scenes["scene-cellar"].world.timeOfDay = {
|
2026-04-12 14:14:12 +02:00
|
|
|
dawn: {
|
2026-04-22 12:34:14 +02:00
|
|
|
background: {
|
|
|
|
|
mode: "verticalGradient",
|
|
|
|
|
topColorHex: "#6680bc",
|
|
|
|
|
bottomColorHex: "#f3b07a"
|
|
|
|
|
},
|
2026-04-12 14:14:12 +02:00
|
|
|
skyTopColorHex: "#6680bc",
|
|
|
|
|
skyBottomColorHex: "#f3b07a",
|
|
|
|
|
ambientColorHex: "#ffe0ba",
|
|
|
|
|
ambientIntensityFactor: 0.78,
|
|
|
|
|
lightColorHex: "#ffd29d",
|
|
|
|
|
lightIntensityFactor: 0.82
|
|
|
|
|
},
|
|
|
|
|
dusk: {
|
2026-04-22 12:34:14 +02:00
|
|
|
background: {
|
|
|
|
|
mode: "verticalGradient",
|
|
|
|
|
topColorHex: "#313d70",
|
|
|
|
|
bottomColorHex: "#e27b5e"
|
|
|
|
|
},
|
2026-04-12 14:14:12 +02:00
|
|
|
skyTopColorHex: "#313d70",
|
|
|
|
|
skyBottomColorHex: "#e27b5e",
|
|
|
|
|
ambientColorHex: "#f0bf9f",
|
|
|
|
|
ambientIntensityFactor: 0.58,
|
|
|
|
|
lightColorHex: "#ff9d79",
|
|
|
|
|
lightIntensityFactor: 0.61
|
|
|
|
|
},
|
|
|
|
|
night: {
|
2026-04-13 15:11:15 +02:00
|
|
|
background: {
|
|
|
|
|
mode: "image",
|
|
|
|
|
assetId: "asset-night-sky",
|
|
|
|
|
environmentIntensity: 0.42
|
|
|
|
|
},
|
2026-04-12 14:14:12 +02:00
|
|
|
ambientColorHex: "#1a2941",
|
|
|
|
|
ambientIntensityFactor: 0.22,
|
|
|
|
|
lightColorHex: "#95b0ff",
|
|
|
|
|
lightIntensityFactor: 0.19
|
|
|
|
|
}
|
2026-04-12 04:35:13 +02:00
|
|
|
};
|
2026-04-22 15:30:37 +02:00
|
|
|
document.scenes["scene-cellar"].paths["path-cellar-patrol"] =
|
|
|
|
|
createScenePath({
|
2026-04-13 21:27:29 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
2026-04-22 15:30:37 +02:00
|
|
|
});
|
2026-04-11 04:18:23 +02:00
|
|
|
|
|
|
|
|
const serializedDocument = serializeProjectDocument(document);
|
|
|
|
|
|
|
|
|
|
expect(parseProjectDocumentJson(serializedDocument)).toEqual(document);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-14 02:05:47 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-14 13:56:33 +02:00
|
|
|
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;
|
2026-04-22 15:30:37 +02:00
|
|
|
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"
|
|
|
|
|
})
|
|
|
|
|
]
|
|
|
|
|
});
|
2026-04-14 13:56:33 +02:00
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
parseProjectDocumentJson(serializeProjectDocument(document))
|
|
|
|
|
).toEqual(document);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-14 03:15:27 +02:00
|
|
|
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
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
document.scenes[document.activeSceneId].entities[pointLight.id] =
|
|
|
|
|
pointLight;
|
2026-04-14 03:15:27 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-14 13:56:33 +02:00
|
|
|
it("migrates v48 project documents with legacy single-effect scheduler routines to the current version", () => {
|
2026-04-14 03:15:27 +02:00
|
|
|
const pointLight = createPointLightEntity({
|
|
|
|
|
id: "entity-point-light-main",
|
|
|
|
|
intensity: 1.25
|
|
|
|
|
});
|
|
|
|
|
const migratedDocument = parseProjectDocumentJson(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
...createEmptyProjectDocument({
|
|
|
|
|
name: "Legacy Scheduler Project"
|
|
|
|
|
}),
|
2026-04-14 13:56:33 +02:00
|
|
|
version: SCHEDULER_CONTROL_EFFECTS_SCENE_DOCUMENT_VERSION,
|
2026-04-14 03:15:27 +02:00
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-13 21:27:29 +02:00
|
|
|
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: {
|
2026-04-13 21:27:44 +02:00
|
|
|
"scene-main": {
|
|
|
|
|
...createEmptyProjectScene({
|
|
|
|
|
id: "scene-main",
|
|
|
|
|
name: "Legacy Scene"
|
|
|
|
|
}),
|
|
|
|
|
paths: undefined
|
|
|
|
|
}
|
2026-04-13 21:27:29 +02:00
|
|
|
},
|
|
|
|
|
materials: createEmptyProjectDocument().materials,
|
|
|
|
|
textures: {},
|
|
|
|
|
assets: {},
|
|
|
|
|
time: createDefaultProjectTimeSettings()
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
|
|
|
|
|
expect(migratedDocument.scenes["scene-main"]?.paths).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-12 04:35:13 +02:00
|
|
|
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());
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-13 15:11:15 +02:00
|
|
|
it("migrates legacy project time environment profiles into scene world settings", () => {
|
2026-04-12 14:14:38 +02:00
|
|
|
const legacyProject = createEmptyProjectDocument({
|
|
|
|
|
name: "Legacy Time Project",
|
|
|
|
|
sceneName: "Atrium"
|
|
|
|
|
});
|
2026-04-12 14:14:51 +02:00
|
|
|
const legacyScene = legacyProject.scenes[legacyProject.activeSceneId];
|
|
|
|
|
|
|
|
|
|
if (legacyScene === undefined) {
|
2026-04-22 15:30:37 +02:00
|
|
|
throw new Error(
|
|
|
|
|
"Expected the legacy project to contain an active scene."
|
|
|
|
|
);
|
2026-04-12 14:14:51 +02:00
|
|
|
}
|
2026-04-12 14:14:38 +02:00
|
|
|
|
|
|
|
|
const migratedDocument = parseProjectDocumentJson(
|
|
|
|
|
JSON.stringify({
|
2026-04-12 14:35:09 +02:00
|
|
|
version: PROJECT_TIME_DAY_NIGHT_PROFILE_SCENE_DOCUMENT_VERSION,
|
2026-04-12 14:14:51 +02:00
|
|
|
name: legacyProject.name,
|
|
|
|
|
activeSceneId: legacyProject.activeSceneId,
|
|
|
|
|
scenes: {
|
|
|
|
|
[legacyScene.id]: {
|
|
|
|
|
...legacyScene,
|
|
|
|
|
world: {
|
|
|
|
|
...legacyScene.world,
|
2026-04-13 15:36:01 +02:00
|
|
|
projectTimeLightingEnabled: undefined,
|
|
|
|
|
timeOfDay: undefined
|
2026-04-12 14:14:51 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
materials: legacyProject.materials,
|
|
|
|
|
textures: legacyProject.textures,
|
|
|
|
|
assets: legacyProject.assets,
|
2026-04-12 14:14:38 +02:00
|
|
|
time: {
|
2026-04-12 14:35:09 +02:00
|
|
|
startDayNumber: 2,
|
2026-04-12 14:14:38 +02:00
|
|
|
startTimeOfDayHours: 17.5,
|
2026-04-12 14:35:09 +02:00
|
|
|
dayLengthMinutes: 20,
|
|
|
|
|
sunriseTimeOfDayHours: 6.25,
|
|
|
|
|
sunsetTimeOfDayHours: 19.5,
|
|
|
|
|
dawnDurationHours: 1.25,
|
|
|
|
|
duskDurationHours: 1.75,
|
|
|
|
|
dawn: {
|
2026-04-13 15:17:03 +02:00
|
|
|
...createDefaultWorldSettings().timeOfDay.dawn,
|
2026-04-12 14:35:09 +02:00
|
|
|
ambientIntensityFactor: 0.74
|
|
|
|
|
},
|
|
|
|
|
dusk: {
|
2026-04-13 15:17:03 +02:00
|
|
|
...createDefaultWorldSettings().timeOfDay.dusk,
|
2026-04-12 14:35:09 +02:00
|
|
|
lightIntensityFactor: 0.63
|
|
|
|
|
},
|
|
|
|
|
night: {
|
2026-04-13 15:17:03 +02:00
|
|
|
...createDefaultWorldTimePhaseProfile("night"),
|
2026-04-12 14:35:09 +02:00
|
|
|
lightIntensityFactor: 0.21
|
|
|
|
|
}
|
2026-04-12 14:14:38 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
|
2026-04-12 14:35:09 +02:00
|
|
|
expect(migratedDocument.time.startDayNumber).toBe(2);
|
2026-04-12 14:14:38 +02:00
|
|
|
expect(migratedDocument.time.startTimeOfDayHours).toBe(17.5);
|
|
|
|
|
expect(migratedDocument.time.dayLengthMinutes).toBe(20);
|
2026-04-12 14:35:09 +02:00
|
|
|
expect(migratedDocument.time.sunriseTimeOfDayHours).toBe(6.25);
|
2026-04-13 15:11:15 +02:00
|
|
|
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
|
2026-04-22 15:30:37 +02:00
|
|
|
).toEqual(createDefaultWorldSettings().timeOfDay.night.background);
|
2026-04-12 14:14:38 +02:00
|
|
|
expect(
|
|
|
|
|
migratedDocument.scenes[migratedDocument.activeSceneId]?.world
|
|
|
|
|
.projectTimeLightingEnabled
|
|
|
|
|
).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-22 14:03:49 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-11 13:26:46 +02:00
|
|
|
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: {
|
2026-04-11 14:29:34 +02:00
|
|
|
"scene-main": (() => {
|
|
|
|
|
const legacyScene = createEmptyProjectScene({
|
|
|
|
|
id: "scene-main",
|
|
|
|
|
name: "Legacy Entry"
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...legacyScene,
|
|
|
|
|
editorPreferences: undefined
|
|
|
|
|
};
|
|
|
|
|
})()
|
2026-04-11 13:26:46 +02:00
|
|
|
},
|
|
|
|
|
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");
|
2026-04-22 15:30:37 +02:00
|
|
|
expect(
|
|
|
|
|
migratedDocument.scenes["scene-main"]?.editorPreferences
|
|
|
|
|
).toMatchObject({
|
|
|
|
|
whiteboxSelectionMode: "object",
|
|
|
|
|
whiteboxSnapEnabled: true,
|
|
|
|
|
whiteboxSnapStep: DEFAULT_SCENE_EDITOR_SNAP_STEP,
|
|
|
|
|
viewportGridVisible: true,
|
|
|
|
|
viewportLayoutMode: "single",
|
|
|
|
|
activeViewportPanelId: "topLeft"
|
|
|
|
|
});
|
2026-04-11 13:26:46 +02:00
|
|
|
});
|
|
|
|
|
|
2026-04-15 03:49:35 +02:00
|
|
|
it("migrates v23 project documents without Scene Entry entities", () => {
|
2026-04-11 04:18:23 +02:00
|
|
|
const legacyScene = createEmptyProjectScene({
|
|
|
|
|
id: "scene-main",
|
|
|
|
|
name: "Legacy Entry"
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const migratedDocument = parseProjectDocumentJson(
|
|
|
|
|
JSON.stringify({
|
2026-04-11 04:40:23 +02:00
|
|
|
version: RUNNER_LOADING_SCREEN_SCENE_DOCUMENT_VERSION,
|
2026-04-11 04:18:23 +02:00
|
|
|
activeSceneId: "scene-main",
|
|
|
|
|
scenes: {
|
2026-04-11 04:40:33 +02:00
|
|
|
"scene-main": legacyScene
|
2026-04-11 04:18:23 +02:00
|
|
|
},
|
|
|
|
|
materials: createEmptyProjectDocument().materials,
|
|
|
|
|
textures: {},
|
|
|
|
|
assets: {}
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
|
|
|
|
|
expect(migratedDocument.scenes["scene-main"]?.loadingScreen).toEqual(
|
2026-04-11 04:40:33 +02:00
|
|
|
legacyScene.loadingScreen
|
2026-04-11 04:18:23 +02:00
|
|
|
);
|
|
|
|
|
});
|
2026-04-11 04:41:24 +02:00
|
|
|
|
2026-04-14 02:05:47 +02:00
|
|
|
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,
|
2026-04-14 02:10:32 +02:00
|
|
|
name: "Legacy Presence Project",
|
2026-04-14 02:10:44 +02:00
|
|
|
time: createDefaultProjectTimeSettings(),
|
2026-04-14 02:05:47 +02:00
|
|
|
activeSceneId: "scene-main",
|
|
|
|
|
scenes: {
|
|
|
|
|
"scene-main": {
|
|
|
|
|
...createEmptyProjectScene({
|
|
|
|
|
id: "scene-main",
|
|
|
|
|
name: "Main"
|
|
|
|
|
}),
|
|
|
|
|
entities: {
|
|
|
|
|
[legacyNpc.id]: legacyNpc
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
materials: createEmptyProjectDocument().materials,
|
|
|
|
|
textures: {},
|
|
|
|
|
assets: {}
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
expect(
|
|
|
|
|
migratedDocument.scenes["scene-main"]?.entities[legacyNpc.id]
|
|
|
|
|
).toEqual(
|
2026-04-14 02:05:47 +02:00
|
|
|
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
|
|
|
|
|
},
|
2026-04-14 13:58:27 +02:00
|
|
|
effects: [
|
|
|
|
|
{
|
|
|
|
|
type: "setActorPresence",
|
|
|
|
|
target: {
|
|
|
|
|
kind: "actor",
|
|
|
|
|
actorId: legacyNpc.actorId
|
|
|
|
|
},
|
|
|
|
|
active: true
|
|
|
|
|
}
|
|
|
|
|
]
|
2026-04-14 02:05:47 +02:00
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-15 03:49:35 +02:00
|
|
|
it("rejects scene transition sequence targets that point at a missing Scene Entry", () => {
|
2026-04-11 04:41:24 +02:00
|
|
|
const document = createEmptyProjectDocument({ sceneName: "Outside" });
|
|
|
|
|
const targetScene = createEmptyProjectScene({
|
|
|
|
|
id: "scene-house",
|
|
|
|
|
name: "House"
|
|
|
|
|
});
|
|
|
|
|
document.scenes[targetScene.id] = targetScene;
|
2026-04-22 15:30:37 +02:00
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
});
|
2026-04-11 04:41:24 +02:00
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
expect(() => parseProjectDocumentJson(JSON.stringify(document))).toThrow(
|
|
|
|
|
"target entry"
|
|
|
|
|
);
|
2026-04-11 04:41:24 +02:00
|
|
|
});
|
2026-04-11 04:18:23 +02:00
|
|
|
});
|