Files
webeditor3d/tests/unit/runtime-interaction-system.test.ts

324 lines
8.6 KiB
TypeScript

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<RuntimeNpc> & { 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<RuntimeInteractable> & { 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();
});
});