diff --git a/packages/core/.nvmrc b/packages/core/.nvmrc deleted file mode 100644 index 4aa0e0a7..00000000 --- a/packages/core/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v26 diff --git a/packages/core/LICENSE b/packages/core/LICENSE index 62c6400b..46724861 100644 --- a/packages/core/LICENSE +++ b/packages/core/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright © 2025 NanoForge +Copyright © 2026 NanoForge Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/core/README.md b/packages/core/README.md index c6579dfe..0833b3b1 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -19,7 +19,7 @@ ## Installation -**Node.js 25 or newer is required.** +**Node.js 26 or newer is required.** ```sh npm install @nanoforge-dev/core diff --git a/packages/core/package.json b/packages/core/package.json index efd95ea4..b8003f3f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -13,8 +13,9 @@ "license": "MIT", "contributors": [ "Bill ", - "Exelo ", + "Exelo ", "Fexkoser ", + "Josephine ", "Tchips " ], "files": [ @@ -57,11 +58,8 @@ "docs": "mint-tsdocs generate" }, "dependencies": { - "@nanoforge-dev/asset-manager": "workspace:*", - "@nanoforge-dev/common": "workspace:*", - "@nanoforge-dev/input": "workspace:*", - "class-transformer": "catalog:config", - "class-validator": "catalog:config" + "@nanoforge-dev/asset": "workspace:*", + "@nanoforge-dev/common": "workspace:*" }, "devDependencies": { "@favware/cliff-jumper": "catalog:ci", @@ -75,7 +73,7 @@ "unrun": "catalog:build", "vitest": "catalog:test" }, - "packageManager": "pnpm@11.24.0", + "packageManager": "pnpm@12.0.0", "engines": { "node": "26" }, diff --git a/packages/core/src/application/application-config.ts b/packages/core/src/application/application-config.ts deleted file mode 100644 index 7eeede0c..00000000 --- a/packages/core/src/application/application-config.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - type IAssetManagerLibrary, - type IComponentSystemLibrary, - type IGraphicsLibrary, - type IInputLibrary, - type ILibrary, - type IMusicLibrary, - type INetworkLibrary, - type ISoundLibrary, - type LibraryHandle, -} from "@nanoforge-dev/common"; - -import { EditableLibraryManager } from "../common/library/manager/library.manager"; - -export class ApplicationConfig { - private readonly _libraryManager: EditableLibraryManager; - - constructor() { - this._libraryManager = new EditableLibraryManager(); - } - - get libraryManager(): EditableLibraryManager { - return this._libraryManager; - } - - public getLibrary(sym: symbol): LibraryHandle { - return this._libraryManager.get(sym); - } - - public useLibrary(sym: symbol, library: ILibrary): void { - this._libraryManager.set(sym, library); - } - - public getComponentSystemLibrary() { - return this._libraryManager.getComponentSystem(); - } - - public useComponentSystemLibrary(library: IComponentSystemLibrary) { - this._libraryManager.setComponentSystem(library); - } - - public getGraphicsLibrary() { - return this._libraryManager.getGraphics(); - } - - public useGraphicsLibrary(library: IGraphicsLibrary) { - this._libraryManager.setGraphics(library); - } - - public getNetworkLibrary() { - return this._libraryManager.getNetwork(); - } - - public useNetworkLibrary(library: INetworkLibrary) { - this._libraryManager.setNetwork(library); - } - - public getAssetManagerLibrary() { - return this._libraryManager.getAssetManager(); - } - - public useAssetManagerLibrary(library: IAssetManagerLibrary) { - this._libraryManager.setAssetManager(library); - } - - public getInputLibrary() { - return this._libraryManager.getInput(); - } - - public useInputLibrary(library: IInputLibrary) { - this._libraryManager.setInput(library); - } - - public getSoundLibrary() { - return this._libraryManager.getSound(); - } - - public useSoundLibrary(library: ISoundLibrary) { - this._libraryManager.setSound(library); - } - - public getMusicLibrary() { - return this._libraryManager.getMusic(); - } - - public useMusicLibrary(library: IMusicLibrary) { - this._libraryManager.setMusic(library); - } -} diff --git a/packages/core/src/application/application-options.type.ts b/packages/core/src/application/application-options.type.ts index 071d99ab..c3f4ab06 100644 --- a/packages/core/src/application/application-options.type.ts +++ b/packages/core/src/application/application-options.type.ts @@ -2,7 +2,7 @@ * Options accepted by `NanoforgeFactory.createClient` and * `NanoforgeFactory.createServer`. */ -export interface IApplicationOptions { +export interface ApplicationOptions { /** * Target game-loop frequency in ticks per second. * @@ -10,3 +10,7 @@ export interface IApplicationOptions { */ tickRate: number; } + +export const DEFAULT_APPLICATION_OPTIONS: ApplicationOptions = { + tickRate: 60, +}; diff --git a/packages/core/src/application/nanoforge-application.ts b/packages/core/src/application/nanoforge-application.ts index 0c650f45..96eb9d21 100644 --- a/packages/core/src/application/nanoforge-application.ts +++ b/packages/core/src/application/nanoforge-application.ts @@ -1,16 +1,17 @@ +import { AssetLibrary } from "@nanoforge-dev/asset"; import { - type IAssetManagerLibrary, - type IComponentSystemLibrary, - type ILibrary, - type INetworkLibrary, - type IRunOptions, + type ClientRunOptions, + type Context, + type InitContext, + type Library, NfNotInitializedException, + type RunOptions, } from "@nanoforge-dev/common"; -import { EditableApplicationContext } from "../common/context/contexts/application.editable-context"; -import { Core } from "../core/core"; -import { ApplicationConfig } from "./application-config"; -import type { IApplicationOptions } from "./application-options.type"; +import { InternalAppState } from "../internal/internal-app-state"; +import { InternalVarsState } from "../internal/internal-vars-state"; +import { LibraryRegistry } from "../library-registry/library-registry"; +import { type ApplicationOptions, DEFAULT_APPLICATION_OPTIONS } from "./application-options.type"; /** * Base class for client and server NanoForge applications. @@ -30,81 +31,34 @@ import type { IApplicationOptions } from "./application-options.type"; * ``` */ export abstract class NanoforgeApplication { - protected applicationConfig: ApplicationConfig; - private _core?: Core; - private readonly _options: IApplicationOptions; + private readonly registry = new LibraryRegistry(); + private readonly appState: InternalAppState; + private readonly varsState = new InternalVarsState(); + private readonly options: ApplicationOptions; + + private context?: Context; /** * @param options - Optional application-level settings such as tickRate. */ - constructor(options?: Partial) { - this.applicationConfig = new ApplicationConfig(); - - this._options = { - tickRate: 60, - ...(options ?? {}), - }; + constructor(options?: Partial) { + this.options = { ...DEFAULT_APPLICATION_OPTIONS, ...options }; + this.appState = new InternalAppState(this.options.tickRate); + this.registry.registerBuiltin(new AssetLibrary()); } /** - * Register a library under a custom symbol. - * - * @remarks - * Use this method for libraries that do not have a dedicated shorthand (e.g. - * game-specific custom libraries). For built-in library types prefer the - * typed helpers such as `useAssetManager`, `useNetwork`, etc. + * Registers a library. Single argument — the library owns its own + * context key (see `defineLibraryKey`). * - * @param sym - Unique symbol identifying the library slot. * @param library - Library instance to register. - */ - public use(sym: symbol, library: ILibrary): void { - this.applicationConfig.useLibrary(sym, library); - } - - /** - * Register the component-system (ECS) library. - * - * @param library - ECS library instance (e.g. ECSClientLibrary or ECSServerLibrary). - */ - public useComponentSystem(library: IComponentSystemLibrary) { - this.applicationConfig.useComponentSystemLibrary(library); - } - - /** - * Register the network library. - * - * @param library - Network library instance (e.g. NetworkClientLibrary or NetworkServerLibrary). - */ - public useNetwork(library: INetworkLibrary) { - this.applicationConfig.useNetworkLibrary(library); - } - - /** - * Register the asset-manager library. - * - * @param library - Asset manager instance (e.g. AssetManagerLibrary). - */ - public useAssetManager(library: IAssetManagerLibrary) { - this.applicationConfig.useAssetManagerLibrary(library); - } - - /** - * Initialise all registered libraries in dependency order and prepare the - * engine for the game loop. - * - * @remarks - * Must be called before `run`. Resolves once every library's `__init` - * hook has completed. * - * @param options - Run options providing the canvas container, files map, and - * environment variables. + * @throws {@link NfDuplicateLibraryException} If the key is already + * registered or reserved (`"app"`, `"vars"`, `"assets"`). */ - public init(options: IRunOptions): Promise { - this._core = new Core( - this.applicationConfig, - new EditableApplicationContext(this.applicationConfig.libraryManager), - ); - return this._core.init(options, this._options); + public use(library: L): void { + if (this.context) throw new Error("Cannot register libraries after init() has been called."); + this.registry.register(library); } /** @@ -115,8 +69,49 @@ export abstract class NanoforgeApplication { * * @throws `NfNotInitializedException` When called before `init`. */ - public run() { - if (!this._core) throw new NfNotInitializedException("Core"); - return this._core?.run(); + public async run(): Promise { + if (!this.context) throw new NfNotInitializedException("NanoforgeApplication"); + const context = this.context; + const orderedForRun = this.registry.getOrderedForRun(); + + const tickLengthMs = 1000 / this.options.tickRate; + let previousTick = Date.now(); + + const loop = async (): Promise => { + if (!context.app.isRunning) { + for (const library of orderedForRun) await library.__clear(context); + return; + } + + const tickStart = Date.now(); + + for (const library of orderedForRun) await library.__events(context); + + if (context.app.isPaused) { + previousTick = tickStart; + } else { + this.appState.setDelta(tickStart - previousTick); + for (const library of orderedForRun) await library.__run(context); + previousTick = tickStart; + } + + setTimeout(loop, tickLengthMs + tickStart - Date.now()); + }; + + this.appState.setIsRunning(true); + setTimeout(loop); + } + + protected async initialize(options: RunOptions | ClientRunOptions): Promise { + const initContext: InitContext = { ...options, vars: this.varsState.asVarsContext() }; + + for (const library of this.registry.getOrderedForInit()) { + await library.__init(initContext); + } + + this.context = this.registry.buildContext( + this.appState.asAppContext(), + this.varsState.asVarsContext(), + ); } } diff --git a/packages/core/src/application/nanoforge-client.ts b/packages/core/src/application/nanoforge-client.ts index 684e132f..bc86f454 100644 --- a/packages/core/src/application/nanoforge-client.ts +++ b/packages/core/src/application/nanoforge-client.ts @@ -1,8 +1,4 @@ -import { - type IGraphicsLibrary, - type IInputLibrary, - type ISoundLibrary, -} from "@nanoforge-dev/common"; +import type { ClientRunOptions } from "@nanoforge-dev/common"; import { NanoforgeApplication } from "./nanoforge-application"; @@ -17,39 +13,26 @@ import { NanoforgeApplication } from "./nanoforge-application"; * @example * ```ts * const client = NanoforgeFactory.createClient(); - * client.useAssetManager(new AssetManagerLibrary()); - * client.useGraphics(new Graphics2DLibrary()); - * client.useInput(new InputLibrary()); - * client.useSound(new SoundLibrary()); + * client.use(new Graphics2DLibrary()); + * client.use(new InputLibrary()); + * client.use(new SoundLibrary()); * await client.init(`container, files, env `); * client.run(); * ``` */ export class NanoforgeClient extends NanoforgeApplication { /** - * Register the graphics library used to render the game. + * Initialise all registered libraries in dependency order and prepare the + * engine for the game loop. * - * @param library - Graphics library instance (e.g. Graphics2DLibrary). - */ - public useGraphics(library: IGraphicsLibrary) { - this.applicationConfig.useGraphicsLibrary(library); - } - - /** - * Register the input library used to read keyboard and mouse state. - * - * @param library - Input library instance (e.g. InputLibrary). - */ - public useInput(library: IInputLibrary) { - this.applicationConfig.useInputLibrary(library); - } - - /** - * Register the sound-effect library. + * @remarks + * Must be called before `run`. Resolves once every library's `__init` + * hook has completed. * - * @param library - Sound library instance (e.g. SoundLibrary). + * @param options - Run options providing the canvas container, files map, and + * environment variables. */ - public useSound(library: ISoundLibrary) { - this.applicationConfig.useSoundLibrary(library); + public async init(options: ClientRunOptions): Promise { + await this.initialize(options); } } diff --git a/packages/core/src/application/nanoforge-factory.ts b/packages/core/src/application/nanoforge-factory.ts index 1c275243..50aadb1f 100644 --- a/packages/core/src/application/nanoforge-factory.ts +++ b/packages/core/src/application/nanoforge-factory.ts @@ -1,4 +1,4 @@ -import { type IApplicationOptions } from "./application-options.type"; +import type { ApplicationOptions } from "./application-options.type"; import { NanoforgeClient } from "./nanoforge-client"; import { NanoforgeServer } from "./nanoforge-server"; @@ -8,8 +8,7 @@ class NanoforgeFactoryStatic { * * @remarks * Returns a `NanoforgeClient` on which you can call - * `useGraphics`, `useInput`, `useSound`, `useAssetManager`, etc. before - * calling `init` and `run`. + * `use` before calling `init` and `run`. * * @param options - Optional application settings (e.g. tickRate). * @returns A pre-configured `NanoforgeClient` instance. @@ -19,7 +18,7 @@ class NanoforgeFactoryStatic { * const client = NanoforgeFactory.createClient(`tickRate: 60 `); * ``` */ - createClient(options?: Partial): NanoforgeClient { + createClient(options?: Partial): NanoforgeClient { return new NanoforgeClient(options); } @@ -28,8 +27,7 @@ class NanoforgeFactoryStatic { * * @remarks * Returns a `NanoforgeServer` on which you can call - * `useNetwork`, `useAssetManager`, `useComponentSystem`, etc. before calling - * `init` and `run`. + * `use` before calling `init` and `run`. * * @param options - Optional application settings (e.g. tickRate). * @returns A pre-configured `NanoforgeServer` instance. @@ -39,7 +37,7 @@ class NanoforgeFactoryStatic { * const server = NanoforgeFactory.createServer(`tickRate: 20 `); * ``` */ - createServer(options?: Partial): NanoforgeServer { + createServer(options?: Partial): NanoforgeServer { return new NanoforgeServer(options); } } @@ -54,8 +52,6 @@ class NanoforgeFactoryStatic { * * @example * ```ts - * import `NanoforgeFactory ` from "@nanoforge-dev/core"; - * * const client = NanoforgeFactory.createClient(); * ``` */ diff --git a/packages/core/src/application/nanoforge-server.ts b/packages/core/src/application/nanoforge-server.ts index 5612515b..fbac5f97 100644 --- a/packages/core/src/application/nanoforge-server.ts +++ b/packages/core/src/application/nanoforge-server.ts @@ -1,3 +1,5 @@ +import type { RunOptions } from "@nanoforge-dev/common"; + import { NanoforgeApplication } from "./nanoforge-application"; /** @@ -10,11 +12,25 @@ import { NanoforgeApplication } from "./nanoforge-application"; * @example * ```ts * const server = NanoforgeFactory.createServer(); - * server.useAssetManager(new AssetManagerLibrary()); - * server.useNetwork(new NetworkServerLibrary()); - * server.useComponentSystem(new ECSServerLibrary()); + * server.use(new NetworkServerLibrary()); + * server.use(new EcsLibrary()); * await server.init(`files, env `); * server.run(); * ``` */ -export class NanoforgeServer extends NanoforgeApplication {} +export class NanoforgeServer extends NanoforgeApplication { + /** + * Initialise all registered libraries in dependency order and prepare the + * engine for the game loop. + * + * @remarks + * Must be called before `run`. Resolves once every library's `__init` + * hook has completed. + * + * @param options - Run options providing the canvas container, files map, and + * environment variables. + */ + public async init(options: RunOptions): Promise { + await this.initialize(options); + } +} diff --git a/packages/core/src/common/context/contexts/application.editable-context.ts b/packages/core/src/common/context/contexts/application.editable-context.ts deleted file mode 100644 index 492b797e..00000000 --- a/packages/core/src/common/context/contexts/application.editable-context.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ApplicationContext } from "@nanoforge-dev/common"; - -import { type EditableLibraryManager } from "../../library/manager/library.manager"; - -export class EditableApplicationContext extends ApplicationContext { - private _libraryManager: EditableLibraryManager; - - constructor(libraryManager: EditableLibraryManager) { - super(); - this._libraryManager = libraryManager; - } - - setDelta(delta: number) { - this._delta = delta; - } - - muteSoundLibraries(): void { - this._libraryManager.getMutableLibraries().forEach((lib) => lib.library.mute()); - } -} diff --git a/packages/core/src/common/context/contexts/executions/clear.editable-context.ts b/packages/core/src/common/context/contexts/executions/clear.editable-context.ts deleted file mode 100644 index 1081686d..00000000 --- a/packages/core/src/common/context/contexts/executions/clear.editable-context.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ClearContext } from "@nanoforge-dev/common"; - -export class EditableClearContext extends ClearContext {} diff --git a/packages/core/src/common/context/contexts/executions/execution.editable-context.ts b/packages/core/src/common/context/contexts/executions/execution.editable-context.ts deleted file mode 100644 index e9b5b3de..00000000 --- a/packages/core/src/common/context/contexts/executions/execution.editable-context.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ExecutionContext } from "@nanoforge-dev/common"; - -export class EditableExecutionContext extends ExecutionContext {} diff --git a/packages/core/src/common/context/contexts/executions/init.editable-context.ts b/packages/core/src/common/context/contexts/executions/init.editable-context.ts deleted file mode 100644 index 7ce44e10..00000000 --- a/packages/core/src/common/context/contexts/executions/init.editable-context.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { InitContext } from "@nanoforge-dev/common"; - -export class EditableInitContext extends InitContext {} diff --git a/packages/core/src/common/context/contexts/library.editable-context.ts b/packages/core/src/common/context/contexts/library.editable-context.ts deleted file mode 100644 index 48f942e8..00000000 --- a/packages/core/src/common/context/contexts/library.editable-context.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { LibraryContext, type LibraryStatusEnum } from "@nanoforge-dev/common"; - -export class EditableLibraryContext extends LibraryContext { - setStatus(status: LibraryStatusEnum) { - this._status = status; - } -} diff --git a/packages/core/src/common/library/manager/library.manager.ts b/packages/core/src/common/library/manager/library.manager.ts deleted file mode 100644 index 6e17b321..00000000 --- a/packages/core/src/common/library/manager/library.manager.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { - ASSET_MANAGER_LIBRARY, - COMPONENT_SYSTEM_LIBRARY, - DefaultLibrariesEnum, - GRAPHICS_LIBRARY, - type IAssetManagerLibrary, - type IComponentSystemLibrary, - type IGraphicsLibrary, - type IInputLibrary, - type ILibrary, - type IMusicLibrary, - type IMutableLibrary, - INPUT_LIBRARY, - type INetworkLibrary, - type IRunnerLibrary, - type ISoundLibrary, - type LibraryHandle, - LibraryManager, - MUSIC_LIBRARY, - NETWORK_LIBRARY, - SOUND_LIBRARY, -} from "@nanoforge-dev/common"; - -import { EditableLibraryContext } from "../../context/contexts/library.editable-context"; -import { Relationship } from "../relationship-functions"; - -const hasMethod = (obj: any, method: string) => { - return typeof obj[method] === "function"; -}; - -export class EditableLibraryManager extends LibraryManager { - public set(sym: symbol, library: ILibrary) { - this.setNewLibrary(sym, library, new EditableLibraryContext()); - } - - public setComponentSystem(library: IComponentSystemLibrary): void { - this._set( - DefaultLibrariesEnum.COMPONENT_SYSTEM, - COMPONENT_SYSTEM_LIBRARY, - library, - new EditableLibraryContext(), - ); - } - - public setGraphics(library: IGraphicsLibrary): void { - this._set( - DefaultLibrariesEnum.GRAPHICS, - GRAPHICS_LIBRARY, - library, - new EditableLibraryContext(), - ); - } - - public setAssetManager(library: IAssetManagerLibrary): void { - this._set( - DefaultLibrariesEnum.ASSET_MANAGER, - ASSET_MANAGER_LIBRARY, - library, - new EditableLibraryContext(), - ); - } - - public setNetwork(library: INetworkLibrary): void { - this._set(DefaultLibrariesEnum.NETWORK, NETWORK_LIBRARY, library, new EditableLibraryContext()); - } - - public setInput(library: IInputLibrary): void { - this._set(DefaultLibrariesEnum.INPUT, INPUT_LIBRARY, library, new EditableLibraryContext()); - } - - public setSound(library: ISoundLibrary): void { - this._set(DefaultLibrariesEnum.SOUND, SOUND_LIBRARY, library, new EditableLibraryContext()); - } - - public setMusic(library: IMusicLibrary): void { - this._set(DefaultLibrariesEnum.MUSIC, MUSIC_LIBRARY, library, new EditableLibraryContext()); - } - - public getLibraries(): LibraryHandle[] { - return this._libraries; - } - - public getInitLibraries(): LibraryHandle[] { - return Relationship.getLibrariesByDependencies(this._libraries); - } - - public getExecutionLibraries(): LibraryHandle[] { - return Relationship.getLibrariesByRun(this._getRunnerLibraries()); - } - - public getClearLibraries(): LibraryHandle[] { - return Relationship.getLibrariesByDependencies(this._libraries, true); - } - - public getMutableLibraries(): LibraryHandle[] { - return this._libraries.filter( - (handle) => handle && hasMethod(handle.library, "mute"), - ) as LibraryHandle[]; - } - - private _getRunnerLibraries(): LibraryHandle[] { - return this._libraries.filter( - (handle) => handle && hasMethod(handle.library, "__run"), - ) as LibraryHandle[]; - } -} diff --git a/packages/core/src/common/library/relationship-functions.ts b/packages/core/src/common/library/relationship-functions.ts deleted file mode 100644 index d9ff0f2b..00000000 --- a/packages/core/src/common/library/relationship-functions.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { type ILibrary, type LibraryHandle } from "@nanoforge-dev/common"; - -class RelationshipStatic { - getLibrariesByDependencies(libraries: LibraryHandle[], reverse: boolean = false) { - let response: LibraryHandle[] = []; - for (const library of libraries) { - if (!library) continue; - response = this._pushLibraryWithDependencies(library, response, [], libraries); - } - - if (reverse) return response.reverse(); - return response; - } - - getLibrariesByRun(libraries: LibraryHandle[]) { - let response: LibraryHandle[] = []; - const dependencies = new Map>( - libraries.map((library) => [library.symbol, new Set()]), - ); - - for (const handle of libraries) { - const key = handle.symbol; - - for (const before of handle.library.__relationship.runBefore) { - this._pushToDependencies(key, before, dependencies); - } - for (const after of handle.library.__relationship.runAfter) { - this._pushToDependencies(after, key, dependencies); - } - } - - for (const library of libraries) { - response = this._pushLibraryWithDependenciesRun( - library, - dependencies, - response, - [], - libraries, - ); - } - return response; - } - - private _pushToDependencies( - key: symbol, - value: symbol, - dependencies: Map>, - ): void { - let curr = dependencies.get(key); - if (!curr) curr = new Set(); - curr.add(value); - dependencies.set(key, curr); - } - - private _pushLibraryWithDependenciesRun( - handle: LibraryHandle, - dependencies: Map>, - response: LibraryHandle[], - cache: symbol[], - libraries: LibraryHandle[], - ): LibraryHandle[] { - const key = handle.symbol; - if (this._symbolIsInList(key, response)) return response; - - if (cache.includes(key)) throw new Error("Circular dependencies !"); - - cache.push(key); - - const deps = dependencies.get(key); - if (!deps) throw new Error("Dependencies not found"); - - for (const dep of deps) { - if (this._symbolIsInList(dep, response)) continue; - - const depHandle = libraries.find((lib) => lib?.symbol === dep) as LibraryHandle; - if (!depHandle) throw new Error(`Cannot find library ${dep.toString()}`); - - response = this._pushLibraryWithDependenciesRun( - depHandle, - dependencies, - response, - cache, - libraries, - ); - } - cache.pop(); - - response.push(handle); - return response; - } - - private _pushLibraryWithDependencies( - handle: LibraryHandle, - response: LibraryHandle[], - cache: symbol[], - libraries: LibraryHandle[], - ): LibraryHandle[] { - if (this._symbolIsInList(handle.symbol, response)) return response; - - if (cache.includes(handle.symbol)) throw new Error("Circular dependencies !"); - - cache.push(handle.symbol); - for (const dep of handle.library.__relationship.dependencies) { - if (this._symbolIsInList(dep, response)) continue; - - const depHandle = libraries.find((lib) => lib?.symbol === dep) as LibraryHandle; - if (!depHandle) throw new Error(`Cannot find library ${dep.toString()}`); - - response = this._pushLibraryWithDependencies(depHandle, response, cache, libraries); - } - cache.pop(); - - response.push(handle); - return response; - } - - private _symbolIsInList(sym: symbol, libraries: LibraryHandle[]): boolean { - return libraries.some((lib) => lib.symbol === sym); - } -} - -export const Relationship = new RelationshipStatic(); diff --git a/packages/core/src/config/config-registry.ts b/packages/core/src/config/config-registry.ts deleted file mode 100644 index 946ed1dc..00000000 --- a/packages/core/src/config/config-registry.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { plainToInstance } from "class-transformer"; -import { validate } from "class-validator"; - -export class ConfigRegistry { - private readonly _env: Record; - - constructor(env: Record) { - this._env = env; - } - - async registerConfig(config: new () => T): Promise { - const data = plainToInstance(config, this._env, { excludeExtraneousValues: true }); - const errors = await validate(data); - if (errors.length > 0) { - throw new Error(errors.toString()); - } - return data; - } -} diff --git a/packages/core/src/core/core.ts b/packages/core/src/core/core.ts deleted file mode 100644 index 1badedcf..00000000 --- a/packages/core/src/core/core.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - ClearContext, - ClientLibraryManager, - Context, - type IRunOptions, - type IRunnerLibrary, - InitContext, - type LibraryHandle, - LibraryStatusEnum, - NfNotInitializedException, -} from "@nanoforge-dev/common"; - -import { type ApplicationConfig } from "../application/application-config"; -import type { IApplicationOptions } from "../application/application-options.type"; -import { type EditableApplicationContext } from "../common/context/contexts/application.editable-context"; -import { EditableExecutionContext } from "../common/context/contexts/executions/execution.editable-context"; -import { type EditableLibraryContext } from "../common/context/contexts/library.editable-context"; -import { ConfigRegistry } from "../config/config-registry"; - -export class Core { - private readonly config: ApplicationConfig; - private readonly context: EditableApplicationContext; - private options?: IApplicationOptions; - private _configRegistry?: ConfigRegistry; - - constructor(config: ApplicationConfig, context: EditableApplicationContext) { - this.config = config; - this.context = context; - } - - public async init(options: IRunOptions, appOptions: IApplicationOptions): Promise { - this.options = appOptions; - this._configRegistry = new ConfigRegistry(options.env); - await this.runInit(this.getInitContext(options)); - } - - public async run(): Promise { - if (!this.options) throw new NfNotInitializedException("Core"); - - const context = this.getExecutionContext(); - const clientContext = this.getClientContext(); - const libraries = this.config.libraryManager.getExecutionLibraries(); - - const runner = async (delta: number) => { - this.context.setDelta(delta); - await this.runExecute(clientContext, libraries); - }; - - const tickLengthMs = 1000 / this.options.tickRate; - let previousTick = Date.now(); - - const render = async () => { - if (!context.application.isRunning) { - await this.runClear(this.getClearContext()); - return; - } - const tickStart = Date.now(); - await runner(tickStart - previousTick); - previousTick = tickStart; - setTimeout(render, tickLengthMs + tickStart - Date.now()); - }; - - context.application.setIsRunning(true); - setTimeout(render); - } - - private getInitContext(options: IRunOptions): InitContext { - if (!this._configRegistry) throw new NfNotInitializedException("Core"); - - return new InitContext(this.context, this.config.libraryManager, this._configRegistry, options); - } - - private getExecutionContext(): EditableExecutionContext { - return new EditableExecutionContext(this.context, this.config.libraryManager); - } - - private getClearContext(): ClearContext { - return new ClearContext(this.context, this.config.libraryManager); - } - - private getClientContext(): Context { - return new Context(this.context, new ClientLibraryManager(this.config.libraryManager)); - } - - private async runInit(context: InitContext): Promise { - for (const handle of this.config.libraryManager.getInitLibraries()) { - await handle.library.__init(context); - (handle.context as EditableLibraryContext).setStatus(LibraryStatusEnum.LOADED); - } - } - - private async runExecute(context: Context, libraries: LibraryHandle[]) { - for (const handle of libraries) { - await handle.library.__run(context); - } - } - - private async runClear(context: ClearContext) { - for (const handle of this.config.libraryManager.getClearLibraries()) { - await handle.library.__clear(context); - (handle.context as EditableLibraryContext).setStatus(LibraryStatusEnum.CLEAR); - } - } -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4273d62e..c958ad63 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export * from "./application/nanoforge-factory"; - +export type { ApplicationOptions } from "./application/application-options.type"; export type { NanoforgeClient } from "./application/nanoforge-client"; +export { NanoforgeFactory } from "./application/nanoforge-factory"; export type { NanoforgeServer } from "./application/nanoforge-server"; diff --git a/packages/core/src/internal/internal-app-state.ts b/packages/core/src/internal/internal-app-state.ts new file mode 100644 index 00000000..d5fb3058 --- /dev/null +++ b/packages/core/src/internal/internal-app-state.ts @@ -0,0 +1,68 @@ +import type { AppContext } from "@nanoforge-dev/common"; + +/** + * The only place `app`'s state can be mutated. + * + * @remarks + * Never exported from this package. `asAppContext()` returns a view backed + * by live getters (not a snapshot), so `ctx.app.isRunning`/`.delta` reflect + * current state across ticks without `Context` being rebuilt every frame. + */ +export class InternalAppState { + private readonly _tickRate: number; + private _isRunning = false; + private _isPaused = false; + private _delta = 0; + + constructor(tickRate: number) { + this._tickRate = tickRate; + } + + get isRunning(): boolean { + return this._isRunning; + } + + get isPaused(): boolean { + return this._isPaused; + } + + setIsRunning(value: boolean): void { + this._isRunning = value; + } + + setIsPaused(value: boolean): void { + this._isPaused = value; + } + + setDelta(value: number): void { + this._delta = value; + } + + asAppContext(): AppContext { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const state = this; + return { + get isRunning() { + return state._isRunning; + }, + get isPaused() { + return state._isPaused; + }, + get delta() { + return state._delta; + }, + get tickRate() { + return state._tickRate; + }, + requestStop: () => { + state.setIsRunning(false); + }, + requestPause: () => { + state.setIsPaused(true); + }, + requestResume: () => { + state.setIsPaused(false); + }, + }; + } +} diff --git a/packages/core/src/internal/internal-vars-state.ts b/packages/core/src/internal/internal-vars-state.ts new file mode 100644 index 00000000..54682a4c --- /dev/null +++ b/packages/core/src/internal/internal-vars-state.ts @@ -0,0 +1,25 @@ +import type { VarsContext } from "@nanoforge-dev/common"; + +/** + * The only place `vars`' storage lives. + * + * @remarks + * Unlike `InternalAppState`, mutation (`set`) is intentionally public on + * the returned `VarsContext` — `vars` is the dev-editable half of `Context`. + */ +export class InternalVarsState { + private readonly map: Map; + + constructor(initial?: Record) { + this.map = new Map(Object.entries(initial ?? {})); + } + + asVarsContext(): VarsContext { + return { + get: (key: string) => this.map.get(key), + set: (key: string, value: unknown) => { + this.map.set(key, value); + }, + }; + } +} diff --git a/packages/core/src/library-registry/library-registry.ts b/packages/core/src/library-registry/library-registry.ts new file mode 100644 index 00000000..1be282a0 --- /dev/null +++ b/packages/core/src/library-registry/library-registry.ts @@ -0,0 +1,59 @@ +import { + type AppContext, + type Context, + type Library, + NfDuplicateLibraryException, + type VarsContext, +} from "@nanoforge-dev/common"; + +import { orderByDependencies, orderByRunSequence } from "./ordering"; + +const RESERVED_KEYS = new Set(["app", "vars", "assets"]); + +/** + * Owns every registered library, enforces key uniqueness, and assembles + * `Context` from their `expose()` results. + * + * @remarks + * Not exported from `@nanoforge-dev/core` — purely internal to + * `NanoforgeApplication`. + */ +export class LibraryRegistry { + private readonly libraries = new Map(); + + /** + * Registers the mandatory built-in asset library, bypassing the + * reserved-key check that applies to `.use()`. + */ + registerBuiltin(library: Library): void { + this.libraries.set(library.key, library); + } + + register(library: Library): void { + if (RESERVED_KEYS.has(library.key) || this.libraries.has(library.key)) { + throw new NfDuplicateLibraryException(library.key); + } + this.libraries.set(library.key, library); + } + + getAll(): Library[] { + return [...this.libraries.values()]; + } + + getOrderedForInit(): Library[] { + return orderByDependencies(this.getAll()); + } + + getOrderedForRun(): Library[] { + return orderByRunSequence(this.getAll()); + } + + buildContext(app: AppContext, vars: VarsContext): Context { + const ctx: Record = { app, vars }; + for (const library of this.getAll()) { + const exposed = library.expose(); + if (exposed !== undefined) ctx[library.key] = exposed; + } + return ctx as Context; + } +} diff --git a/packages/core/src/library-registry/ordering.ts b/packages/core/src/library-registry/ordering.ts new file mode 100644 index 00000000..f077f660 --- /dev/null +++ b/packages/core/src/library-registry/ordering.ts @@ -0,0 +1,55 @@ +import type { Library } from "@nanoforge-dev/common"; + +const topologicalOrder = ( + libraries: Library[], + getDependencyKeys: (lib: Library) => string[], +): Library[] => { + const byKey = new Map(libraries.map((lib) => [lib.key, lib])); + const ordered: Library[] = []; + const visited = new Set(); + const visiting: string[] = []; + + const visit = (lib: Library): void => { + if (visited.has(lib.key)) return; + if (visiting.includes(lib.key)) throw new Error("Circular dependencies!"); + + visiting.push(lib.key); + for (const depKey of getDependencyKeys(lib)) { + const dep = byKey.get(depKey); + if (!dep) throw new Error(`Cannot find library "${depKey}"`); + visit(dep); + } + visiting.pop(); + + visited.add(lib.key); + ordered.push(lib); + }; + + for (const lib of libraries) visit(lib); + return ordered; +}; + +/** Orders libraries so each one's `dependencies` come before it. */ +export const orderByDependencies = (libraries: Library[]): Library[] => + topologicalOrder(libraries, (lib) => lib.relationships.dependencies); + +/** + * Orders libraries for the tick loop: each library's `runBefore` entries + * come before it, and its `runAfter` entries come after it. + */ +export const orderByRunSequence = (libraries: Library[]): Library[] => { + const runDependencies = new Map>( + libraries.map((lib) => [lib.key, new Set()]), + ); + + for (const library of libraries) { + for (const before of library.relationships.runBefore) { + runDependencies.get(library.key)?.add(before); + } + for (const after of library.relationships.runAfter) { + runDependencies.get(after)?.add(library.key); + } + } + + return topologicalOrder(libraries, (lib) => [...(runDependencies.get(lib.key) ?? [])]); +}; diff --git a/packages/core/test/config-registry.spec.ts b/packages/core/test/config-registry.spec.ts deleted file mode 100644 index 0a1e2844..00000000 --- a/packages/core/test/config-registry.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Expose } from "class-transformer"; -import { IsString } from "class-validator"; -import { describe, expect, it } from "vitest"; - -import { ConfigRegistry } from "../src/config/config-registry"; - -class ValidConfig { - @Expose() - @IsString() - name!: string; -} - -class OptionalConfig { - @Expose() - @IsString() - name!: string; - - @Expose() - host?: string; -} - -describe("ConfigRegistry", () => { - describe("registerConfig", () => { - it("should return a transformed config instance when env is valid", async () => { - const registry = new ConfigRegistry({ name: "hello" }); - const config = await registry.registerConfig(ValidConfig); - expect(config).toBeInstanceOf(ValidConfig); - expect(config.name).toBe("hello"); - }); - - it("should exclude values not decorated with @Expose", async () => { - const registry = new ConfigRegistry({ name: "hello", extra: "ignored" }); - const config = await registry.registerConfig(ValidConfig); - expect((config as any)["extra"]).toBeUndefined(); - }); - - it("should throw when a required field is missing", async () => { - const registry = new ConfigRegistry({}); - await expect(registry.registerConfig(ValidConfig)).rejects.toThrow(); - }); - - it("should throw when a field has the wrong type", async () => { - const registry = new ConfigRegistry({ name: 42 }); - await expect(registry.registerConfig(ValidConfig)).rejects.toThrow(); - }); - - it("should map multiple env fields correctly", async () => { - const registry = new ConfigRegistry({ name: "world", host: "localhost" }); - const config = await registry.registerConfig(OptionalConfig); - expect(config.name).toBe("world"); - expect(config.host).toBe("localhost"); - }); - }); -}); diff --git a/packages/core/test/editable-library-manager.spec.ts b/packages/core/test/editable-library-manager.spec.ts deleted file mode 100644 index 0e808baa..00000000 --- a/packages/core/test/editable-library-manager.spec.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { COMPONENT_SYSTEM_LIBRARY, type ILibrary, LibraryStatusEnum } from "@nanoforge-dev/common"; -import { beforeEach, describe, expect, it } from "vitest"; - -import { Library } from "../../common/src/library/libraries/library"; -import { EditableLibraryManager } from "../src/common/library/manager/library.manager"; - -class StubLibrary extends Library { - private readonly _name: string; - - constructor(name: string, options?: ConstructorParameters[0]) { - super(options); - this._name = name; - } - - get __name(): string { - return this._name; - } -} - -class StubRunnerLibrary extends StubLibrary { - async __run(): Promise {} -} - -class StubMutableLibrary extends StubLibrary { - mute(): void {} -} - -describe("EditableLibraryManager", () => { - let manager: EditableLibraryManager; - - beforeEach(() => { - manager = new EditableLibraryManager(); - }); - - describe("typed setters and getters", () => { - it("should store and retrieve a component system library", () => { - const lib = new StubLibrary("ComponentSystem"); - manager.setComponentSystem(lib as any); - expect(manager.getComponentSystem().library).toBe(lib); - }); - - it("should store and retrieve a graphics library", () => { - const lib = new StubLibrary("Graphics"); - manager.setGraphics(lib as any); - expect(manager.getGraphics().library).toBe(lib); - }); - - it("should store and retrieve an asset manager library", () => { - const lib = new StubLibrary("AssetManager"); - manager.setAssetManager(lib as any); - expect(manager.getAssetManager().library).toBe(lib); - }); - - it("should store and retrieve a network library", () => { - const lib = new StubLibrary("Network"); - manager.setNetwork(lib as any); - expect(manager.getNetwork().library).toBe(lib); - }); - - it("should store and retrieve an input library", () => { - const lib = new StubLibrary("Input"); - manager.setInput(lib as any); - expect(manager.getInput().library).toBe(lib); - }); - - it("should store and retrieve a sound library", () => { - const lib = new StubLibrary("Sound"); - manager.setSound(lib as any); - expect(manager.getSound().library).toBe(lib); - }); - - it("should store and retrieve a music library", () => { - const lib = new StubLibrary("Music"); - manager.setMusic(lib as any); - expect(manager.getMusic().library).toBe(lib); - }); - - it("should throw when getting a typed library that was not set", () => { - expect(() => manager.getComponentSystem()).toThrow(); - }); - }); - - describe("set and get (custom symbol)", () => { - it("should store and retrieve a library by Symbol.for key", () => { - const sym = Symbol.for("customLib"); - const lib = new StubLibrary("Custom"); - - manager.setAssetManager(new StubLibrary("Asset") as any); - manager.set(sym, lib as unknown as ILibrary); - - expect(manager.get(sym).library).toBe(lib); - }); - }); - - describe("getLibraries", () => { - it("should return the list of all set libraries", () => { - const lib = new StubLibrary("ComponentSystem"); - manager.setComponentSystem(lib as any); - const libs = manager.getLibraries().filter(Boolean); - expect(libs.some((h) => h.library === (lib as unknown as ILibrary))).toBe(true); - }); - }); - - describe("getInitLibraries", () => { - it("should return libraries in dependency order", () => { - const libA = new StubLibrary("A", { dependencies: [COMPONENT_SYSTEM_LIBRARY] }); - const libB = new StubLibrary("B"); - - manager.setComponentSystem(libB as any); - manager.setGraphics(libA as any); - - const order = manager.getInitLibraries().map((h) => h.library.__name); - const idxB = order.indexOf("B"); - const idxA = order.indexOf("A"); - - expect(idxB).toBeLessThan(idxA); - }); - - it("should return all set libraries", () => { - manager.setAssetManager(new StubLibrary("Asset") as any); - manager.setGraphics(new StubLibrary("Graphics") as any); - - expect(manager.getInitLibraries().length).toBe(2); - }); - }); - - describe("getClearLibraries", () => { - it("should return libraries in reverse dependency order", () => { - const libA = new StubLibrary("A", { dependencies: [COMPONENT_SYSTEM_LIBRARY] }); - const libB = new StubLibrary("B"); - - manager.setComponentSystem(libB as any); - manager.setGraphics(libA as any); - - const order = manager.getClearLibraries().map((h) => h.library.__name); - const idxA = order.indexOf("A"); - const idxB = order.indexOf("B"); - - expect(idxA).toBeLessThan(idxB); - }); - }); - - describe("getExecutionLibraries", () => { - it("should only return libraries that implement __run", () => { - manager.setComponentSystem(new StubLibrary("NotARunner") as any); - manager.setGraphics(new StubRunnerLibrary("Runner") as any); - - const runners = manager.getExecutionLibraries(); - expect(runners.every((h) => typeof (h.library as any).__run === "function")).toBe(true); - expect(runners.some((h) => h.library.__name === "Runner")).toBe(true); - expect(runners.some((h) => h.library.__name === "NotARunner")).toBe(false); - }); - - it("should return empty when no runner libraries are set", () => { - manager.setComponentSystem(new StubLibrary("Static") as any); - expect(manager.getExecutionLibraries()).toHaveLength(0); - }); - }); - - describe("getMutableLibraries", () => { - it("should only return libraries that implement mute", () => { - manager.setSound(new StubMutableLibrary("MutableSound") as any); - manager.setGraphics(new StubLibrary("NonMutableGraphics") as any); - - const mutable = manager.getMutableLibraries(); - expect(mutable.some((h) => h.library.__name === "MutableSound")).toBe(true); - expect(mutable.some((h) => h.library.__name === "NonMutableGraphics")).toBe(false); - }); - - it("should return empty when no mutable libraries are set", () => { - manager.setGraphics(new StubLibrary("Graphics") as any); - expect(manager.getMutableLibraries()).toHaveLength(0); - }); - }); - - describe("library context status", () => { - it("should start with UNLOADED status", () => { - manager.setComponentSystem(new StubLibrary("Comp") as any); - expect(manager.getComponentSystem().context.status).toBe(LibraryStatusEnum.UNLOADED); - }); - }); -}); diff --git a/packages/core/test/internal-app-state.spec.ts b/packages/core/test/internal-app-state.spec.ts new file mode 100644 index 00000000..54e6635f --- /dev/null +++ b/packages/core/test/internal-app-state.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { InternalAppState } from "../src/internal/internal-app-state"; + +describe("InternalAppState", () => { + it("reflects the configured tickRate", () => { + const state = new InternalAppState(30); + expect(state.asAppContext().tickRate).toBe(30); + }); + + it("starts not running, not paused, with zero delta", () => { + const state = new InternalAppState(60); + const ctx = state.asAppContext(); + expect(ctx.isRunning).toBe(false); + expect(ctx.isPaused).toBe(false); + expect(ctx.delta).toBe(0); + }); + + it("the returned view reflects live state, not a snapshot", () => { + const state = new InternalAppState(60); + const ctx = state.asAppContext(); + + state.setIsRunning(true); + state.setDelta(16); + + expect(ctx.isRunning).toBe(true); + expect(ctx.delta).toBe(16); + }); + + it("requestStop() stops the run state", () => { + const state = new InternalAppState(60); + state.setIsRunning(true); + const ctx = state.asAppContext(); + + ctx.requestStop(); + + expect(state.isRunning).toBe(false); + expect(ctx.isRunning).toBe(false); + }); + + it("requestPause()/requestResume() toggle isPaused", () => { + const state = new InternalAppState(60); + const ctx = state.asAppContext(); + + ctx.requestPause(); + expect(state.isPaused).toBe(true); + expect(ctx.isPaused).toBe(true); + + ctx.requestResume(); + expect(state.isPaused).toBe(false); + expect(ctx.isPaused).toBe(false); + }); + + it("has no setter reachable on the returned view", () => { + const ctx = new InternalAppState(60).asAppContext(); + expect("setIsRunning" in ctx).toBe(false); + expect("setIsPaused" in ctx).toBe(false); + expect("setDelta" in ctx).toBe(false); + }); +}); diff --git a/packages/core/test/internal-vars-state.spec.ts b/packages/core/test/internal-vars-state.spec.ts new file mode 100644 index 00000000..859a95f4 --- /dev/null +++ b/packages/core/test/internal-vars-state.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { InternalVarsState } from "../src/internal/internal-vars-state"; + +describe("InternalVarsState", () => { + it("returns undefined for an unset key", () => { + const vars = new InternalVarsState().asVarsContext(); + expect(vars.get("score")).toBeUndefined(); + }); + + it("seeds from the given initial values", () => { + const vars = new InternalVarsState({ score: 0 }).asVarsContext(); + expect(vars.get("score")).toBe(0); + }); + + it("set() is reflected by later get() calls", () => { + const vars = new InternalVarsState().asVarsContext(); + vars.set("score", 10); + expect(vars.get("score")).toBe(10); + }); +}); diff --git a/packages/core/test/nanoforge-application.spec.ts b/packages/core/test/nanoforge-application.spec.ts new file mode 100644 index 00000000..769e450b --- /dev/null +++ b/packages/core/test/nanoforge-application.spec.ts @@ -0,0 +1,249 @@ +import { + type Context, + type InitContext, + Library, + NfDuplicateLibraryException, + NfNotInitializedException, +} from "@nanoforge-dev/common"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { NanoforgeServer } from "../src/application/nanoforge-server"; + +class RecordingLibrary extends Library { + public capturedContext?: Context; + public runCount = 0; + public eventsCount = 0; + public clearCount = 0; + + constructor( + public readonly key: string, + private readonly initLog: string[], + options?: ConstructorParameters[0], + ) { + super(options); + } + + override async __init(_ctx: InitContext): Promise { + this.initLog.push(this.key); + } + + override async __events(ctx: Context): Promise { + this.eventsCount++; + this.capturedContext = ctx; + } + + override async __run(ctx: Context): Promise { + this.runCount++; + this.capturedContext = ctx; + } + + override async __clear(_ctx: Context): Promise { + this.clearCount++; + } + + override expose(): { key: string } { + return { key: this.key }; + } +} + +class StoppingLibrary extends Library { + readonly key = "stopper"; + public runCount = 0; + public clearCount = 0; + + override async __run(ctx: Context): Promise { + this.runCount++; + ctx.app.requestStop(); + } + + override async __clear(): Promise { + this.clearCount++; + } +} + +const makeRunOptions = () => ({ files: new Map(), env: {} }); + +describe("NanoforgeApplication", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + describe("use", () => { + it("throws NfDuplicateLibraryException on a duplicate key", async () => { + const server = new NanoforgeServer(); + server.use(new RecordingLibrary("a", [])); + expect(() => server.use(new RecordingLibrary("a", []))).toThrow(NfDuplicateLibraryException); + }); + + it("throws NfDuplicateLibraryException on a reserved key", () => { + const server = new NanoforgeServer(); + expect(() => server.use(new RecordingLibrary("assets", []))).toThrow( + NfDuplicateLibraryException, + ); + expect(() => server.use(new RecordingLibrary("app", []))).toThrow( + NfDuplicateLibraryException, + ); + expect(() => server.use(new RecordingLibrary("vars", []))).toThrow( + NfDuplicateLibraryException, + ); + }); + + it("throws after init() has been called", async () => { + const server = new NanoforgeServer(); + await server.init(makeRunOptions()); + expect(() => server.use(new RecordingLibrary("late", []))).toThrow(); + }); + }); + + describe("init", () => { + it("initializes libraries in dependency order", async () => { + const log: string[] = []; + const server = new NanoforgeServer(); + const a = new RecordingLibrary("a", log, { dependencies: ["b"] }); + const b = new RecordingLibrary("b", log); + server.use(a); + server.use(b); + + await server.init(makeRunOptions()); + + expect(log).toEqual(["b", "a"]); + }); + + it("makes ctx.assets available without it being explicitly registered", async () => { + const server = new NanoforgeServer(); + const probe = new RecordingLibrary("probe", []); + server.use(probe); + await server.init(makeRunOptions()); + + vi.useFakeTimers(); + await server.run(); + await vi.advanceTimersByTimeAsync(50); + + expect(probe.capturedContext?.assets).toBeDefined(); + expect(typeof probe.capturedContext?.assets.getAsset).toBe("function"); + }); + + it("assigns a library's expose() result to ctx[key]", async () => { + const server = new NanoforgeServer(); + const probe = new RecordingLibrary("probe", []); + server.use(probe); + await server.init(makeRunOptions()); + + vi.useFakeTimers(); + await server.run(); + await vi.advanceTimersByTimeAsync(50); + + expect((probe.capturedContext as any).probe).toEqual({ key: "probe" }); + }); + }); + + describe("run", () => { + it("throws NfNotInitializedException if called before init()", async () => { + const server = new NanoforgeServer(); + await expect(server.run()).rejects.toThrow(NfNotInitializedException); + }); + + it("calls __run each tick and stops after requestStop()", async () => { + vi.useFakeTimers(); + + const server = new NanoforgeServer({ tickRate: 1000 }); + const stopper = new StoppingLibrary(); + server.use(stopper); + await server.init(makeRunOptions()); + + await server.run(); + await vi.advanceTimersByTimeAsync(10); // tick 1: __run fires, requests stop + await vi.advanceTimersByTimeAsync(10); // tick 2: sees isRunning === false, runs __clear + + expect(stopper.runCount).toBe(1); + expect(stopper.clearCount).toBe(1); + }); + + it("skips __run on every library while paused, then resumes", async () => { + vi.useFakeTimers(); + + const server = new NanoforgeServer({ tickRate: 60 }); + const probe = new RecordingLibrary("probe", []); + server.use(probe); + await server.init(makeRunOptions()); + + await server.run(); + await vi.advanceTimersByTimeAsync(1); // let the first tick fire + const runsBeforePause = probe.runCount; + expect(runsBeforePause).toBeGreaterThan(0); + + probe.capturedContext!.app.requestPause(); + await vi.advanceTimersByTimeAsync(200); // many tick intervals while paused + expect(probe.runCount).toBe(runsBeforePause); + expect(probe.capturedContext!.app.isPaused).toBe(true); + + probe.capturedContext!.app.requestResume(); + await vi.advanceTimersByTimeAsync(50); // at least one more tick after resume + expect(probe.runCount).toBeGreaterThan(runsBeforePause); + }); + + it("keeps calling __events every tick while paused, unlike __run", async () => { + vi.useFakeTimers(); + + const server = new NanoforgeServer({ tickRate: 60 }); + const probe = new RecordingLibrary("probe", []); + server.use(probe); + await server.init(makeRunOptions()); + + await server.run(); + await vi.advanceTimersByTimeAsync(1); // let the first tick fire + const runsBeforePause = probe.runCount; + const eventsBeforePause = probe.eventsCount; + expect(runsBeforePause).toBeGreaterThan(0); + expect(eventsBeforePause).toBeGreaterThan(0); + + probe.capturedContext!.app.requestPause(); + await vi.advanceTimersByTimeAsync(200); // many tick intervals while paused + + expect(probe.runCount).toBe(runsBeforePause); // __run stayed skipped + expect(probe.eventsCount).toBeGreaterThan(eventsBeforePause); // __events kept running + + probe.capturedContext!.app.requestResume(); + await vi.advanceTimersByTimeAsync(50); + expect(probe.runCount).toBeGreaterThan(runsBeforePause); + }); + + it("resumes on the same tick an event handler calls requestResume(), since __events runs before the pause check", async () => { + vi.useFakeTimers(); + + class EditorStandIn extends Library { + readonly key = "editor"; + private _resumeQueued = false; + + queueResume(): void { + this._resumeQueued = true; + } + + override async __events(ctx: Context): Promise { + if (this._resumeQueued) { + ctx.app.requestResume(); + this._resumeQueued = false; + } + } + } + + const server = new NanoforgeServer({ tickRate: 60 }); + const editor = new EditorStandIn(); + const probe = new RecordingLibrary("probe", []); + server.use(editor); + server.use(probe); + await server.init(makeRunOptions()); + + await server.run(); + await vi.advanceTimersByTimeAsync(1); + probe.capturedContext!.app.requestPause(); + await vi.advanceTimersByTimeAsync(200); + const runsWhilePaused = probe.runCount; + + editor.queueResume(); + await vi.advanceTimersByTimeAsync(50); // next tick: __events drains the resume, __run fires too + + expect(probe.runCount).toBeGreaterThan(runsWhilePaused); + }); + }); +}); diff --git a/packages/core/test/nanoforge-factory.spec.ts b/packages/core/test/nanoforge-factory.spec.ts new file mode 100644 index 00000000..e6b203cd --- /dev/null +++ b/packages/core/test/nanoforge-factory.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { NanoforgeFactory } from "../src/application/nanoforge-factory"; + +describe("NanoforgeFactory", () => { + it("creates a client that can be initialized", async () => { + const client = NanoforgeFactory.createClient(); + await expect( + client.init({ files: new Map(), env: {}, container: {} as unknown as HTMLDivElement }), + ).resolves.toBeUndefined(); + }); + + it("creates a server that can be initialized", async () => { + const server = NanoforgeFactory.createServer({ tickRate: 30 }); + await expect(server.init({ files: new Map(), env: {} })).resolves.toBeUndefined(); + }); +}); diff --git a/packages/core/test/ordering.spec.ts b/packages/core/test/ordering.spec.ts new file mode 100644 index 00000000..06309952 --- /dev/null +++ b/packages/core/test/ordering.spec.ts @@ -0,0 +1,79 @@ +import { Library } from "@nanoforge-dev/common"; +import { describe, expect, it } from "vitest"; + +import { orderByDependencies, orderByRunSequence } from "../src/library-registry/ordering"; + +class StubLibrary extends Library { + constructor( + public readonly key: string, + options?: ConstructorParameters[0], + ) { + super(options); + } +} + +describe("orderByDependencies", () => { + it("returns libraries in the same order when no dependencies are declared", () => { + const a = new StubLibrary("A"); + const b = new StubLibrary("B"); + expect(orderByDependencies([a, b]).map((l) => l.key)).toEqual(["A", "B"]); + }); + + it("puts a dependency before the library that depends on it", () => { + const b = new StubLibrary("B"); + const a = new StubLibrary("A", { dependencies: ["B"] }); + const result = orderByDependencies([a, b]).map((l) => l.key); + expect(result.indexOf("B")).toBeLessThan(result.indexOf("A")); + }); + + it("does not duplicate a shared dependency", () => { + const dep = new StubLibrary("Dep"); + const a = new StubLibrary("A", { dependencies: ["Dep"] }); + const b = new StubLibrary("B", { dependencies: ["Dep"] }); + const result = orderByDependencies([a, b, dep]).map((l) => l.key); + expect(result.filter((k) => k === "Dep")).toHaveLength(1); + }); + + it("throws on circular dependencies", () => { + const a = new StubLibrary("A", { dependencies: ["B"] }); + const b = new StubLibrary("B", { dependencies: ["A"] }); + expect(() => orderByDependencies([a, b])).toThrow(/[Cc]ircular/); + }); + + it("throws when a dependency key doesn't exist", () => { + const a = new StubLibrary("A", { dependencies: ["Missing"] }); + expect(() => orderByDependencies([a])).toThrow(/Missing/); + }); +}); + +describe("orderByRunSequence", () => { + it("returns all libraries when no ordering is specified", () => { + const a = new StubLibrary("A"); + const b = new StubLibrary("B"); + expect(orderByRunSequence([a, b])).toHaveLength(2); + }); + + it("places a library's runBefore entry before it", () => { + const a = new StubLibrary("A", { runBefore: ["B"] }); + const b = new StubLibrary("B"); + const result = orderByRunSequence([a, b]).map((l) => l.key); + expect(result.indexOf("B")).toBeLessThan(result.indexOf("A")); + }); + + it("places a library's runAfter entry after it", () => { + const a = new StubLibrary("A"); + const b = new StubLibrary("B", { runAfter: ["A"] }); + const result = orderByRunSequence([a, b]).map((l) => l.key); + expect(result.indexOf("B")).toBeLessThan(result.indexOf("A")); + }); + + it("throws on circular run ordering", () => { + const a = new StubLibrary("A", { runBefore: ["B"] }); + const b = new StubLibrary("B", { runBefore: ["A"] }); + expect(() => orderByRunSequence([a, b])).toThrow(/[Cc]ircular/); + }); + + it("returns an empty array for empty input", () => { + expect(orderByRunSequence([])).toHaveLength(0); + }); +}); diff --git a/packages/core/test/relationship.spec.ts b/packages/core/test/relationship.spec.ts deleted file mode 100644 index 776fa493..00000000 --- a/packages/core/test/relationship.spec.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { type ILibrary, LibraryContext, LibraryHandle } from "@nanoforge-dev/common"; -import { describe, expect, it } from "vitest"; - -import { Library } from "../../common/src/library/libraries/library"; -import { Relationship } from "../src/common/library/relationship-functions"; - -class StubLibrary extends Library { - private readonly _name: string; - - constructor(name: string, options?: ConstructorParameters[0]) { - super(options); - this._name = name; - } - - get __name(): string { - return this._name; - } -} - -const makeHandle = ( - sym: symbol, - name: string, - options?: ConstructorParameters[0], -): LibraryHandle => { - return new LibraryHandle( - sym, - new StubLibrary(name, options) as unknown as ILibrary, - new LibraryContext(), - ); -}; - -describe("Relationship.getLibrariesByDependencies", () => { - it("should return libraries in same order when no dependencies are declared", () => { - const symA = Symbol("A"); - const symB = Symbol("B"); - const handleA = makeHandle(symA, "A"); - const handleB = makeHandle(symB, "B"); - - const result = Relationship.getLibrariesByDependencies([handleA, handleB]); - expect(result.map((h) => h.library.__name)).toEqual(["A", "B"]); - }); - - it("should put a dependency before the library that depends on it", () => { - const symA = Symbol("A"); - const symB = Symbol("B"); - const handleB = makeHandle(symB, "B"); - const handleA = makeHandle(symA, "A", { dependencies: [symB] }); - - const result = Relationship.getLibrariesByDependencies([handleA, handleB]); - const names = result.map((h) => h.library.__name); - expect(names.indexOf("B")).toBeLessThan(names.indexOf("A")); - }); - - it("should not duplicate a shared dependency", () => { - const symDep = Symbol("Dep"); - const symA = Symbol("A"); - const symB = Symbol("B"); - const handleDep = makeHandle(symDep, "Dep"); - const handleA = makeHandle(symA, "A", { dependencies: [symDep] }); - const handleB = makeHandle(symB, "B", { dependencies: [symDep] }); - - const result = Relationship.getLibrariesByDependencies([handleA, handleB, handleDep]); - const names = result.map((h) => h.library.__name); - expect(names.filter((n) => n === "Dep")).toHaveLength(1); - }); - - it("should return libraries in reverse dependency order when reverse=true", () => { - const symA = Symbol("A"); - const symB = Symbol("B"); - const handleB = makeHandle(symB, "B"); - const handleA = makeHandle(symA, "A", { dependencies: [symB] }); - - const result = Relationship.getLibrariesByDependencies([handleA, handleB], true); - const names = result.map((h) => h.library.__name); - expect(names.indexOf("A")).toBeLessThan(names.indexOf("B")); - }); - - it("should throw on circular dependencies", () => { - const symA = Symbol("A"); - const symB = Symbol("B"); - const handleA = makeHandle(symA, "A", { dependencies: [symB] }); - const handleB = makeHandle(symB, "B", { dependencies: [symA] }); - - expect(() => Relationship.getLibrariesByDependencies([handleA, handleB])).toThrow( - /[Cc]ircular/, - ); - }); -}); - -describe("Relationship.getLibrariesByRun", () => { - it("should return all runner libraries when no ordering is specified", () => { - const symA = Symbol("A"); - const symB = Symbol("B"); - const handleA = makeHandle(symA, "A"); - const handleB = makeHandle(symB, "B"); - - const result = Relationship.getLibrariesByRun([handleA, handleB]); - expect(result).toHaveLength(2); - }); - - it("should place a library before another when runBefore is set", () => { - const symA = Symbol("A"); - const symB = Symbol("B"); - const handleA = makeHandle(symA, "A", { runBefore: [symB] }); - const handleB = makeHandle(symB, "B"); - - const result = Relationship.getLibrariesByRun([handleA, handleB]); - const names = result.map((h) => h.library.__name); - expect(names.indexOf("B")).toBeLessThan(names.indexOf("A")); - }); - - it("should place a library after another when runAfter is set", () => { - const symA = Symbol("A"); - const symB = Symbol("B"); - const handleA = makeHandle(symA, "A"); - const handleB = makeHandle(symB, "B", { runAfter: [symA] }); - - const result = Relationship.getLibrariesByRun([handleA, handleB]); - const names = result.map((h) => h.library.__name); - expect(names.indexOf("B")).toBeLessThan(names.indexOf("A")); - }); - - it("should throw on circular run dependencies", () => { - const symA = Symbol("A"); - const symB = Symbol("B"); - const handleA = makeHandle(symA, "A", { runBefore: [symB] }); - const handleB = makeHandle(symB, "B", { runBefore: [symA] }); - - expect(() => Relationship.getLibrariesByRun([handleA, handleB])).toThrow(/[Cc]ircular/); - }); - - it("should return empty array for empty input", () => { - expect(Relationship.getLibrariesByRun([])).toHaveLength(0); - }); -});