diff --git a/packages/app/src/app.js b/packages/app/src/app.js index 7e7ea82e..869e5e38 100644 --- a/packages/app/src/app.js +++ b/packages/app/src/app.js @@ -1,4 +1,4 @@ -/** @import { SystemConfig, SystemGroupConfig, ScheduleConfig } from '@wimaengine/schedule' */ +/** @import { CrossWorldScheduleConfig, CrossWorldSystemConfig, ScheduleConfig, SystemConfig, SystemGroupConfig } from '@wimaengine/schedule' */ /** @import { Constructor,TypeId } from '@wimaengine/type'*/ import { World, ComponentHooks } from '@wimaengine/ecs' @@ -7,7 +7,7 @@ import { Scheduler, SchedulerBuilder } from '@wimaengine/schedule' import { typeid } from '@wimaengine/type' import { Worlds } from './worlds' -const registererror = 'Schedules, system groups, systems, plugins, worlds, resource aliases or resources should be registered or set before `App().run()`' +const registererror = 'Schedules, crossworld schedules, system groups, systems, crossworld systems, plugins, worlds, resource aliases or resources should be registered or set before `App().run()`' export class PluginRegistry { @@ -166,6 +166,17 @@ export class App { return this } + /** + * @param {CrossWorldScheduleConfig} config + */ + registerCrossWorldSchedule(config) { + assertTrue(!this.initialized, registererror) + + SchedulerBuilder.Instance.addCrossWorldSchedule(config) + + return this + } + /** * @param {import('@wimaengine/schedule').Runner} runner */ @@ -232,6 +243,17 @@ export class App { return this } + /** + * @param {CrossWorldSystemConfig} config + */ + registerCrossworldSystem(config) { + assertTrue(!this.initialized, registererror) + + SchedulerBuilder.Instance.addCrossworldSystem(config) + + return this + } + /** * @param {SystemGroupConfig} config */ diff --git a/packages/core/src/core/runner.js b/packages/core/src/core/runner.js index 33e1ccfc..def7ddee 100644 --- a/packages/core/src/core/runner.js +++ b/packages/core/src/core/runner.js @@ -7,11 +7,21 @@ import { typeid } from '@wimaengine/type' */ export function defaultRunner(scheduler, worlds) { + /** @type {Map} */ + const crossWorldState = new Map() + /** @type {Map} */ const state = new Map() const now = performance.now() + for (const executable of scheduler.crossWorldValues()) { + crossWorldState.set(executable.typeId, { + active: true, + nextRunAt: now + executable.delay + }) + } + for (const executable of scheduler.values()) { state.set(executable.typeId, { active: true, @@ -20,16 +30,42 @@ export function defaultRunner(scheduler, worlds) { } const update = (/** @type {number} */ time) => { + for (const executable of scheduler.crossWorldValues()) { + const execState = crossWorldState.get(executable.typeId) + + if (!execState || !execState.active) continue + + if (time >= execState.nextRunAt) { + const targetTypeId = typeid(executable.world) + const targetWorld = worlds.get(targetTypeId) + + assert(targetWorld, `The world \`${targetTypeId}\` does not exist.`) + + const sourceTypeId = typeid(executable.sourceWorld) + const sourceWorld = worlds.get(sourceTypeId) + + assert(sourceWorld, `The source world \`${sourceTypeId}\` does not exist.`) + + executable.schedule.run(targetWorld, sourceWorld, executable.errorHandler) + + if (executable.repeat) { + execState.nextRunAt = time + executable.delay + } else { + execState.active = false + } + } + } + for (const executable of scheduler.values()) { const execState = state.get(executable.typeId) if (!execState || !execState.active) continue if (time >= execState.nextRunAt) { - const typeId = typeid(executable.world) - const world = worlds.get(typeId) + const targetTypeId = typeid(executable.world) + const world = worlds.get(targetTypeId) - assert(world, `The world \`${typeId}\` does not exist.`) + assert(world, `The world \`${targetTypeId}\` does not exist.`) executable.schedule.run(world, executable.errorHandler) diff --git a/packages/core/tests/defaultRunner.test.js b/packages/core/tests/defaultRunner.test.js new file mode 100644 index 00000000..43508cd3 --- /dev/null +++ b/packages/core/tests/defaultRunner.test.js @@ -0,0 +1,85 @@ +import { strictEqual, deepStrictEqual } from 'node:assert' +import { afterEach, describe, test, vi } from 'vitest' +import { World } from '@wimaengine/ecs' +import { typeid } from '@wimaengine/type' +import { CrossWorldSchedule, Executable, Scheduler } from '@wimaengine/schedule' +import { defaultRunner } from '../src/core/runner.js' + +class TargetWorld { } +class SourceWorld { } +class NormalSchedule { } +class CrossWorldScheduleLabel { } + +describe('Testing `defaultRunner`', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + test('runs crossworld schedules before normal schedules', () => { + const scheduler = new Scheduler() + const targetWorld = new World() + const sourceWorld = new World() + const worlds = new Map([ + [typeid(TargetWorld), targetWorld], + [typeid(SourceWorld), sourceWorld] + ]) + /** @type {string[]} */ + const order = [] + /** @type {((time: number) => void)[]} */ + const frames = [] + + scheduler.set(new Executable({ + label: NormalSchedule, + repeat: false, + world: TargetWorld + })) + scheduler.setCrossWorld(new Executable({ + label: CrossWorldScheduleLabel, + repeat: false, + schedule: new CrossWorldSchedule(), + world: TargetWorld, + sourceWorld: SourceWorld + })) + + const normalSchedule = scheduler.get(NormalSchedule) + if (!normalSchedule) { + throw new Error('The normal schedule was not created.') + } + + normalSchedule.add(() => { + order.push('normal') + }) + + const crossWorldSchedule = scheduler.getCrossWorld(CrossWorldScheduleLabel) + if (!crossWorldSchedule) { + throw new Error('The crossworld schedule was not created.') + } + + crossWorldSchedule.add((target, source) => { + strictEqual(target, targetWorld) + strictEqual(source, sourceWorld) + order.push('crossworld') + }) + + vi.stubGlobal('performance', { + now: () => 0 + }) + vi.stubGlobal('requestAnimationFrame', (callback) => { + frames.push(callback) + return 0 + }) + + defaultRunner(scheduler, worlds) + + const frame = frames.shift() + + if (!frame) { + throw new Error('The animation frame callback was not scheduled.') + } + + frame(0) + + deepStrictEqual(order, ['crossworld', 'normal']) + }) +}) diff --git a/packages/schedule/src/core/crossworldschedule.js b/packages/schedule/src/core/crossworldschedule.js new file mode 100644 index 00000000..df0c8454 --- /dev/null +++ b/packages/schedule/src/core/crossworldschedule.js @@ -0,0 +1,71 @@ +/** @import { CrossWorldSystemFunc } from './crossworldsystem' */ +/** @import { SystemId } from '@wimaengine/ecs' */ +/** @import { World } from '@wimaengine/ecs' */ + +import { Bitset } from '@wimaengine/datastructures' +import { World as EcsWorld } from '@wimaengine/ecs' + +/** + * Stores a collection of crossworld systems which are in order. + * + * Crossworld systems receive both the target world and the source world. + */ +export class CrossWorldSchedule { + + /** + * @private + * @type {CrossWorldSystemFunc[]} + */ + systems = [] + + /** + * @private + * @type {Bitset} + */ + condition = new Bitset() + + /** + * @param {CrossWorldSystemFunc} system + * @returns {SystemId} + */ + add(system) { + const { length } = this.systems + + this.systems.push(system) + this.condition.resize(length + 1) + this.condition.set(length) + + return length + } + + /** + * @param {World} targetWorld + * @param {World} sourceWorld + * @param {(error: Error, world: EcsWorld) => void} [errorHandler] + */ + run(targetWorld, sourceWorld, errorHandler) { + const handler = errorHandler ?? defaultErrorHandler + + for (let i = 0; i < this.systems.length; i++) { + try { + if (this.condition.get(i)) this.systems[i](targetWorld, sourceWorld) + } catch(error) { + if (error instanceof Error) { + handler(error, targetWorld) + } else if (typeof error === 'string') { + handler(new Error(error), targetWorld) + } else { + handler(new Error(String(error)), targetWorld) + } + } + } + } +} + +/** + * @param {Error} error + * @throws {Error} + */ +function defaultErrorHandler(error) { + throw error +} diff --git a/packages/schedule/src/core/crossworldsystem.js b/packages/schedule/src/core/crossworldsystem.js new file mode 100644 index 00000000..3ac68b32 --- /dev/null +++ b/packages/schedule/src/core/crossworldsystem.js @@ -0,0 +1,9 @@ +/** @import { World } from '@wimaengine/ecs' */ + +/** + * @callback CrossWorldSystemFunc + * @param {World} targetWorld + * @param {World} sourceWorld + * @returns {void} + */ +export {} diff --git a/packages/schedule/src/core/executable.js b/packages/schedule/src/core/executable.js index 8f9f6122..c7ccdde1 100644 --- a/packages/schedule/src/core/executable.js +++ b/packages/schedule/src/core/executable.js @@ -3,15 +3,21 @@ import { typeid } from '@wimaengine/type' import { Schedule } from './schedule' /** + * @template ScheduleType + * @template {import('@wimaengine/type').Constructor | undefined} SourceWorldType * @typedef {{ * label: import('@wimaengine/type').Constructor, * repeat?: boolean, * delay?: number, * errorHandler?: (error: Error, world: World) => void, * defaultSystemGroup?: import('@wimaengine/type').Constructor, - * world: import('@wimaengine/type').Constructor + * world: import('@wimaengine/type').Constructor, + * sourceWorld: SourceWorldType, + * schedule: ScheduleType * }} ExecutableConfig - * + */ + +/** * @typedef {{ * label: import('@wimaengine/type').Constructor, * repeat?: boolean, @@ -26,6 +32,9 @@ import { Schedule } from './schedule' * This is the binding between a labeled {@link Schedule schedule} * and its runtime configuration. * + * @template ScheduleType + * @template {import('@wimaengine/type').Constructor | undefined} SourceWorldType + * * @example * ```ts * function helloWorld(){ @@ -57,9 +66,9 @@ export class Executable { /** * @readonly - * @type {Schedule} + * @type {ScheduleType} */ - schedule = new Schedule() + schedule /** * @readonly @@ -85,6 +94,12 @@ export class Executable { */ world + /** + * @readonly + * @type {SourceWorldType} + */ + sourceWorld + /** * @readonly * @type {((error: Error, world: World) => void) | undefined} @@ -92,7 +107,7 @@ export class Executable { errorHandler /** - * @param {ExecutableConfig} config + * @param {ExecutableConfig} config */ constructor(config) { this.label = config.label @@ -101,6 +116,8 @@ export class Executable { this.errorHandler = config.errorHandler this.defaultSystemGroup = config.defaultSystemGroup this.world = config.world + this.sourceWorld = config.sourceWorld + this.schedule = /** @type {ScheduleType} */ (config.schedule ?? new Schedule()) } /** diff --git a/packages/schedule/src/core/index.js b/packages/schedule/src/core/index.js index 1632e013..aae639e8 100644 --- a/packages/schedule/src/core/index.js +++ b/packages/schedule/src/core/index.js @@ -1,4 +1,6 @@ export * from './schedule' +export * from './crossworldsystem' +export * from './crossworldschedule' export * from './scheduler' export * from './executable' export * from './schedulerbuilder' diff --git a/packages/schedule/src/core/scheduler.js b/packages/schedule/src/core/scheduler.js index 703339fd..bc40943f 100644 --- a/packages/schedule/src/core/scheduler.js +++ b/packages/schedule/src/core/scheduler.js @@ -1,6 +1,8 @@ +/** @import { CrossWorldSchedule } from './crossworldschedule' */ +/** @import { Schedule } from './schedule' */ +/** @import { Constructor } from '@wimaengine/type' */ import { typeid } from '@wimaengine/type' import { Executable } from './executable' -import { Schedule } from './schedule' /** * Stores labeled {@link Executable executables}. @@ -20,17 +22,29 @@ import { Schedule } from './schedule' export class Scheduler { /** - * @type {Map} + * @type {Map>} */ executables = new Map() /** - * @param {Executable} executable + * @type {Map>} + */ + crossWorldExecutables = new Map() + + /** + * @param {Executable} executable */ set(executable) { this.executables.set(typeid(executable.label), executable) } + /** + * @param {Executable} executable + */ + setCrossWorld(executable) { + this.crossWorldExecutables.set(typeid(executable.label), executable) + } + /** * @param {import('@wimaengine/type').Constructor} label * @returns {Schedule | undefined} @@ -40,9 +54,24 @@ export class Scheduler { } /** - * @returns {IterableIterator} + * @param {import('@wimaengine/type').Constructor} label + * @returns {CrossWorldSchedule | undefined} + */ + getCrossWorld(label) { + return this.crossWorldExecutables.get(typeid(label))?.schedule + } + + /** + * @returns {IterableIterator>} */ values() { return this.executables.values() } + + /** + * @returns {IterableIterator>} + */ + crossWorldValues() { + return this.crossWorldExecutables.values() + } } diff --git a/packages/schedule/src/core/schedulerbuilder.js b/packages/schedule/src/core/schedulerbuilder.js index 80a1de66..71cfb84f 100644 --- a/packages/schedule/src/core/schedulerbuilder.js +++ b/packages/schedule/src/core/schedulerbuilder.js @@ -1,4 +1,4 @@ -/** @import { SystemConfig, SystemGroupConfig } from './systemconfig' */ +/** @import { CrossWorldScheduleConfig, CrossWorldSystemConfig, CrossWorldSystemOrderReference, SystemConfig, SystemGroupConfig } from './systemconfig' */ /** @import { ExecutableConfig, ScheduleConfig } from './executable' */ /** @import { Scheduler } from './scheduler' */ /** @import { SystemFunc } from '@wimaengine/ecs' */ @@ -6,7 +6,9 @@ import { assert, throws } from '@wimaengine/logger' import { typeid } from '@wimaengine/type' import { Graph, kahnTopologySort } from 'vifaa' +import { CrossWorldSchedule } from './crossworldschedule' import { Executable } from './executable' +import { Schedule } from './schedule' export class ScheduleContext { @@ -467,6 +469,201 @@ export class ScheduleContext { } } +/** + * Crossworld schedules keep the same ordering semantics as normal schedules + * but they do not use system groups. + */ +export class CrossWorldScheduleContext { + + /** + * @param {Constructor} label + */ + constructor(label) { + this.label = label + } + + /** + * @type {Constructor} + */ + label + + /** + * @type {CrossWorldSystemRegistration[]} + */ + systems = [] + + /** + * @type {Map} + */ + nodesByLabel = new Map() + + /** + * @param {CrossWorldSystemConfig} config + */ + addSystem(config) { + const systemLabel = config.label || config.system.name + + const system = { + id: this.systems.length, + config + } + + this.systems.push(system) + + if (systemLabel !== '') { + const existing = this.nodesByLabel.get(systemLabel) + + if (existing) { + throws(`Duplicate system label "${systemLabel}" on schedule "${config.schedule.name}". Use a unique label or direct function references in ordering.`) + } + + this.nodesByLabel.set(systemLabel, { kind: ScheduleNodeKind.System, id: system.id }) + } + + return system + } + + /** + * @returns {CrossWorldSystemRegistration[]} + */ + sortSystems() { + const { graph, systemsByGraphId } = this.expandScheduleGraph() + const sorted = kahnTopologySort(graph) + + if (!sorted) { + throws(`Schedule "${this.label.name}" contains cyclic system ordering constraints.`) + } + + /** @type {CrossWorldSystemRegistration[]} */ + const ordered = [] + + for (let i = 0; i < sorted.length; i++) { + const system = systemsByGraphId.get(sorted[i]) + + if (!system) continue + + ordered.push(system) + } + + return ordered + } + + /** + * @returns {{ graph: Graph, systemsByGraphId: Map }} + */ + expandScheduleGraph() { + const graph = /** @type {Graph} */ (new Graph(true)) + + /** @type {Map} */ + const graphIdsBySystemId = new Map() + + /** @type {Map} */ + const systemsByGraphId = new Map() + + /** @type {Set} */ + const edges = new Set() + + for (let i = 0; i < this.systems.length; i++) { + const system = this.systems[i] + const graphId = graph.addNode(system) + + graphIdsBySystemId.set(system.id, graphId) + systemsByGraphId.set(graphId, system) + } + + for (let i = 0; i < this.systems.length; i++) { + const system = this.systems[i] + + this.addNodeOrdering(graph, graphIdsBySystemId, edges, { + kind: ScheduleNodeKind.System, + id: system.id + }, system.config.before, system.config.after) + } + + return { + graph, + systemsByGraphId + } + } + + /** + * @param {Graph} graph + * @param {Map} graphIdsBySystemId + * @param {Set} edges + * @param {ScheduleNodeRef} source + * @param {CrossWorldSystemOrderReference[] | undefined} before + * @param {CrossWorldSystemOrderReference[] | undefined} after + */ + addNodeOrdering(graph, graphIdsBySystemId, edges, source, before, after) { + if (before) { + for (let i = 0; i < before.length; i++) { + const label = describeReference(before[i]) + + this.addExpandedEdge(graph, graphIdsBySystemId, edges, source, this.resolveNode(label), before[i]) + } + } + + if (after) { + for (let i = 0; i < after.length; i++) { + const label = describeReference(after[i]) + + this.addExpandedEdge(graph, graphIdsBySystemId, edges, this.resolveNode(label), source, after[i]) + } + } + } + + /** + * @param {string} label + * @returns {ScheduleNodeRef} + */ + resolveNode(label) { + const node = this.nodesByLabel.get(label) + + if (!node) { + throws(`Could not resolve the system label "${label}" on schedule "${this.label.name}".`) + } + + return node + } + + /** + * @param {Graph} graph + * @param {Map} graphIdsBySystemId + * @param {Set} edges + * @param {ScheduleNodeRef} from + * @param {ScheduleNodeRef} to + * @param {CrossWorldSystemOrderReference} targetLabel + */ + addExpandedEdge(graph, graphIdsBySystemId, edges, from, to, targetLabel) { + const fromNodeId = this.expandNodeToOrderingNode(from, graphIdsBySystemId) + const toNodeId = this.expandNodeToOrderingNode(to, graphIdsBySystemId) + + if (fromNodeId === toNodeId) { + throws(`The reference "${describeReference(targetLabel)}" creates a self-referential system ordering on schedule "${this.label.name}".`) + } + + const key = `${fromNodeId}:${toNodeId}` + + if (edges.has(key)) return + + edges.add(key) + graph.addEdge(fromNodeId, toNodeId, undefined) + } + + /** + * @param {ScheduleNodeRef} node + * @param {Map} graphIdsBySystemId + * @returns {number} + */ + expandNodeToOrderingNode(node, graphIdsBySystemId) { + const graphId = graphIdsBySystemId.get(node.id) + + assert(graphId, `Internal error: Could not resolve graph node for system ${node.id} on schedule "${this.label.name}".`) + + return graphId + } +} + export class SchedulerBuilder { /** @@ -475,18 +672,36 @@ export class SchedulerBuilder { */ schedules = new Map() + /** + * @private + * @type {Map} + */ + crossWorldSchedules = new Map() + /** * @private * @type {ScheduleConfig[]} */ scheduleConfigs = [] + /** + * @private + * @type {CrossWorldScheduleConfig[]} + */ + crossWorldScheduleConfigs = [] + /** * @private * @type {SystemConfig[]} */ systems = [] + /** + * @private + * @type {CrossWorldSystemConfig[]} + */ + crossWorldSystems = [] + /** * @private * @type {SystemGroupConfig[]} @@ -498,9 +713,12 @@ export class SchedulerBuilder { */ clear() { this.scheduleConfigs = [] + this.crossWorldScheduleConfigs = [] this.systems = [] + this.crossWorldSystems = [] this.systemGroups = [] this.schedules = new Map() + this.crossWorldSchedules = new Map() return this } @@ -512,6 +730,13 @@ export class SchedulerBuilder { this.scheduleConfigs.push(config) } + /** + * @param {CrossWorldScheduleConfig} config + */ + addCrossWorldSchedule(config) { + this.crossWorldScheduleConfigs.push(config) + } + /** * @param {SystemConfig} config */ @@ -519,6 +744,13 @@ export class SchedulerBuilder { this.systems.push(config) } + /** + * @param {CrossWorldSystemConfig} config + */ + addCrossworldSystem(config) { + this.crossWorldSystems.push(config) + } + /** * @param {SystemGroupConfig} config */ @@ -537,12 +769,29 @@ export class SchedulerBuilder { assert(world, `The world for schedule "${config.label.name}" is not set.`) - const executableConfig = /** @type {ExecutableConfig} */ ({ + const executableConfig = /** @type {ExecutableConfig} */ ({ + ...config, + world, + schedule: new Schedule() + }) + + scheduler.set(/** @type {Executable} */ (new Executable(executableConfig))) + } + + for (let i = 0; i < this.crossWorldScheduleConfigs.length; i++) { + const config = this.crossWorldScheduleConfigs[i] + const world = config.world || defaultWorld + + assert(world, `The world for crossworld schedule "${config.label.name}" is not set.`) + assert(config.sourceWorld, `The source world for crossworld schedule "${config.label.name}" is not set.`) + + const executableConfig = /** @type {ExecutableConfig} */ ({ ...config, - world + world, + schedule: new CrossWorldSchedule() }) - scheduler.set(new Executable(executableConfig)) + scheduler.setCrossWorld(/** @type {Executable} */ (new Executable(executableConfig))) } /** @type {Map} */ @@ -565,6 +814,18 @@ export class SchedulerBuilder { schedule.add(system.config.system) } } + + const crossWorldSchedules = this.createCrossWorldScheduleContexts() + + for (const [, context] of crossWorldSchedules) { + const schedule = scheduler.getCrossWorld(context.label) + + assert(schedule, `The crossworld schedule label "${context.label.name}" is not set in the provided \`Scheduler\`.`) + + for (const system of context.sortSystems()) { + schedule.add(system.config.system) + } + } } /** @@ -596,6 +857,21 @@ export class SchedulerBuilder { return this.schedules } + /** + * @private + * @returns {Map} + */ + createCrossWorldScheduleContexts() { + for (let i = 0; i < this.crossWorldSystems.length; i++) { + const config = this.crossWorldSystems[i] + const context = getOrCreateCrossWorldScheduleContext(this.crossWorldSchedules, config.schedule) + + context.addSystem(config) + } + + return this.crossWorldSchedules + } + static Instance = new SchedulerBuilder() } @@ -618,7 +894,25 @@ function getOrCreateScheduleContext(schedules, label) { } /** - * @param {SystemFunc | Constructor | string} reference + * @param {Map} schedules + * @param {Constructor} label + * @returns {CrossWorldScheduleContext} + */ +function getOrCreateCrossWorldScheduleContext(schedules, label) { + const scheduleTypeId = typeid(label) + const existing = schedules.get(scheduleTypeId) + + if (existing) return existing + + const created = new CrossWorldScheduleContext(label) + + schedules.set(scheduleTypeId, created) + + return created +} + +/** + * @param {Function | Constructor | string} reference */ function describeReference(reference) { if (typeof reference === 'string') return reference @@ -646,6 +940,12 @@ const ScheduleNodeKind = Object.freeze({ * @property {number[]} systems */ +/** + * @typedef CrossWorldSystemRegistration + * @property {number} id + * @property {CrossWorldSystemConfig} config + */ + /** * @typedef ScheduleNodeRef * @property {ScheduleNodeKind} kind diff --git a/packages/schedule/src/core/systemconfig.js b/packages/schedule/src/core/systemconfig.js index 5d8c3756..435aabd8 100644 --- a/packages/schedule/src/core/systemconfig.js +++ b/packages/schedule/src/core/systemconfig.js @@ -1,5 +1,6 @@ /** @import { SystemFunc } from "@wimaengine/ecs" */ /** @import { Constructor } from "@wimaengine/type" */ +/** @import { CrossWorldSystemFunc } from "./crossworldsystem" */ /** * @typedef {SystemFunc | Constructor | string} SystemOrderReference @@ -24,4 +25,27 @@ * @property {SystemOrderReference[] | undefined} [after] */ +/** + * @typedef {CrossWorldSystemFunc | Constructor | string} CrossWorldSystemOrderReference + */ + +/** + * @typedef CrossWorldSystemConfig + * @property {CrossWorldSystemFunc} system + * @property {Constructor} schedule + * @property {string | undefined} [label] + * @property {CrossWorldSystemOrderReference[] | undefined} [before] + * @property {CrossWorldSystemOrderReference[] | undefined} [after] + */ + +/** + * @typedef CrossWorldScheduleConfig + * @property {Constructor} label + * @property {Constructor | undefined} [world] + * @property {Constructor} sourceWorld + * @property {number | undefined} [delay] + * @property {boolean | undefined} [repeat] + * @property {(error: Error, world: import("@wimaengine/ecs").World) => void | undefined} [errorHandler] + */ + export {}