import { describe, expect, it } from "vitest"; import { AnimationClip, BoxGeometry, PlaneGeometry } from "three"; import { createActorControlTargetRef, createFollowActorPathControlEffect, createPlayActorAnimationControlEffect, createSetActorPresenceControlEffect } from "../../src/controls/control-surface"; import { createBoxBrush } from "../../src/document/brushes"; import { createScenePath } from "../../src/document/paths"; import { createDefaultProjectTimeSettings } from "../../src/document/project-time-settings"; import { createTerrain } from "../../src/document/terrains"; import { createEmptySceneDocument } from "../../src/document/scene-document"; import { createCameraRigEntity, createCameraRigEntityTargetRef, DEFAULT_PLAYER_START_MOVE_SPEED, DEFAULT_PLAYER_START_MOVEMENT_CAPABILITIES, createNpcEntity, createPointLightEntity, createInteractableEntity, createPlayerStartMovementTemplate, createPlayerStartInputBindings, createPlayerStartEntity, createSceneEntryEntity, createSoundEmitterEntity, createSpotLightEntity, createTeleportTargetEntity, createTriggerVolumeEntity } from "../../src/entities/entity-instances"; import { createRunSequenceInteractionLink, createTeleportPlayerInteractionLink, createToggleVisibilityInteractionLink } from "../../src/interactions/interaction-links"; import { createProjectScheduleRoutine } from "../../src/scheduler/project-scheduler"; import { createProjectSequence } from "../../src/sequencer/project-sequences"; import { createModelInstance } from "../../src/assets/model-instances"; import { createProjectAssetStorageKey, type AudioAssetRecord } from "../../src/assets/project-assets"; import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build"; import { createFixtureLoadedModelAssetFromGeometry } from "../helpers/model-collider-fixtures"; const defaultMovementTemplate = createPlayerStartMovementTemplate(); describe("buildRuntimeSceneFromDocument", () => { it("builds enabled fixed camera rigs into runtime entities", () => { const interactable = createInteractableEntity({ id: "entity-interactable-camera-anchor", position: { x: 4, y: 1, z: -2 }, prompt: "Anchor" }); const cameraRig = createCameraRigEntity({ id: "entity-camera-rig-overlook", position: { x: 8, y: 3, z: -6 }, priority: 7, defaultActive: false, target: createCameraRigEntityTargetRef(interactable.id), targetOffset: { x: 0, y: 1.5, z: 0 }, transitionMode: "blend", transitionDurationSeconds: 0.6, lookAround: { enabled: true, yawLimitDegrees: 10, pitchLimitDegrees: 6, recenterSpeed: 5 } }); const disabledCameraRig = createCameraRigEntity({ id: "entity-camera-rig-disabled", enabled: false }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Camera Rig Runtime Scene" }), entities: { [interactable.id]: interactable, [cameraRig.id]: cameraRig, [disabledCameraRig.id]: disabledCameraRig } }); expect(runtimeScene.entities.cameraRigs).toEqual([ { entityId: cameraRig.id, rigType: "fixed", priority: 7, defaultActive: false, position: { x: 8, y: 3, z: -6 }, target: { kind: "entity", entityId: interactable.id }, targetOffset: { x: 0, y: 1.5, z: 0 }, transitionMode: "blend", transitionDurationSeconds: 0.6, lookAround: { enabled: true, yawLimitDegrees: 10, pitchLimitDegrees: 6, recenterSpeed: 5 } } ]); }); it("builds enabled rail camera rigs into runtime entities", () => { const interactable = createInteractableEntity({ id: "entity-interactable-rail-anchor", position: { x: 3, y: 1, z: 2 }, prompt: "Anchor" }); const path = createScenePath({ id: "path-camera-rail", points: [ { id: "point-a", position: { x: 0, y: 3, z: 0 } }, { id: "point-b", position: { x: 10, y: 3, z: 0 } } ] }); const cameraRig = createCameraRigEntity({ id: "entity-camera-rig-rail", rigType: "rail", pathId: path.id, priority: 9, defaultActive: true, target: createCameraRigEntityTargetRef(interactable.id), targetOffset: { x: 0, y: 1.25, z: 0 }, transitionMode: "cut" }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Rail Camera Rig Runtime Scene" }), paths: { [path.id]: path }, entities: { [interactable.id]: interactable, [cameraRig.id]: cameraRig } }); expect(runtimeScene.entities.cameraRigs).toEqual([ { entityId: cameraRig.id, rigType: "rail", priority: 9, defaultActive: true, pathId: path.id, railPlacementMode: "nearestToTarget", target: { kind: "entity", entityId: interactable.id }, targetOffset: { x: 0, y: 1.25, z: 0 }, transitionMode: "cut", transitionDurationSeconds: 0.35, lookAround: { enabled: true, yawLimitDegrees: 12, pitchLimitDegrees: 8, recenterSpeed: 3.5 } } ]); }); it("builds mapped rail camera rigs into runtime entities", () => { const interactable = createInteractableEntity({ id: "entity-interactable-mapped-rail-anchor", position: { x: 2, y: 1, z: 2 }, prompt: "Anchor" }); const path = createScenePath({ id: "path-camera-rail-mapped", points: [ { id: "point-a", position: { x: 0, y: 3, z: 0 } }, { id: "point-b", position: { x: 10, y: 3, z: 0 } } ] }); const cameraRig = createCameraRigEntity({ id: "entity-camera-rig-rail-mapped", rigType: "rail", pathId: path.id, railPlacementMode: "mapTargetBetweenPoints", trackStartPoint: { x: 0, y: 1, z: 2 }, trackEndPoint: { x: 10, y: 1, z: 2 }, railStartProgress: 0.2, railEndProgress: 0.8, target: createCameraRigEntityTargetRef(interactable.id), transitionMode: "blend", transitionDurationSeconds: 0.5 }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Mapped Rail Camera Rig Runtime Scene" }), paths: { [path.id]: path }, entities: { [interactable.id]: interactable, [cameraRig.id]: cameraRig } }); expect(runtimeScene.entities.cameraRigs).toEqual([ { entityId: cameraRig.id, rigType: "rail", priority: 0, defaultActive: true, pathId: path.id, railPlacementMode: "mapTargetBetweenPoints", trackStartPoint: { x: 0, y: 1, z: 2 }, trackEndPoint: { x: 10, y: 1, z: 2 }, railStartProgress: 0.2, railEndProgress: 0.8, target: { kind: "entity", entityId: interactable.id }, targetOffset: { x: 0, y: 1.4, z: 0 }, transitionMode: "blend", transitionDurationSeconds: 0.5, lookAround: { enabled: true, yawLimitDegrees: 12, pitchLimitDegrees: 8, recenterSpeed: 3.5 } } ]); }); it("builds runtime brush data, colliders, and an authored player spawn from the document", () => { const brush = createBoxBrush({ id: "brush-room-floor", center: { x: 0, y: -0.5, z: 0 }, size: { x: 8, y: 1, z: 8 } }); brush.faces.posY.materialId = "starter-concrete-checker"; const playerStart = createPlayerStartEntity({ id: "entity-player-start-main", position: { x: 2, y: 0, z: -1 }, yawDegrees: 90, collider: { mode: "box", eyeHeight: 1.4, capsuleRadius: 0.3, capsuleHeight: 1.8, boxSize: { x: 0.8, y: 1.6, z: 0.7 } } }); const soundEmitter = createSoundEmitterEntity({ id: "entity-sound-lobby", position: { x: -1, y: 1, z: 0 }, audioAssetId: "asset-audio-lobby", volume: 0.75, refDistance: 8, maxDistance: 24, autoplay: true, loop: false }); const triggerVolume = createTriggerVolumeEntity({ id: "entity-trigger-door", position: { x: 0, y: 1, z: 2 }, size: { x: 2, y: 2, z: 1 }, triggerOnEnter: true, triggerOnExit: false }); const teleportTarget = createTeleportTargetEntity({ id: "entity-teleport-target-main", position: { x: 6, y: 0, z: -3 }, yawDegrees: 270 }); const interactable = createInteractableEntity({ id: "entity-interactable-console", position: { x: 1, y: 1, z: 1 }, radius: 1.5, prompt: "Use Console", interactionEnabled: true }); const sceneEntry = createSceneEntryEntity({ id: "entity-scene-entry-house-front", position: { x: -3, y: 0, z: 2 }, yawDegrees: 135 }); const npc = createNpcEntity({ id: "entity-npc-guide", actorId: "actor-house-guide", position: { x: -1, y: 0, z: -2 }, yawDegrees: 45, modelAssetId: "asset-model-triangle", dialogues: [ { id: "dialogue-warning", title: "Warning", lines: [ { id: "dialogue-line-warning-1", text: "The generator is unstable." } ] } ], defaultDialogueId: "dialogue-warning" }); const pointLight = createPointLightEntity({ id: "entity-point-light-main", position: { x: 2, y: 3, z: 1 } }); const spotLight = createSpotLightEntity({ id: "entity-spot-light-main", position: { x: -2, y: 4, z: 0 }, direction: { x: 0.2, y: -1, z: 0.1 } }); const imageAsset = { id: "asset-background-panorama", kind: "image" as const, sourceName: "skybox-panorama.svg", mimeType: "image/svg+xml", storageKey: createProjectAssetStorageKey("asset-background-panorama"), byteLength: 2048, metadata: { kind: "image" as const, width: 512, height: 256, hasAlpha: false, warnings: [] } }; const audioAsset = { id: "asset-audio-lobby", kind: "audio" as const, sourceName: "lobby-loop.ogg", mimeType: "audio/ogg", storageKey: createProjectAssetStorageKey("asset-audio-lobby"), byteLength: 4096, metadata: { kind: "audio" as const, durationSeconds: 3.25, channelCount: 2, sampleRateHz: 48000, warnings: [] } } satisfies AudioAssetRecord; const modelAsset = { id: "asset-model-triangle", kind: "model" as const, sourceName: "tiny-triangle.gltf", mimeType: "model/gltf+json", storageKey: createProjectAssetStorageKey("asset-model-triangle"), byteLength: 36, metadata: { kind: "model" as const, format: "gltf" as const, sceneName: "Fixture Triangle Scene", nodeCount: 2, meshCount: 1, materialNames: ["Fixture Material"], textureNames: [], animationNames: [], boundingBox: { min: { x: 0, y: 0, z: 0 }, max: { x: 1, y: 1, z: 0 }, size: { x: 1, y: 1, z: 0 } }, warnings: [] } }; const modelInstance = createModelInstance({ id: "model-instance-triangle", assetId: modelAsset.id, position: { x: -2, y: 0, z: 4 }, rotationDegrees: { x: 0, y: 90, z: 0 }, scale: { x: 2, y: 2, z: 2 } }); const path = createScenePath({ id: "path-lobby-route", name: "Lobby Route", points: [ { id: "path-point-a", position: { x: -2, y: 0, z: -2 } }, { id: "path-point-b", position: { x: -2, y: 0, z: 1 } }, { id: "path-point-c", position: { x: 2, y: 0, z: 1 } } ] }); const document = { ...createEmptySceneDocument({ name: "Runtime Slice" }), brushes: { [brush.id]: brush }, paths: { [path.id]: path }, assets: { [audioAsset.id]: audioAsset, [modelAsset.id]: modelAsset, [imageAsset.id]: imageAsset }, modelInstances: { [modelInstance.id]: modelInstance }, entities: { [playerStart.id]: playerStart, [npc.id]: npc, [soundEmitter.id]: soundEmitter, [triggerVolume.id]: triggerVolume, [teleportTarget.id]: teleportTarget, [interactable.id]: interactable, [sceneEntry.id]: sceneEntry, [pointLight.id]: pointLight, [spotLight.id]: spotLight }, interactionLinks: { "link-teleport": createTeleportPlayerInteractionLink({ id: "link-teleport", sourceEntityId: triggerVolume.id, trigger: "enter", targetEntityId: teleportTarget.id }), "link-hide-brush": createToggleVisibilityInteractionLink({ id: "link-hide-brush", sourceEntityId: triggerVolume.id, trigger: "exit", targetBrushId: brush.id, visible: false }), "link-click-teleport": createTeleportPlayerInteractionLink({ id: "link-click-teleport", sourceEntityId: interactable.id, trigger: "click", targetEntityId: teleportTarget.id }), "link-interactable-dialogue": createRunSequenceInteractionLink({ id: "link-interactable-dialogue", sourceEntityId: interactable.id, trigger: "click", sequenceId: "sequence-warning-dialogue" }), "link-trigger-dialogue": createRunSequenceInteractionLink({ id: "link-trigger-dialogue", sourceEntityId: triggerVolume.id, trigger: "enter", sequenceId: "sequence-warning-dialogue" }) } }; document.sequences.sequences["sequence-warning-dialogue"] = createProjectSequence({ id: "sequence-warning-dialogue", title: "Warning Dialogue", effects: [ { stepClass: "impulse", type: "makeNpcTalk", npcEntityId: npc.id, dialogueId: "dialogue-warning" } ] }); document.world.background = { mode: "image", assetId: imageAsset.id, environmentIntensity: 0.75 }; document.world.ambientLight.intensity = 0.55; document.world.sunLight.direction = { x: -0.8, y: 1.2, z: 0.1 }; document.time = { ...createDefaultProjectTimeSettings(), startTimeOfDayHours: 18.5, dayLengthMinutes: 16 }; const runtimeScene = buildRuntimeSceneFromDocument(document); expect(runtimeScene.time).toEqual(document.time); expect(runtimeScene.time).not.toBe(document.time); expect(runtimeScene.world).toEqual(document.world); expect(runtimeScene.world).not.toBe(document.world); expect(runtimeScene.world.sunLight.direction).not.toBe(document.world.sunLight.direction); expect(runtimeScene.brushes).toHaveLength(1); expect(runtimeScene.modelInstances).toEqual([ { instanceId: "model-instance-triangle", assetId: "asset-model-triangle", name: undefined, visible: true, position: { x: -2, y: 0, z: 4 }, rotationDegrees: { x: 0, y: 90, z: 0 }, scale: { x: 2, y: 2, z: 2 } } ]); expect(runtimeScene.paths).toEqual([ { id: "path-lobby-route", name: "Lobby Route", visible: true, enabled: true, loop: false, points: [ { pointId: "path-point-a", position: { x: -2, y: 0, z: -2 } }, { pointId: "path-point-b", position: { x: -2, y: 0, z: 1 } }, { pointId: "path-point-c", position: { x: 2, y: 0, z: 1 } } ], segments: [ { index: 0, startPointId: "path-point-a", endPointId: "path-point-b", start: { x: -2, y: 0, z: -2 }, end: { x: -2, y: 0, z: 1 }, length: 3, distanceStart: 0, distanceEnd: 3, tangent: { x: 0, y: 0, z: 1 } }, { index: 1, startPointId: "path-point-b", endPointId: "path-point-c", start: { x: -2, y: 0, z: 1 }, end: { x: 2, y: 0, z: 1 }, length: 4, distanceStart: 3, distanceEnd: 7, tangent: { x: 1, y: 0, z: 0 } } ], totalLength: 7 } ]); expect(runtimeScene.brushes[0].rotationDegrees).toEqual({ x: 0, y: 0, z: 0 }); expect(runtimeScene.brushes[0].faces.posY.material?.id).toBe("starter-concrete-checker"); expect(runtimeScene.colliders).toHaveLength(2); expect(runtimeScene.colliders[0]).toMatchObject({ kind: "trimesh", source: "brush", brushId: "brush-room-floor", center: { x: 0, y: -0.5, z: 0 }, rotationDegrees: { x: 0, y: 0, z: 0 }, worldBounds: { min: { x: -4, y: -1, z: -4 }, max: { x: 4, y: 0, z: 4 } } }); const brushCollider = runtimeScene.colliders[0]; if (brushCollider.kind !== "trimesh") { throw new Error(`Expected a trimesh brush collider, received ${brushCollider.kind}.`); } expect(Array.from(brushCollider.vertices)).toHaveLength(24); expect(Array.from(brushCollider.indices)).toHaveLength(36); expect(runtimeScene.sceneBounds).toEqual({ min: { x: -4, y: -1, z: -4 }, max: { x: 4, y: 1.8, z: 4 }, center: { x: 0, y: 0.4, z: 0 }, size: { x: 8, y: 2.8, z: 8 } }); expect(runtimeScene.entities).toEqual({ playerStarts: [ { entityId: "entity-player-start-main", position: { x: 2, y: 0, z: -1 }, yawDegrees: 90, navigationMode: "firstPerson", movement: { templateKind: "default", moveSpeed: DEFAULT_PLAYER_START_MOVE_SPEED, maxSpeed: defaultMovementTemplate.maxSpeed, maxStepHeight: defaultMovementTemplate.maxStepHeight, capabilities: DEFAULT_PLAYER_START_MOVEMENT_CAPABILITIES, jump: defaultMovementTemplate.jump, sprint: defaultMovementTemplate.sprint, crouch: defaultMovementTemplate.crouch }, inputBindings: playerStart.inputBindings, collider: { mode: "box", eyeHeight: 1.4, size: { x: 0.8, y: 1.6, z: 0.7 } } } ], cameraRigs: [], sceneEntries: [ { entityId: "entity-scene-entry-house-front", position: { x: -3, y: 0, z: 2 }, yawDegrees: 135 } ], npcs: [ { entityId: "entity-npc-guide", actorId: "actor-house-guide", name: undefined, visible: true, position: { x: -1, y: 0, z: -2 }, yawDegrees: 45, modelAssetId: modelAsset.id, dialogues: [ { id: "dialogue-warning", title: "Warning", lines: [ { id: "dialogue-line-warning-1", text: "The generator is unstable." } ] } ], defaultDialogueId: "dialogue-warning", activeRoutineTitle: null, animationClipName: null, animationLoop: undefined, resolvedPath: null, collider: { mode: "capsule", radius: 0.3, height: 1.8, eyeHeight: 1.6 } } ], soundEmitters: [ { entityId: "entity-sound-lobby", position: { x: -1, y: 1, z: 0 }, audioAssetId: audioAsset.id, volume: 0.75, refDistance: 8, maxDistance: 24, autoplay: true, loop: false } ], triggerVolumes: [ { entityId: "entity-trigger-door", position: { x: 0, y: 1, z: 2 }, size: { x: 2, y: 2, z: 1 }, triggerOnEnter: true, triggerOnExit: true } ], teleportTargets: [ { entityId: "entity-teleport-target-main", position: { x: 6, y: 0, z: -3 }, yawDegrees: 270 } ], interactables: [ { entityId: "entity-interactable-console", position: { x: 1, y: 1, z: 1 }, radius: 1.5, prompt: "Use Console", interactionEnabled: true } ] }); expect(runtimeScene.localLights).toEqual({ pointLights: [ { entityId: "entity-point-light-main", enabled: true, position: { x: 2, y: 3, z: 1 }, colorHex: "#ffffff", intensity: 1.25, distance: 8 } ], spotLights: [ { entityId: "entity-spot-light-main", enabled: true, position: { x: -2, y: 4, z: 0 }, direction: { x: 0.2, y: -1, z: 0.1 }, colorHex: "#ffffff", intensity: 1.5, distance: 12, angleDegrees: 35 } ] }); expect(runtimeScene.interactionLinks).toEqual([ { id: "link-click-teleport", sourceEntityId: "entity-interactable-console", trigger: "click", action: { type: "teleportPlayer", targetEntityId: "entity-teleport-target-main" } }, { id: "link-interactable-dialogue", sourceEntityId: "entity-interactable-console", trigger: "click", action: { type: "runSequence", sequenceId: "sequence-warning-dialogue" } }, { id: "link-teleport", sourceEntityId: "entity-trigger-door", trigger: "enter", action: { type: "teleportPlayer", targetEntityId: "entity-teleport-target-main" } }, { id: "link-trigger-dialogue", sourceEntityId: "entity-trigger-door", trigger: "enter", action: { type: "runSequence", sequenceId: "sequence-warning-dialogue" } }, { id: "link-hide-brush", sourceEntityId: "entity-trigger-door", trigger: "exit", action: { type: "toggleVisibility", targetBrushId: "brush-room-floor", visible: false } } ]); expect(runtimeScene.playerStart).toEqual({ entityId: "entity-player-start-main", position: { x: 2, y: 0, z: -1 }, yawDegrees: 90, navigationMode: "firstPerson", movement: { templateKind: "default", moveSpeed: DEFAULT_PLAYER_START_MOVE_SPEED, maxSpeed: defaultMovementTemplate.maxSpeed, maxStepHeight: defaultMovementTemplate.maxStepHeight, capabilities: DEFAULT_PLAYER_START_MOVEMENT_CAPABILITIES, jump: defaultMovementTemplate.jump, sprint: defaultMovementTemplate.sprint, crouch: defaultMovementTemplate.crouch }, inputBindings: playerStart.inputBindings, collider: { mode: "box", eyeHeight: 1.4, size: { x: 0.8, y: 1.6, z: 0.7 } } }); expect(runtimeScene.colliders[1]).toMatchObject({ kind: "character", source: "npc", entityId: "entity-npc-guide", position: { x: -1, y: 0, z: -2 }, rotationDegrees: { x: 0, y: 45, z: 0 }, shape: { mode: "capsule", radius: 0.3, height: 1.8, eyeHeight: 1.6 }, worldBounds: { min: { x: -1.3, y: 0, z: -2.3 }, max: { x: -0.7, y: 1.8, z: -1.7 } } }); expect(runtimeScene.playerCollider).toEqual({ mode: "box", eyeHeight: 1.4, size: { x: 0.8, y: 1.6, z: 0.7 } }); expect(runtimeScene.playerMovement).toEqual({ templateKind: "default", moveSpeed: DEFAULT_PLAYER_START_MOVE_SPEED, maxSpeed: defaultMovementTemplate.maxSpeed, maxStepHeight: defaultMovementTemplate.maxStepHeight, capabilities: DEFAULT_PLAYER_START_MOVEMENT_CAPABILITIES, jump: defaultMovementTemplate.jump, sprint: defaultMovementTemplate.sprint, crouch: defaultMovementTemplate.crouch }); expect(runtimeScene.playerInputBindings).toEqual(playerStart.inputBindings); expect(runtimeScene.navigationMode).toBe("firstPerson"); expect(runtimeScene.spawn).toEqual({ source: "playerStart", entityId: "entity-player-start-main", position: { x: 2, y: 0, z: -1 }, yawDegrees: 90 }); }); it("filters active NPCs from the project scheduler against the current runtime clock", () => { const alwaysNpc = createNpcEntity({ id: "entity-npc-always", actorId: "actor-town-always" }); const daytimeNpc = createNpcEntity({ id: "entity-npc-daytime", actorId: "actor-town-daytime" }); const overnightNpc = createNpcEntity({ id: "entity-npc-overnight", actorId: "actor-town-overnight" }); const document = { ...createEmptySceneDocument({ name: "NPC Presence Scene" }), entities: { [alwaysNpc.id]: alwaysNpc, [daytimeNpc.id]: daytimeNpc, [overnightNpc.id]: overnightNpc } }; document.scheduler.routines["routine-daytime"] = createProjectScheduleRoutine({ id: "routine-daytime", title: "Day Shift", target: createActorControlTargetRef(daytimeNpc.actorId), startHour: 9, endHour: 17, effect: createSetActorPresenceControlEffect({ target: createActorControlTargetRef(daytimeNpc.actorId), active: true }) }); document.scheduler.routines["routine-overnight"] = createProjectScheduleRoutine({ id: "routine-overnight", title: "Night Shift", target: createActorControlTargetRef(overnightNpc.actorId), startHour: 22, endHour: 2, effect: createSetActorPresenceControlEffect({ target: createActorControlTargetRef(overnightNpc.actorId), active: true }) }); const daytimeRuntimeScene = buildRuntimeSceneFromDocument(document, { runtimeClock: { timeOfDayHours: 10, dayCount: 2, dayLengthMinutes: 24 } }); const overnightRuntimeScene = buildRuntimeSceneFromDocument(document, { runtimeClock: { timeOfDayHours: 23.5, dayCount: 2, dayLengthMinutes: 24 } }); expect( daytimeRuntimeScene.entities.npcs.map((npc) => npc.entityId) ).toEqual([ "entity-npc-always", "entity-npc-daytime", "entity-npc-overnight" ]); expect( daytimeRuntimeScene.npcDefinitions.map((npc) => ({ entityId: npc.entityId, active: npc.active })) ).toEqual([ { entityId: "entity-npc-always", active: true }, { entityId: "entity-npc-daytime", active: true }, { entityId: "entity-npc-overnight", active: true } ]); expect( daytimeRuntimeScene.colliders .filter((collider) => collider.source === "npc") .map((collider) => collider.entityId) ).toEqual([ "entity-npc-always", "entity-npc-daytime", "entity-npc-overnight" ]); expect( overnightRuntimeScene.entities.npcs.map((npc) => npc.entityId) ).toEqual([ "entity-npc-always", "entity-npc-daytime", "entity-npc-overnight" ]); expect( overnightRuntimeScene.colliders .filter((collider) => collider.source === "npc") .map((collider) => collider.entityId) ).toEqual([ "entity-npc-always", "entity-npc-daytime", "entity-npc-overnight" ]); expect( overnightRuntimeScene.entities.npcs.find( (npc) => npc.entityId === overnightNpc.id ) ).toEqual( expect.objectContaining({ activeRoutineTitle: "Night Shift" }) ); }); it("builds a deterministic fallback spawn when no PlayerStart is authored", () => { const brush = createBoxBrush({ id: "brush-room-wall", center: { x: 0, y: 1, z: 0 }, size: { x: 6, y: 2, z: 6 } }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Fallback Runtime Scene" }), brushes: { [brush.id]: brush } }); expect(runtimeScene.playerStart).toBeNull(); expect(runtimeScene.playerCollider).toEqual({ mode: "capsule", radius: 0.3, height: 1.8, eyeHeight: 1.6 }); expect(runtimeScene.playerMovement).toEqual({ templateKind: "default", moveSpeed: DEFAULT_PLAYER_START_MOVE_SPEED, maxSpeed: defaultMovementTemplate.maxSpeed, maxStepHeight: defaultMovementTemplate.maxStepHeight, capabilities: DEFAULT_PLAYER_START_MOVEMENT_CAPABILITIES, jump: defaultMovementTemplate.jump, sprint: defaultMovementTemplate.sprint, crouch: defaultMovementTemplate.crouch }); expect(runtimeScene.playerInputBindings).toEqual( createPlayerStartInputBindings() ); expect(runtimeScene.navigationMode).toBe("thirdPerson"); expect(runtimeScene.entities).toEqual({ playerStarts: [], cameraRigs: [], sceneEntries: [], npcs: [], soundEmitters: [], triggerVolumes: [], teleportTargets: [], interactables: [] }); expect(runtimeScene.interactionLinks).toEqual([]); expect(runtimeScene.spawn).toEqual({ source: "fallback", entityId: null, position: { x: 0, y: 2.1, z: 6 }, yawDegrees: 180 }); }); it("blocks first-person runtime builds when PlayerStart is missing", () => { expect(() => buildRuntimeSceneFromDocument(createEmptySceneDocument({ name: "Missing Player Start" }), { navigationMode: "firstPerson" }) ).toThrow("First-person run requires an authored Player Start"); }); it("uses the authored Player Start navigation mode for third-person scenes", () => { const playerStart = createPlayerStartEntity({ id: "entity-player-start-third-person", navigationMode: "thirdPerson" }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Authored Third Person Scene" }), entities: { [playerStart.id]: playerStart } }); expect(runtimeScene.navigationMode).toBe("thirdPerson"); expect(runtimeScene.playerStart?.navigationMode).toBe("thirdPerson"); expect(runtimeScene.entities.playerStarts[0]?.navigationMode).toBe( "thirdPerson" ); }); it("keeps hidden authored objects active but excludes disabled authored objects from the runtime build", () => { const hiddenBrush = createBoxBrush({ id: "brush-hidden-runtime", visible: false }); const disabledBrush = createBoxBrush({ id: "brush-disabled-runtime", enabled: false }); const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry( "asset-authored-state-runtime", new BoxGeometry(1, 1, 1) ); const hiddenModelInstance = createModelInstance({ id: "model-instance-hidden-runtime", assetId: asset.id, visible: false, collision: { mode: "static", visible: false } }); const disabledModelInstance = createModelInstance({ id: "model-instance-disabled-runtime", assetId: asset.id, enabled: false, collision: { mode: "static", visible: false } }); const disabledPlayerStart = createPlayerStartEntity({ id: "entity-player-start-disabled-runtime", enabled: false, position: { x: -4, y: 0, z: 0 } }); const enabledPlayerStart = createPlayerStartEntity({ id: "entity-player-start-enabled-runtime", position: { x: 4, y: 0, z: 0 } }); const hiddenInteractable = createInteractableEntity({ id: "entity-interactable-hidden-runtime", visible: false, interactionEnabled: true, prompt: "Hidden but active" }); const disabledInteractable = createInteractableEntity({ id: "entity-interactable-disabled-runtime", enabled: false, interactionEnabled: true, prompt: "Disabled" }); const teleportTarget = createTeleportTargetEntity({ id: "entity-teleport-target-runtime", position: { x: 12, y: 0, z: 0 } }); const runtimeScene = buildRuntimeSceneFromDocument( { ...createEmptySceneDocument({ name: "Authored State Runtime Scene" }), assets: { [asset.id]: asset }, brushes: { [hiddenBrush.id]: hiddenBrush, [disabledBrush.id]: disabledBrush }, modelInstances: { [hiddenModelInstance.id]: hiddenModelInstance, [disabledModelInstance.id]: disabledModelInstance }, entities: { [disabledPlayerStart.id]: disabledPlayerStart, [enabledPlayerStart.id]: enabledPlayerStart, [hiddenInteractable.id]: hiddenInteractable, [disabledInteractable.id]: disabledInteractable, [teleportTarget.id]: teleportTarget }, interactionLinks: { "link-hidden-click": createTeleportPlayerInteractionLink({ id: "link-hidden-click", sourceEntityId: hiddenInteractable.id, trigger: "click", targetEntityId: teleportTarget.id }), "link-disabled-click": createTeleportPlayerInteractionLink({ id: "link-disabled-click", sourceEntityId: disabledInteractable.id, trigger: "click", targetEntityId: teleportTarget.id }) } }, { navigationMode: "firstPerson", loadedModelAssets: { [asset.id]: loadedAsset } } ); expect(runtimeScene.playerStart?.entityId).toBe(enabledPlayerStart.id); expect(runtimeScene.brushes.map((brush) => brush.id)).toEqual([hiddenBrush.id]); expect(runtimeScene.brushes[0]?.visible).toBe(false); expect(runtimeScene.modelInstances).toEqual([ expect.objectContaining({ instanceId: hiddenModelInstance.id, assetId: asset.id, visible: false }) ]); expect( runtimeScene.entities.interactables.map((entity) => entity.entityId) ).toEqual([hiddenInteractable.id]); expect(runtimeScene.colliders).toEqual( expect.arrayContaining([ expect.objectContaining({ source: "brush", brushId: hiddenBrush.id }), expect.objectContaining({ source: "modelInstance", instanceId: hiddenModelInstance.id, assetId: asset.id }) ]) ); expect(runtimeScene.interactionLinks.map((link) => link.id)).toEqual([ "link-hidden-click" ]); }); it("uses a requested Scene Entry as the runtime spawn without replacing the Player Start collider", () => { const playerStart = createPlayerStartEntity({ id: "entity-player-start-main", position: { x: 0, y: 0, z: 0 }, yawDegrees: 90 }); const sceneEntry = createSceneEntryEntity({ id: "entity-scene-entry-basement-door", position: { x: 4, y: 0, z: -2 }, yawDegrees: 270 }); const runtimeScene = buildRuntimeSceneFromDocument( { ...createEmptySceneDocument({ name: "Scene Entry Spawn" }), entities: { [playerStart.id]: playerStart, [sceneEntry.id]: sceneEntry } }, { navigationMode: "firstPerson", sceneEntryId: sceneEntry.id } ); expect(runtimeScene.playerStart?.entityId).toBe(playerStart.id); expect(runtimeScene.spawn).toEqual({ source: "sceneEntry", entityId: sceneEntry.id, position: { x: 4, y: 0, z: -2 }, yawDegrees: 270 }); }); it("adds generated imported-model colliders to the runtime scene build", () => { const floorBrush = createBoxBrush({ id: "brush-runtime-floor", center: { x: 0, y: -0.5, z: 0 }, size: { x: 8, y: 1, z: 8 } }); const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-runtime-collider", new BoxGeometry(1, 2, 1)); const modelInstance = createModelInstance({ id: "model-instance-runtime-collider", assetId: asset.id, position: { x: 2, y: 1, z: 0 }, collision: { mode: "static", visible: true } }); const runtimeScene = buildRuntimeSceneFromDocument( { ...createEmptySceneDocument({ name: "Imported Collider Scene" }), assets: { [asset.id]: asset }, brushes: { [floorBrush.id]: floorBrush }, modelInstances: { [modelInstance.id]: modelInstance } }, { loadedModelAssets: { [asset.id]: loadedAsset } } ); expect(runtimeScene.colliders).toHaveLength(2); expect(runtimeScene.colliders[1]).toMatchObject({ source: "modelInstance", instanceId: modelInstance.id, assetId: asset.id, kind: "trimesh", mode: "static", visible: true }); expect(runtimeScene.sceneBounds?.max.y).toBeGreaterThanOrEqual(2); }); it("adds authored terrain heightfield colliders to the runtime scene build", () => { const terrain = createTerrain({ id: "terrain-runtime-heightfield", position: { x: -4, y: 0, z: -4 }, sampleCountX: 3, sampleCountZ: 3, cellSize: 4, heights: [0, 1, 0, 0, 2, 0, 0, 1, 0] }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Authored Terrain Collider Scene" }), terrains: { [terrain.id]: terrain } }); expect(runtimeScene.terrains[0]).toMatchObject({ id: terrain.id, collisionEnabled: true }); expect(runtimeScene.colliders[0]).toMatchObject({ source: "terrain", terrainId: terrain.id, kind: "heightfield", rows: 3, cols: 3, minX: 0, maxX: 8, minZ: 0, maxZ: 8 }); if ( runtimeScene.colliders[0] === undefined || runtimeScene.colliders[0].source !== "terrain" ) { throw new Error("Expected the runtime collider to be an authored terrain heightfield."); } expect(Array.from(runtimeScene.colliders[0].heights)).toEqual( terrain.heights ); expect(runtimeScene.sceneBounds?.max.y).toBe(2); }); it("adds static-simple imported-model colliders as compound box pieces", () => { const wallGeometry = new PlaneGeometry(4, 4, 4, 4); wallGeometry.rotateY(Math.PI * 0.5); const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry("asset-runtime-static-simple", wallGeometry); const modelInstance = createModelInstance({ id: "model-instance-runtime-static-simple", assetId: asset.id, position: { x: 2, y: 2, z: 0 }, collision: { mode: "static-simple", visible: true } }); const runtimeScene = buildRuntimeSceneFromDocument( { ...createEmptySceneDocument({ name: "Imported Static Simple Collider Scene" }), assets: { [asset.id]: asset }, modelInstances: { [modelInstance.id]: modelInstance } }, { loadedModelAssets: { [asset.id]: loadedAsset } } ); expect(runtimeScene.colliders).toHaveLength(1); expect(runtimeScene.colliders[0]).toMatchObject({ source: "modelInstance", instanceId: modelInstance.id, assetId: asset.id, kind: "compound", mode: "static-simple", visible: true, decomposition: "surface-voxel-boxes" }); if (runtimeScene.colliders[0].source !== "modelInstance" || runtimeScene.colliders[0].kind !== "compound") { throw new Error("Expected the runtime collider to be a generated compound model collider."); } expect(runtimeScene.colliders[0].pieces.every((piece) => piece.kind === "box")).toBe(true); }); it("preserves rotated whitebox box transforms for runner rendering and collision bounds", () => { const brush = createBoxBrush({ id: "brush-rotated-room", center: { x: 1.25, y: 1.5, z: -0.75 }, rotationDegrees: { x: 0, y: 45, z: 0 }, size: { x: 2, y: 2, z: 4 } }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Rotated Whitebox Scene" }), brushes: { [brush.id]: brush } }); expect(runtimeScene.brushes[0]).toMatchObject({ center: brush.center, rotationDegrees: brush.rotationDegrees, size: brush.size }); expect(runtimeScene.colliders[0]).toMatchObject({ kind: "trimesh", source: "brush", brushId: brush.id, center: brush.center, rotationDegrees: brush.rotationDegrees }); expect(runtimeScene.sceneBounds?.min.x).toBeCloseTo(-0.8713203436); expect(runtimeScene.sceneBounds?.max.x).toBeCloseTo(3.3713203436); expect(runtimeScene.sceneBounds?.min.z).toBeCloseTo(-2.8713203436); expect(runtimeScene.sceneBounds?.max.z).toBeCloseTo(1.3713203436); }); it("builds non-blocking water, fog, and light volumes from whitebox boxes", () => { const solidBrush = createBoxBrush({ id: "brush-solid", center: { x: 0, y: 0, z: 0 }, size: { x: 6, y: 1, z: 6 } }); const waterBrush = createBoxBrush({ id: "brush-water", center: { x: 2, y: 0.5, z: 1 }, size: { x: 3, y: 2, z: 3 }, volume: { mode: "water", water: { colorHex: "#2f79c4", surfaceOpacity: 0.7, waveStrength: 0.35, foamContactLimit: 6, surfaceDisplacementEnabled: false } } }); const fogBrush = createBoxBrush({ id: "brush-fog", center: { x: -2, y: 1, z: -1 }, size: { x: 4, y: 3, z: 2 }, volume: { mode: "fog", fog: { colorHex: "#99aac4", density: 0.55, padding: 0.25 } } }); const lightBrush = createBoxBrush({ id: "brush-light", center: { x: 0, y: 2, z: -3 }, size: { x: 5, y: 4, z: 3 }, volume: { mode: "light", light: { colorHex: "#ffe0b6", intensity: 2.25, padding: 0.5, falloff: "smoothstep" } } }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Volume Runtime Scene" }), brushes: { [solidBrush.id]: solidBrush, [waterBrush.id]: waterBrush, [fogBrush.id]: fogBrush, [lightBrush.id]: lightBrush } }); expect(runtimeScene.brushes).toHaveLength(4); expect(runtimeScene.colliders).toHaveLength(1); expect(runtimeScene.colliders[0]).toMatchObject({ source: "brush", brushId: solidBrush.id }); expect(runtimeScene.volumes.water).toEqual([ { brushId: waterBrush.id, center: waterBrush.center, rotationDegrees: waterBrush.rotationDegrees, size: waterBrush.size, colorHex: "#2f79c4", surfaceOpacity: 0.7, waveStrength: 0.35 } ]); expect(runtimeScene.volumes.fog).toEqual([ { brushId: fogBrush.id, center: fogBrush.center, rotationDegrees: fogBrush.rotationDegrees, size: fogBrush.size, colorHex: "#99aac4", density: 0.55, padding: 0.25 } ]); expect(runtimeScene.volumes.light).toEqual([ expect.objectContaining({ brushId: lightBrush.id, enabled: true, center: lightBrush.center, rotationDegrees: lightBrush.rotationDegrees, size: lightBrush.size, colorHex: "#ffe0b6", intensity: 2.25, padding: 0.5, falloff: "smoothstep", lights: expect.arrayContaining([ expect.objectContaining({ intensity: expect.any(Number), distance: expect.any(Number), decay: 2 }) ]) }) ]); expect(runtimeScene.volumes.light[0]?.lights).toHaveLength(4); }); it("resolves active actor routines into NPC animation and deterministic follow-path pose", () => { const actorTarget = createActorControlTargetRef("actor-patroller"); const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry( "asset-npc-patroller", new BoxGeometry(0.8, 1.8, 0.6) ); asset.metadata.animationNames = ["Walk"]; loadedAsset.animations = [new AnimationClip("Walk", 1, [])]; const npc = createNpcEntity({ id: "entity-npc-patroller", actorId: actorTarget.actorId, modelAssetId: asset.id, dialogues: [ { id: "dialogue-patrol", title: "Patrol", lines: [ { id: "dialogue-line-patrol-1", text: "All clear." } ] } ], defaultDialogueId: "dialogue-patrol", yawDegrees: 15 }); const path = createScenePath({ id: "path-patrol", points: [ { id: "path-point-start", position: { x: 0, y: 0, z: 0 } }, { id: "path-point-end", position: { x: 8, y: 0, z: 0 } } ] }); const document = createEmptySceneDocument(); document.assets[asset.id] = asset; document.entities[npc.id] = npc; document.paths[path.id] = path; document.scheduler.routines["routine-patrol"] = createProjectScheduleRoutine({ id: "routine-patrol", title: "Patrolling", target: actorTarget, startHour: 9, endHour: 13, effects: [ createSetActorPresenceControlEffect({ target: actorTarget, active: true }), createPlayActorAnimationControlEffect({ target: actorTarget, clipName: "Walk", loop: true }), createFollowActorPathControlEffect({ target: actorTarget, pathId: path.id, speed: 2, loop: false, progressMode: "deriveFromTime" }) ] }); const runtimeScene = buildRuntimeSceneFromDocument(document, { runtimeClock: { timeOfDayHours: 11, dayCount: 0, dayLengthMinutes: 24 }, loadedModelAssets: { [asset.id]: loadedAsset } }); expect(runtimeScene.npcDefinitions[0]).toEqual( expect.objectContaining({ entityId: npc.id, active: true, activeRoutineTitle: "Patrolling", animationClipName: "Walk", yawDegrees: 90, position: { x: 4, y: 0, z: 0 }, resolvedPath: expect.objectContaining({ pathId: path.id, progress: 0.5, elapsedHours: 2, yawDegrees: 90 }) }) ); expect(runtimeScene.entities.npcs).toEqual([ expect.objectContaining({ entityId: npc.id, animationClipName: "Walk", defaultDialogueId: "dialogue-patrol", dialogues: expect.arrayContaining([ expect.objectContaining({ id: "dialogue-patrol" }) ]), position: { x: 4, y: 0, z: 0 }, activeRoutineTitle: "Patrolling", resolvedPath: expect.objectContaining({ pathId: path.id, progress: 0.5 }) }) ]); }); });