import { waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createActiveSceneControlTargetRef, createActorControlTargetRef, createFollowActorPathControlEffect, createLightControlTargetRef, createModelInstanceControlTargetRef, createPlayActorAnimationControlEffect, createPlayModelAnimationControlEffect, createPlaySoundControlEffect, createSetActorPresenceControlEffect, type ControlEffect, createSetAmbientLightColorControlEffect, createSetAmbientLightIntensityControlEffect, createSetLightEnabledControlEffect, createSetLightColorControlEffect, createSetLightIntensityControlEffect, createSetModelInstanceVisibleControlEffect, createSetSoundVolumeControlEffect, createSetSunLightColorControlEffect, createSetSunLightIntensityControlEffect, createSoundEmitterControlTargetRef, createStopModelAnimationControlEffect, createStopSoundControlEffect } from "../../src/controls/control-surface"; import { createScenePath } from "../../src/document/paths"; import { createEmptySceneDocument } from "../../src/document/scene-document"; import { createNpcEntity, createPointLightEntity, createSoundEmitterEntity, createTriggerVolumeEntity } from "../../src/entities/entity-instances"; import { createControlInteractionLink, createStartDialogueInteractionLink, type InteractionLink } from "../../src/interactions/interaction-links"; import { createProjectScheduleRoutine } from "../../src/scheduler/project-scheduler"; 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 RuntimeSceneLoadState } from "../../src/runtime-three/runtime-host"; import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build"; import { AnimationClip, BoxGeometry, 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); } }; } 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("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("opens, advances, and closes project 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 triggerVolume = createTriggerVolumeEntity({ id: "entity-trigger-main" }); const document = createEmptySceneDocument(); document.entities[triggerVolume.id] = triggerVolume; document.dialogues.dialogues["dialogue-warning"] = { id: "dialogue-warning", title: "Generator Warning", lines: [ { id: "dialogue-line-warning-1", speakerName: "Operator", text: "The generator is unstable." }, { id: "dialogue-line-warning-2", speakerName: null, text: "A low hum fills the room." } ] }; document.interactionLinks["link-start-dialogue"] = createStartDialogueInteractionLink({ id: "link-start-dialogue", sourceEntityId: triggerVolume.id, trigger: "enter", dialogueId: "dialogue-warning" }); 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(): { startDialogue( dialogueId: string, source?: { kind: "interactionLink" | "npc" | "direct"; sourceEntityId: string | null; linkId: string | null; } ): void; }; }; const dispatcher = hostInternals.createInteractionDispatcher(); const dialogueLink = document.interactionLinks["link-start-dialogue"]!; dispatcher.startDialogue("dialogue-warning", { kind: "interactionLink", sourceEntityId: dialogueLink.sourceEntityId, linkId: dialogueLink.id }); host.advanceRuntimeDialogue(); host.advanceRuntimeDialogue(); expect(dialogueStates).toEqual([ expect.objectContaining({ dialogueId: "dialogue-warning", lineIndex: 0, speakerName: "Operator", text: "The generator is unstable.", source: { kind: "interactionLink", sourceEntityId: triggerVolume.id, linkId: dialogueLink.id } }), expect.objectContaining({ dialogueId: "dialogue-warning", lineIndex: 1, speakerName: null, text: "A low hum fills the room.", source: { kind: "interactionLink", sourceEntityId: triggerVolume.id, linkId: dialogueLink.id } }), null ]); host.dispose(); }); it("publishes late dialogue handlers, ignores repeated same-dialogue starts, and replaces with a different 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(); document.dialogues.dialogues["dialogue-a"] = { id: "dialogue-a", title: "A", lines: [ { id: "dialogue-line-a-1", speakerName: null, text: "First dialogue." } ] }; document.dialogues.dialogues["dialogue-b"] = { id: "dialogue-b", title: "B", lines: [ { id: "dialogue-line-b-1", speakerName: "Merchant", text: "Second dialogue." } ] }; const host = new RuntimeHost({ enableRendering: false }); host.loadScene(buildRuntimeSceneFromDocument(document)); const hostInternals = host as unknown as { createInteractionDispatcher(): { startDialogue( dialogueId: string, source?: { kind: "interactionLink" | "npc" | "direct"; sourceEntityId: string | null; linkId: string | null; } ): void; }; }; const dispatcher = hostInternals.createInteractionDispatcher(); dispatcher.startDialogue("dialogue-a", { kind: "interactionLink", sourceEntityId: "entity-trigger-a", linkId: "link-dialogue-a" }); const dialogueStates: Array = []; host.setRuntimeDialogueHandler((dialogue) => { dialogueStates.push(dialogue); }); dispatcher.startDialogue("dialogue-a", { kind: "interactionLink", sourceEntityId: "entity-trigger-a", linkId: "link-dialogue-a" }); dispatcher.startDialogue("dialogue-b", { kind: "npc", sourceEntityId: "entity-npc-merchant", linkId: null }); expect(dialogueStates).toEqual([ expect.objectContaining({ dialogueId: "dialogue-a", text: "First dialogue.", source: { kind: "interactionLink", sourceEntityId: "entity-trigger-a", linkId: "link-dialogue-a" } }), expect.objectContaining({ dialogueId: "dialogue-b", text: "Second dialogue.", source: { kind: "npc", sourceEntityId: "entity-npc-merchant", linkId: null } }) ]); 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([]); 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(hostInternals.runtimeScene?.npcDefinitions[0]).toEqual( expect.objectContaining({ entityId: npc.id, active: false, 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(hostInternals.runtimeScene?.npcDefinitions[0]).toEqual( expect.objectContaining({ entityId: npc.id, active: false, activeRoutineTitle: null, animationClipName: null, yawDegrees: 15, position: { x: 0, y: 0, z: 0 }, resolvedPath: null }) ); 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(); }); });