Files
webeditor3d/tests/domain/runtime-schedule-sync.test.ts

311 lines
9.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
createActorControlTargetRef,
createProjectGlobalControlTargetRef,
createLightControlTargetRef,
createFollowActorPathControlEffect,
createPlayActorAnimationControlEffect,
createSetActorPresenceControlEffect,
createSetLightIntensityControlEffect
} from "../../src/controls/control-surface";
import { createScenePath } from "../../src/document/paths";
import { createEmptySceneDocument } from "../../src/document/scene-document";
import {
createNpcEntity,
createPointLightEntity
} from "../../src/entities/entity-instances";
import { createProjectScheduleRoutine } from "../../src/scheduler/project-scheduler";
import { createProjectSequence } from "../../src/sequencer/project-sequences";
import {
applyRuntimeProjectScheduleToControlState,
resolveRuntimeProjectScheduleState
} from "../../src/runtime-three/runtime-project-scheduler";
import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build";
import {
commitRuntimeScheduleSyncResult,
createRuntimeScheduleSyncContext,
syncRuntimeSceneScheduleToClock
} from "../../src/runtime-three/runtime-schedule-sync";
import { BoxGeometry } from "three";
import { createFixtureLoadedModelAssetFromGeometry } from "../helpers/model-collider-fixtures";
describe("runtime schedule sync", () => {
it("matches direct scheduler resolution while applying actor path and animation state", () => {
const actorTarget = createActorControlTargetRef("actor-patroller");
const { asset, loadedAsset } = createFixtureLoadedModelAssetFromGeometry(
"asset-npc-patroller",
new BoxGeometry(0.8, 1.8, 0.6)
);
asset.metadata.animationNames = ["Walk"];
const npc = createNpcEntity({
id: "entity-npc-patroller",
actorId: actorTarget.actorId,
modelAssetId: asset.id,
yawDegrees: 15
});
const path = createScenePath({
id: "path-patrol",
points: [
{
id: "path-point-start",
position: {
x: 0,
y: 0,
z: 0
}
},
{
id: "path-point-end",
position: {
x: 8,
y: 0,
z: 0
}
}
]
});
const document = createEmptySceneDocument();
document.assets[asset.id] = asset;
document.entities[npc.id] = npc;
document.paths[path.id] = path;
document.scheduler.routines["routine-patrol"] =
createProjectScheduleRoutine({
id: "routine-patrol",
title: "Patrolling",
target: actorTarget,
startHour: 9,
endHour: 13,
effects: [
createSetActorPresenceControlEffect({
target: actorTarget,
active: true
}),
createPlayActorAnimationControlEffect({
target: actorTarget,
clipName: "Walk",
loop: true
}),
createFollowActorPathControlEffect({
target: actorTarget,
pathId: path.id,
speed: 2,
loop: false,
progressMode: "deriveFromTime"
})
]
});
const runtimeScene = buildRuntimeSceneFromDocument(document, {
runtimeClock: {
timeOfDayHours: 6,
dayCount: 0,
dayLengthMinutes: 24
},
loadedModelAssets: {
[asset.id]: loadedAsset
}
});
const context = createRuntimeScheduleSyncContext(runtimeScene);
const clock = {
timeOfDayHours: 11,
dayCount: 0,
dayLengthMinutes: 24
};
const directResolvedScheduler = resolveRuntimeProjectScheduleState({
scheduler: runtimeScene.scheduler.document,
sequences: runtimeScene.sequences,
actorIds: runtimeScene.npcDefinitions.map(
(definition) => definition.actorId
),
dayNumber: clock.dayCount + 1,
timeOfDayHours: clock.timeOfDayHours,
pathsById: context.pathsById
});
const directResolvedControl = applyRuntimeProjectScheduleToControlState(
runtimeScene.control.resolved,
directResolvedScheduler,
runtimeScene.control.baselineResolved
);
const result = syncRuntimeSceneScheduleToClock({
runtimeScene,
clock,
context
});
commitRuntimeScheduleSyncResult(runtimeScene, result);
expect(result.resolvedScheduler).toEqual(directResolvedScheduler);
expect(result.resolvedControl).toEqual(directResolvedControl);
expect(result.npcChanges).toHaveLength(1);
expect(result.npcEntityCollectionChanged).toBe(true);
expect(runtimeScene.npcDefinitions[0]).toEqual(
expect.objectContaining({
entityId: npc.id,
activeRoutineTitle: "Patrolling",
animationClipName: "Walk",
yawDegrees: 90,
position: {
x: 4,
y: 0,
z: 0
},
resolvedPath: expect.objectContaining({
pathId: path.id,
progress: 0.5
})
})
);
expect(runtimeScene.entities.npcs).toEqual([
expect.objectContaining({
entityId: npc.id,
activeRoutineTitle: "Patrolling",
animationClipName: "Walk",
position: {
x: 4,
y: 0,
z: 0
}
})
]);
});
it("reuses scene-stable scheduler inputs and leaves NPC collections untouched when output is unchanged", () => {
const npc = createNpcEntity({
id: "entity-npc-stable",
actorId: "actor-stable"
});
const document = createEmptySceneDocument();
document.entities[npc.id] = npc;
const runtimeScene = buildRuntimeSceneFromDocument(document);
const context = createRuntimeScheduleSyncContext(runtimeScene);
const clock = {
timeOfDayHours: 6,
dayCount: 0,
dayLengthMinutes: 24
};
const firstResult = syncRuntimeSceneScheduleToClock({
runtimeScene,
clock,
context
});
const npcEntities = runtimeScene.entities.npcs;
const colliders = runtimeScene.colliders;
const pathsById = context.pathsById;
const actorStatesByActorId = context.actorStatesByActorId;
const actorIds = context.actorIds;
const secondResult = syncRuntimeSceneScheduleToClock({
runtimeScene,
clock,
context
});
expect(firstResult.npcChanges).toHaveLength(0);
expect(secondResult.npcChanges).toHaveLength(0);
expect(context.pathsById).toBe(pathsById);
expect(context.actorIds).toBe(actorIds);
expect(context.actorStatesByActorId).toBe(actorStatesByActorId);
expect(runtimeScene.entities.npcs).toBe(npcEntities);
expect(runtimeScene.colliders).toBe(colliders);
});
it("resolves scheduled controls and impulse routines through the shared helper", () => {
const pointLight = createPointLightEntity({
id: "entity-point-light-night-lamp",
intensity: 1.25
});
const document = createEmptySceneDocument();
document.entities[pointLight.id] = pointLight;
document.sequences.sequences["sequence-transition"] = createProjectSequence({
id: "sequence-transition",
title: "Transition",
effects: [
{
stepClass: "impulse",
type: "startSceneTransition",
targetSceneId: "scene-house",
targetEntryEntityId: "entry-house"
}
]
});
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
})
});
document.scheduler.routines["routine-transition"] =
createProjectScheduleRoutine({
id: "routine-transition",
title: "Transition Window",
target: createProjectGlobalControlTargetRef(),
startHour: 20,
endHour: 22,
sequenceId: "sequence-transition",
effects: []
});
const runtimeScene = buildRuntimeSceneFromDocument(document);
const context = createRuntimeScheduleSyncContext(runtimeScene);
const clock = {
timeOfDayHours: 21,
dayCount: 0,
dayLengthMinutes: 24
};
const directResolvedScheduler = resolveRuntimeProjectScheduleState({
scheduler: runtimeScene.scheduler.document,
sequences: runtimeScene.sequences,
actorIds: context.actorIds,
dayNumber: clock.dayCount + 1,
timeOfDayHours: clock.timeOfDayHours,
pathsById: context.pathsById
});
const directResolvedControl = applyRuntimeProjectScheduleToControlState(
runtimeScene.control.resolved,
directResolvedScheduler,
runtimeScene.control.baselineResolved
);
const result = syncRuntimeSceneScheduleToClock({
runtimeScene,
clock,
context
});
expect(result.resolvedScheduler).toEqual(directResolvedScheduler);
expect(result.resolvedControl).toEqual(directResolvedControl);
expect(result.resolvedScheduler.impulses).toEqual([
expect.objectContaining({
routineId: "routine-transition",
effects: [
expect.objectContaining({
type: "startSceneTransition",
targetSceneId: "scene-house",
targetEntryEntityId: "entry-house"
})
]
})
]);
expect(result.nextActiveImpulseRoutineIds).toEqual(
new Set(["routine-transition"])
);
expect(result.resolvedControl.channels).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "lightIntensity",
value: 3.5,
source: {
kind: "scheduler",
scheduleId: "routine-night-lamp"
}
})
])
);
});
});