Files
webeditor3d/tests/unit/runtime-host.test.ts

5497 lines
158 KiB
TypeScript

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<T>() {
let resolve: ((value: T) => void) | null = null;
let reject: ((error: unknown) => void) | null = null;
const promise = new Promise<T>((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> = {}
): 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<RapierCollisionWorld>();
vi.spyOn(RapierCollisionWorld, "create").mockReturnValue(
deferredCollisionWorld.promise
);
const runtimeMessages: Array<string | null> = [];
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. Click inside the runner viewport to capture mouse look, or drag to orbit if pointer lock is unavailable. Scroll to zoom and use the right stick for gamepad camera look."
);
});
host.dispose();
expect(collisionWorld.dispose).toHaveBeenCalledTimes(1);
});
it("finishes loading when initial schedule sync changes NPC colliders", async () => {
const npc = createNpcEntity({
id: "entity-npc-scene-load-scheduled",
actorId: "actor-scene-load-scheduled"
});
const document = createEmptySceneDocument();
document.entities[npc.id] = npc;
document.scheduler.routines["routine-hide-at-night"] =
createProjectScheduleRoutine({
id: "routine-hide-at-night",
title: "Hide At Night",
target: createActorControlTargetRef(npc.actorId),
startHour: 20,
endHour: 4,
effect: createSetActorPresenceControlEffect({
target: createActorControlTargetRef(npc.actorId),
active: false
})
});
const runtimeScene = buildRuntimeSceneFromDocument(document, {
runtimeClock: {
timeOfDayHours: 21,
dayCount: 0,
dayLengthMinutes: 24
}
});
const collisionWorld = {
dispose: vi.fn(),
resolveThirdPersonCameraCollision: vi.fn(
(_pivot, desiredCameraPosition) => desiredCameraPosition
)
} as unknown as RapierCollisionWorld;
const deferredCollisionWorld = createDeferred<RapierCollisionWorld>();
const createCollisionWorld = vi
.spyOn(RapierCollisionWorld, "create")
.mockReturnValue(deferredCollisionWorld.promise);
const sceneLoadStates: RuntimeSceneLoadState[] = [];
const host = new RuntimeHost({
enableRendering: false
});
host.setSceneLoadStateHandler((state) => {
sceneLoadStates.push(state);
});
expect(runtimeScene.entities.npcs).toEqual([]);
host.loadScene(runtimeScene);
expect(runtimeScene.entities.npcs).toEqual([
expect.objectContaining({
entityId: npc.id
})
]);
expect(createCollisionWorld).toHaveBeenCalledTimes(1);
expect(sceneLoadStates).toEqual([
{
status: "loading",
message: null
}
]);
deferredCollisionWorld.resolve(collisionWorld);
await waitFor(() => {
expect(sceneLoadStates).toContainEqual({
status: "ready",
message: null
});
});
host.dispose();
expect(collisionWorld.dispose).toHaveBeenCalledTimes(1);
});
it("pauses project clock advancement while a scene is loading", () => {
vi.spyOn(window, "requestAnimationFrame").mockReturnValue(1);
vi.spyOn(performance, "now").mockReturnValue(1100);
const host = new RuntimeHost({
enableRendering: false
});
const hostInternals = host as unknown as {
previousFrameTime: number;
sceneReady: boolean;
currentClockState: {
timeOfDayHours: number;
dayCount: number;
dayLengthMinutes: number;
};
render(): void;
};
hostInternals.currentClockState = {
timeOfDayHours: 9,
dayCount: 0,
dayLengthMinutes: 24
};
hostInternals.previousFrameTime = 1000;
hostInternals.sceneReady = false;
hostInternals.render();
expect(hostInternals.currentClockState).toEqual({
timeOfDayHours: 9,
dayCount: 0,
dayLengthMinutes: 24
});
hostInternals.previousFrameTime = 1000;
hostInternals.sceneReady = true;
hostInternals.render();
expect(hostInternals.currentClockState.timeOfDayHours).toBeGreaterThan(9);
host.dispose();
});
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<typeof vi.fn>;
};
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<typeof buildRuntimeSceneFromDocument> | 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<RuntimeDialogueState | null> = [];
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<RuntimeDialogueState | null> = [];
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<string, { visible: boolean }>;
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<string, AnimationMixer>;
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("cycles the active target from the target button when authored", () => {
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;
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" | "interactable";
entityId: string;
} | null;
camera: PerspectiveCamera;
currentPlayerControllerTelemetry: unknown;
activateOrCycleRuntimeTarget(): void;
isRuntimeTargetPlayerVisible(target: unknown): boolean;
};
const firstTarget = {
kind: "npc" as const,
entityId: "npc-one",
prompt: "Talk",
position: { x: 0, y: 0, z: 4 },
center: { x: -0.5, y: 1, z: 4 },
distance: 4,
range: 1.5,
viewDot: 1,
score: 3
};
const secondTarget = {
...firstTarget,
entityId: "npc-two",
center: { x: 0.5, y: 1, z: 4 },
score: 2.5
};
hostInternals.runtimeScene = {
playerStart: {
targetButtonCyclesActiveTarget: true
},
entities: {
cameraRigs: [],
interactables: [],
npcs: []
}
} as never;
hostInternals.sceneReady = true;
hostInternals.activeController = hostInternals.thirdPersonController;
hostInternals.currentPlayerControllerTelemetry = {
eyePosition: { x: 0, y: 1.6, z: 0 }
};
hostInternals.runtimeTargetCandidates = [firstTarget, secondTarget];
hostInternals.activeRuntimeTargetReference = {
kind: "npc",
entityId: "npc-one"
};
hostInternals.camera.position.set(0, 1.6, 0);
hostInternals.camera.lookAt(0, 1, 4);
hostInternals.camera.updateMatrixWorld();
hostInternals.camera.updateProjectionMatrix();
hostInternals.activateOrCycleRuntimeTarget();
expect(hostInternals.activeRuntimeTargetReference).toEqual({
kind: "npc",
entityId: "npc-two"
});
hostInternals.activateOrCycleRuntimeTarget();
expect(hostInternals.activeRuntimeTargetReference).toEqual({
kind: "npc",
entityId: "npc-one"
});
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 the authored clear-target key", () => {
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;
const clearTargetEvent = {
code: "KeyQ",
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: {
clearTarget: "KeyQ",
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).toEqual({
kind: "npc",
entityId: "npc-active"
});
expect(escapeEvent.preventDefault).not.toHaveBeenCalled();
expect(escapeEvent.stopImmediatePropagation).not.toHaveBeenCalled();
hostInternals.handleRuntimeKeyDown(clearTargetEvent);
expect(hostInternals.activeRuntimeTargetReference).toBeNull();
expect(clearTargetEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(clearTargetEvent.stopImmediatePropagation).toHaveBeenCalledTimes(1);
host.dispose();
});
it("reserves Escape for pointer-lock release before gameplay bindings", () => {
const host = new RuntimeHost({
enableRendering: false
});
const hostInternals = host as unknown as {
domElement: HTMLCanvasElement;
runtimeScene: unknown;
sceneReady: boolean;
pressedKeys: Set<string>;
activeRuntimeTargetReference: {
kind: "npc" | "interactable";
entityId: string;
} | null;
handlePointerLockEscapeKeyDown(event: KeyboardEvent): void;
updateClearTargetInputState(): void;
};
let pointerLockElement: Element | null = hostInternals.domElement;
const exitPointerLock = vi.fn(() => {
pointerLockElement = null;
});
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;
Object.defineProperty(document, "pointerLockElement", {
configurable: true,
get: () => pointerLockElement
});
Object.defineProperty(document, "exitPointerLock", {
configurable: true,
value: exitPointerLock
});
hostInternals.runtimeScene = {
playerInputBindings: {
keyboard: {
clearTarget: "Escape",
pauseTime: "KeyP"
}
},
entities: {
cameraRigs: [],
interactables: [],
npcs: []
},
interactionLinks: []
} as never;
hostInternals.sceneReady = true;
hostInternals.activeRuntimeTargetReference = {
kind: "npc",
entityId: "npc-active"
};
hostInternals.handlePointerLockEscapeKeyDown(escapeEvent);
hostInternals.updateClearTargetInputState();
expect(hostInternals.activeRuntimeTargetReference).toEqual({
kind: "npc",
entityId: "npc-active"
});
expect(hostInternals.pressedKeys.has("Escape")).toBe(false);
expect(exitPointerLock).toHaveBeenCalledTimes(1);
expect(escapeEvent.preventDefault).not.toHaveBeenCalled();
expect(escapeEvent.stopImmediatePropagation).toHaveBeenCalledTimes(1);
Object.defineProperty(document, "pointerLockElement", {
configurable: true,
get: () => null
});
host.dispose();
});
it("reserves Escape immediately after browser pointer-lock release", () => {
const host = new RuntimeHost({
enableRendering: false
});
const hostInternals = host as unknown as {
controllerContext: {
setPlayerControllerTelemetry(telemetry: unknown): void;
};
runtimeScene: unknown;
sceneReady: boolean;
pressedKeys: Set<string>;
activeRuntimeTargetReference: {
kind: "npc" | "interactable";
entityId: string;
} | null;
handlePointerLockEscapeKeyDown(event: KeyboardEvent): void;
updateClearTargetInputState(): void;
};
const exitPointerLock = vi.fn();
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;
Object.defineProperty(document, "pointerLockElement", {
configurable: true,
get: () => null
});
Object.defineProperty(document, "exitPointerLock", {
configurable: true,
value: exitPointerLock
});
hostInternals.runtimeScene = {
playerInputBindings: {
keyboard: {
clearTarget: "Escape",
pauseTime: "KeyP"
}
},
entities: {
cameraRigs: [],
interactables: [],
npcs: []
},
interactionLinks: []
} as never;
hostInternals.sceneReady = true;
hostInternals.activeRuntimeTargetReference = {
kind: "npc",
entityId: "npc-active"
};
hostInternals.pressedKeys.add("Escape");
hostInternals.controllerContext.setPlayerControllerTelemetry({
pointerLocked: true,
hooks: {
audio: null
}
});
hostInternals.controllerContext.setPlayerControllerTelemetry({
pointerLocked: false,
hooks: {
audio: null
}
});
hostInternals.handlePointerLockEscapeKeyDown(escapeEvent);
hostInternals.updateClearTargetInputState();
expect(hostInternals.activeRuntimeTargetReference).toEqual({
kind: "npc",
entityId: "npc-active"
});
expect(hostInternals.pressedKeys.size).toBe(0);
expect(exitPointerLock).not.toHaveBeenCalled();
expect(escapeEvent.preventDefault).not.toHaveBeenCalled();
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("keeps the active target when authored look-input switching is disabled", () => {
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;
};
};
hostInternals.runtimeScene = {
playerStart: {
allowLookInputTargetSwitch: false
},
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: []
}
],
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" } }
]
} 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
}
];
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: false,
switchInputHeld: false
});
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();
});
});