import { waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createActiveSceneControlTargetRef, createActorControlTargetRef, createFollowActorPathControlEffect, createLightControlTargetRef, createModelInstanceControlTargetRef, createPlayActorAnimationControlEffect, createPlayModelAnimationControlEffect, createProjectGlobalControlTargetRef, createPlaySoundControlEffect, createSetActorPresenceControlEffect, createSetProjectTimePausedControlEffect, type ControlEffect, createSetAmbientLightColorControlEffect, createSetAmbientLightIntensityControlEffect, createSetLightEnabledControlEffect, createSetLightColorControlEffect, createSetLightIntensityControlEffect, createSetModelInstanceVisibleControlEffect, createSetSoundVolumeControlEffect, createSetSunLightColorControlEffect, createSetSunLightIntensityControlEffect, createSoundEmitterControlTargetRef, createStopModelAnimationControlEffect, createStopSoundControlEffect } from "../../src/controls/control-surface"; import { createBoxBrush } from "../../src/document/brushes"; import { createScenePath } from "../../src/document/paths"; import { createEmptySceneDocument } from "../../src/document/scene-document"; import { createCameraRigEntity, createCameraRigPlayerTargetRef, createCameraRigWorldPointTargetRef, createNpcEntity, createPointLightEntity, createPlayerStartEntity, createSoundEmitterEntity, createTriggerVolumeEntity } from "../../src/entities/entity-instances"; import { createControlInteractionLink, type InteractionLink } from "../../src/interactions/interaction-links"; import { createProjectScheduleRoutine } from "../../src/scheduler/project-scheduler"; import { createProjectSequence } from "../../src/sequencer/project-sequences"; import { createProjectAssetStorageKey, type AudioAssetRecord, type ModelAssetRecord } from "../../src/assets/project-assets"; import { createModelInstance } from "../../src/assets/model-instances"; import { RapierCollisionWorld } from "../../src/runtime-three/rapier-collision-world"; import { RuntimeHost, type RuntimeDialogueState, type RuntimePauseState, type RuntimeSceneLoadState } from "../../src/runtime-three/runtime-host"; import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build"; import { AnimationClip, BoxGeometry, PerspectiveCamera, Vector3, type AnimationMixer } from "three"; import { createFixtureLoadedModelAssetFromGeometry } from "../helpers/model-collider-fixtures"; function createDeferred() { let resolve: ((value: T) => void) | null = null; let reject: ((error: unknown) => void) | null = null; const promise = new Promise((innerResolve, innerReject) => { resolve = innerResolve; reject = innerReject; }); return { promise, resolve(value: T) { resolve?.(value); }, reject(error: unknown) { reject?.(error); } }; } function resolveYawPitchRadians(direction: Vector3) { return { yawRadians: Math.atan2(direction.x, direction.z), pitchRadians: Math.asin(Math.max(-1, Math.min(1, direction.y))) }; } describe("RuntimeHost", () => { afterEach(() => { vi.restoreAllMocks(); }); it("delays controller activation until collision setup reports the scene as ready", async () => { const runtimeScene = buildRuntimeSceneFromDocument( createEmptySceneDocument() ); vi.spyOn(console, "warn").mockImplementation(() => undefined); const collisionWorld = { dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld; const deferredCollisionWorld = createDeferred(); vi.spyOn(RapierCollisionWorld, "create").mockReturnValue( deferredCollisionWorld.promise ); const runtimeMessages: Array = []; const sceneLoadStates: RuntimeSceneLoadState[] = []; const host = new RuntimeHost({ enableRendering: false }); host.setRuntimeMessageHandler((message) => { runtimeMessages.push(message); }); host.setSceneLoadStateHandler((state) => { sceneLoadStates.push(state); }); host.loadScene(runtimeScene); host.setNavigationMode("thirdPerson"); expect(sceneLoadStates).toEqual([ { status: "loading", message: null } ]); expect(runtimeMessages).toEqual([null]); deferredCollisionWorld.resolve(collisionWorld); await waitFor(() => { expect(sceneLoadStates).toContainEqual({ status: "ready", message: null }); expect(runtimeMessages).toContain( "Third Person active. Drag to orbit the camera, use the right stick for gamepad camera look, move with your authored bindings, and scroll to zoom." ); }); host.dispose(); expect(collisionWorld.dispose).toHaveBeenCalledTimes(1); }); it("resolves fixed camera rigs by priority, supports explicit overrides, and blends transitions", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld); const defaultRig = createCameraRigEntity({ id: "entity-camera-rig-default", position: { x: 0, y: 5, z: 10 }, priority: 10, defaultActive: true, target: createCameraRigWorldPointTargetRef({ x: 0, y: 1, z: 0 }), transitionMode: "cut" }); const overrideRig = createCameraRigEntity({ id: "entity-camera-rig-override", position: { x: 10, y: 4, z: -6 }, priority: 0, defaultActive: false, target: createCameraRigWorldPointTargetRef({ x: 2, y: 2, z: -1 }), transitionMode: "blend", transitionDurationSeconds: 0.5 }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Camera Rig Priority Scene" }), entities: { [defaultRig.id]: defaultRig, [overrideRig.id]: overrideRig } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { sceneReady: boolean; camera: PerspectiveCamera; activeRuntimeCameraRig: { entityId: string } | null; applyActiveCameraRig(dt: number): { entityId: string } | null; }; hostInternals.sceneReady = true; expect(hostInternals.applyActiveCameraRig(0)?.entityId).toBe(defaultRig.id); expect(hostInternals.camera.position).toMatchObject(defaultRig.position); host.setActiveCameraRigOverride(overrideRig.id); expect(hostInternals.applyActiveCameraRig(0.25)?.entityId).toBe( overrideRig.id ); expect(hostInternals.activeRuntimeCameraRig?.entityId).toBe(overrideRig.id); expect(hostInternals.camera.position.x).toBeCloseTo(5, 4); expect(hostInternals.camera.position.y).toBeCloseTo(4.5, 4); expect(hostInternals.camera.position.z).toBeCloseTo(2, 4); hostInternals.applyActiveCameraRig(0.25); expect(hostInternals.camera.position).toMatchObject(overrideRig.position); host.dispose(); }); it("locks a fixed camera rig to its target and clamps authored look-around input", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld); const playerStart = createPlayerStartEntity({ id: "entity-player-start-camera-rig", position: { x: 1, y: 0, z: -2 } }); const cameraRig = createCameraRigEntity({ id: "entity-camera-rig-lookaround", position: { x: -4, y: 3, z: -8 }, target: createCameraRigPlayerTargetRef(), targetOffset: { x: 0, y: 1.6, z: 0 }, transitionMode: "cut", lookAround: { enabled: true, yawLimitDegrees: 10, pitchLimitDegrees: 5, recenterSpeed: 12 } }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Camera Rig Look Scene" }), entities: { [playerStart.id]: playerStart, [cameraRig.id]: cameraRig } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { sceneReady: boolean; camera: PerspectiveCamera; applyActiveCameraRig(dt: number): { entityId: string } | null; handleRuntimePointerDown(event: { button: number; clientX: number; clientY: number; preventDefault(): void; stopImmediatePropagation(): void; }): void; handleRuntimePointerMove(event: { clientX: number; clientY: number; preventDefault(): void; stopImmediatePropagation(): void; }): void; handleRuntimePointerUp(event: { stopImmediatePropagation(): void; }): void; }; hostInternals.sceneReady = true; expect(hostInternals.applyActiveCameraRig(0)?.entityId).toBe(cameraRig.id); const expectedBaseDirection = new Vector3( playerStart.position.x - cameraRig.position.x, playerStart.position.y + 1.6 - cameraRig.position.y, playerStart.position.z - cameraRig.position.z ).normalize(); const baseDirection = hostInternals.camera.getWorldDirection(new Vector3()); expect(baseDirection.angleTo(expectedBaseDirection)).toBeLessThan(1e-4); hostInternals.handleRuntimePointerDown({ button: 0, clientX: 0, clientY: 0, preventDefault: vi.fn(), stopImmediatePropagation: vi.fn() }); hostInternals.handleRuntimePointerMove({ clientX: -10000, clientY: 10000, preventDefault: vi.fn(), stopImmediatePropagation: vi.fn() }); hostInternals.applyActiveCameraRig(0); const lookedDirection = hostInternals.camera.getWorldDirection(new Vector3()); const baseAngles = resolveYawPitchRadians(expectedBaseDirection); const lookedAngles = resolveYawPitchRadians(lookedDirection); expect( ((lookedAngles.yawRadians - baseAngles.yawRadians) * 180) / Math.PI ).toBeCloseTo(10, 1); expect( ((lookedAngles.pitchRadians - baseAngles.pitchRadians) * 180) / Math.PI ).toBeCloseTo(-5, 1); hostInternals.handleRuntimePointerUp({ stopImmediatePropagation: vi.fn() }); hostInternals.applyActiveCameraRig(0.5); const recenteredAngles = resolveYawPitchRadians( hostInternals.camera.getWorldDirection(new Vector3()) ); expect( Math.abs(recenteredAngles.yawRadians - baseAngles.yawRadians) ).toBeLessThan(Math.abs(lookedAngles.yawRadians - baseAngles.yawRadians)); expect( Math.abs(recenteredAngles.pitchRadians - baseAngles.pitchRadians) ).toBeLessThan( Math.abs(lookedAngles.pitchRadians - baseAngles.pitchRadians) ); host.dispose(); }); it("applies typed light control effects through the runtime dispatcher", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld); const pointLight = createPointLightEntity({ id: "entity-point-light-main", intensity: 1.25 }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument(), entities: { [pointLight.id]: pointLight } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const disableEffect = createSetLightEnabledControlEffect({ target: createLightControlTargetRef("pointLight", pointLight.id), enabled: false }); const intensityEffect = createSetLightIntensityControlEffect({ target: createLightControlTargetRef("pointLight", pointLight.id), intensity: 3.5 }); const disableLink = createControlInteractionLink({ id: "link-light-disable", sourceEntityId: "entity-trigger-main", effect: disableEffect }); const intensityLink = createControlInteractionLink({ id: "link-light-intensity", sourceEntityId: "entity-trigger-main", effect: intensityEffect }); const hostInternals = host as unknown as { createInteractionDispatcher(): { dispatchControlEffect( effect: ControlEffect, link: InteractionLink ): void; }; localLightObjects: Map< string, { group: { visible: boolean }; light: { intensity: number }; } >; }; const dispatcher = hostInternals.createInteractionDispatcher(); const renderObjects = hostInternals.localLightObjects.get(pointLight.id); expect(renderObjects).toBeDefined(); expect(renderObjects?.group.visible).toBe(true); expect(renderObjects?.light.intensity).toBe(1.25); dispatcher.dispatchControlEffect(disableEffect, disableLink); dispatcher.dispatchControlEffect(intensityEffect, intensityLink); expect(renderObjects?.group.visible).toBe(false); expect(renderObjects?.light.intensity).toBe(3.5); expect(runtimeScene.control.resolved.discrete).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "lightEnabled", value: false, source: { kind: "interactionLink", linkId: disableLink.id } }) ]) ); expect(runtimeScene.control.resolved.channels).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "lightIntensity", value: 3.5, source: { kind: "interactionLink", linkId: intensityLink.id } }) ]) ); host.dispose(); }); it("creates derived runtime point lights for authored light volumes", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld); const lightBrush = createBoxBrush({ id: "brush-runtime-light-volume", size: { x: 6, y: 5, z: 3 }, volume: { mode: "light", light: { colorHex: "#ffe0b6", intensity: 2, padding: 0.4, falloff: "smoothstep" } } }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument(), brushes: { [lightBrush.id]: lightBrush } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { lightVolumeObjects: Map< string, { group: { visible: boolean }; lights: Array<{ intensity: number; distance: number; castShadow: boolean }>; } >; }; const renderObjects = hostInternals.lightVolumeObjects.get(lightBrush.id); expect(runtimeScene.volumes.light).toHaveLength(1); expect(runtimeScene.volumes.light[0]?.lights).toHaveLength(4); expect(renderObjects).toBeDefined(); expect(renderObjects?.group.visible).toBe(true); expect(renderObjects?.lights).toHaveLength(4); expect( renderObjects?.lights.every( (light) => light.intensity > 0 && light.distance > 0 && light.castShadow === false ) ).toBe(true); host.dispose(); }); it("applies project time pause control effects through the runtime dispatcher", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld); const runtimeScene = buildRuntimeSceneFromDocument( createEmptySceneDocument() ); const host = new RuntimeHost({ enableRendering: false }); const pauseStates: RuntimePauseState[] = []; host.setRuntimePauseStateHandler((state) => { pauseStates.push(state); }); host.loadScene(runtimeScene); const pauseEffect = createSetProjectTimePausedControlEffect({ target: createProjectGlobalControlTargetRef(), paused: true }); const resumeEffect = createSetProjectTimePausedControlEffect({ target: createProjectGlobalControlTargetRef(), paused: false }); const pauseLink = createControlInteractionLink({ id: "link-pause-time", sourceEntityId: "entity-trigger-main", effect: pauseEffect }); const resumeLink = createControlInteractionLink({ id: "link-resume-time", sourceEntityId: "entity-trigger-main", effect: resumeEffect }); const hostInternals = host as unknown as { createInteractionDispatcher(): { dispatchControlEffect( effect: ControlEffect, link: InteractionLink ): void; }; }; const dispatcher = hostInternals.createInteractionDispatcher(); dispatcher.dispatchControlEffect(pauseEffect, pauseLink); expect(pauseStates).toContainEqual({ paused: true, source: "control" }); expect(runtimeScene.control.resolved.discrete).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "projectTimePaused", target: { kind: "global", scope: "project" }, value: true, source: { kind: "interactionLink", linkId: pauseLink.id } }) ]) ); dispatcher.dispatchControlEffect(resumeEffect, resumeLink); expect(pauseStates).toContainEqual({ paused: false, source: null }); host.dispose(); }); it("opens, advances, and closes NPC dialogues through the runtime host", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld); const document = createEmptySceneDocument(); const npc = createNpcEntity({ id: "entity-npc-operator", dialogues: [ { id: "dialogue-warning", title: "Generator Warning", lines: [ { id: "dialogue-line-warning-1", text: "The generator is unstable." }, { id: "dialogue-line-warning-2", text: "A low hum fills the room." } ] } ], defaultDialogueId: "dialogue-warning" }); document.entities[npc.id] = npc; const runtimeScene = buildRuntimeSceneFromDocument(document); const host = new RuntimeHost({ enableRendering: false }); const dialogueStates: Array = []; host.setRuntimeDialogueHandler((dialogue) => { dialogueStates.push(dialogue); }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { createInteractionDispatcher(): { startNpcDialogue( npcEntityId: string, dialogueId: string | null, source?: { kind: "interactionLink" | "npc" | "direct"; sourceEntityId: string | null; linkId: string | null; trigger: "enter" | "exit" | "click" | null; } ): void; }; }; const dispatcher = hostInternals.createInteractionDispatcher(); dispatcher.startNpcDialogue(npc.id, "dialogue-warning", { kind: "npc", sourceEntityId: npc.id, linkId: null, trigger: "click" }); host.advanceRuntimeDialogue(); host.advanceRuntimeDialogue(); expect(dialogueStates).toEqual([ expect.objectContaining({ dialogueId: "dialogue-warning", npcEntityId: npc.id, lineIndex: 0, speakerName: npc.actorId, text: "The generator is unstable.", source: { kind: "npc", sourceEntityId: npc.id, linkId: null, trigger: "click" } }), expect.objectContaining({ dialogueId: "dialogue-warning", npcEntityId: npc.id, lineIndex: 1, speakerName: npc.actorId, text: "A low hum fills the room.", source: { kind: "npc", sourceEntityId: npc.id, linkId: null, trigger: "click" } }), null ]); host.dispose(); }); it("publishes late dialogue handlers, ignores repeated same-NPC dialogue starts, and replaces with a different NPC dialogue", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld); const document = createEmptySceneDocument(); const npcA = createNpcEntity({ id: "entity-npc-a", dialogues: [ { id: "dialogue-a", title: "A", lines: [ { id: "dialogue-line-a-1", text: "First dialogue." } ] } ], defaultDialogueId: "dialogue-a" }); const npcB = createNpcEntity({ id: "entity-npc-b", dialogues: [ { id: "dialogue-b", title: "B", lines: [ { id: "dialogue-line-b-1", text: "Second dialogue." } ] } ], defaultDialogueId: "dialogue-b" }); document.entities[npcA.id] = npcA; document.entities[npcB.id] = npcB; const host = new RuntimeHost({ enableRendering: false }); host.loadScene(buildRuntimeSceneFromDocument(document)); const hostInternals = host as unknown as { createInteractionDispatcher(): { startNpcDialogue( npcEntityId: string, dialogueId: string | null, source?: { kind: "interactionLink" | "npc" | "direct"; sourceEntityId: string | null; linkId: string | null; trigger: "enter" | "exit" | "click" | null; } ): void; }; }; const dispatcher = hostInternals.createInteractionDispatcher(); dispatcher.startNpcDialogue(npcA.id, "dialogue-a", { kind: "npc", sourceEntityId: npcA.id, linkId: null, trigger: "click" }); const dialogueStates: Array = []; host.setRuntimeDialogueHandler((dialogue) => { dialogueStates.push(dialogue); }); dispatcher.startNpcDialogue(npcA.id, "dialogue-a", { kind: "npc", sourceEntityId: npcA.id, linkId: null, trigger: "click" }); dispatcher.startNpcDialogue(npcB.id, "dialogue-b", { kind: "npc", sourceEntityId: npcB.id, linkId: null, trigger: "click" }); expect(dialogueStates).toEqual([ expect.objectContaining({ dialogueId: "dialogue-a", npcEntityId: npcA.id, text: "First dialogue.", source: { kind: "npc", sourceEntityId: npcA.id, linkId: null, trigger: "click" } }), expect.objectContaining({ dialogueId: "dialogue-b", npcEntityId: npcB.id, text: "Second dialogue.", source: { kind: "npc", sourceEntityId: npcB.id, linkId: null, trigger: "click" } }) ]); host.dispose(); }); it("applies expanded typed control effects for model, sound, and scene lighting", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld); const triggerVolume = createTriggerVolumeEntity({ id: "entity-trigger-main" }); const pointLight = createPointLightEntity({ id: "entity-point-light-main", colorHex: "#ff8800", intensity: 1.25 }); const soundEmitter = createSoundEmitterEntity({ id: "entity-sound-main", audioAssetId: "asset-audio-main", volume: 0.8 }); const modelAsset = { id: "asset-model-animated", kind: "model" as const, sourceName: "animated.glb", mimeType: "model/gltf-binary", storageKey: createProjectAssetStorageKey("asset-model-animated"), byteLength: 1024, metadata: { kind: "model" as const, format: "glb" as const, sceneName: null, nodeCount: 1, meshCount: 1, materialNames: [], textureNames: [], animationNames: ["Idle"], boundingBox: null, warnings: [] } } satisfies ModelAssetRecord; const audioAsset = { id: "asset-audio-main", kind: "audio" as const, sourceName: "loop.ogg", mimeType: "audio/ogg", storageKey: createProjectAssetStorageKey("asset-audio-main"), byteLength: 512, metadata: { kind: "audio" as const, durationSeconds: 2, channelCount: 2, sampleRateHz: 48000, warnings: [] } } satisfies AudioAssetRecord; const modelInstance = createModelInstance({ id: "model-instance-animated", assetId: modelAsset.id }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument(), assets: { [modelAsset.id]: modelAsset, [audioAsset.id]: audioAsset }, modelInstances: { [modelInstance.id]: modelInstance }, entities: { [triggerVolume.id]: triggerVolume, [pointLight.id]: pointLight, [soundEmitter.id]: soundEmitter } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { createInteractionDispatcher(): { dispatchControlEffect( effect: ControlEffect, link: InteractionLink ): void; }; localLightObjects: Map< string, { group: { visible: boolean }; light: { intensity: number; color: { getHexString(): string } }; } >; modelRenderObjects: Map; ambientLight: { intensity: number; color: { getHexString(): string }; }; sunLight: { intensity: number; color: { getHexString(): string }; }; audioSystem: { hasSoundEmitter(soundEmitterId: string): boolean; playSound(soundEmitterId: string, link: InteractionLink | null): void; stopSound(soundEmitterId: string): void; setSoundEmitterVolume(soundEmitterId: string, volume: number): void; }; animationMixers: Map; applyPlayAnimationAction( instanceId: string, clipName: string, loop: boolean | undefined ): void; applyStopAnimationAction(instanceId: string): void; }; const dispatcher = hostInternals.createInteractionDispatcher(); const lightRenderObjects = hostInternals.localLightObjects.get(pointLight.id); const modelRenderGroup = hostInternals.modelRenderObjects.get( modelInstance.id ); const initialAmbientIntensity = hostInternals.ambientLight.intensity; const initialAmbientColor = hostInternals.ambientLight.color.getHexString(); const initialSunIntensity = hostInternals.sunLight.intensity; const initialSunColor = hostInternals.sunLight.color.getHexString(); const hasSoundEmitterSpy = vi .spyOn(hostInternals.audioSystem, "hasSoundEmitter") .mockReturnValue(true); const playSoundSpy = vi .spyOn(hostInternals.audioSystem, "playSound") .mockImplementation(() => undefined); const stopSoundSpy = vi .spyOn(hostInternals.audioSystem, "stopSound") .mockImplementation(() => undefined); const setSoundEmitterVolumeSpy = vi .spyOn(hostInternals.audioSystem, "setSoundEmitterVolume") .mockImplementation(() => undefined); hostInternals.animationMixers.set( modelInstance.id, { stopAllAction: vi.fn() } as unknown as AnimationMixer ); const playAnimationSpy = vi .spyOn(hostInternals, "applyPlayAnimationAction") .mockImplementation(() => undefined); const stopAnimationSpy = vi .spyOn(hostInternals, "applyStopAnimationAction") .mockImplementation(() => undefined); const hideModelEffect = createSetModelInstanceVisibleControlEffect({ target: createModelInstanceControlTargetRef(modelInstance.id), visible: false }); const playAnimationEffect = createPlayModelAnimationControlEffect({ target: createModelInstanceControlTargetRef(modelInstance.id), clipName: "Idle", loop: false }); const stopAnimationEffect = createStopModelAnimationControlEffect({ target: createModelInstanceControlTargetRef(modelInstance.id) }); const playSoundEffect = createPlaySoundControlEffect({ target: createSoundEmitterControlTargetRef(soundEmitter.id) }); const stopSoundEffect = createStopSoundControlEffect({ target: createSoundEmitterControlTargetRef(soundEmitter.id) }); const setSoundVolumeEffect = createSetSoundVolumeControlEffect({ target: createSoundEmitterControlTargetRef(soundEmitter.id), volume: 0.2 }); const lightColorEffect = createSetLightColorControlEffect({ target: createLightControlTargetRef("pointLight", pointLight.id), colorHex: "#00ffaa" }); const ambientIntensityEffect = createSetAmbientLightIntensityControlEffect({ target: createActiveSceneControlTargetRef(), intensity: 0.6 }); const ambientColorEffect = createSetAmbientLightColorControlEffect({ target: createActiveSceneControlTargetRef(), colorHex: "#112233" }); const sunIntensityEffect = createSetSunLightIntensityControlEffect({ target: createActiveSceneControlTargetRef(), intensity: 0.75 }); const sunColorEffect = createSetSunLightColorControlEffect({ target: createActiveSceneControlTargetRef(), colorHex: "#ffeeaa" }); const links = { hideModel: createControlInteractionLink({ id: "link-hide-model", sourceEntityId: triggerVolume.id, effect: hideModelEffect }), playAnimation: createControlInteractionLink({ id: "link-play-animation", sourceEntityId: triggerVolume.id, effect: playAnimationEffect }), stopAnimation: createControlInteractionLink({ id: "link-stop-animation", sourceEntityId: triggerVolume.id, effect: stopAnimationEffect }), playSound: createControlInteractionLink({ id: "link-play-sound", sourceEntityId: triggerVolume.id, effect: playSoundEffect }), stopSound: createControlInteractionLink({ id: "link-stop-sound", sourceEntityId: triggerVolume.id, effect: stopSoundEffect }), setSoundVolume: createControlInteractionLink({ id: "link-set-sound-volume", sourceEntityId: triggerVolume.id, effect: setSoundVolumeEffect }), lightColor: createControlInteractionLink({ id: "link-light-color", sourceEntityId: triggerVolume.id, effect: lightColorEffect }), ambientIntensity: createControlInteractionLink({ id: "link-ambient-intensity", sourceEntityId: triggerVolume.id, effect: ambientIntensityEffect }), ambientColor: createControlInteractionLink({ id: "link-ambient-color", sourceEntityId: triggerVolume.id, effect: ambientColorEffect }), sunIntensity: createControlInteractionLink({ id: "link-sun-intensity", sourceEntityId: triggerVolume.id, effect: sunIntensityEffect }), sunColor: createControlInteractionLink({ id: "link-sun-color", sourceEntityId: triggerVolume.id, effect: sunColorEffect }) }; dispatcher.dispatchControlEffect(hideModelEffect, links.hideModel); dispatcher.dispatchControlEffect(playAnimationEffect, links.playAnimation); dispatcher.dispatchControlEffect(stopAnimationEffect, links.stopAnimation); dispatcher.dispatchControlEffect(playSoundEffect, links.playSound); dispatcher.dispatchControlEffect(stopSoundEffect, links.stopSound); dispatcher.dispatchControlEffect(setSoundVolumeEffect, links.setSoundVolume); dispatcher.dispatchControlEffect(lightColorEffect, links.lightColor); dispatcher.dispatchControlEffect( ambientIntensityEffect, links.ambientIntensity ); dispatcher.dispatchControlEffect(ambientColorEffect, links.ambientColor); dispatcher.dispatchControlEffect(sunIntensityEffect, links.sunIntensity); dispatcher.dispatchControlEffect(sunColorEffect, links.sunColor); expect(modelRenderGroup?.visible).toBe(false); expect(runtimeScene.modelInstances[0]).toEqual( expect.objectContaining({ visible: false, animationClipName: undefined, animationAutoplay: false }) ); expect(playAnimationSpy).toHaveBeenCalledWith( modelInstance.id, "Idle", false ); expect(stopAnimationSpy).toHaveBeenCalledWith(modelInstance.id); expect(hasSoundEmitterSpy).toHaveBeenCalledWith(soundEmitter.id); expect(playSoundSpy).toHaveBeenCalledWith(soundEmitter.id, links.playSound); expect(stopSoundSpy).toHaveBeenCalledWith(soundEmitter.id); expect(setSoundEmitterVolumeSpy).toHaveBeenCalledWith(soundEmitter.id, 0.2); expect(runtimeScene.entities.soundEmitters[0]).toEqual( expect.objectContaining({ autoplay: false, volume: 0.2 }) ); expect(lightRenderObjects?.light.color.getHexString()).toBe("00ffaa"); expect(runtimeScene.localLights.pointLights[0]).toEqual( expect.objectContaining({ colorHex: "#00ffaa" }) ); expect(hostInternals.ambientLight.intensity).not.toBeCloseTo( initialAmbientIntensity ); expect(hostInternals.ambientLight.color.getHexString()).not.toBe( initialAmbientColor ); expect(hostInternals.sunLight.intensity).not.toBeCloseTo(initialSunIntensity); expect(hostInternals.sunLight.color.getHexString()).not.toBe(initialSunColor); expect(runtimeScene.world.ambientLight).toEqual( expect.objectContaining({ intensity: 0.6, colorHex: "#112233" }) ); expect(runtimeScene.world.sunLight).toEqual( expect.objectContaining({ intensity: 0.75, colorHex: "#ffeeaa" }) ); expect(runtimeScene.control.resolved.discrete).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "modelVisibility", value: false }), expect.objectContaining({ type: "modelAnimationPlayback", clipName: null }), expect.objectContaining({ type: "soundPlayback", value: false }), expect.objectContaining({ type: "lightColor", value: "#00ffaa" }), expect.objectContaining({ type: "ambientLightColor", value: "#112233" }), expect.objectContaining({ type: "sunLightColor", value: "#ffeeaa" }) ]) ); expect(runtimeScene.control.resolved.channels).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "soundVolume", value: 0.2 }), expect.objectContaining({ type: "ambientLightIntensity", value: 0.6 }), expect.objectContaining({ type: "sunLightIntensity", value: 0.75 }) ]) ); host.dispose(); }); it("re-resolves NPC activity from the project scheduler when the runtime clock advances", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld); const npc = createNpcEntity({ id: "entity-npc-night-guard", actorId: "actor-night-guard" }); const document = createEmptySceneDocument(); document.entities[npc.id] = npc; document.scheduler.routines["routine-night-guard"] = createProjectScheduleRoutine({ id: "routine-night-guard", title: "Night Shift", target: createActorControlTargetRef(npc.actorId), startHour: 20, endHour: 4, effect: createSetActorPresenceControlEffect({ target: createActorControlTargetRef(npc.actorId), active: true }) }); const runtimeScene = buildRuntimeSceneFromDocument(document); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { currentClockState: { timeOfDayHours: number; dayCount: number; dayLengthMinutes: number; } | null; sceneReady: boolean; runtimeScene: typeof runtimeScene | null; syncRuntimeScheduleToCurrentClock(): void; }; expect(runtimeScene.entities.npcs).toEqual([ expect.objectContaining({ entityId: npc.id, activeRoutineTitle: null }) ]); hostInternals.sceneReady = true; hostInternals.currentClockState = { timeOfDayHours: 21, dayCount: 0, dayLengthMinutes: 24 }; hostInternals.syncRuntimeScheduleToCurrentClock(); expect(hostInternals.runtimeScene?.entities.npcs).toEqual([ expect.objectContaining({ entityId: npc.id, actorId: npc.actorId, activeRoutineTitle: "Night Shift" }) ]); hostInternals.currentClockState = { timeOfDayHours: 6, dayCount: 1, dayLengthMinutes: 24 }; hostInternals.syncRuntimeScheduleToCurrentClock(); expect(hostInternals.runtimeScene?.entities.npcs).toEqual([ expect.objectContaining({ entityId: npc.id, activeRoutineTitle: null }) ]); expect(hostInternals.runtimeScene?.npcDefinitions[0]).toEqual( expect.objectContaining({ entityId: npc.id, active: true, activeRoutineTitle: null }) ); host.dispose(); }); it("re-resolves NPC animation and follow-path pose from the project scheduler", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld); 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, 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: 6, dayCount: 0, dayLengthMinutes: 24 }, loadedModelAssets: { [asset.id]: loadedAsset } }); const host = new RuntimeHost({ enableRendering: false }); host.updateAssets( document.assets, { [asset.id]: loadedAsset }, {}, {} ); host.loadScene(runtimeScene); const hostInternals = host as unknown as { currentClockState: { timeOfDayHours: number; dayCount: number; dayLengthMinutes: number; } | null; sceneReady: boolean; runtimeScene: typeof runtimeScene | null; modelRenderObjects: Map< string, { visible: boolean; position: { x: number; y: number; z: number }; rotation: { y: number }; } >; applyPlayAnimationAction( instanceId: string, clipName: string, loop: boolean | undefined ): void; syncRuntimeScheduleToCurrentClock(): void; }; const playAnimationSpy = vi .spyOn(hostInternals, "applyPlayAnimationAction") .mockImplementation(() => undefined); hostInternals.sceneReady = true; hostInternals.currentClockState = { timeOfDayHours: 11, dayCount: 0, dayLengthMinutes: 24 }; hostInternals.syncRuntimeScheduleToCurrentClock(); expect(playAnimationSpy).toHaveBeenCalledWith(npc.id, "Walk", true); expect(hostInternals.runtimeScene?.entities.npcs).toEqual([ expect.objectContaining({ entityId: npc.id, activeRoutineTitle: "Patrolling", animationClipName: "Walk", position: { x: 4, y: 0, z: 0 } }) ]); expect(hostInternals.runtimeScene?.npcDefinitions[0]).toEqual( expect.objectContaining({ entityId: npc.id, animationClipName: "Walk", yawDegrees: 90, resolvedPath: expect.objectContaining({ pathId: path.id, progress: 0.5 }) }) ); expect(hostInternals.modelRenderObjects.get(npc.id)).toEqual( expect.objectContaining({ visible: true, position: expect.objectContaining({ x: 4, y: 0, z: 0 }), rotation: expect.objectContaining({ y: Math.PI / 2 }) }) ); hostInternals.currentClockState = { timeOfDayHours: 14, dayCount: 0, dayLengthMinutes: 24 }; hostInternals.syncRuntimeScheduleToCurrentClock(); expect(hostInternals.runtimeScene?.entities.npcs).toEqual([ expect.objectContaining({ entityId: npc.id, activeRoutineTitle: null, animationClipName: null, position: { x: 8, y: 0, z: 0 }, resolvedPath: expect.objectContaining({ pathId: path.id, progress: 1 }) }) ]); expect(hostInternals.runtimeScene?.npcDefinitions[0]).toEqual( expect.objectContaining({ entityId: npc.id, active: true, activeRoutineTitle: null, animationClipName: null, yawDegrees: 90, position: { x: 8, y: 0, z: 0 }, resolvedPath: expect.objectContaining({ pathId: path.id, progress: 1, yawDegrees: 90 }) }) ); host.dispose(); }); it("applies scheduler-controlled light effects and restores authored defaults when the routine ends", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld); const pointLight = createPointLightEntity({ id: "entity-point-light-night-lamp", intensity: 1.25 }); const document = createEmptySceneDocument(); document.entities[pointLight.id] = pointLight; document.scheduler.routines["routine-night-lamp"] = createProjectScheduleRoutine({ id: "routine-night-lamp", title: "Night Lamp", target: createLightControlTargetRef("pointLight", pointLight.id), startHour: 20, endHour: 4, effect: createSetLightIntensityControlEffect({ target: createLightControlTargetRef("pointLight", pointLight.id), intensity: 3.5 }) }); const runtimeScene = buildRuntimeSceneFromDocument(document); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { currentClockState: { timeOfDayHours: number; dayCount: number; dayLengthMinutes: number; } | null; sceneReady: boolean; runtimeScene: typeof runtimeScene | null; localLightObjects: Map< string, { light: { intensity: number }; } >; syncRuntimeScheduleToCurrentClock(): void; }; hostInternals.sceneReady = true; hostInternals.currentClockState = { timeOfDayHours: 21, dayCount: 0, dayLengthMinutes: 24 }; hostInternals.syncRuntimeScheduleToCurrentClock(); expect( hostInternals.localLightObjects.get(pointLight.id)?.light.intensity ).toBe(3.5); expect(hostInternals.runtimeScene?.control.resolved.channels).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "lightIntensity", value: 3.5, source: { kind: "scheduler", scheduleId: "routine-night-lamp" } }) ]) ); hostInternals.currentClockState = { timeOfDayHours: 6, dayCount: 1, dayLengthMinutes: 24 }; hostInternals.syncRuntimeScheduleToCurrentClock(); expect( hostInternals.localLightObjects.get(pointLight.id)?.light.intensity ).toBe(1.25); expect(hostInternals.runtimeScene?.control.resolved.channels).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "lightIntensity", value: 1.25, source: { kind: "default" } }) ]) ); host.dispose(); }); it("fires scheduler impulse sequences only once until the runtime session is reset", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld); const document = createEmptySceneDocument(); document.sequences.sequences["sequence-scene-transition"] = createProjectSequence({ id: "sequence-scene-transition", title: "Scene Transition", effects: [ { stepClass: "impulse", type: "startSceneTransition", targetSceneId: "scene-house", targetEntryEntityId: "entry-house" } ] }); document.scheduler.routines["routine-scene-transition"] = createProjectScheduleRoutine({ id: "routine-scene-transition", title: "Scene Transition Window", target: createProjectGlobalControlTargetRef(), sequenceId: "sequence-scene-transition", startHour: 8, endHour: 12, priority: 0, effects: [] }); const runtimeScene = buildRuntimeSceneFromDocument(document); const host = new RuntimeHost({ enableRendering: false }); const transitions: Array<{ sourceEntityId: string | null; targetSceneId: string; targetEntryEntityId: string; }> = []; host.setSceneTransitionHandler((request) => { transitions.push(request); }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { currentClockState: { timeOfDayHours: number; dayCount: number; dayLengthMinutes: number; } | null; sceneReady: boolean; syncRuntimeScheduleToCurrentClock(): void; }; hostInternals.sceneReady = true; hostInternals.currentClockState = { timeOfDayHours: 9, dayCount: 0, dayLengthMinutes: 24 }; hostInternals.syncRuntimeScheduleToCurrentClock(); hostInternals.currentClockState = { timeOfDayHours: 10, dayCount: 0, dayLengthMinutes: 24 }; hostInternals.syncRuntimeScheduleToCurrentClock(); host.loadScene(runtimeScene); hostInternals.currentClockState = { timeOfDayHours: 10.5, dayCount: 0, dayLengthMinutes: 24 }; hostInternals.syncRuntimeScheduleToCurrentClock(); hostInternals.currentClockState = { timeOfDayHours: 9, dayCount: 1, dayLengthMinutes: 24 }; hostInternals.syncRuntimeScheduleToCurrentClock(); expect(transitions).toEqual([ { sourceEntityId: null, targetSceneId: "scene-house", targetEntryEntityId: "entry-house" } ]); host.dispose(); }); });