2026-04-11 04:18:13 +02:00
import { waitFor } from "@testing-library/react" ;
import { afterEach , describe , expect , it , vi } from "vitest" ;
2026-04-14 01:37:48 +02:00
import {
2026-04-14 02:41:16 +02:00
createActiveSceneControlTargetRef ,
2026-04-23 02:39:52 +02:00
createActivateCameraRigOverrideControlEffect ,
2026-04-14 02:06:39 +02:00
createActorControlTargetRef ,
2026-04-23 02:39:52 +02:00
createCameraRigControlTargetRef ,
createClearCameraRigOverrideControlEffect ,
2026-04-14 13:54:47 +02:00
createFollowActorPathControlEffect ,
2026-04-14 01:37:48 +02:00
createLightControlTargetRef ,
2026-04-14 02:41:16 +02:00
createModelInstanceControlTargetRef ,
2026-04-14 13:54:47 +02:00
createPlayActorAnimationControlEffect ,
2026-04-14 02:41:16 +02:00
createPlayModelAnimationControlEffect ,
2026-04-15 05:45:49 +02:00
createProjectGlobalControlTargetRef ,
2026-04-14 02:41:16 +02:00
createPlaySoundControlEffect ,
2026-04-14 02:06:39 +02:00
createSetActorPresenceControlEffect ,
2026-04-14 22:26:16 +02:00
createSetProjectTimePausedControlEffect ,
2026-04-14 01:38:26 +02:00
type ControlEffect ,
2026-04-14 02:41:16 +02:00
createSetAmbientLightColorControlEffect ,
createSetAmbientLightIntensityControlEffect ,
2026-04-14 01:37:48 +02:00
createSetLightEnabledControlEffect ,
2026-04-14 02:41:16 +02:00
createSetLightColorControlEffect ,
createSetLightIntensityControlEffect ,
createSetModelInstanceVisibleControlEffect ,
createSetSoundVolumeControlEffect ,
createSetSunLightColorControlEffect ,
createSetSunLightIntensityControlEffect ,
createSoundEmitterControlTargetRef ,
createStopModelAnimationControlEffect ,
createStopSoundControlEffect
2026-04-14 01:37:48 +02:00
} from "../../src/controls/control-surface" ;
2026-04-22 14:06:39 +02:00
import { createBoxBrush } from "../../src/document/brushes" ;
2026-04-14 13:54:47 +02:00
import { createScenePath } from "../../src/document/paths" ;
2026-04-11 04:18:13 +02:00
import { createEmptySceneDocument } from "../../src/document/scene-document" ;
2026-04-14 02:06:39 +02:00
import {
2026-04-22 17:23:15 +02:00
createCameraRigEntity ,
2026-04-22 18:30:54 +02:00
createCameraRigEntityTargetRef ,
2026-04-22 17:23:15 +02:00
createCameraRigPlayerTargetRef ,
createCameraRigWorldPointTargetRef ,
2026-04-22 18:30:54 +02:00
createInteractableEntity ,
2026-04-14 02:06:39 +02:00
createNpcEntity ,
2026-04-14 02:41:16 +02:00
createPointLightEntity ,
2026-04-22 17:23:15 +02:00
createPlayerStartEntity ,
2026-04-14 02:41:16 +02:00
createSoundEmitterEntity ,
createTriggerVolumeEntity
2026-04-14 02:06:39 +02:00
} from "../../src/entities/entity-instances" ;
2026-04-14 01:38:26 +02:00
import {
createControlInteractionLink ,
type InteractionLink
} from "../../src/interactions/interaction-links" ;
2026-04-14 02:06:39 +02:00
import { createProjectScheduleRoutine } from "../../src/scheduler/project-scheduler" ;
2026-04-15 05:45:49 +02:00
import { createProjectSequence } from "../../src/sequencer/project-sequences" ;
2026-04-14 02:41:16 +02:00
import {
createProjectAssetStorageKey ,
type AudioAssetRecord ,
type ModelAssetRecord
} from "../../src/assets/project-assets" ;
import { createModelInstance } from "../../src/assets/model-instances" ;
2026-04-11 04:18:13 +02:00
import { RapierCollisionWorld } from "../../src/runtime-three/rapier-collision-world" ;
import {
RuntimeHost ,
2026-04-25 10:28:35 +02:00
resolveRuntimeTargetVisualPlacement ,
2026-04-14 20:02:17 +02:00
type RuntimeDialogueState ,
2026-04-14 22:26:16 +02:00
type RuntimePauseState ,
2026-04-11 04:18:13 +02:00
type RuntimeSceneLoadState
} from "../../src/runtime-three/runtime-host" ;
import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build" ;
2026-04-27 15:55:36 +02:00
import {
AnimationClip ,
BoxGeometry ,
PerspectiveCamera ,
Quaternion ,
Vector3 ,
type AnimationMixer
} from "three" ;
2026-04-14 13:54:47 +02:00
import { createFixtureLoadedModelAssetFromGeometry } from "../helpers/model-collider-fixtures" ;
2026-04-11 04:18:13 +02:00
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 ) ;
}
} ;
}
2026-04-22 17:23:15 +02:00
function resolveYawPitchRadians ( direction : Vector3 ) {
return {
yawRadians : Math.atan2 ( direction . x , direction . z ) ,
pitchRadians : Math.asin ( Math . max ( - 1 , Math . min ( 1 , direction . y ) ) )
} ;
}
2026-04-23 09:08:40 +02:00
function captureCameraPose ( camera : PerspectiveCamera ) {
const position = camera . position . clone ( ) ;
2026-04-27 15:55:36 +02:00
const lookTarget = position
. clone ( )
. add ( camera . getWorldDirection ( new Vector3 ( ) ) ) ;
2026-04-23 09:08:40 +02:00
return {
position ,
lookTarget
} ;
}
2026-04-27 15:44:58 +02:00
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 ;
}
2026-04-27 15:55:36 +02:00
function resolveShortestAngleDeltaDegrees (
fromDegrees : number ,
toDegrees : number
) {
2026-04-25 03:56:40 +02:00
return ( ( toDegrees - fromDegrees + 540 ) % 360 ) - 180 ;
}
2026-04-11 04:18:13 +02:00
describe ( "RuntimeHost" , ( ) = > {
afterEach ( ( ) = > {
vi . restoreAllMocks ( ) ;
} ) ;
it ( "delays controller activation until collision setup reports the scene as ready" , async ( ) = > {
2026-04-11 04:19:50 +02:00
const runtimeScene = buildRuntimeSceneFromDocument (
createEmptySceneDocument ( )
) ;
2026-04-11 04:19:15 +02:00
vi . spyOn ( console , "warn" ) . mockImplementation ( ( ) = > undefined ) ;
2026-04-11 04:18:13 +02:00
const collisionWorld = {
2026-04-11 11:22:17 +02:00
dispose : vi.fn ( ) ,
resolveThirdPersonCameraCollision : vi.fn (
( _pivot , desiredCameraPosition ) = > desiredCameraPosition
)
2026-04-11 04:18:13 +02:00
} 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 ) ;
2026-04-11 11:19:00 +02:00
host . setNavigationMode ( "thirdPerson" ) ;
2026-04-11 04:18:13 +02:00
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 (
2026-04-27 18:43:10 +02:00
"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."
2026-04-11 04:18:13 +02:00
) ;
} ) ;
host . dispose ( ) ;
expect ( collisionWorld . dispose ) . toHaveBeenCalledTimes ( 1 ) ;
} ) ;
2026-04-14 01:37:48 +02:00
2026-04-27 17:51:45 +02:00
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 ) ;
} ) ;
2026-04-27 18:00:36 +02:00
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 ( ) ;
} ) ;
2026-04-27 15:44:58 +02:00
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 )
) ;
} ) ;
2026-04-23 09:08:40 +02:00
it ( "starts default-active rigs in place and blends rig-to-rig overrides" , ( ) = > {
2026-04-22 17:23:15 +02:00
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
} ) ,
2026-04-23 09:08:40 +02:00
transitionMode : "blend" ,
transitionDurationSeconds : 0.75
2026-04-22 17:23:15 +02:00
} ) ;
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 ;
2026-04-23 09:08:40 +02:00
cameraTransitionState : { elapsedSeconds : number } | null ;
2026-04-22 17:23:15 +02:00
applyActiveCameraRig ( dt : number ) : { entityId : string } | null ;
} ;
hostInternals . sceneReady = true ;
2026-04-23 09:08:40 +02:00
hostInternals . camera . position . set ( - 12 , 3 , 14 ) ;
hostInternals . camera . lookAt ( 0 , 1.6 , 0 ) ;
2026-04-22 17:23:15 +02:00
2026-04-27 15:55:36 +02:00
expect ( hostInternals . applyActiveCameraRig ( 0.1 ) ? . entityId ) . toBe (
defaultRig . id
) ;
2026-04-22 17:23:15 +02:00
expect ( hostInternals . camera . position ) . toMatchObject ( defaultRig . position ) ;
2026-04-23 09:08:40 +02:00
expect ( hostInternals . cameraTransitionState ) . toBeNull ( ) ;
2026-04-22 17:23:15 +02:00
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 ( ) ;
} ) ;
2026-04-23 09:08:40 +02:00
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 ) ;
2026-04-27 15:55:36 +02:00
expect ( hostInternals . applyActiveCameraRig ( 0.25 ) ? . entityId ) . toBe (
cameraRig . id
) ;
2026-04-23 09:08:40 +02:00
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 ) ;
2026-04-27 15:55:36 +02:00
expect (
hostInternals . applyActiveCameraRig ( 0.25 , previousRigPose )
) . toBeNull ( ) ;
2026-04-23 09:08:40 +02:00
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 ( ) ;
} ) ;
2026-04-25 03:14:42 +02:00
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 ) ;
2026-04-27 15:55:36 +02:00
hostInternals . applyActiveCameraRig (
0 ,
captureCameraPose ( hostInternals . camera )
) ;
2026-04-25 03:14:42 +02:00
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 ) ;
2026-04-25 03:16:58 +02:00
hostInternals . applyActiveCameraRig ( 0.175 , gameplayPose ) ;
2026-04-25 03:17:19 +02:00
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 ) ;
2026-04-25 03:14:42 +02:00
host . dispose ( ) ;
} ) ;
2026-04-25 03:34:53 +02:00
it ( "resolves dialogue attention camera collision from the conversation midpoint" , ( ) = > {
vi . spyOn ( console , "warn" ) . mockImplementation ( ( ) = > undefined ) ;
const resolveThirdPersonCameraCollision = vi . fn (
2026-04-25 03:35:56 +02:00
(
pivot : { x : number ; y : number ; z : number } ,
2026-04-25 16:42:33 +02:00
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
} ;
}
2026-04-25 03:34:53 +02:00
) ;
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 ) ;
2026-04-25 03:35:23 +02:00
expect ( resolveThirdPersonCameraCollision ) . toHaveBeenCalled ( ) ;
2026-04-27 15:55:36 +02:00
const lastCollisionCall =
resolveThirdPersonCameraCollision . mock . calls . at ( - 1 ) ;
2026-04-25 03:35:56 +02:00
expect ( lastCollisionCall ) . toBeDefined ( ) ;
const [ pivot , desiredCameraPosition , radius ] = lastCollisionCall as [
{ x : number ; y : number ; z : number } ,
{ x : number ; y : number ; z : number } ,
number
] ;
2026-04-25 03:34:53 +02:00
expect ( pivot ) . toMatchObject ( {
x : 1 ,
z : 1
} ) ;
expect ( pivot ? . y ) . toBeCloseTo ( 1.36 , 5 ) ;
expect ( radius ) . toBe ( 0.2 ) ;
2026-04-25 19:02:34 +02:00
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
) ;
2026-04-25 03:34:53 +02:00
host . dispose ( ) ;
} ) ;
2026-04-25 19:01:50 +02:00
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 ( ) ;
} ) ;
2026-04-25 03:56:40 +02:00
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 ;
2026-04-25 04:04:59 +02:00
camera : PerspectiveCamera ;
2026-04-25 03:56:40 +02:00
collisionWorld : RapierCollisionWorld | null ;
2026-04-25 04:04:59 +02:00
activeCameraSourceKey : string | null ;
2026-04-27 15:55:36 +02:00
currentPlayerControllerTelemetry : {
feetPosition : { x : number ; y : number ; z : number } ;
yawDegrees : number ;
} | null ;
2026-04-25 03:56:40 +02:00
dialogueParticipantState : { npcEntityId : string } | null ;
runtimeScene : ReturnType < typeof buildRuntimeSceneFromDocument > | null ;
activateDesiredNavigationController ( ) : void ;
updateRuntimeDialogueParticipants ( dt : number ) : void ;
2026-04-25 04:04:59 +02:00
applyActiveCameraRig (
dt : number ,
previousCameraPose ? : {
position : Vector3 ;
lookTarget : Vector3 ;
}
) : { entityId : string } | null ;
2026-04-25 03:56:40 +02:00
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 ( ) ;
2026-04-27 15:55:36 +02:00
expect (
hostInternals . currentPlayerControllerTelemetry ? . feetPosition
) . toEqual ( playerStart . position ) ;
2026-04-25 03:56:40 +02:00
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 ) ;
2026-04-25 04:04:59 +02:00
hostInternals . applyActiveCameraRig (
0.1 ,
captureCameraPose ( hostInternals . camera )
) ;
2026-04-25 03:56:40 +02:00
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 =
2026-04-27 15:55:36 +02:00
( Math . atan2 (
npc . position . x - playerFeetPosition . x ,
npc . position . z - playerFeetPosition . z
) *
2026-04-25 03:56:40 +02:00
180 ) /
Math . PI ;
2026-04-25 04:04:59 +02:00
expect ( playerDistanceFromNpc ) . toBeGreaterThan ( 0.1 ) ;
expect ( playerDistanceFromNpc ) . toBeLessThan ( 1.09 ) ;
expect ( hostInternals . activeCameraSourceKey ) . toBe ( "gameplay" ) ;
2026-04-25 03:56:40 +02:00
expect (
Math . abs (
resolveShortestAngleDeltaDegrees (
playerTelemetry ? . yawDegrees ? ? 0 ,
playerTargetYawDegrees
)
)
) . toBeLessThan ( 35 ) ;
expect (
2026-04-27 15:55:36 +02:00
Math . abs ( resolveShortestAngleDeltaDegrees ( runtimeNpc ? . yawDegrees ? ? 0 , 0 ) )
2026-04-25 03:56:40 +02:00
) . toBeGreaterThan ( 10 ) ;
2026-04-25 04:04:59 +02:00
hostInternals . updateRuntimeDialogueParticipants ( 0.1 ) ;
hostInternals . updateRuntimeDialogueParticipants ( 0.1 ) ;
hostInternals . applyActiveCameraRig (
0.1 ,
captureCameraPose ( hostInternals . camera )
) ;
2026-04-27 15:55:36 +02:00
const stagedPlayerTelemetry =
hostInternals . currentPlayerControllerTelemetry ;
2026-04-25 04:04:59 +02:00
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 } ` ) ;
2026-04-25 03:56:40 +02:00
host . closeRuntimeDialogue ( ) ;
hostInternals . updateRuntimeDialogueParticipants ( 0.05 ) ;
expect ( runtimeNpc ? . yawDegrees ) . not . toBeCloseTo ( 0 , 3 ) ;
2026-04-25 04:01:58 +02:00
for ( let step = 0 ; step < 10 ; step += 1 ) {
2026-04-25 03:56:40 +02:00
hostInternals . updateRuntimeDialogueParticipants ( 0.05 ) ;
}
2026-04-27 15:55:36 +02:00
expect (
Math . abs ( runtimeNpc ? . yawDegrees ? ? Number . POSITIVE_INFINITY )
) . toBeLessThan ( 1 ) ;
2026-04-25 03:56:40 +02:00
expect ( hostInternals . dialogueParticipantState ) . toBeNull ( ) ;
host . dispose ( ) ;
} ) ;
2026-04-25 03:14:42 +02:00
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"
} ) ;
2026-04-27 15:55:36 +02:00
hostInternals . applyActiveCameraRig (
0.35 ,
captureCameraPose ( hostInternals . camera )
) ;
2026-04-25 03:14:42 +02:00
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 ( ) ;
} ) ;
2026-04-22 17:23:15 +02:00
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
} ,
2026-04-22 17:26:34 +02:00
transitionMode : "cut" ,
2026-04-22 17:23:15 +02:00
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 ;
2026-04-27 15:55:36 +02:00
handleRuntimePointerUp ( event : { stopImmediatePropagation ( ) : void } ) : void ;
2026-04-22 17:23:15 +02:00
} ;
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 ( 1 e - 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 ) ;
2026-04-27 15:55:36 +02:00
const lookedDirection = hostInternals . camera . getWorldDirection (
new Vector3 ( )
) ;
2026-04-22 17:23:15 +02:00
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 ( ) ;
} ) ;
2026-04-23 02:39:52 +02:00
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 ) ;
2026-04-27 15:55:36 +02:00
expect ( hostInternals . applyActiveCameraRig ( 0 ) ? . entityId ) . toBe (
overrideRig . id
) ;
2026-04-23 02:39:52 +02:00
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 ( ) ;
} ) ;
2026-04-22 18:31:21 +02:00
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 ;
2026-04-27 15:55:36 +02:00
handleRuntimePointerUp ( event : { stopImmediatePropagation ( ) : void } ) : void ;
2026-04-22 18:31:21 +02:00
} ;
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 ( ) ;
} ) ;
2026-04-23 09:08:58 +02:00
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 ( {
2026-04-27 15:55:36 +02:00
. . . createEmptySceneDocument ( {
name : "Mapped Rail Camera Rig Runtime Scene"
} ) ,
2026-04-23 09:08:58 +02:00
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 ( ) ;
} ) ;
2026-04-14 01:37:48 +02:00
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 ( ) : {
2026-04-14 01:40:15 +02:00
dispatchControlEffect (
effect : ControlEffect ,
link : InteractionLink
) : void ;
2026-04-14 01:37:48 +02:00
} ;
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 ( ) ;
} ) ;
2026-04-14 02:06:39 +02:00
2026-04-22 14:06:51 +02:00
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" ,
2026-04-22 14:10:12 +02:00
size : {
x : 6 ,
y : 5 ,
z : 3
} ,
2026-04-22 14:06:51 +02:00
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 } ;
2026-04-27 15:55:36 +02:00
lights : Array < {
intensity : number ;
distance : number ;
castShadow : boolean ;
} > ;
2026-04-22 14:06:51 +02:00
}
> ;
} ;
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 (
2026-04-27 15:55:36 +02:00
( light ) = >
light . intensity > 0 &&
light . distance > 0 &&
light . castShadow === false
2026-04-22 14:06:51 +02:00
)
) . toBe ( true ) ;
host . dispose ( ) ;
} ) ;
2026-04-14 22:26:16 +02:00
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 ( ) ;
} ) ;
2026-04-15 09:57:42 +02:00
it ( "opens, advances, and closes NPC dialogues through the runtime host" , ( ) = > {
2026-04-14 20:02:24 +02:00
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 ( ) ;
2026-04-15 09:57:42 +02:00
const npc = createNpcEntity ( {
id : "entity-npc-operator" ,
dialogues : [
2026-04-14 20:02:24 +02:00
{
2026-04-15 09:57:42 +02:00
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."
}
]
2026-04-14 20:02:24 +02:00
}
2026-04-15 09:57:42 +02:00
] ,
defaultDialogueId : "dialogue-warning"
} ) ;
document . entities [ npc . id ] = npc ;
2026-04-14 20:02:24 +02:00
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 ( ) : {
2026-04-15 09:57:42 +02:00
startNpcDialogue (
npcEntityId : string ,
dialogueId : string | null ,
2026-04-14 20:34:18 +02:00
source ? : {
kind : "interactionLink" | "npc" | "direct" ;
sourceEntityId : string | null ;
linkId : string | null ;
2026-04-14 21:19:30 +02:00
trigger : "enter" | "exit" | "click" | null ;
2026-04-14 20:34:18 +02:00
}
) : void ;
2026-04-14 20:02:24 +02:00
} ;
} ;
const dispatcher = hostInternals . createInteractionDispatcher ( ) ;
2026-04-15 09:57:42 +02:00
dispatcher . startNpcDialogue ( npc . id , "dialogue-warning" , {
kind : "npc" ,
sourceEntityId : npc.id ,
linkId : null ,
trigger : "click"
2026-04-14 20:34:18 +02:00
} ) ;
2026-04-14 20:02:24 +02:00
host . advanceRuntimeDialogue ( ) ;
host . advanceRuntimeDialogue ( ) ;
expect ( dialogueStates ) . toEqual ( [
expect . objectContaining ( {
dialogueId : "dialogue-warning" ,
2026-04-15 09:57:42 +02:00
npcEntityId : npc.id ,
2026-04-14 20:02:24 +02:00
lineIndex : 0 ,
2026-04-15 10:11:38 +02:00
speakerName : npc.actorId ,
2026-04-14 20:34:18 +02:00
text : "The generator is unstable." ,
source : {
2026-04-15 09:57:42 +02:00
kind : "npc" ,
sourceEntityId : npc.id ,
linkId : null ,
trigger : "click"
2026-04-14 20:34:18 +02:00
}
2026-04-14 20:02:24 +02:00
} ) ,
expect . objectContaining ( {
dialogueId : "dialogue-warning" ,
2026-04-15 09:57:42 +02:00
npcEntityId : npc.id ,
2026-04-14 20:02:24 +02:00
lineIndex : 1 ,
2026-04-15 10:11:38 +02:00
speakerName : npc.actorId ,
2026-04-14 20:34:18 +02:00
text : "A low hum fills the room." ,
source : {
2026-04-15 09:57:42 +02:00
kind : "npc" ,
sourceEntityId : npc.id ,
linkId : null ,
trigger : "click"
2026-04-14 20:34:18 +02:00
}
2026-04-14 20:02:24 +02:00
} ) ,
null
] ) ;
host . dispose ( ) ;
} ) ;
2026-04-25 03:15:08 +02:00
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 ;
2026-04-25 03:16:42 +02:00
applyActiveCameraRig (
dt : number ,
previousCameraPose ? : {
position : Vector3 ;
lookTarget : Vector3 ;
}
) : { entityId : string } | null ;
2026-04-25 03:15:08 +02:00
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 ) ;
2026-04-27 15:55:36 +02:00
hostInternals . applyActiveCameraRig (
0 ,
captureCameraPose ( hostInternals . camera )
) ;
2026-04-25 03:15:08 +02:00
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 } ` ) ;
2026-04-27 15:55:36 +02:00
expect ( hostInternals . cameraTransitionState ? . elapsedSeconds ) . toBeGreaterThan (
0
) ;
2026-04-25 03:15:08 +02:00
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 ( ) ;
} ) ;
2026-04-15 09:57:42 +02:00
it ( "publishes late dialogue handlers, ignores repeated same-NPC dialogue starts, and replaces with a different NPC dialogue" , ( ) = > {
2026-04-14 20:34:18 +02:00
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 ( ) ;
2026-04-15 09:57:42 +02:00
const npcA = createNpcEntity ( {
id : "entity-npc-a" ,
dialogues : [
2026-04-14 20:34:18 +02:00
{
2026-04-15 09:57:42 +02:00
id : "dialogue-a" ,
title : "A" ,
lines : [
{
id : "dialogue-line-a-1" ,
text : "First dialogue."
}
]
2026-04-14 20:34:18 +02:00
}
2026-04-15 09:57:42 +02:00
] ,
defaultDialogueId : "dialogue-a"
} ) ;
const npcB = createNpcEntity ( {
id : "entity-npc-b" ,
dialogues : [
2026-04-14 20:34:18 +02:00
{
2026-04-15 09:57:42 +02:00
id : "dialogue-b" ,
title : "B" ,
lines : [
{
id : "dialogue-line-b-1" ,
text : "Second dialogue."
}
]
2026-04-14 20:34:18 +02:00
}
2026-04-15 09:57:42 +02:00
] ,
defaultDialogueId : "dialogue-b"
} ) ;
document . entities [ npcA . id ] = npcA ;
document . entities [ npcB . id ] = npcB ;
2026-04-14 20:34:18 +02:00
const host = new RuntimeHost ( {
enableRendering : false
} ) ;
host . loadScene ( buildRuntimeSceneFromDocument ( document ) ) ;
const hostInternals = host as unknown as {
createInteractionDispatcher ( ) : {
2026-04-15 09:57:42 +02:00
startNpcDialogue (
npcEntityId : string ,
dialogueId : string | null ,
2026-04-14 20:34:18 +02:00
source ? : {
kind : "interactionLink" | "npc" | "direct" ;
sourceEntityId : string | null ;
linkId : string | null ;
2026-04-14 21:19:30 +02:00
trigger : "enter" | "exit" | "click" | null ;
2026-04-14 20:34:18 +02:00
}
) : void ;
} ;
} ;
const dispatcher = hostInternals . createInteractionDispatcher ( ) ;
2026-04-15 09:57:42 +02:00
dispatcher . startNpcDialogue ( npcA . id , "dialogue-a" , {
kind : "npc" ,
sourceEntityId : npcA.id ,
linkId : null ,
trigger : "click"
2026-04-14 20:34:18 +02:00
} ) ;
const dialogueStates : Array < RuntimeDialogueState | null > = [ ] ;
host . setRuntimeDialogueHandler ( ( dialogue ) = > {
dialogueStates . push ( dialogue ) ;
} ) ;
2026-04-15 09:57:42 +02:00
dispatcher . startNpcDialogue ( npcA . id , "dialogue-a" , {
kind : "npc" ,
sourceEntityId : npcA.id ,
linkId : null ,
trigger : "click"
2026-04-14 20:34:18 +02:00
} ) ;
2026-04-15 09:57:42 +02:00
dispatcher . startNpcDialogue ( npcB . id , "dialogue-b" , {
2026-04-14 20:34:18 +02:00
kind : "npc" ,
2026-04-15 09:57:42 +02:00
sourceEntityId : npcB.id ,
2026-04-14 21:19:30 +02:00
linkId : null ,
trigger : "click"
2026-04-14 20:34:18 +02:00
} ) ;
expect ( dialogueStates ) . toEqual ( [
expect . objectContaining ( {
dialogueId : "dialogue-a" ,
2026-04-15 09:57:42 +02:00
npcEntityId : npcA.id ,
2026-04-14 20:34:18 +02:00
text : "First dialogue." ,
source : {
2026-04-15 09:57:42 +02:00
kind : "npc" ,
sourceEntityId : npcA.id ,
linkId : null ,
trigger : "click"
2026-04-14 20:34:18 +02:00
}
} ) ,
expect . objectContaining ( {
dialogueId : "dialogue-b" ,
2026-04-15 09:57:42 +02:00
npcEntityId : npcB.id ,
2026-04-14 20:34:18 +02:00
text : "Second dialogue." ,
source : {
kind : "npc" ,
2026-04-15 09:57:42 +02:00
sourceEntityId : npcB.id ,
2026-04-14 21:19:30 +02:00
linkId : null ,
trigger : "click"
2026-04-14 20:34:18 +02:00
}
} )
] ) ;
host . dispose ( ) ;
} ) ;
2026-04-14 02:41:55 +02:00
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 ( ) ;
2026-04-27 15:55:36 +02:00
const lightRenderObjects = hostInternals . localLightObjects . get (
pointLight . id
) ;
2026-04-14 02:41:55 +02:00
const modelRenderGroup = hostInternals . modelRenderObjects . get (
modelInstance . id
) ;
2026-04-14 02:43:20 +02:00
const initialAmbientIntensity = hostInternals . ambientLight . intensity ;
const initialAmbientColor = hostInternals . ambientLight . color . getHexString ( ) ;
const initialSunIntensity = hostInternals . sunLight . intensity ;
const initialSunColor = hostInternals . sunLight . color . getHexString ( ) ;
2026-04-14 02:41:55 +02:00
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 ) ;
2026-04-27 15:55:36 +02:00
hostInternals . animationMixers . set ( modelInstance . id , {
stopAllAction : vi.fn ( )
} as unknown as AnimationMixer ) ;
2026-04-14 02:41:55 +02:00
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 ) ;
2026-04-27 15:55:36 +02:00
dispatcher . dispatchControlEffect (
setSoundVolumeEffect ,
links . setSoundVolume
) ;
2026-04-14 02:41:55 +02:00
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"
} )
) ;
2026-04-14 02:43:20 +02:00
expect ( hostInternals . ambientLight . intensity ) . not . toBeCloseTo (
initialAmbientIntensity
) ;
expect ( hostInternals . ambientLight . color . getHexString ( ) ) . not . toBe (
initialAmbientColor
) ;
2026-04-27 15:55:36 +02:00
expect ( hostInternals . sunLight . intensity ) . not . toBeCloseTo (
initialSunIntensity
) ;
expect ( hostInternals . sunLight . color . getHexString ( ) ) . not . toBe (
initialSunColor
) ;
2026-04-14 02:41:55 +02:00
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 ( ) ;
} ) ;
2026-04-14 02:06:39 +02:00
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 ;
2026-04-14 03:14:50 +02:00
syncRuntimeScheduleToCurrentClock ( ) : void ;
2026-04-14 02:06:39 +02:00
} ;
2026-04-15 09:34:00 +02:00
expect ( runtimeScene . entities . npcs ) . toEqual ( [
expect . objectContaining ( {
entityId : npc.id ,
activeRoutineTitle : null
} )
] ) ;
2026-04-14 02:06:39 +02:00
hostInternals . sceneReady = true ;
hostInternals . currentClockState = {
timeOfDayHours : 21 ,
dayCount : 0 ,
dayLengthMinutes : 24
} ;
2026-04-14 03:14:50 +02:00
hostInternals . syncRuntimeScheduleToCurrentClock ( ) ;
2026-04-14 02:06:39 +02:00
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
} ;
2026-04-14 03:14:50 +02:00
hostInternals . syncRuntimeScheduleToCurrentClock ( ) ;
2026-04-14 02:06:39 +02:00
2026-04-15 09:34:00 +02:00
expect ( hostInternals . runtimeScene ? . entities . npcs ) . toEqual ( [
expect . objectContaining ( {
entityId : npc.id ,
activeRoutineTitle : null
} )
] ) ;
2026-04-14 02:06:39 +02:00
expect ( hostInternals . runtimeScene ? . npcDefinitions [ 0 ] ) . toEqual (
expect . objectContaining ( {
entityId : npc.id ,
2026-04-15 09:38:07 +02:00
active : true ,
2026-04-14 02:06:39 +02:00
activeRoutineTitle : null
} )
) ;
host . dispose ( ) ;
} ) ;
2026-04-14 03:14:50 +02:00
2026-04-14 13:55:43 +02:00
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 ;
2026-04-27 15:55:36 +02:00
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"
} )
]
} ) ;
2026-04-14 13:55:43 +02:00
const runtimeScene = buildRuntimeSceneFromDocument ( document , {
runtimeClock : {
timeOfDayHours : 6 ,
dayCount : 0 ,
dayLengthMinutes : 24
} ,
loadedModelAssets : {
[ asset . id ] : loadedAsset
}
} ) ;
const host = new RuntimeHost ( {
enableRendering : false
} ) ;
2026-04-14 17:22:44 +02:00
host . updateAssets (
document . assets ,
{
[ asset . id ] : loadedAsset
} ,
{ } ,
{ }
) ;
2026-04-14 13:55:43 +02:00
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 ( ) ;
2026-04-15 09:34:00 +02:00
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
} )
} )
] ) ;
2026-04-14 13:55:43 +02:00
expect ( hostInternals . runtimeScene ? . npcDefinitions [ 0 ] ) . toEqual (
expect . objectContaining ( {
entityId : npc.id ,
2026-04-15 09:38:07 +02:00
active : true ,
2026-04-14 13:55:43 +02:00
activeRoutineTitle : null ,
animationClipName : null ,
2026-04-15 09:34:00 +02:00
yawDegrees : 90 ,
2026-04-14 13:55:43 +02:00
position : {
2026-04-15 09:34:00 +02:00
x : 8 ,
2026-04-14 13:55:43 +02:00
y : 0 ,
z : 0
} ,
2026-04-15 09:34:00 +02:00
resolvedPath : expect.objectContaining ( {
pathId : path.id ,
progress : 1 ,
yawDegrees : 90
} )
2026-04-14 13:55:43 +02:00
} )
) ;
host . dispose ( ) ;
} ) ;
2026-04-14 03:14:50 +02:00
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 ( ) ;
} ) ;
2026-04-15 05:45:49 +02:00
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 ( ) ;
} ) ;
2026-04-25 04:22:17 +02:00
2026-04-25 15:57:12 +02:00
it ( "toggles the proposed runtime target instead of cycling candidates" , ( ) = > {
2026-04-25 04:22:17 +02:00
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 ( ) ;
2026-04-25 15:57:12 +02:00
expect ( hostInternals . activeRuntimeTargetReference ) . toBeNull ( ) ;
2026-04-25 04:22:17 +02:00
hostInternals . clearActiveRuntimeTarget ( ) ;
expect ( hostInternals . activeRuntimeTargetReference ) . toBeNull ( ) ;
host . dispose ( ) ;
} ) ;
2026-04-27 16:02:50 +02:00
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 ( ) ;
} ) ;
2026-04-25 10:28:50 +02:00
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
} ) ;
2026-04-25 18:36:48 +02:00
expect ( placement . luxPosition . y ) . toBeCloseTo ( 2.18 ) ;
2026-04-25 18:44:16 +02:00
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 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 18:44:16 +02:00
name : "Active" ,
defaultDialogueId : null ,
dialogues : [ ]
}
] ,
interactables : [ ] ,
cameraRigs : [ ]
} ,
interactionLinks : [
2026-04-27 15:55:36 +02:00
{
id : "link-active" ,
sourceEntityId : "npc-active" ,
trigger : "click" ,
action : { type : "runSequence" , sequenceId : "noop" }
}
2026-04-25 18:44:16 +02:00
]
} 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 ) {
2026-04-27 15:55:36 +02:00
const inwardDirection = arrow . position
. clone ( )
. multiplyScalar ( - 1 )
. normalize ( ) ;
2026-04-25 18:44:16 +02:00
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 ( ) ;
2026-04-25 10:28:50 +02:00
} ) ;
2026-04-25 18:31:38 +02:00
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 ) ;
2026-04-25 18:36:48 +02:00
expect ( hostInternals . targetingLuxGroup . position . x ) . toBeGreaterThan ( 0.005 ) ;
2026-04-25 18:31:38 +02:00
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 ( ) ;
} ) ;
2026-04-27 16:02:50 +02:00
it ( "uses Lux-only proposal feedback and consumes the authored clear-target key" , ( ) = > {
2026-04-25 10:45:04 +02:00
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 ;
2026-04-27 16:02:50 +02:00
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 ;
2026-04-25 10:45:04 +02:00
hostInternals . runtimeScene = {
playerInputBindings : {
keyboard : {
2026-04-27 16:02:50 +02:00
clearTarget : "KeyQ" ,
2026-04-25 10:45:04 +02:00
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 ) ;
2026-04-27 16:02:50 +02:00
expect ( hostInternals . activeRuntimeTargetReference ) . toEqual ( {
kind : "npc" ,
entityId : "npc-active"
} ) ;
expect ( escapeEvent . preventDefault ) . not . toHaveBeenCalled ( ) ;
expect ( escapeEvent . stopImmediatePropagation ) . not . toHaveBeenCalled ( ) ;
hostInternals . handleRuntimeKeyDown ( clearTargetEvent ) ;
2026-04-25 10:45:04 +02:00
expect ( hostInternals . activeRuntimeTargetReference ) . toBeNull ( ) ;
2026-04-27 16:02:50 +02:00
expect ( clearTargetEvent . preventDefault ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( clearTargetEvent . stopImmediatePropagation ) . toHaveBeenCalledTimes ( 1 ) ;
2026-04-25 10:45:04 +02:00
host . dispose ( ) ;
} ) ;
2026-04-27 18:43:10 +02:00
it ( "clears active target when third-person Escape releases pointer lock" , ( ) = > {
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 ;
domElement : HTMLCanvasElement ;
controllerContext : {
setPlayerControllerTelemetry ( telemetry : unknown ) : void ;
} ;
} ;
const requestPointerLock = vi . fn ( ) ;
Object . defineProperty ( hostInternals . domElement , "requestPointerLock" , {
configurable : true ,
value : requestPointerLock
} ) ;
hostInternals . runtimeScene = {
playerInputBindings : {
keyboard : {
clearTarget : "Escape"
}
} ,
entities : {
cameraRigs : [ ] ,
interactables : [ ] ,
npcs : [ ]
}
} as never ;
hostInternals . activeController = hostInternals . thirdPersonController ;
hostInternals . activeRuntimeTargetReference = {
kind : "npc" ,
entityId : "npc-active"
} ;
hostInternals . controllerContext . setPlayerControllerTelemetry ( {
pointerLocked : true ,
hooks : {
audio : null
}
} ) ;
hostInternals . controllerContext . setPlayerControllerTelemetry ( {
pointerLocked : false ,
hooks : {
audio : null
}
} ) ;
expect ( hostInternals . activeRuntimeTargetReference ) . toBeNull ( ) ;
expect ( requestPointerLock ) . toHaveBeenCalledTimes ( 1 ) ;
host . dispose ( ) ;
} ) ;
2026-04-25 16:30:53 +02:00
it ( "switches an active target once from directional screen-space look input" , ( ) = > {
2026-04-25 15:33:19 +02:00
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 ;
2026-04-25 16:30:53 +02:00
camera : PerspectiveCamera ;
handleRuntimeTargetLookInput ( input : {
horizontal : number ;
vertical : number ;
} ) : {
activeTargetLocked : boolean ;
switchedTarget : boolean ;
switchInputHeld : boolean ;
} ;
2026-04-25 15:33:19 +02:00
updateActiveRuntimeTargetLockState ( ) : void ;
} ;
hostInternals . runtimeScene = {
entities : {
npcs : [
{
entityId : "npc-active" ,
visible : true ,
position : { x : 0 , y : 0 , z : 5 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 15:33:19 +02:00
name : "Active" ,
defaultDialogueId : null ,
dialogues : [ ]
} ,
{
entityId : "npc-right" ,
visible : true ,
position : { x : 2 , y : 0 , z : 5 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 15:33:19 +02:00
name : "Right" ,
defaultDialogueId : null ,
dialogues : [ ]
2026-04-25 16:32:02 +02:00
} ,
{
entityId : "npc-above" ,
visible : true ,
position : { x : 0 , y : 2 , z : 5 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 16:32:02 +02:00
name : "Above" ,
defaultDialogueId : null ,
dialogues : [ ]
2026-04-25 15:33:19 +02:00
}
] ,
interactables : [ ] ,
cameraRigs : [ ]
} ,
interactionLinks : [
2026-04-27 15:55:36 +02:00
{
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" }
}
2026-04-25 15:33:19 +02:00
]
} 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
2026-04-25 16:32:02 +02:00
} ,
{
kind : "npc" ,
entityId : "npc-above" ,
center : { x : 0 , y : 2.9 , z : 5 } ,
score : 2.4
2026-04-25 15:33:19 +02:00
}
] ;
hostInternals . activeRuntimeTargetReference = {
kind : "npc" ,
entityId : "npc-active"
} ;
2026-04-25 16:30:53 +02:00
hostInternals . camera . position . set ( 0 , 1.6 , 0 ) ;
hostInternals . camera . lookAt ( 0 , 0.9 , 5 ) ;
hostInternals . camera . updateMatrixWorld ( ) ;
hostInternals . camera . updateProjectionMatrix ( ) ;
expect (
hostInternals . handleRuntimeTargetLookInput ( {
2026-04-25 16:39:14 +02:00
horizontal : - 1 ,
2026-04-25 16:30:53 +02:00
vertical : 0
} )
) . toEqual ( {
activeTargetLocked : true ,
switchedTarget : true ,
switchInputHeld : true
} ) ;
2026-04-25 15:33:19 +02:00
2026-04-25 15:58:18 +02:00
expect ( hostInternals . activeRuntimeTargetReference ) . toEqual ( {
kind : "npc" ,
entityId : "npc-right"
} ) ;
2026-04-25 16:30:53 +02:00
expect (
hostInternals . handleRuntimeTargetLookInput ( {
2026-04-25 16:39:14 +02:00
horizontal : - 1 ,
2026-04-25 16:30:53 +02:00
vertical : 0
} )
) . toEqual ( {
activeTargetLocked : true ,
switchedTarget : false ,
switchInputHeld : true
} ) ;
2026-04-25 15:33:19 +02:00
expect ( hostInternals . activeRuntimeTargetReference ) . toEqual ( {
kind : "npc" ,
entityId : "npc-right"
} ) ;
2026-04-25 16:32:02 +02:00
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"
} ) ;
2026-04-25 15:33:19 +02:00
host . dispose ( ) ;
} ) ;
2026-04-25 15:58:18 +02:00
it ( "does not switch active target from camera angle without look input" , ( ) = > {
2026-04-25 15:33:19 +02:00
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 ;
2026-04-25 15:44:03 +02:00
camera : PerspectiveCamera ;
2026-04-25 15:33:19 +02:00
updateActiveRuntimeTargetLockState ( ) : void ;
} ;
hostInternals . runtimeScene = {
entities : {
npcs : [
{
entityId : "npc-active" ,
visible : true ,
position : { x : 0 , y : 0 , z : 5 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 15:33:19 +02:00
name : "Active" ,
defaultDialogueId : null ,
dialogues : [ ]
}
] ,
interactables : [ ] ,
cameraRigs : [ ]
} ,
interactionLinks : [
2026-04-27 15:55:36 +02:00
{
id : "link-active" ,
sourceEntityId : "npc-active" ,
trigger : "click" ,
action : { type : "runSequence" , sequenceId : "noop" }
}
2026-04-25 15:33:19 +02:00
]
} 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"
} ;
2026-04-25 15:44:03 +02:00
hostInternals . camera . position . set ( 0 , 1.6 , 0 ) ;
hostInternals . camera . lookAt ( 10 , 1.6 , 3 ) ;
2026-04-25 15:33:19 +02:00
hostInternals . updateActiveRuntimeTargetLockState ( ) ;
2026-04-25 15:58:18 +02:00
expect ( hostInternals . activeRuntimeTargetReference ) . toEqual ( {
kind : "npc" ,
entityId : "npc-active"
} ) ;
2026-04-25 15:33:19 +02:00
host . dispose ( ) ;
} ) ;
2026-04-27 16:02:50 +02:00
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 ( ) ;
} ) ;
2026-04-25 16:39:14 +02:00
it ( "retargets to the centered on-screen candidate before clearing a distant active target" , ( ) = > {
2026-04-25 15:33:19 +02:00
const host = new RuntimeHost ( {
enableRendering : false
} ) ;
const hostInternals = host as unknown as {
runtimeScene : unknown ;
activeController : unknown ;
thirdPersonController : unknown ;
currentPlayerControllerTelemetry : unknown ;
2026-04-25 16:39:14 +02:00
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 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 16:39:14 +02:00
name : "Far" ,
defaultDialogueId : null ,
dialogues : [ ]
} ,
{
entityId : "npc-near" ,
visible : true ,
position : { x : 0 , y : 0 , z : 6 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 16:39:14 +02:00
name : "Near" ,
defaultDialogueId : null ,
dialogues : [ ]
}
] ,
interactables : [ ] ,
cameraRigs : [ ]
} ,
interactionLinks : [
2026-04-27 15:55:36 +02:00
{
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" }
}
2026-04-25 16:39:14 +02:00
]
} 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 ( ) ;
} ) ;
2026-04-25 16:48:48 +02:00
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 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 16:48:48 +02:00
name : "Border A" ,
defaultDialogueId : null ,
dialogues : [ ]
} ,
{
entityId : "npc-border-b" ,
visible : true ,
position : { x : 0.3 , y : 0 , z : 15.2 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 16:48:48 +02:00
name : "Border B" ,
defaultDialogueId : null ,
dialogues : [ ]
}
] ,
interactables : [ ] ,
cameraRigs : [ ]
} ,
interactionLinks : [
2026-04-27 15:55:36 +02:00
{
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" }
}
2026-04-25 16:48:48 +02:00
]
} 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 ( ) ;
} ) ;
2026-04-25 17:34:58 +02:00
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 ( ) ;
} ) ;
2026-04-25 16:39:14 +02:00
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 [ ] ;
2026-04-25 15:33:19 +02:00
activeRuntimeTargetReference : {
kind : "npc" ;
entityId : string ;
} | null ;
updateActiveRuntimeTargetLockState ( ) : void ;
} ;
hostInternals . runtimeScene = {
entities : {
npcs : [
{
entityId : "npc-far" ,
visible : true ,
2026-04-25 15:58:18 +02:00
position : { x : 0 , y : 0 , z : 16 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 15:33:19 +02:00
name : "Far" ,
defaultDialogueId : null ,
dialogues : [ ]
}
] ,
interactables : [ ] ,
cameraRigs : [ ]
} ,
interactionLinks : [
2026-04-27 15:55:36 +02:00
{
id : "link-far" ,
sourceEntityId : "npc-far" ,
trigger : "click" ,
action : { type : "runSequence" , sequenceId : "noop" }
}
2026-04-25 15:33:19 +02:00
]
} as never ;
hostInternals . activeController = hostInternals . thirdPersonController ;
hostInternals . currentPlayerControllerTelemetry = {
eyePosition : { x : 0 , y : 1.6 , z : 0 }
} ;
hostInternals . activeRuntimeTargetReference = {
kind : "npc" ,
entityId : "npc-far"
} ;
2026-04-25 16:39:14 +02:00
hostInternals . runtimeTargetCandidates = [ ] ;
2026-04-25 15:33:19 +02:00
hostInternals . updateActiveRuntimeTargetLockState ( ) ;
expect ( hostInternals . activeRuntimeTargetReference ) . toBeNull ( ) ;
host . dispose ( ) ;
} ) ;
2026-04-25 16:39:14 +02:00
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 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 16:39:14 +02:00
name : "Close Edge" ,
defaultDialogueId : null ,
dialogues : [ ]
} ,
{
entityId : "npc-center-farther" ,
visible : true ,
position : { x : 0 , y : 0 , z : 10 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 16:39:14 +02:00
name : "Center Farther" ,
defaultDialogueId : null ,
dialogues : [ ]
}
] ,
interactables : [ ] ,
cameraRigs : [ ]
} ,
interactionLinks : [
2026-04-27 15:55:36 +02:00
{
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" }
}
2026-04-25 16:39:14 +02:00
]
} 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 ( ) ;
2026-04-25 16:40:07 +02:00
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
2026-04-25 16:39:14 +02:00
) ;
expect ( hostInternals . proposedRuntimeTarget ) . toMatchObject ( {
kind : "npc" ,
entityId : "npc-center-farther"
} ) ;
host . dispose ( ) ;
} ) ;
2026-04-26 21:51:17 +02:00
it ( "biases Lux proposal focus slightly above screen center" , ( ) = > {
2026-04-26 21:50:34 +02:00
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" ,
2026-04-26 21:51:17 +02:00
entityId : "npc-above-center"
2026-04-26 21:50:34 +02:00
} ) ;
host . dispose ( ) ;
} ) ;
2026-04-25 18:08:55 +02:00
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 ;
2026-04-25 18:11:54 +02:00
dispose ( ) : void ;
2026-04-25 18:08:55 +02:00
} ;
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 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 18:08:55 +02:00
name : "Visible" ,
defaultDialogueId : null ,
dialogues : [ ]
} ,
{
entityId : "npc-occluded" ,
visible : true ,
position : { x : 0 , y : 0 , z : 8 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 18:08:55 +02:00
name : "Occluded" ,
defaultDialogueId : null ,
dialogues : [ ]
}
] ,
interactables : [ ] ,
cameraRigs : [ ]
} ,
interactionLinks : [
2026-04-27 15:55:36 +02:00
{
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" }
}
2026-04-25 18:08:55 +02:00
]
} as never ;
hostInternals . sceneReady = true ;
hostInternals . activeController = hostInternals . thirdPersonController ;
hostInternals . currentPlayerControllerTelemetry = {
eyePosition : { x : 0 , y : 1.6 , z : 0 }
} ;
hostInternals . collisionWorld = {
2026-04-25 18:11:54 +02:00
isLineSegmentClear : ( _start , end ) = > end . z < 8 ,
dispose : vi.fn ( )
2026-04-25 18:08:55 +02:00
} ;
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 ;
2026-04-25 18:11:54 +02:00
dispose ( ) : void ;
2026-04-25 18:08:55 +02:00
} ;
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 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 18:08:55 +02:00
name : "Player Occluded" ,
defaultDialogueId : null ,
dialogues : [ ]
} ,
{
entityId : "npc-player-visible" ,
visible : true ,
position : { x : 0.8 , y : 0 , z : 8 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 18:08:55 +02:00
name : "Player Visible" ,
defaultDialogueId : null ,
dialogues : [ ]
}
] ,
interactables : [ ] ,
cameraRigs : [ ]
} ,
interactionLinks : [
2026-04-27 15:55:36 +02:00
{
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" }
}
2026-04-25 18:08:55 +02:00
]
} as never ;
hostInternals . sceneReady = true ;
hostInternals . activeController = hostInternals . thirdPersonController ;
hostInternals . currentPlayerControllerTelemetry = {
eyePosition : { x : 0 , y : 1.6 , z : 0 }
} ;
hostInternals . collisionWorld = {
2026-04-27 15:55:36 +02:00
isLineSegmentClear : ( start , end ) = > start . x !== 0 || end . z !== 7 ,
2026-04-25 18:11:54 +02:00
dispose : vi.fn ( )
2026-04-25 18:08:55 +02:00
} ;
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 ( ) ;
2026-04-25 18:21:29 +02:00
} ) ;
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 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 18:21:29 +02:00
name : "Close Above" ,
defaultDialogueId : null ,
dialogues : [ ]
}
] ,
interactables : [ ] ,
cameraRigs : [ ]
} ,
interactionLinks : [
2026-04-27 15:55:36 +02:00
{
id : "link-close-above" ,
sourceEntityId : "npc-close-above" ,
trigger : "click" ,
action : { type : "runSequence" , sequenceId : "noop" }
}
2026-04-25 18:21:29 +02:00
]
} 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 ( ) ;
2026-04-25 18:08:55 +02:00
} ) ;
2026-04-25 18:09:59 +02:00
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 ;
2026-04-25 18:11:54 +02:00
dispose ( ) : void ;
2026-04-25 18:09:59 +02:00
} ;
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 } ,
2026-04-27 15:55:36 +02:00
collider : {
mode : "capsule" ,
radius : 0.35 ,
height : 1.8 ,
eyeHeight : 1.6
} ,
2026-04-25 18:09:59 +02:00
name : "Occluded Active" ,
defaultDialogueId : null ,
dialogues : [ ]
}
] ,
interactables : [ ] ,
cameraRigs : [ ]
} ,
interactionLinks : [
2026-04-27 15:55:36 +02:00
{
id : "link-occluded-active" ,
sourceEntityId : "npc-occluded-active" ,
trigger : "click" ,
action : { type : "runSequence" , sequenceId : "noop" }
}
2026-04-25 18:09:59 +02:00
]
} as never ;
hostInternals . activeController = hostInternals . thirdPersonController ;
hostInternals . currentPlayerControllerTelemetry = {
eyePosition : { x : 0 , y : 1.6 , z : 0 }
} ;
hostInternals . collisionWorld = {
2026-04-25 18:11:54 +02:00
isLineSegmentClear : ( ) = > false ,
dispose : vi.fn ( )
2026-04-25 18:09:59 +02:00
} ;
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 ( ) ;
} ) ;
2026-04-25 04:22:17 +02:00
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 ( ) ;
} ) ;
2026-04-11 04:18:13 +02:00
} ) ;