import { waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createActiveSceneControlTargetRef, createActivateCameraRigOverrideControlEffect, createActorControlTargetRef, createCameraRigControlTargetRef, createClearCameraRigOverrideControlEffect, 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, createCameraRigEntityTargetRef, createCameraRigPlayerTargetRef, createCameraRigWorldPointTargetRef, createInteractableEntity, 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, resolveRuntimeTargetVisualPlacement, 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, Quaternion, 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))) }; } function captureCameraPose(camera: PerspectiveCamera) { const position = camera.position.clone(); const lookTarget = position.clone().add(camera.getWorldDirection(new Vector3())); return { position, lookTarget }; } function createRuntimeKeyEvent( code: string, overrides: Partial = {} ): KeyboardEvent { return { code, defaultPrevented: false, repeat: false, altKey: false, ctrlKey: false, metaKey: false, target: null, preventDefault: vi.fn(), stopImmediatePropagation: vi.fn(), ...overrides } as unknown as KeyboardEvent; } function resolveShortestAngleDeltaDegrees(fromDegrees: number, toDegrees: number) { return ((toDegrees - fromDegrees + 540) % 360) - 180; } 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("uses the authored interact binding instead of always dispatching left mouse clicks", () => { const playerStart = createPlayerStartEntity({ id: "entity-player-start-authored-interact", inputBindings: { keyboard: { interact: "KeyE" }, gamepad: { interact: "buttonWest" } } }); const interactable = createInteractableEntity({ id: "entity-authored-interact-target" }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Authored Interact Scene" }), entities: { [playerStart.id]: playerStart, [interactable.id]: interactable } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { sceneReady: boolean; activeController: unknown; thirdPersonController: unknown; currentInteractionPrompt: { sourceEntityId: string; prompt: string; distance: number; range: number; } | null; interactionSystem: { dispatchClickInteraction: ReturnType; }; handleRuntimePointerDown(event: { button: number; clientX: number; clientY: number; preventDefault(): void; stopImmediatePropagation(): void; }): void; handleRuntimeKeyDown(event: KeyboardEvent): void; }; const dispatchClickInteraction = vi.fn(); hostInternals.sceneReady = true; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentInteractionPrompt = { sourceEntityId: interactable.id, prompt: interactable.prompt, distance: 0, range: interactable.radius }; hostInternals.interactionSystem.dispatchClickInteraction = dispatchClickInteraction; hostInternals.handleRuntimePointerDown({ button: 0, clientX: 0, clientY: 0, preventDefault: vi.fn(), stopImmediatePropagation: vi.fn() }); expect(dispatchClickInteraction).not.toHaveBeenCalled(); hostInternals.handleRuntimeKeyDown(createRuntimeKeyEvent("KeyE")); expect(dispatchClickInteraction).toHaveBeenCalledTimes(1); expect(dispatchClickInteraction).toHaveBeenCalledWith( interactable.id, runtimeScene, expect.any(Object) ); }); it("starts default-active rigs in place and blends rig-to-rig overrides", () => { 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: "blend", transitionDurationSeconds: 0.75 }); 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; cameraTransitionState: { elapsedSeconds: number } | null; applyActiveCameraRig(dt: number): { entityId: string } | null; }; hostInternals.sceneReady = true; hostInternals.camera.position.set(-12, 3, 14); hostInternals.camera.lookAt(0, 1.6, 0); expect(hostInternals.applyActiveCameraRig(0.1)?.entityId).toBe(defaultRig.id); expect(hostInternals.camera.position).toMatchObject(defaultRig.position); expect(hostInternals.cameraTransitionState).toBeNull(); 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("blends from gameplay camera into an active rig override", () => { 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 cameraRig = createCameraRigEntity({ id: "entity-camera-rig-gameplay-entry", defaultActive: false, position: { x: 8, y: 4, z: -6 }, target: createCameraRigWorldPointTargetRef({ x: 0, y: 1.5, z: 0 }), transitionMode: "blend", transitionDurationSeconds: 0.5 }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Camera Rig Gameplay Entry Scene" }), entities: { [cameraRig.id]: cameraRig } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { sceneReady: boolean; camera: PerspectiveCamera; cameraTransitionState: { elapsedSeconds: number } | null; applyActiveCameraRig(dt: number): { entityId: string } | null; }; hostInternals.sceneReady = true; hostInternals.applyActiveCameraRig(0); hostInternals.camera.position.set(0, 2, 12); hostInternals.camera.lookAt(0, 1.5, 0); host.setActiveCameraRigOverride(cameraRig.id); expect(hostInternals.applyActiveCameraRig(0.25)?.entityId).toBe(cameraRig.id); expect(hostInternals.cameraTransitionState).not.toBeNull(); expect(hostInternals.camera.position.x).toBeCloseTo(4, 4); expect(hostInternals.camera.position.y).toBeCloseTo(3, 4); expect(hostInternals.camera.position.z).toBeCloseTo(3, 4); hostInternals.applyActiveCameraRig(0.25); expect(hostInternals.camera.position).toMatchObject(cameraRig.position); host.dispose(); }); it("blends from a rig back to the gameplay camera", () => { 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 cameraRig = createCameraRigEntity({ id: "entity-camera-rig-gameplay-exit", defaultActive: false, position: { x: 8, y: 4, z: -6 }, target: createCameraRigWorldPointTargetRef({ x: 0, y: 1.5, z: 0 }), transitionMode: "blend", transitionDurationSeconds: 0.5 }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Camera Rig Gameplay Exit Scene" }), entities: { [cameraRig.id]: cameraRig } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { sceneReady: boolean; camera: PerspectiveCamera; cameraTransitionState: { elapsedSeconds: number } | null; applyActiveCameraRig( dt: number, previousCameraPose?: { position: Vector3; lookTarget: Vector3; } ): { entityId: string } | null; }; hostInternals.sceneReady = true; hostInternals.applyActiveCameraRig(0); hostInternals.camera.position.set(0, 2, 12); hostInternals.camera.lookAt(0, 1.5, 0); host.setActiveCameraRigOverride(cameraRig.id); hostInternals.applyActiveCameraRig(0.5); const previousRigPose = captureCameraPose(hostInternals.camera); host.setActiveCameraRigOverride(null); hostInternals.camera.position.set(-6, 3, 8); hostInternals.camera.lookAt(0, 1.5, 0); expect(hostInternals.applyActiveCameraRig(0.25, previousRigPose)).toBeNull(); expect(hostInternals.cameraTransitionState).not.toBeNull(); expect(hostInternals.camera.position.x).toBeCloseTo(1, 4); expect(hostInternals.camera.position.y).toBeCloseTo(3.5, 4); expect(hostInternals.camera.position.z).toBeCloseTo(1, 4); hostInternals.camera.position.set(-6, 3, 8); hostInternals.camera.lookAt(0, 1.5, 0); hostInternals.applyActiveCameraRig(0.25, previousRigPose); expect(hostInternals.camera.position).toMatchObject({ x: -6, y: 3, z: 8 }); host.dispose(); }); it("activates the dialogue attention camera and pauses runtime when dialogue opens", () => { 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-dialogue-camera", position: { x: 0, y: 0, z: 0 } }); const npc = createNpcEntity({ id: "entity-npc-dialogue-camera", position: { x: 2, y: 0, z: 2 }, dialogues: [ { id: "dialogue-attention", title: "Attention", lines: [ { id: "dialogue-attention-line-1", text: "Look this way." } ] } ], defaultDialogueId: "dialogue-attention" }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Dialogue Attention Scene" }), entities: { [playerStart.id]: playerStart, [npc.id]: npc } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { sceneReady: boolean; camera: PerspectiveCamera; currentPauseState: RuntimePauseState; activeCameraSourceKey: string | null; activeRuntimeCameraRig: { entityId: string } | null; cameraTransitionState: { elapsedSeconds: number } | null; applyActiveCameraRig( dt: number, previousCameraPose?: { position: Vector3; lookTarget: Vector3; } ): { entityId: string } | null; 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(); hostInternals.sceneReady = true; hostInternals.camera.position.set(0, 2.6, 6); hostInternals.camera.lookAt(0, 1.6, 0); hostInternals.applyActiveCameraRig(0, captureCameraPose(hostInternals.camera)); dispatcher.startNpcDialogue(npc.id, null, { kind: "npc", sourceEntityId: npc.id, linkId: null, trigger: "click" }); const gameplayPose = captureCameraPose(hostInternals.camera); hostInternals.applyActiveCameraRig(0.175, gameplayPose); expect(hostInternals.currentPauseState).toEqual({ paused: true, source: "dialogue" }); expect(hostInternals.activeCameraSourceKey).toBe(`dialogue:${npc.id}`); expect(hostInternals.activeRuntimeCameraRig).toBeNull(); expect(hostInternals.cameraTransitionState).not.toBeNull(); expect(hostInternals.camera.position.z).toBeLessThan(6); hostInternals.applyActiveCameraRig(0.175, gameplayPose); const cameraForward = hostInternals.camera.getWorldDirection(new Vector3()); const playerFocusDirection = new Vector3( -hostInternals.camera.position.x, 1.312 - hostInternals.camera.position.y, -hostInternals.camera.position.z ).normalize(); const npcFocusDirection = new Vector3( 2 - hostInternals.camera.position.x, 1.408 - hostInternals.camera.position.y, 2 - hostInternals.camera.position.z ).normalize(); expect(cameraForward.dot(playerFocusDirection)).toBeGreaterThan(0.6); expect(cameraForward.dot(npcFocusDirection)).toBeGreaterThan(0.6); host.dispose(); }); it("resolves dialogue attention camera collision from the conversation midpoint", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); const resolveThirdPersonCameraCollision = vi.fn( ( pivot: { x: number; y: number; z: number }, desiredCameraPosition: { x: number; y: number; z: number }, radius: number ) => { expect(radius).toBeGreaterThan(0); return { x: pivot.x + (desiredCameraPosition.x - pivot.x) * 0.55, y: pivot.y + (desiredCameraPosition.y - pivot.y) * 0.55, z: pivot.z + (desiredCameraPosition.z - pivot.z) * 0.55 }; } ); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), resolveThirdPersonCameraCollision } as unknown as RapierCollisionWorld); const playerStart = createPlayerStartEntity({ id: "entity-player-start-dialogue-collision", position: { x: 0, y: 0, z: 0 } }); const npc = createNpcEntity({ id: "entity-npc-dialogue-collision", position: { x: 2, y: 0, z: 2 }, dialogues: [ { id: "dialogue-collision", title: "Collision", lines: [ { id: "dialogue-collision-line-1", text: "Avoid the wall." } ] } ], defaultDialogueId: "dialogue-collision" }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Dialogue Collision Scene" }), entities: { [playerStart.id]: playerStart, [npc.id]: npc } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { sceneReady: boolean; camera: PerspectiveCamera; collisionWorld: RapierCollisionWorld | null; applyActiveCameraRig( dt: number, previousCameraPose?: { position: Vector3; lookTarget: Vector3; } ): { entityId: string } | null; 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(); hostInternals.sceneReady = true; hostInternals.collisionWorld = { dispose: vi.fn(), resolveThirdPersonCameraCollision } as unknown as RapierCollisionWorld; hostInternals.camera.position.set(0, 2.6, 6); hostInternals.camera.lookAt(0, 1.6, 0); dispatcher.startNpcDialogue(npc.id, null, { kind: "npc", sourceEntityId: npc.id, linkId: null, trigger: "click" }); const gameplayPose = captureCameraPose(hostInternals.camera); hostInternals.applyActiveCameraRig(0.175, gameplayPose); expect(resolveThirdPersonCameraCollision).toHaveBeenCalled(); const lastCollisionCall = resolveThirdPersonCameraCollision.mock.calls.at(-1); expect(lastCollisionCall).toBeDefined(); const [pivot, desiredCameraPosition, radius] = lastCollisionCall as [ { x: number; y: number; z: number }, { x: number; y: number; z: number }, number ]; expect(pivot).toMatchObject({ x: 1, z: 1 }); expect(pivot?.y).toBeCloseTo(1.36, 5); expect(radius).toBe(0.2); expect(hostInternals.camera.position.x).toBeCloseTo( pivot.x + (desiredCameraPosition.x - pivot.x) * 0.55 ); expect(hostInternals.camera.position.y).toBeCloseTo( pivot.y + (desiredCameraPosition.y - pivot.y) * 0.55 ); expect(hostInternals.camera.position.z).toBeCloseTo( pivot.z + (desiredCameraPosition.z - pivot.z) * 0.55 ); host.dispose(); }); it("smooths runtime camera collision recovery when an obstruction clears", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { camera: PerspectiveCamera; collisionWorld: RapierCollisionWorld | null; applyCameraPose( pose: { position: Vector3; lookTarget: Vector3; collisionPivot: Vector3; collisionRadius: number; }, dt?: number ): void; }; const pivot = new Vector3(0, 1, 0); const desiredPosition = new Vector3(0, 2, -4); const pose = { position: desiredPosition, lookTarget: new Vector3(0, 1, 0), collisionPivot: pivot, collisionRadius: 0.2 }; let collisionScale = 0.25; const distanceFromPivot = () => hostInternals.camera.position.distanceTo(pivot); const desiredDistance = desiredPosition.distanceTo(pivot); hostInternals.collisionWorld = { dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( ( collisionPivot: { x: number; y: number; z: number }, desiredCameraPosition: { x: number; y: number; z: number } ) => ({ x: collisionPivot.x + (desiredCameraPosition.x - collisionPivot.x) * collisionScale, y: collisionPivot.y + (desiredCameraPosition.y - collisionPivot.y) * collisionScale, z: collisionPivot.z + (desiredCameraPosition.z - collisionPivot.z) * collisionScale }) ) } as unknown as RapierCollisionWorld; hostInternals.applyCameraPose(pose, 0); const blockedDistance = distanceFromPivot(); collisionScale = 1; hostInternals.applyCameraPose(pose, 0.1); const recoveringDistance = distanceFromPivot(); expect(recoveringDistance).toBeGreaterThan(blockedDistance); expect(recoveringDistance).toBeLessThan(desiredDistance); hostInternals.applyCameraPose(pose, 1); expect(distanceFromPivot()).toBeCloseTo(desiredDistance); host.dispose(); }); it("stages dialogue participants with minimum spacing and restores npc yaw after dialogue", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); const canOccupyPlayerShape = vi.fn(() => true); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), canOccupyPlayerShape, resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld); const playerStart = createPlayerStartEntity({ id: "entity-player-start-dialogue-spacing", position: { x: 1.9, y: 0, z: 2 }, yawDegrees: 0 }); const npc = createNpcEntity({ id: "entity-npc-dialogue-spacing", position: { x: 2, y: 0, z: 2 }, yawDegrees: 0, dialogues: [ { id: "dialogue-spacing", title: "Spacing", lines: [ { id: "dialogue-spacing-line-1", text: "Take a step back." } ] } ], defaultDialogueId: "dialogue-spacing" }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Dialogue Spacing Scene" }), entities: { [playerStart.id]: playerStart, [npc.id]: npc } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { sceneReady: boolean; camera: PerspectiveCamera; collisionWorld: RapierCollisionWorld | null; activeCameraSourceKey: string | null; currentPlayerControllerTelemetry: | { feetPosition: { x: number; y: number; z: number }; yawDegrees: number; } | null; dialogueParticipantState: { npcEntityId: string } | null; runtimeScene: ReturnType | null; activateDesiredNavigationController(): void; updateRuntimeDialogueParticipants(dt: number): void; applyActiveCameraRig( dt: number, previousCameraPose?: { position: Vector3; lookTarget: Vector3; } ): { entityId: string } | null; 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(); hostInternals.sceneReady = true; hostInternals.collisionWorld = { dispose: vi.fn(), canOccupyPlayerShape, resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld; hostInternals.activateDesiredNavigationController(); expect(hostInternals.currentPlayerControllerTelemetry?.feetPosition).toEqual( playerStart.position ); dispatcher.startNpcDialogue(npc.id, null, { kind: "npc", sourceEntityId: npc.id, linkId: null, trigger: "click" }); expect(hostInternals.dialogueParticipantState?.npcEntityId).toBe(npc.id); hostInternals.updateRuntimeDialogueParticipants(0.05); hostInternals.updateRuntimeDialogueParticipants(0.05); hostInternals.applyActiveCameraRig( 0.1, captureCameraPose(hostInternals.camera) ); const playerTelemetry = hostInternals.currentPlayerControllerTelemetry; const runtimeNpc = hostInternals.runtimeScene?.entities.npcs.find( (candidate) => candidate.entityId === npc.id ) ?? null; expect(canOccupyPlayerShape).toHaveBeenCalled(); expect(playerTelemetry).not.toBeNull(); expect(runtimeNpc).not.toBeNull(); const playerFeetPosition = playerTelemetry?.feetPosition ?? { x: 0, y: 0, z: 0 }; const playerDistanceFromNpc = Math.hypot( playerFeetPosition.x - npc.position.x, playerFeetPosition.z - npc.position.z ); const playerTargetYawDegrees = (Math.atan2(npc.position.x - playerFeetPosition.x, npc.position.z - playerFeetPosition.z) * 180) / Math.PI; expect(playerDistanceFromNpc).toBeGreaterThan(0.1); expect(playerDistanceFromNpc).toBeLessThan(1.09); expect(hostInternals.activeCameraSourceKey).toBe("gameplay"); expect( Math.abs( resolveShortestAngleDeltaDegrees( playerTelemetry?.yawDegrees ?? 0, playerTargetYawDegrees ) ) ).toBeLessThan(35); expect( Math.abs( resolveShortestAngleDeltaDegrees(runtimeNpc?.yawDegrees ?? 0, 0) ) ).toBeGreaterThan(10); hostInternals.updateRuntimeDialogueParticipants(0.1); hostInternals.updateRuntimeDialogueParticipants(0.1); hostInternals.applyActiveCameraRig( 0.1, captureCameraPose(hostInternals.camera) ); const stagedPlayerTelemetry = hostInternals.currentPlayerControllerTelemetry; const stagedPlayerDistanceFromNpc = Math.hypot( (stagedPlayerTelemetry?.feetPosition.x ?? 0) - npc.position.x, (stagedPlayerTelemetry?.feetPosition.z ?? 0) - npc.position.z ); expect(stagedPlayerDistanceFromNpc).toBeGreaterThanOrEqual(1.09); expect(hostInternals.activeCameraSourceKey).toBe(`dialogue:${npc.id}`); host.closeRuntimeDialogue(); hostInternals.updateRuntimeDialogueParticipants(0.05); expect(runtimeNpc?.yawDegrees).not.toBeCloseTo(0, 3); for (let step = 0; step < 10; step += 1) { hostInternals.updateRuntimeDialogueParticipants(0.05); } expect(Math.abs(runtimeNpc?.yawDegrees ?? Number.POSITIVE_INFINITY)).toBeLessThan(1); expect(hostInternals.dialogueParticipantState).toBeNull(); host.dispose(); }); it("keeps explicit camera rig overrides above dialogue attention", () => { 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-dialogue-rig-priority", position: { x: 2, y: 0, z: 2 }, dialogues: [ { id: "dialogue-priority", title: "Priority", lines: [ { id: "dialogue-priority-line-1", text: "Rig wins." } ] } ], defaultDialogueId: "dialogue-priority" }); const rig = createCameraRigEntity({ id: "entity-camera-rig-dialogue-override", position: { x: 8, y: 4, z: -6 }, target: createCameraRigEntityTargetRef(npc.id), transitionMode: "cut" }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Dialogue Rig Priority Scene" }), entities: { [npc.id]: npc, [rig.id]: rig } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { sceneReady: boolean; camera: PerspectiveCamera; currentPauseState: RuntimePauseState; activeCameraSourceKey: string | null; activeRuntimeCameraRig: { entityId: string } | null; applyActiveCameraRig( dt: number, previousCameraPose?: { position: Vector3; lookTarget: Vector3; } ): { entityId: string } | null; 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(); hostInternals.sceneReady = true; dispatcher.startNpcDialogue(npc.id, null, { kind: "npc", sourceEntityId: npc.id, linkId: null, trigger: "click" }); host.setActiveCameraRigOverride(rig.id); expect(hostInternals.applyActiveCameraRig(0)?.entityId).toBe(rig.id); expect(hostInternals.currentPauseState).toEqual({ paused: true, source: "dialogue" }); expect(hostInternals.activeCameraSourceKey).toBe(`rig:${rig.id}`); expect(hostInternals.activeRuntimeCameraRig?.entityId).toBe(rig.id); expect(hostInternals.camera.position).toMatchObject(rig.position); host.dispose(); }); it("blends back to gameplay camera when dialogue closes", () => { 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-dialogue-exit", position: { x: 0, y: 0, z: 0 } }); const npc = createNpcEntity({ id: "entity-npc-dialogue-exit", position: { x: 2, y: 0, z: 2 }, dialogues: [ { id: "dialogue-exit", title: "Exit", lines: [ { id: "dialogue-exit-line-1", text: "Back to play." } ] } ], defaultDialogueId: "dialogue-exit" }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Dialogue Exit Scene" }), entities: { [playerStart.id]: playerStart, [npc.id]: npc } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { sceneReady: boolean; camera: PerspectiveCamera; currentPauseState: RuntimePauseState; activeCameraSourceKey: string | null; cameraTransitionState: { elapsedSeconds: number } | null; applyActiveCameraRig( dt: number, previousCameraPose?: { position: Vector3; lookTarget: Vector3; } ): { entityId: string } | null; 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(); hostInternals.sceneReady = true; hostInternals.camera.position.set(0, 2.6, 6); hostInternals.camera.lookAt(0, 1.6, 0); dispatcher.startNpcDialogue(npc.id, null, { kind: "npc", sourceEntityId: npc.id, linkId: null, trigger: "click" }); hostInternals.applyActiveCameraRig(0.35, captureCameraPose(hostInternals.camera)); const dialoguePose = captureCameraPose(hostInternals.camera); host.closeRuntimeDialogue(); hostInternals.camera.position.set(0, 2.6, 6); hostInternals.camera.lookAt(0, 1.6, 0); expect(hostInternals.applyActiveCameraRig(0.175, dialoguePose)).toBeNull(); expect(hostInternals.currentPauseState).toEqual({ paused: false, source: null }); expect(hostInternals.activeCameraSourceKey).toBe("gameplay"); expect(hostInternals.cameraTransitionState).not.toBeNull(); expect(hostInternals.camera.position.z).toBeGreaterThan( dialoguePose.position.z ); expect(hostInternals.camera.position.z).toBeLessThan(6); hostInternals.camera.position.set(0, 2.6, 6); hostInternals.camera.lookAt(0, 1.6, 0); hostInternals.applyActiveCameraRig(0.175, dialoguePose); expect(hostInternals.camera.position).toMatchObject({ x: 0, y: 2.6, z: 6 }); 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("routes camera rig control effects through the runtime override path", () => { 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-control", position: { x: 0, y: 3, z: 6 }, defaultActive: true, target: createCameraRigWorldPointTargetRef({ x: 0, y: 1.5, z: 0 }) }); const overrideRig = createCameraRigEntity({ id: "entity-camera-rig-override-control", position: { x: 10, y: 5, z: -4 }, defaultActive: false, target: createCameraRigWorldPointTargetRef({ x: 1, y: 2, z: 0 }) }); const triggerVolume = createTriggerVolumeEntity({ id: "entity-trigger-camera-control" }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Camera Control Runtime Scene" }), entities: { [defaultRig.id]: defaultRig, [overrideRig.id]: overrideRig, [triggerVolume.id]: triggerVolume } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const activateEffect = createActivateCameraRigOverrideControlEffect({ target: createCameraRigControlTargetRef(overrideRig.id) }); const clearEffect = createClearCameraRigOverrideControlEffect({ target: createCameraRigControlTargetRef(overrideRig.id) }); const activateLink = createControlInteractionLink({ id: "link-camera-activate", sourceEntityId: triggerVolume.id, effect: activateEffect }); const clearLink = createControlInteractionLink({ id: "link-camera-clear", sourceEntityId: triggerVolume.id, effect: clearEffect }); const hostInternals = host as unknown as { sceneReady: boolean; activeCameraRigOverrideEntityId: string | null; activeRuntimeCameraRig: { entityId: string } | null; applyActiveCameraRig(dt: number): { entityId: string } | null; createInteractionDispatcher(): { dispatchControlEffect( effect: ControlEffect, link: InteractionLink ): void; }; }; const dispatcher = hostInternals.createInteractionDispatcher(); hostInternals.sceneReady = true; expect(hostInternals.applyActiveCameraRig(0)?.entityId).toBe(defaultRig.id); dispatcher.dispatchControlEffect(activateEffect, activateLink); expect(hostInternals.activeCameraRigOverrideEntityId).toBe(overrideRig.id); expect(hostInternals.applyActiveCameraRig(0)?.entityId).toBe(overrideRig.id); expect(runtimeScene.control.resolved.discrete).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "cameraRigOverride", entityId: overrideRig.id, source: { kind: "interactionLink", linkId: activateLink.id } }) ]) ); dispatcher.dispatchControlEffect(clearEffect, clearLink); expect(hostInternals.activeCameraRigOverrideEntityId).toBeNull(); expect(hostInternals.applyActiveCameraRig(0)?.entityId).toBe(defaultRig.id); expect(runtimeScene.control.resolved.discrete).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "cameraRigOverride", entityId: null, source: { kind: "interactionLink", linkId: clearLink.id } }) ]) ); host.dispose(); }); it("resolves rail camera rigs from the target's nearest path progress and preserves look-around", () => { 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 target = createInteractableEntity({ id: "entity-camera-rail-target", position: { x: 3, y: 1, z: 2 }, prompt: "Anchor" }); const path = createScenePath({ id: "path-camera-rail-runtime", 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-runtime", rigType: "rail", pathId: path.id, target: createCameraRigEntityTargetRef(target.id), targetOffset: { x: 0, y: 1.5, z: 0 }, transitionMode: "cut", lookAround: { enabled: true, yawLimitDegrees: 12, pitchLimitDegrees: 6, recenterSpeed: 10 } }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Rail Camera Rig Runtime Scene" }), paths: { [path.id]: path }, entities: { [target.id]: target, [cameraRig.id]: cameraRig } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { sceneReady: boolean; camera: PerspectiveCamera; runtimeScene: typeof runtimeScene; 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); expect(hostInternals.camera.position).toMatchObject({ x: 3, y: 3, z: 0 }); const initialDirection = hostInternals.camera.getWorldDirection( new Vector3() ); hostInternals.handleRuntimePointerDown({ button: 0, clientX: 0, clientY: 0, preventDefault: vi.fn(), stopImmediatePropagation: vi.fn() }); hostInternals.handleRuntimePointerMove({ clientX: -1000, clientY: 0, preventDefault: vi.fn(), stopImmediatePropagation: vi.fn() }); hostInternals.applyActiveCameraRig(0); expect(hostInternals.camera.position).toMatchObject({ x: 3, y: 3, z: 0 }); expect( hostInternals.camera .getWorldDirection(new Vector3()) .angleTo(initialDirection) ).toBeGreaterThan(0.05); hostInternals.handleRuntimePointerUp({ stopImmediatePropagation: vi.fn() }); hostInternals.runtimeScene.entities.interactables[0]!.position = { x: 8, y: 1, z: 2 }; hostInternals.applyActiveCameraRig(0); expect(hostInternals.camera.position).toMatchObject({ x: 8, y: 3, z: 0 }); host.dispose(); }); it("maps rail camera rig progress between authored world points", () => { 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 target = createInteractableEntity({ id: "entity-camera-mapped-rail-target", position: { x: 2, y: 1, z: 2 }, prompt: "Anchor" }); const path = createScenePath({ id: "path-camera-mapped-rail-runtime", 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-mapped-rail-runtime", rigType: "rail", pathId: path.id, railPlacementMode: "mapTargetBetweenPoints", trackStartPoint: { x: 0, y: 1, z: 2 }, trackEndPoint: { x: 10, y: 1, z: 2 }, railStartProgress: 0.25, railEndProgress: 0.75, target: createCameraRigEntityTargetRef(target.id), transitionMode: "cut" }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Mapped Rail Camera Rig Runtime Scene" }), paths: { [path.id]: path }, entities: { [target.id]: target, [cameraRig.id]: cameraRig } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { sceneReady: boolean; camera: PerspectiveCamera; runtimeScene: typeof runtimeScene; applyActiveCameraRig(dt: number): { entityId: string } | null; }; hostInternals.sceneReady = true; expect(hostInternals.applyActiveCameraRig(0)?.entityId).toBe(cameraRig.id); expect(hostInternals.camera.position).toMatchObject({ x: 3.5, y: 3, z: 0 }); hostInternals.runtimeScene.entities.interactables[0]!.position = { x: 10, y: 1, z: 2 }; hostInternals.applyActiveCameraRig(0); expect(hostInternals.camera.position).toMatchObject({ x: 7.5, y: 3, z: 0 }); 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("keeps dialogue pause active for runtime progression while camera blends and dialogue advance still work", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ dispose: vi.fn(), resolveThirdPersonCameraCollision: vi.fn( (_pivot, desiredCameraPosition) => desiredCameraPosition ) } as unknown as RapierCollisionWorld); vi.spyOn(window, "requestAnimationFrame").mockReturnValue(1); vi.spyOn(performance, "now").mockReturnValue(1100); const playerStart = createPlayerStartEntity({ id: "entity-player-start-dialogue-pause", position: { x: 0, y: 0, z: 0 } }); const npc = createNpcEntity({ id: "entity-npc-dialogue-pause", position: { x: 2, y: 0, z: 2 }, dialogues: [ { id: "dialogue-pause", title: "Pause", lines: [ { id: "dialogue-pause-line-1", text: "Time should stop." }, { id: "dialogue-pause-line-2", text: "But dialogue should keep going." } ] } ], defaultDialogueId: "dialogue-pause" }); const runtimeScene = buildRuntimeSceneFromDocument({ ...createEmptySceneDocument({ name: "Dialogue Pause Scene" }), entities: { [playerStart.id]: playerStart, [npc.id]: npc } }); const host = new RuntimeHost({ enableRendering: false }); host.loadScene(runtimeScene); const hostInternals = host as unknown as { sceneReady: boolean; camera: PerspectiveCamera; currentClockState: { timeOfDayHours: number; dayCount: number; dayLengthMinutes: number; } | null; currentDialogue: RuntimeDialogueState | null; currentPauseState: RuntimePauseState; activeCameraSourceKey: string | null; cameraTransitionState: { elapsedSeconds: number } | null; previousFrameTime: number; applyActiveCameraRig( dt: number, previousCameraPose?: { position: Vector3; lookTarget: Vector3; } ): { entityId: string } | null; render(): void; 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(); hostInternals.sceneReady = true; hostInternals.previousFrameTime = 1000; hostInternals.camera.position.set(0, 2.6, 6); hostInternals.camera.lookAt(0, 1.6, 0); hostInternals.applyActiveCameraRig(0, captureCameraPose(hostInternals.camera)); const clockBefore = { ...hostInternals.currentClockState! }; dispatcher.startNpcDialogue(npc.id, null, { kind: "npc", sourceEntityId: npc.id, linkId: null, trigger: "click" }); hostInternals.render(); expect(hostInternals.currentPauseState).toEqual({ paused: true, source: "dialogue" }); expect(hostInternals.activeCameraSourceKey).toBe(`dialogue:${npc.id}`); expect(hostInternals.cameraTransitionState?.elapsedSeconds).toBeGreaterThan(0); expect(hostInternals.currentClockState).toEqual(clockBefore); host.advanceRuntimeDialogue(); expect(hostInternals.currentDialogue?.lineIndex).toBe(1); expect(hostInternals.currentDialogue?.text).toBe( "But dialogue should keep going." ); 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(); }); it("toggles the proposed runtime target instead of cycling candidates", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; sceneReady: boolean; activeController: unknown; thirdPersonController: unknown; runtimeTargetCandidates: Array<{ kind: "npc" | "interactable"; entityId: string; }>; proposedRuntimeTarget: { kind: "npc" | "interactable"; entityId: string; } | null; activeRuntimeTargetReference: { kind: "npc" | "interactable"; entityId: string; } | null; activateOrCycleRuntimeTarget(): void; clearActiveRuntimeTarget(): void; }; const firstTarget = { kind: "npc" as const, entityId: "npc-one", prompt: "Talk", position: { x: 0, y: 0, z: 2 }, center: { x: 0, y: 1, z: 2 }, distance: 0, range: 2, viewDot: 1, score: 3 }; const secondTarget = { ...firstTarget, kind: "interactable" as const, entityId: "switch-two", prompt: "Use", score: 2.5 }; hostInternals.runtimeScene = {} as never; hostInternals.sceneReady = true; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.runtimeTargetCandidates = [firstTarget, secondTarget]; hostInternals.proposedRuntimeTarget = firstTarget; hostInternals.activateOrCycleRuntimeTarget(); expect(hostInternals.activeRuntimeTargetReference).toEqual({ kind: "npc", entityId: "npc-one" }); hostInternals.activateOrCycleRuntimeTarget(); expect(hostInternals.activeRuntimeTargetReference).toBeNull(); hostInternals.clearActiveRuntimeTarget(); expect(hostInternals.activeRuntimeTargetReference).toBeNull(); host.dispose(); }); it("places targeting visuals above the target focus at readable scale", () => { const placement = resolveRuntimeTargetVisualPlacement({ center: { x: 1, y: 1.1, z: -2 }, range: 1.5 }); expect(placement.luxPosition).toMatchObject({ x: 1, z: -2 }); expect(placement.luxPosition.y).toBeCloseTo(2.18); expect(placement.activeMarkerPosition).toMatchObject({ x: 1, y: 1.1, z: -2 }); expect(placement.activeMarkerRadius).toBeGreaterThan(0.6); expect(placement.activeMarkerScale).toBeGreaterThan(0.75); }); it("uses three active target arrows that point inward at target center", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; sceneReady: boolean; activeController: unknown; thirdPersonController: unknown; currentPlayerControllerTelemetry: unknown; activeRuntimeTargetReference: { kind: "npc"; entityId: string; } | null; targetingActiveGroup: { children: unknown[]; position: Vector3; visible: boolean; }; targetingActiveArrows: Array<{ position: Vector3; quaternion: Quaternion; }>; camera: PerspectiveCamera; updateRuntimeTargetingVisuals(dt: number): void; }; hostInternals.runtimeScene = { entities: { npcs: [ { entityId: "npc-active", visible: true, position: { x: 0, y: 0, z: 4 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Active", defaultDialogueId: null, dialogues: [] } ], interactables: [], cameraRigs: [] }, interactionLinks: [ { id: "link-active", sourceEntityId: "npc-active", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } } ] } as never; hostInternals.sceneReady = true; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentPlayerControllerTelemetry = { feetPosition: { x: 0, y: 0, z: 0 }, eyePosition: { x: 0, y: 1.6, z: 0 } }; hostInternals.activeRuntimeTargetReference = { kind: "npc", entityId: "npc-active" }; hostInternals.camera.position.set(0, 1.6, -3); hostInternals.camera.lookAt(0, 0.9, 4); hostInternals.camera.updateMatrixWorld(); hostInternals.camera.updateProjectionMatrix(); hostInternals.updateRuntimeTargetingVisuals(0.25); expect(hostInternals.targetingActiveGroup.visible).toBe(true); expect(hostInternals.targetingActiveGroup.children).toHaveLength(3); expect(hostInternals.targetingActiveGroup.position.y).toBeCloseTo(0.9); for (const arrow of hostInternals.targetingActiveArrows) { const inwardDirection = arrow.position.clone().multiplyScalar(-1).normalize(); const arrowTipDirection = new Vector3(0, 1, 0) .applyQuaternion(arrow.quaternion) .normalize(); expect(arrow.position.length()).toBeGreaterThan(0.7); expect(arrowTipDirection.dot(inwardDirection)).toBeGreaterThan(0.99); } host.dispose(); }); it("flies Lux out from the player center when a target is proposed", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; sceneReady: boolean; activeController: unknown; thirdPersonController: unknown; currentPlayerControllerTelemetry: unknown; proposedRuntimeTarget: { kind: "npc"; entityId: string; prompt: string; position: { x: number; y: number; z: number }; center: { x: number; y: number; z: number }; distance: number; range: number; viewDot: number; score: number; } | null; targetingVisualGroup: { visible: boolean }; targetingLuxGroup: { position: Vector3; visible: boolean }; targetingLuxFlightState: string; camera: PerspectiveCamera; updateRuntimeTargetingVisuals(dt: number): void; }; hostInternals.runtimeScene = { entities: { npcs: [], interactables: [], cameraRigs: [] } } as never; hostInternals.sceneReady = true; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentPlayerControllerTelemetry = { feetPosition: { x: 0, y: 0, z: 0 }, eyePosition: { x: 0, y: 1.6, z: 0 } }; hostInternals.proposedRuntimeTarget = { kind: "npc", entityId: "npc-target", prompt: "Talk", position: { x: 0, y: 0, z: 4 }, center: { x: 0, y: 1, z: 4 }, distance: 4, range: 1.5, viewDot: 1, score: 1 }; hostInternals.camera.position.set(0, 1.6, -3); hostInternals.updateRuntimeTargetingVisuals(0); expect(hostInternals.targetingVisualGroup.visible).toBe(true); expect(hostInternals.targetingLuxGroup.visible).toBe(true); expect(hostInternals.targetingLuxFlightState).toBe("outbound"); expect(hostInternals.targetingLuxGroup.position.x).toBeCloseTo(0); expect(hostInternals.targetingLuxGroup.position.y).toBeCloseTo(0.832); expect(hostInternals.targetingLuxGroup.position.z).toBeCloseTo(0); hostInternals.updateRuntimeTargetingVisuals(0.1); expect(hostInternals.targetingLuxGroup.position.x).toBeGreaterThan(0.005); expect(hostInternals.targetingLuxGroup.position.y).toBeGreaterThan(0.832); expect(hostInternals.targetingLuxGroup.position.z).toBeGreaterThan(0); expect(hostInternals.targetingLuxGroup.position.z).toBeLessThan(4); host.dispose(); }); it("flies Lux back to the player before hiding when the proposal disappears", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; sceneReady: boolean; activeController: unknown; thirdPersonController: unknown; currentPlayerControllerTelemetry: unknown; proposedRuntimeTarget: { kind: "npc"; entityId: string; prompt: string; position: { x: number; y: number; z: number }; center: { x: number; y: number; z: number }; distance: number; range: number; viewDot: number; score: number; } | null; targetingVisualGroup: { visible: boolean }; targetingLuxGroup: { position: Vector3; visible: boolean }; targetingLuxFlightState: string; camera: PerspectiveCamera; updateRuntimeTargetingVisuals(dt: number): void; }; hostInternals.runtimeScene = { entities: { npcs: [], interactables: [], cameraRigs: [] } } as never; hostInternals.sceneReady = true; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentPlayerControllerTelemetry = { feetPosition: { x: 0, y: 0, z: 0 }, eyePosition: { x: 0, y: 1.6, z: 0 } }; hostInternals.proposedRuntimeTarget = { kind: "npc", entityId: "npc-target", prompt: "Talk", position: { x: 0, y: 0, z: 5 }, center: { x: 0, y: 1, z: 5 }, distance: 5, range: 1.5, viewDot: 1, score: 1 }; hostInternals.camera.position.set(0, 1.6, -3); hostInternals.updateRuntimeTargetingVisuals(0); hostInternals.updateRuntimeTargetingVisuals(1); const homePosition = new Vector3(0, 0.832, 0); const distanceBeforeReturn = hostInternals.targetingLuxGroup.position.distanceTo(homePosition); hostInternals.proposedRuntimeTarget = null; hostInternals.updateRuntimeTargetingVisuals(0.1); expect(hostInternals.targetingVisualGroup.visible).toBe(true); expect(hostInternals.targetingLuxGroup.visible).toBe(true); expect(hostInternals.targetingLuxFlightState).toBe("returning"); expect( hostInternals.targetingLuxGroup.position.distanceTo(homePosition) ).toBeLessThan(distanceBeforeReturn); for (let index = 0; index < 20; index += 1) { hostInternals.updateRuntimeTargetingVisuals(0.1); } expect(hostInternals.targetingVisualGroup.visible).toBe(false); expect(hostInternals.targetingLuxGroup.visible).toBe(false); expect(hostInternals.targetingLuxFlightState).toBe("hidden"); host.dispose(); }); it("uses Lux-only proposal feedback and consumes Escape when clearing an active target", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; sceneReady: boolean; activeController: unknown; thirdPersonController: unknown; proposedRuntimeTarget: { kind: "npc" | "interactable"; entityId: string; center: { x: number; y: number; z: number }; } | null; activeRuntimeTargetReference: { kind: "npc" | "interactable"; entityId: string; } | null; resolveThirdPersonTargetAssist(): unknown; handleRuntimeKeyDown(event: KeyboardEvent): void; }; const escapeEvent = { code: "Escape", defaultPrevented: false, repeat: false, altKey: false, ctrlKey: false, metaKey: false, target: null, preventDefault: vi.fn(), stopImmediatePropagation: vi.fn() } as unknown as KeyboardEvent; hostInternals.runtimeScene = { playerInputBindings: { keyboard: { pauseTime: "KeyP" } }, entities: { cameraRigs: [], interactables: [], npcs: [] }, interactionLinks: [] } as never; hostInternals.sceneReady = true; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.proposedRuntimeTarget = { kind: "npc", entityId: "npc-proposed", center: { x: 0, y: 1, z: 3 } }; expect(hostInternals.resolveThirdPersonTargetAssist()).toBeNull(); hostInternals.activeRuntimeTargetReference = { kind: "npc", entityId: "npc-active" }; hostInternals.handleRuntimeKeyDown(escapeEvent); expect(hostInternals.activeRuntimeTargetReference).toBeNull(); expect(escapeEvent.preventDefault).toHaveBeenCalledTimes(1); expect(escapeEvent.stopImmediatePropagation).toHaveBeenCalledTimes(1); host.dispose(); }); it("switches an active target once from directional screen-space look input", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; activeController: unknown; thirdPersonController: unknown; currentPlayerControllerTelemetry: unknown; runtimeTargetCandidates: Array<{ kind: "npc"; entityId: string; center: { x: number; y: number; z: number }; score: number; }>; activeRuntimeTargetReference: { kind: "npc"; entityId: string; } | null; camera: PerspectiveCamera; handleRuntimeTargetLookInput(input: { horizontal: number; vertical: number; }): { activeTargetLocked: boolean; switchedTarget: boolean; switchInputHeld: boolean; }; updateActiveRuntimeTargetLockState(): void; }; hostInternals.runtimeScene = { entities: { npcs: [ { entityId: "npc-active", visible: true, position: { x: 0, y: 0, z: 5 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Active", defaultDialogueId: null, dialogues: [] }, { entityId: "npc-right", visible: true, position: { x: 2, y: 0, z: 5 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Right", defaultDialogueId: null, dialogues: [] }, { entityId: "npc-above", visible: true, position: { x: 0, y: 2, z: 5 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Above", defaultDialogueId: null, dialogues: [] } ], interactables: [], cameraRigs: [] }, interactionLinks: [ { id: "link-active", sourceEntityId: "npc-active", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } }, { id: "link-right", sourceEntityId: "npc-right", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } }, { id: "link-above", sourceEntityId: "npc-above", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } } ] } as never; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentPlayerControllerTelemetry = { eyePosition: { x: 0, y: 1.6, z: 0 } }; hostInternals.runtimeTargetCandidates = [ { kind: "npc", entityId: "npc-active", center: { x: 0, y: 0.9, z: 5 }, score: 3 }, { kind: "npc", entityId: "npc-right", center: { x: 2, y: 0.9, z: 5 }, score: 2.5 }, { kind: "npc", entityId: "npc-above", center: { x: 0, y: 2.9, z: 5 }, score: 2.4 } ]; hostInternals.activeRuntimeTargetReference = { kind: "npc", entityId: "npc-active" }; hostInternals.camera.position.set(0, 1.6, 0); hostInternals.camera.lookAt(0, 0.9, 5); hostInternals.camera.updateMatrixWorld(); hostInternals.camera.updateProjectionMatrix(); expect( hostInternals.handleRuntimeTargetLookInput({ horizontal: -1, vertical: 0 }) ).toEqual({ activeTargetLocked: true, switchedTarget: true, switchInputHeld: true }); expect(hostInternals.activeRuntimeTargetReference).toEqual({ kind: "npc", entityId: "npc-right" }); expect( hostInternals.handleRuntimeTargetLookInput({ horizontal: -1, vertical: 0 }) ).toEqual({ activeTargetLocked: true, switchedTarget: false, switchInputHeld: true }); expect(hostInternals.activeRuntimeTargetReference).toEqual({ kind: "npc", entityId: "npc-right" }); expect( hostInternals.handleRuntimeTargetLookInput({ horizontal: 0, vertical: 0 }) ).toEqual({ activeTargetLocked: true, switchedTarget: false, switchInputHeld: false }); hostInternals.activeRuntimeTargetReference = { kind: "npc", entityId: "npc-active" }; expect( hostInternals.handleRuntimeTargetLookInput({ horizontal: 0, vertical: 1 }) ).toEqual({ activeTargetLocked: true, switchedTarget: true, switchInputHeld: true }); expect(hostInternals.activeRuntimeTargetReference).toEqual({ kind: "npc", entityId: "npc-above" }); host.dispose(); }); it("does not switch active target from camera angle without look input", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; activeController: unknown; thirdPersonController: unknown; currentPlayerControllerTelemetry: unknown; runtimeTargetCandidates: Array<{ kind: "npc"; entityId: string; center: { x: number; y: number; z: number }; score: number; }>; activeRuntimeTargetReference: { kind: "npc"; entityId: string; } | null; camera: PerspectiveCamera; updateActiveRuntimeTargetLockState(): void; }; hostInternals.runtimeScene = { entities: { npcs: [ { entityId: "npc-active", visible: true, position: { x: 0, y: 0, z: 5 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Active", defaultDialogueId: null, dialogues: [] } ], interactables: [], cameraRigs: [] }, interactionLinks: [ { id: "link-active", sourceEntityId: "npc-active", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } } ] } as never; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentPlayerControllerTelemetry = { eyePosition: { x: 0, y: 1.6, z: 0 } }; hostInternals.runtimeTargetCandidates = [ { kind: "npc", entityId: "npc-active", center: { x: 0, y: 0.9, z: 5 }, score: 3 }, { kind: "npc", entityId: "npc-other", center: { x: -2, y: 0.9, z: 5 }, score: 2.5 } ]; hostInternals.activeRuntimeTargetReference = { kind: "npc", entityId: "npc-active" }; hostInternals.camera.position.set(0, 1.6, 0); hostInternals.camera.lookAt(10, 1.6, 3); hostInternals.updateActiveRuntimeTargetLockState(); expect(hostInternals.activeRuntimeTargetReference).toEqual({ kind: "npc", entityId: "npc-active" }); host.dispose(); }); it("retargets to the centered on-screen candidate before clearing a distant active target", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; activeController: unknown; thirdPersonController: unknown; currentPlayerControllerTelemetry: unknown; runtimeTargetCandidates: Array<{ kind: "npc"; entityId: string; prompt: string; position: { x: number; y: number; z: number }; center: { x: number; y: number; z: number }; distance: number; range: number; viewDot: number; score: number; }>; proposedRuntimeTarget: { kind: "npc"; entityId: string; } | null; activeRuntimeTargetReference: { kind: "npc"; entityId: string; } | null; camera: PerspectiveCamera; updateActiveRuntimeTargetLockState(): void; }; hostInternals.runtimeScene = { entities: { npcs: [ { entityId: "npc-far", visible: true, position: { x: 0, y: 0, z: 16 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Far", defaultDialogueId: null, dialogues: [] }, { entityId: "npc-near", visible: true, position: { x: 0, y: 0, z: 6 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Near", defaultDialogueId: null, dialogues: [] } ], interactables: [], cameraRigs: [] }, interactionLinks: [ { id: "link-far", sourceEntityId: "npc-far", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } }, { id: "link-near", sourceEntityId: "npc-near", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } } ] } as never; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentPlayerControllerTelemetry = { eyePosition: { x: 0, y: 1.6, z: 0 } }; hostInternals.activeRuntimeTargetReference = { kind: "npc", entityId: "npc-far" }; hostInternals.runtimeTargetCandidates = [ { kind: "npc", entityId: "npc-near", prompt: "Talk", position: { x: 0, y: 0, z: 6 }, center: { x: 0, y: 0.9, z: 6 }, distance: 6, range: 1.5, viewDot: 1, score: 2 } ]; hostInternals.camera.position.set(0, 1.6, 0); hostInternals.camera.lookAt(0, 0.9, 6); hostInternals.camera.updateMatrixWorld(); hostInternals.camera.updateProjectionMatrix(); hostInternals.updateActiveRuntimeTargetLockState(); expect(hostInternals.activeRuntimeTargetReference).toEqual({ kind: "npc", entityId: "npc-near" }); expect(hostInternals.proposedRuntimeTarget).toEqual({ kind: "npc", entityId: "npc-near", prompt: "Talk", position: { x: 0, y: 0, z: 6 }, center: { x: 0, y: 0.9, z: 6 }, distance: 6, range: 1.5, viewDot: 1, score: 2 }); host.dispose(); }); it("does not auto-retarget between close border targets inside the distance hysteresis", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; activeController: unknown; thirdPersonController: unknown; currentPlayerControllerTelemetry: unknown; runtimeTargetCandidates: Array<{ kind: "npc"; entityId: string; prompt: string; position: { x: number; y: number; z: number }; center: { x: number; y: number; z: number }; distance: number; range: number; viewDot: number; score: number; }>; activeRuntimeTargetReference: { kind: "npc"; entityId: string; } | null; camera: PerspectiveCamera; updateActiveRuntimeTargetLockState(): void; }; hostInternals.runtimeScene = { entities: { npcs: [ { entityId: "npc-border-a", visible: true, position: { x: 0, y: 0, z: 15.5 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Border A", defaultDialogueId: null, dialogues: [] }, { entityId: "npc-border-b", visible: true, position: { x: 0.3, y: 0, z: 15.2 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Border B", defaultDialogueId: null, dialogues: [] } ], interactables: [], cameraRigs: [] }, interactionLinks: [ { id: "link-border-a", sourceEntityId: "npc-border-a", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } }, { id: "link-border-b", sourceEntityId: "npc-border-b", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } } ] } as never; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentPlayerControllerTelemetry = { eyePosition: { x: 0, y: 1.6, z: 0 } }; hostInternals.activeRuntimeTargetReference = { kind: "npc", entityId: "npc-border-a" }; hostInternals.runtimeTargetCandidates = [ { kind: "npc", entityId: "npc-border-b", prompt: "Talk", position: { x: 0.3, y: 0, z: 15.2 }, center: { x: 0.3, y: 0.9, z: 15.2 }, distance: 14.9, range: 1.5, viewDot: 1, score: 2 } ]; hostInternals.camera.position.set(0, 1.6, 0); hostInternals.camera.lookAt(0, 0.9, 15.5); hostInternals.camera.updateMatrixWorld(); hostInternals.camera.updateProjectionMatrix(); hostInternals.updateActiveRuntimeTargetLockState(); expect(hostInternals.activeRuntimeTargetReference).toEqual({ kind: "npc", entityId: "npc-border-a" }); host.dispose(); }); it("clears active target on manual look boundary without retargeting", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { controllerContext: { handleRuntimeTargetLookBoundaryReached(): boolean; }; runtimeTargetCandidates: Array<{ kind: "npc"; entityId: string; prompt: string; position: { x: number; y: number; z: number }; center: { x: number; y: number; z: number }; distance: number; range: number; viewDot: number; score: number; }>; proposedRuntimeTarget: { kind: "npc"; entityId: string; } | null; activeRuntimeTargetReference: { kind: "npc"; entityId: string; } | null; }; hostInternals.activeRuntimeTargetReference = { kind: "npc", entityId: "npc-active" }; hostInternals.proposedRuntimeTarget = null; hostInternals.runtimeTargetCandidates = [ { kind: "npc", entityId: "npc-near", prompt: "Talk", position: { x: 0, y: 0, z: 6 }, center: { x: 0, y: 0.9, z: 6 }, distance: 6, range: 1.5, viewDot: 1, score: 2 } ]; expect( hostInternals.controllerContext.handleRuntimeTargetLookBoundaryReached() ).toBe(false); expect(hostInternals.activeRuntimeTargetReference).toBeNull(); expect(hostInternals.proposedRuntimeTarget).toBeNull(); host.dispose(); }); it("clears an active target when the player moves too far away without an on-screen fallback", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; activeController: unknown; thirdPersonController: unknown; currentPlayerControllerTelemetry: unknown; runtimeTargetCandidates: unknown[]; activeRuntimeTargetReference: { kind: "npc"; entityId: string; } | null; updateActiveRuntimeTargetLockState(): void; }; hostInternals.runtimeScene = { entities: { npcs: [ { entityId: "npc-far", visible: true, position: { x: 0, y: 0, z: 16 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Far", defaultDialogueId: null, dialogues: [] } ], interactables: [], cameraRigs: [] }, interactionLinks: [ { id: "link-far", sourceEntityId: "npc-far", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } } ] } as never; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentPlayerControllerTelemetry = { eyePosition: { x: 0, y: 1.6, z: 0 } }; hostInternals.activeRuntimeTargetReference = { kind: "npc", entityId: "npc-far" }; hostInternals.runtimeTargetCandidates = []; hostInternals.updateActiveRuntimeTargetLockState(); expect(hostInternals.activeRuntimeTargetReference).toBeNull(); host.dispose(); }); it("proposes the target closest to screen center instead of the nearest candidate", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; sceneReady: boolean; activeController: unknown; thirdPersonController: unknown; currentPlayerControllerTelemetry: unknown; runtimeTargetCandidates: Array<{ kind: "npc"; entityId: string; distance: number; }>; proposedRuntimeTarget: { kind: "npc"; entityId: string; } | null; camera: PerspectiveCamera; refreshRuntimeTargetingState(): void; }; hostInternals.runtimeScene = { entities: { npcs: [ { entityId: "npc-close-edge", visible: true, position: { x: 3, y: 0, z: 4 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Close Edge", defaultDialogueId: null, dialogues: [] }, { entityId: "npc-center-farther", visible: true, position: { x: 0, y: 0, z: 10 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Center Farther", defaultDialogueId: null, dialogues: [] } ], interactables: [], cameraRigs: [] }, interactionLinks: [ { id: "link-close-edge", sourceEntityId: "npc-close-edge", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } }, { id: "link-center-farther", sourceEntityId: "npc-center-farther", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } } ] } as never; hostInternals.sceneReady = true; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentPlayerControllerTelemetry = { eyePosition: { x: 0, y: 1.6, z: 0 } }; hostInternals.camera.position.set(0, 1.6, 0); hostInternals.camera.lookAt(0, 0.9, 10); hostInternals.camera.updateMatrixWorld(); hostInternals.camera.updateProjectionMatrix(); hostInternals.refreshRuntimeTargetingState(); const closeCandidate = hostInternals.runtimeTargetCandidates.find( (candidate) => candidate.entityId === "npc-close-edge" ); const centeredCandidate = hostInternals.runtimeTargetCandidates.find( (candidate) => candidate.entityId === "npc-center-farther" ); expect(closeCandidate?.distance).toBeLessThan( centeredCandidate?.distance ?? 0 ); expect(hostInternals.proposedRuntimeTarget).toMatchObject({ kind: "npc", entityId: "npc-center-farther" }); host.dispose(); }); it("biases Lux proposal focus slightly above screen center", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeTargetCandidates: Array<{ kind: "npc"; entityId: string; prompt: string; position: { x: number; y: number; z: number }; center: { x: number; y: number; z: number }; distance: number; range: number; viewDot: number; score: number; }>; camera: PerspectiveCamera; resolveRuntimeTargetCandidateNearestScreenCenter(): { kind: "npc"; entityId: string; } | null; }; hostInternals.runtimeTargetCandidates = [ { kind: "npc", entityId: "npc-above-center", prompt: "Talk", position: { x: 0, y: 0.98, z: 8 }, center: { x: 0, y: 1.88, z: 8 }, distance: 8, range: 1.5, viewDot: 1, score: 1 }, { kind: "npc", entityId: "npc-below-center", prompt: "Talk", position: { x: 0, y: -0.14, z: 8 }, center: { x: 0, y: 0.76, z: 8 }, distance: 8, range: 1.5, viewDot: 1, score: 1 } ]; hostInternals.camera.position.set(0, 1.6, 0); hostInternals.camera.lookAt(0, 1.6, 8); hostInternals.camera.updateMatrixWorld(); hostInternals.camera.updateProjectionMatrix(); expect( hostInternals.resolveRuntimeTargetCandidateNearestScreenCenter() ).toMatchObject({ kind: "npc", entityId: "npc-above-center" }); host.dispose(); }); it("filters Lux target candidates when the camera ray is occluded", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; sceneReady: boolean; activeController: unknown; thirdPersonController: unknown; currentPlayerControllerTelemetry: unknown; collisionWorld: { isLineSegmentClear( start: { x: number; y: number; z: number }, end: { x: number; y: number; z: number } ): boolean; dispose(): void; }; runtimeTargetCandidates: Array<{ kind: "npc"; entityId: string; }>; proposedRuntimeTarget: { kind: "npc"; entityId: string; } | null; camera: PerspectiveCamera; refreshRuntimeTargetingState(): void; }; hostInternals.runtimeScene = { entities: { npcs: [ { entityId: "npc-visible", visible: true, position: { x: 0, y: 0, z: 6 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Visible", defaultDialogueId: null, dialogues: [] }, { entityId: "npc-occluded", visible: true, position: { x: 0, y: 0, z: 8 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Occluded", defaultDialogueId: null, dialogues: [] } ], interactables: [], cameraRigs: [] }, interactionLinks: [ { id: "link-visible", sourceEntityId: "npc-visible", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } }, { id: "link-occluded", sourceEntityId: "npc-occluded", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } } ] } as never; hostInternals.sceneReady = true; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentPlayerControllerTelemetry = { eyePosition: { x: 0, y: 1.6, z: 0 } }; hostInternals.collisionWorld = { isLineSegmentClear: (_start, end) => end.z < 8, dispose: vi.fn() }; hostInternals.camera.position.set(0, 1.6, 0); hostInternals.camera.lookAt(0, 0.9, 8); hostInternals.camera.updateMatrixWorld(); hostInternals.camera.updateProjectionMatrix(); hostInternals.refreshRuntimeTargetingState(); expect( hostInternals.runtimeTargetCandidates.some( (candidate) => candidate.entityId === "npc-occluded" ) ).toBe(false); expect(hostInternals.proposedRuntimeTarget).toMatchObject({ kind: "npc", entityId: "npc-visible" }); host.dispose(); }); it("requires player-eye visibility for Lux proposal even when the camera can see the target", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; sceneReady: boolean; activeController: unknown; thirdPersonController: unknown; currentPlayerControllerTelemetry: unknown; collisionWorld: { isLineSegmentClear( start: { x: number; y: number; z: number }, end: { x: number; y: number; z: number } ): boolean; dispose(): void; }; runtimeTargetCandidates: Array<{ kind: "npc"; entityId: string; }>; proposedRuntimeTarget: { kind: "npc"; entityId: string; } | null; camera: PerspectiveCamera; refreshRuntimeTargetingState(): void; }; hostInternals.runtimeScene = { entities: { npcs: [ { entityId: "npc-player-occluded", visible: true, position: { x: 0, y: 0, z: 7 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Player Occluded", defaultDialogueId: null, dialogues: [] }, { entityId: "npc-player-visible", visible: true, position: { x: 0.8, y: 0, z: 8 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Player Visible", defaultDialogueId: null, dialogues: [] } ], interactables: [], cameraRigs: [] }, interactionLinks: [ { id: "link-player-occluded", sourceEntityId: "npc-player-occluded", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } }, { id: "link-player-visible", sourceEntityId: "npc-player-visible", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } } ] } as never; hostInternals.sceneReady = true; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentPlayerControllerTelemetry = { eyePosition: { x: 0, y: 1.6, z: 0 } }; hostInternals.collisionWorld = { isLineSegmentClear: (start, end) => start.x !== 0 || end.z !== 7, dispose: vi.fn() }; hostInternals.camera.position.set(1, 1.6, 0); hostInternals.camera.lookAt(0, 0.9, 7); hostInternals.camera.updateMatrixWorld(); hostInternals.camera.updateProjectionMatrix(); hostInternals.refreshRuntimeTargetingState(); expect( hostInternals.runtimeTargetCandidates.some( (candidate) => candidate.entityId === "npc-player-occluded" ) ).toBe(true); expect(hostInternals.proposedRuntimeTarget).toMatchObject({ kind: "npc", entityId: "npc-player-visible" }); host.dispose(); }); it("keeps Lux proposed when close above-target rays need body sample clearance", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; sceneReady: boolean; activeController: unknown; thirdPersonController: unknown; currentPlayerControllerTelemetry: unknown; collisionWorld: { isLineSegmentClear( start: { x: number; y: number; z: number }, end: { x: number; y: number; z: number }, options?: { targetClearance?: number } ): boolean; dispose(): void; }; runtimeTargetCandidates: Array<{ kind: "npc"; entityId: string; }>; proposedRuntimeTarget: { kind: "npc"; entityId: string; } | null; camera: PerspectiveCamera; refreshRuntimeTargetingState(): void; }; hostInternals.runtimeScene = { entities: { npcs: [ { entityId: "npc-close-above", visible: true, position: { x: 0, y: 0, z: 1 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Close Above", defaultDialogueId: null, dialogues: [] } ], interactables: [], cameraRigs: [] }, interactionLinks: [ { id: "link-close-above", sourceEntityId: "npc-close-above", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } } ] } as never; hostInternals.sceneReady = true; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentPlayerControllerTelemetry = { eyePosition: { x: 0, y: 2.6, z: 0.8 } }; hostInternals.collisionWorld = { isLineSegmentClear: vi.fn((_start, end, options) => { const clearance = options?.targetClearance ?? 0; return end.y > 1.35 && clearance >= 0.5; }), dispose: vi.fn() }; hostInternals.camera.position.set(0, 3, 0.2); hostInternals.camera.lookAt(0, 1.45, 1); hostInternals.camera.updateMatrixWorld(); hostInternals.camera.updateProjectionMatrix(); hostInternals.refreshRuntimeTargetingState(); expect(hostInternals.runtimeTargetCandidates).toEqual([ expect.objectContaining({ kind: "npc", entityId: "npc-close-above" }) ]); expect(hostInternals.proposedRuntimeTarget).toMatchObject({ kind: "npc", entityId: "npc-close-above" }); host.dispose(); }); it("keeps an occluded active target through a short camera visibility grace", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; activeController: unknown; thirdPersonController: unknown; currentPlayerControllerTelemetry: unknown; collisionWorld: { isLineSegmentClear(): boolean; dispose(): void; }; activeRuntimeTargetReference: { kind: "npc"; entityId: string; } | null; camera: PerspectiveCamera; updateActiveRuntimeTargetLockState(dt?: number): void; }; hostInternals.runtimeScene = { entities: { npcs: [ { entityId: "npc-occluded-active", visible: true, position: { x: 0, y: 0, z: 6 }, collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, name: "Occluded Active", defaultDialogueId: null, dialogues: [] } ], interactables: [], cameraRigs: [] }, interactionLinks: [ { id: "link-occluded-active", sourceEntityId: "npc-occluded-active", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } } ] } as never; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentPlayerControllerTelemetry = { eyePosition: { x: 0, y: 1.6, z: 0 } }; hostInternals.collisionWorld = { isLineSegmentClear: () => false, dispose: vi.fn() }; hostInternals.activeRuntimeTargetReference = { kind: "npc", entityId: "npc-occluded-active" }; hostInternals.camera.position.set(0, 1.6, 0); hostInternals.updateActiveRuntimeTargetLockState(0.2); expect(hostInternals.activeRuntimeTargetReference).toEqual({ kind: "npc", entityId: "npc-occluded-active" }); hostInternals.updateActiveRuntimeTargetLockState(0.2); expect(hostInternals.activeRuntimeTargetReference).toBeNull(); host.dispose(); }); it("clears runtime targeting when switching into first-person mode", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { activeRuntimeTargetReference: { kind: "npc" | "interactable"; entityId: string; } | null; proposedRuntimeTarget: unknown; runtimeTargetCandidates: unknown[]; }; hostInternals.activeRuntimeTargetReference = { kind: "npc", entityId: "npc-one" }; hostInternals.proposedRuntimeTarget = { kind: "npc", entityId: "npc-one" }; hostInternals.runtimeTargetCandidates = [{}]; host.setNavigationMode("firstPerson"); expect(hostInternals.activeRuntimeTargetReference).toBeNull(); expect(hostInternals.proposedRuntimeTarget).toBeNull(); expect(hostInternals.runtimeTargetCandidates).toEqual([]); host.dispose(); }); it("invalidates an active runtime target when it is no longer targetable", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; sceneReady: boolean; activeController: unknown; thirdPersonController: unknown; currentPlayerControllerTelemetry: unknown; activeRuntimeTargetReference: { kind: "npc" | "interactable"; entityId: string; } | null; camera: PerspectiveCamera; refreshRuntimeTargetingState(): void; }; hostInternals.runtimeScene = { entities: { interactables: [ { entityId: "switch-one", position: { x: 0, y: 1, z: 2 }, radius: 3, prompt: "Use", interactionEnabled: false } ], npcs: [], playerStarts: [], sceneEntries: [], cameraRigs: [], soundEmitters: [], triggerVolumes: [], teleportTargets: [] }, interactionLinks: [ { id: "link-switch-one", sourceEntityId: "switch-one", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } } ] } as never; hostInternals.sceneReady = true; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.currentPlayerControllerTelemetry = { eyePosition: { x: 0, y: 1.6, z: 0 } }; hostInternals.activeRuntimeTargetReference = { kind: "interactable", entityId: "switch-one" }; hostInternals.camera.position.set(0, 1.6, -2); hostInternals.camera.lookAt(0, 1, 2); hostInternals.refreshRuntimeTargetingState(); expect(hostInternals.activeRuntimeTargetReference).toBeNull(); host.dispose(); }); it("does not provide gameplay target camera assist while a camera rig is active", () => { const host = new RuntimeHost({ enableRendering: false }); const hostInternals = host as unknown as { runtimeScene: unknown; activeController: unknown; thirdPersonController: unknown; activeRuntimeTargetReference: { kind: "npc" | "interactable"; entityId: string; } | null; resolveThirdPersonTargetAssist(): unknown; }; hostInternals.runtimeScene = { entities: { cameraRigs: [ { entityId: "camera-rig-default", defaultActive: true, priority: 1 } ], interactables: [], npcs: [], playerStarts: [], sceneEntries: [], soundEmitters: [], triggerVolumes: [], teleportTargets: [] }, interactionLinks: [] } as never; hostInternals.activeController = hostInternals.thirdPersonController; hostInternals.activeRuntimeTargetReference = { kind: "interactable", entityId: "switch-one" }; expect(hostInternals.resolveThirdPersonTargetAssist()).toBeNull(); host.dispose(); }); });