import { describe, expect, it } from "vitest"; import type { InteractionLink } from "../../src/interactions/interaction-links"; import { resolveRuntimeTargetCandidates, resolveRuntimeTargetReference, resolveStableRuntimeTargetProposal, RuntimeInteractionSystem, type RuntimeTargetCandidate } from "../../src/runtime-three/runtime-interaction-system"; import type { RuntimeInteractable, RuntimeNpc, RuntimeSceneDefinition } from "../../src/runtime-three/runtime-scene-build"; function createClickLink(sourceEntityId: string): InteractionLink { return { id: `link-${sourceEntityId}`, sourceEntityId, trigger: "click", action: { type: "runSequence", sequenceId: "noop" } }; } function createNpc(overrides: Partial & { entityId: string }): RuntimeNpc { return { entityId: overrides.entityId, actorId: overrides.actorId ?? "", name: overrides.name, visible: overrides.visible ?? true, position: overrides.position ?? { x: 0, y: 0, z: 0 }, yawDegrees: overrides.yawDegrees ?? 0, modelAssetId: overrides.modelAssetId ?? null, dialogues: overrides.dialogues ?? [], defaultDialogueId: overrides.defaultDialogueId ?? null, collider: overrides.collider ?? { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 }, activeRoutineTitle: overrides.activeRoutineTitle ?? null, animationClipName: overrides.animationClipName ?? null, animationLoop: overrides.animationLoop, resolvedPath: overrides.resolvedPath ?? null }; } function createInteractable( overrides: Partial & { entityId: string } ): RuntimeInteractable { return { entityId: overrides.entityId, position: overrides.position ?? { x: 0, y: 0, z: 0 }, radius: overrides.radius ?? 4, prompt: overrides.prompt ?? "Interact", interactionEnabled: overrides.interactionEnabled ?? true }; } function createRuntimeSceneFixture(options: { npcs?: RuntimeNpc[]; interactables?: RuntimeInteractable[]; links?: InteractionLink[]; }): RuntimeSceneDefinition { return { entities: { npcs: options.npcs ?? [], interactables: options.interactables ?? [], playerStarts: [], sceneEntries: [], cameraRigs: [], soundEmitters: [], triggerVolumes: [], teleportTargets: [] }, interactionLinks: options.links ?? [] } as unknown as RuntimeSceneDefinition; } describe("runtime interaction targeting", () => { const centerView = { x: 0, y: 0, z: 1 }; it("orders visible NPC and interactable targets by view and distance score", () => { const centerNpc = createNpc({ entityId: "npc-center", name: "Center", position: { x: 0, y: 0, z: 3 } }); const sideInteractable = createInteractable({ entityId: "interactable-side", position: { x: 1.5, y: 1, z: 3 }, radius: 4, prompt: "Use" }); const scene = createRuntimeSceneFixture({ npcs: [centerNpc], interactables: [sideInteractable], links: [createClickLink(centerNpc.entityId), createClickLink(sideInteractable.entityId)] }); const candidates = resolveRuntimeTargetCandidates({ interactionOrigin: { x: 0, y: 1, z: 2.5 }, cameraPosition: { x: 0, y: 1.6, z: 0 }, cameraForward: { x: 0, y: 0, z: 1 }, runtimeScene: scene }); expect(candidates.map((candidate) => candidate.entityId)).toEqual([ "npc-center", "interactable-side" ]); }); it("keeps a previous proposed target stable across small score changes", () => { const candidates: RuntimeTargetCandidate[] = [ { kind: "npc", entityId: "new-best", prompt: "Talk", position: { x: 0, y: 0, z: 0 }, center: { x: 0, y: 1, z: 0 }, distance: 1, range: 2, viewDot: 0.99, score: 2.05 }, { kind: "interactable", entityId: "previous", prompt: "Use", position: { x: 0, y: 0, z: 0 }, center: { x: 0, y: 1, z: 0 }, distance: 1, range: 2, viewDot: 0.95, score: 2 } ]; expect( resolveStableRuntimeTargetProposal(candidates, "previous")?.entityId ).toBe("previous"); }); it("proposes farther in-view targets without broadening click prompt range", () => { const distantNpc = createNpc({ entityId: "npc-distant", name: "Far Guard", position: { x: 0, y: 0, z: 14.4 } }); const distantInteractable = createInteractable({ entityId: "interactable-distant", position: { x: 1.2, y: 1, z: 13.5 }, radius: 1, prompt: "Use" }); const scene = createRuntimeSceneFixture({ npcs: [distantNpc], interactables: [distantInteractable], links: [ createClickLink(distantNpc.entityId), createClickLink(distantInteractable.entityId) ] }); const candidates = resolveRuntimeTargetCandidates({ interactionOrigin: { x: 0, y: 1, z: 0 }, cameraPosition: { x: 0, y: 1.6, z: -1 }, cameraForward: { x: 0, y: 0, z: 1 }, runtimeScene: scene }); const system = new RuntimeInteractionSystem(); expect(candidates.map((candidate) => candidate.entityId)).toEqual( expect.arrayContaining(["interactable-distant", "npc-distant"]) ); expect( system.resolveClickInteractionPrompt( { x: 0, y: 1, z: 0 }, { x: 0, y: 0, z: 1 }, 1.5, 30, scene ) ).toBeNull(); }); it("captures off-center targets without ray dead zones", () => { const npc = createNpc({ entityId: "npc-dead-zone", name: "Dead Zone", position: { x: 0.52, y: 0, z: 2.55 } }); const scene = createRuntimeSceneFixture({ npcs: [npc], links: [createClickLink(npc.entityId)] }); const system = new RuntimeInteractionSystem(); const prompt = system.resolveClickInteractionPrompt( { x: 0, y: 1, z: 0 }, centerView, 3, 30, scene ); expect(prompt?.sourceEntityId).toBe(npc.entityId); }); it("uses the authored interaction angle to widen prompt acquisition", () => { const npc = createNpc({ entityId: "npc-angle", name: "Angle", position: { x: 1.15, y: 0, z: 2.35 } }); const scene = createRuntimeSceneFixture({ npcs: [npc], links: [createClickLink(npc.entityId)] }); const system = new RuntimeInteractionSystem(); expect( system.resolveClickInteractionPrompt( { x: 0, y: 1, z: 0 }, centerView, 3, 20, scene ) ).toBeNull(); expect( system.resolveClickInteractionPrompt( { x: 0, y: 1, z: 0 }, centerView, 3, 60, scene )?.sourceEntityId ).toBe(npc.entityId); }); it("uses the authored interaction reach to extend prompt distance", () => { const npc = createNpc({ entityId: "npc-reach", name: "Reach", position: { x: 0, y: 0, z: 2.2 } }); const scene = createRuntimeSceneFixture({ npcs: [npc], links: [createClickLink(npc.entityId)] }); const system = new RuntimeInteractionSystem(); expect( system.resolveClickInteractionPrompt( { x: 0, y: 1, z: 0 }, centerView, 1.5, 30, scene ) ).toBeNull(); expect( system.resolveClickInteractionPrompt( { x: 0, y: 1, z: 0 }, centerView, 2.5, 30, scene )?.sourceEntityId ).toBe(npc.entityId); }); it("keeps click prompt resolution coherent with the shared target sources", () => { const npc = createNpc({ entityId: "npc-talk", name: "Guard", position: { x: 0, y: 0, z: 3 } }); const scene = createRuntimeSceneFixture({ npcs: [npc], links: [createClickLink(npc.entityId)] }); const system = new RuntimeInteractionSystem(); const prompt = system.resolveClickInteractionPrompt( { x: 0, y: 1, z: 2.5 }, centerView, 1.5, 30, scene ); expect(prompt?.sourceEntityId).toBe(npc.entityId); expect(prompt?.prompt).toBe("Interact with Guard"); }); it("invalidates resolved targets when the runtime target is no longer usable", () => { const interactable = createInteractable({ entityId: "switch", interactionEnabled: false }); const scene = createRuntimeSceneFixture({ interactables: [interactable], links: [createClickLink(interactable.entityId)] }); expect( resolveRuntimeTargetReference(scene, { kind: "interactable", entityId: interactable.entityId }) ).toBeNull(); }); });