From bdfbdd329aa8892f90f8770144faea31ef61844c Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 14 Apr 2026 13:41:01 +0200 Subject: [PATCH] Update project scheduler to handle multiple effects and improve effect normalization logic --- src/scheduler/project-scheduler.ts | 169 +++++++++++++++++++++++++---- 1 file changed, 147 insertions(+), 22 deletions(-) diff --git a/src/scheduler/project-scheduler.ts b/src/scheduler/project-scheduler.ts index af83915c..fe25efc1 100644 --- a/src/scheduler/project-scheduler.ts +++ b/src/scheduler/project-scheduler.ts @@ -3,6 +3,7 @@ import { areControlEffectsEqual, cloneControlEffect, cloneControlTargetRef, + getControlEffectResolutionKey, type ControlEffect, type ControlTargetRef, createActorControlTargetRef, @@ -49,7 +50,7 @@ export interface ProjectScheduleRoutine { startHour: number; endHour: number; priority: number; - effect: ControlEffect; + effects: ControlEffect[]; } export interface ProjectScheduler { @@ -123,30 +124,98 @@ function normalizeProjectScheduleHours(value: number, label: string): number { return normalizeTimeOfDayHours(value); } -function normalizeProjectScheduleEffectTarget( - target: ControlTargetRef, - effect: ControlEffect | undefined -): ControlEffect { - if (effect === undefined) { - if (target.kind !== "actor") { - throw new Error( - "Project schedule routines must author an explicit control effect for non-actor targets." - ); +function compareProjectScheduleEffectOrder( + left: ControlEffect, + right: ControlEffect +): number { + const getOrder = (effect: ControlEffect): number => { + switch (effect.type) { + case "setActorPresence": + return 0; + case "playActorAnimation": + return 1; + case "followActorPath": + return 2; + default: + return 10; } + }; - return createSetActorPresenceControlEffect({ - target, - active: true - }); + return ( + getOrder(left) - getOrder(right) || + getControlEffectResolutionKey(left).localeCompare( + getControlEffectResolutionKey(right) + ) + ); +} + +function normalizeProjectScheduleEffects( + target: ControlTargetRef, + options: { + effect?: ControlEffect; + effects?: ControlEffect[]; } +): ControlEffect[] { + const authoredEffects = + options.effects !== undefined + ? options.effects + : options.effect === undefined + ? [] + : [options.effect]; + const normalizedEffects = authoredEffects.map(cloneControlEffect); - if (getControlTargetRefKey(effect.target) !== getControlTargetRefKey(target)) { + if (normalizedEffects.length === 0 && target.kind !== "actor") { throw new Error( - "Project schedule routine effects must target the same authored control target." + "Project schedule routines must author an explicit control effect for non-actor targets." ); } - return cloneControlEffect(effect); + for (const effect of normalizedEffects) { + if (getControlTargetRefKey(effect.target) !== getControlTargetRefKey(target)) { + throw new Error( + "Project schedule routine effects must target the same authored control target." + ); + } + } + + if ( + target.kind === "actor" && + !normalizedEffects.some((effect) => effect.type === "setActorPresence") + ) { + normalizedEffects.push( + createSetActorPresenceControlEffect({ + target, + active: true + }) + ); + } + + if (normalizedEffects.length === 0) { + throw new Error("Project schedule routines must contain at least one control effect."); + } + + if (target.kind !== "actor" && normalizedEffects.length !== 1) { + throw new Error( + "Non-actor project schedule routines must currently author exactly one control effect." + ); + } + + const seenResolutionKeys = new Set(); + + for (const effect of normalizedEffects) { + const resolutionKey = getControlEffectResolutionKey(effect); + + if (seenResolutionKeys.has(resolutionKey)) { + throw new Error( + `Project schedule routines cannot author multiple effects for ${resolutionKey}.` + ); + } + + seenResolutionKeys.add(resolutionKey); + } + + normalizedEffects.sort(compareProjectScheduleEffectOrder); + return normalizedEffects; } export function isProjectScheduleWeekday( @@ -180,9 +249,11 @@ export function createProjectScheduleRoutine( overrides: Partial< Pick< ProjectScheduleRoutine, - "id" | "title" | "enabled" | "target" | "days" | "startHour" | "endHour" | "priority" | "effect" + "id" | "title" | "enabled" | "target" | "days" | "startHour" | "endHour" | "priority" | "effects" > - > & + > & { + effect?: ControlEffect; + } & Pick = { target: createActorControlTargetRef("actor-default"), title: "Routine" @@ -211,7 +282,10 @@ export function createProjectScheduleRoutine( startHour, endHour, priority: normalizeProjectSchedulePriority(overrides.priority), - effect: normalizeProjectScheduleEffectTarget(target, overrides.effect) + effects: normalizeProjectScheduleEffects(target, { + effect: overrides.effect, + effects: overrides.effects + }) }; } @@ -238,7 +312,7 @@ export function cloneProjectScheduleRoutine( startHour: routine.startHour, endHour: routine.endHour, priority: routine.priority, - effect: cloneControlEffect(routine.effect) + effects: routine.effects.map(cloneControlEffect) }; } @@ -287,7 +361,24 @@ export function areProjectScheduleRoutinesEqual( left.startHour === right.startHour && left.endHour === right.endHour && left.priority === right.priority && - areControlEffectsEqual(left.effect, right.effect) + left.effects.length === right.effects.length && + left.effects.every((effect, index) => + areControlEffectsEqual(effect, right.effects[index] as ControlEffect) + ) + ); +} + +export function findProjectScheduleRoutineEffect< + TType extends ControlEffect["type"] +>( + routine: ProjectScheduleRoutine, + type: TType +): Extract | null { + return ( + routine.effects.find( + (effect): effect is Extract => + effect.type === type + ) ?? null ); } @@ -381,6 +472,40 @@ export function isProjectScheduleRoutineActiveAt( ); } +export function getProjectScheduleRoutineDurationHours( + routine: Pick +): number { + return routine.startHour < routine.endHour + ? routine.endHour - routine.startHour + : HOURS_PER_DAY - routine.startHour + routine.endHour; +} + +export function getProjectScheduleRoutineElapsedHoursAt( + routine: ProjectScheduleRoutine, + dayNumber: number, + timeOfDayHours: number +): number | null { + if (!isProjectScheduleRoutineActiveAt(routine, dayNumber, timeOfDayHours)) { + return null; + } + + const normalizedTimeOfDayHours = normalizeTimeOfDayHours(timeOfDayHours); + const weekday = resolveProjectScheduleWeekday(dayNumber); + + if (routine.startHour < routine.endHour) { + return normalizedTimeOfDayHours - routine.startHour; + } + + if ( + isProjectScheduleDaySelectionActive(routine.days, weekday) && + normalizedTimeOfDayHours >= routine.startHour + ) { + return normalizedTimeOfDayHours - routine.startHour; + } + + return HOURS_PER_DAY - routine.startHour + normalizedTimeOfDayHours; +} + export function compareProjectScheduleRoutinePriority( left: ProjectScheduleRoutine, right: ProjectScheduleRoutine