diff --git a/ecs/packages/ecs-gpu/package.json b/ecs/packages/ecs-gpu/package.json index a0c9089e..1f4c6ad8 100644 --- a/ecs/packages/ecs-gpu/package.json +++ b/ecs/packages/ecs-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/ecs-gpu", - "version": "0.0.2", + "version": "0.0.3", "description": "WebGPU devices, surfaces, render targets, views and frame passes for @adobe/ecs", "license": "MIT", "type": "module", diff --git a/ecs/packages/ecs-guide/src/compile/compile-run.test.ts b/ecs/packages/ecs-guide/src/compile/compile-run.test.ts index fdb362fb..bb1bda6f 100644 --- a/ecs/packages/ecs-guide/src/compile/compile-run.test.ts +++ b/ecs/packages/ecs-guide/src/compile/compile-run.test.ts @@ -46,7 +46,7 @@ it("compiles every chapter's files without a diagnostic, their kernels inlined, if (module.text.includes("identify$(")) expect(module.text).toContain(`"${chapter.package}@`); } } - expect(kernels).toBe(25); + expect(kernels).toBe(27); }); it("reports what the build rejects as a diagnostic located in the file, and gives no run", async () => { diff --git a/ecs/packages/ecs-persistence/README.md b/ecs/packages/ecs-persistence/README.md index e554875a..dae8f0f1 100644 --- a/ecs/packages/ecs-persistence/README.md +++ b/ecs/packages/ecs-persistence/README.md @@ -75,6 +75,10 @@ const spawned = await spawn({ world: other, snapshot: loaded }); snapshot's names it; `entities` maps the one to the other. Keys compare as stored, a number's or a vector's bytes and a string's or an object's value. It fails for an index whose key, `with` or `not` names a component the snapshot's trait doesn't cover, or two of the snapshot's entities one index holds with one key. +- **Updating:** with `update: true`, each entity `match` found takes the snapshot's components of its own, as + `commands.add` gives them: added where it lacks one, replaced and marked changed where it has it, its other + components left as they are. So an entity the application makes at startup, such as a camera, keeps a component that + is a saved setting: `spawn({ world, snapshot: settings, match: [byCamera], update: true })`. Each reports why it didn't happen as `{ failure }`, rather than throwing, and `result.failure !== undefined` tells which. A snapshot is opaque: only these functions read it. diff --git a/ecs/packages/ecs-persistence/package.json b/ecs/packages/ecs-persistence/package.json index c20a3cb8..9503d556 100644 --- a/ecs/packages/ecs-persistence/package.json +++ b/ecs/packages/ecs-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/ecs-persistence", - "version": "0.1.0", + "version": "0.1.1", "description": "Saving, loading and copying the entities of @adobe/ecs worlds, as binary columns", "license": "MIT", "type": "module", diff --git a/ecs/packages/ecs-persistence/src/guide/04-settings.md b/ecs/packages/ecs-persistence/src/guide/04-settings.md index 7763e88d..dd9d5121 100644 --- a/ecs/packages/ecs-persistence/src/guide/04-settings.md +++ b/ecs/packages/ecs-persistence/src/guide/04-settings.md @@ -1,15 +1,20 @@ # Documents and settings An application saves to more than one place, each with a trait of its own: [`file`](04-settings.ts#L5-L8) for what a -document holds, its voxels, their materials and its size, and `settings` for the user's own choices, the tool and -whether a panel is open, which never go into a document. A key carries the trait of each place it's saved in, under the +document holds, its voxels, their materials and its size, and `settings` for the user's own choices, the tool, +whether a panel is open and how far the view orbits, which never go into a document. A key carries the trait of each place it's saved in, under the name it has there, and a name only has to be unique among the keys one trait covers. -- **Taking each:** [`snapshot({ world, by: file })`](04-settings.ts#L46) takes the document, and - [`snapshot({ world, by: settings })`](04-settings.ts#L48) the settings. The settings - [are resources alone](04-settings.ts#L26-L27), so their snapshot holds no entity. +- **Taking each:** [`snapshot({ world, by: file })`](04-settings.ts#L59) takes the document, and + [`snapshot({ world, by: settings })`](04-settings.ts#L61) the settings: the tool and the panel, which are resources, + and the view, an entity whose [`camera` and `orbit`](04-settings.ts#L26-L27) the settings cover. - **Kept apart:** `decode` reads bytes by the trait it's given, and - [refuses a name](04-settings.ts#L53-L57) the trait doesn't cover, so settings can't be loaded as a document, nor a + [refuses a name](04-settings.ts#L67-L70) the trait doesn't cover, so settings can't be loaded as a document, nor a document as settings. - **Loading each:** `spawn` takes no trait: a snapshot holds the one it was taken or read by. A document opened in another world leaves that world's settings as they are. +- **A setting on an entity the application makes:** the view is made at startup, by + [`startView`](04-settings.ts#L37), before the settings load, so loading them mustn't spawn a second one. + [`spawn` with `match: [byCamera]` and `update: true`](04-settings.ts#L86) finds the view by its camera, a unique + index's key, and writes the saved orbit onto it, as `commands.add` would; with `match` alone, the view would keep the + orbit it started with. diff --git a/ecs/packages/ecs-persistence/src/guide/04-settings.out b/ecs/packages/ecs-persistence/src/guide/04-settings.out index 4e8475da..1b5e0f84 100644 --- a/ecs/packages/ecs-persistence/src/guide/04-settings.out +++ b/ecs/packages/ecs-persistence/src/guide/04-settings.out @@ -1,4 +1,4 @@ -settings as a document: This snapshot holds resource "tool", which the trait doesn't cover. +settings as a document: This snapshot holds component "camera", which the trait doesn't cover. a document as settings: This snapshot holds component "voxel", which the trait doesn't cover. opened: 2 voxels, size 32, tool select, panel closed -restored: 0 entities, size 16, tool brush, panel open +restored: size 16, tool brush, panel open | views 25 diff --git a/ecs/packages/ecs-persistence/src/guide/04-settings.ts b/ecs/packages/ecs-persistence/src/guide/04-settings.ts index 44a54690..87e6b5b4 100644 --- a/ecs/packages/ecs-persistence/src/guide/04-settings.ts +++ b/ecs/packages/ecs-persistence/src/guide/04-settings.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { System, World, type Vec3 } from "@adobe/ecs"; +import { Index, System, World, type Vec3 } from "@adobe/ecs"; import { decode, encode, snapshot, spawn } from "../index.js"; /** Saved in the document's file, under its name there. */ @@ -11,6 +11,8 @@ const material = Symbol(); const size = Symbol(); const tool = Symbol(); const panelOpen = Symbol(); +const camera = Symbol(); +const orbit = Symbol(); declare module "@adobe/ecs" { interface Traits { @@ -20,6 +22,9 @@ declare module "@adobe/ecs" { interface Components { [voxel]: { type: Vec3.I32; [file]: "voxel" }; [material]: { type: string; [file]: "material" }; + // A view the application makes at startup, whose orbit is the user's own. + [camera]: { type: string; [settings]: "camera" }; + [orbit]: { type: number; [settings]: "orbit" }; } interface Resources { [size]: { type: number; default: 16; [file]: "size" }; @@ -28,6 +33,10 @@ declare module "@adobe/ecs" { } } +const byCamera = Index.create({ key: [camera], unique: true }); +const startView = System.create(({ commands }) => { commands.spawn([camera, orbit])({ [camera]: "main", [orbit]: 10 }); }); +const zoomOut = System.create({ components: { with: [orbit] } }, row => { row[orbit] = 25; }); + const edit = System.create(({ commands, resources }) => { commands.spawn([voxel, material])({ [voxel]: [0, 0, 0], [material]: "stone" }); commands.spawn([voxel, material])({ [voxel]: [0, 1, 0], [material]: "grass" }); @@ -39,10 +48,14 @@ const edit = System.create(({ commands, resources }) => { const report = System.create({ readonly: true }, ({ resources }) => `size ${resources[size]}, tool ${resources[tool]}, panel ${resources[panelOpen] ? "open" : "closed"}`); let voxels = 0; const countVoxels = System.create({ readonly: true, components: { with: [voxel] } }, () => { voxels++; }); +const views: number[] = []; +const readViews = System.create({ readonly: true, components: { with: [camera, orbit] } }, row => { views.push(row[orbit]); }); export const main = async (): Promise => { await using world = World.create(); + await world.run(startView); await world.run(edit); + await world.run(zoomOut); const drawing = await snapshot({ world, by: file }); if (drawing.failure !== undefined) return console.log(drawing.failure); const preferences = await snapshot({ world, by: settings }); @@ -65,11 +78,13 @@ export const main = async (): Promise => { await opened.run(countVoxels); console.log("opened:", voxels, "voxels,", await opened.run(report)); - // The settings alone, in a world of their own: resources, no entities. + // The settings alone, in a world of their own, which makes its view at startup: the saved orbit updates it. await using restored = World.create(); + await restored.run(startView); const own = decode(settingsBytes, { by: settings }); if (own.failure !== undefined) return console.log(own.failure); - const applied = await spawn({ world: restored, snapshot: own }); + const applied = await spawn({ world: restored, snapshot: own, match: [byCamera], update: true }); if (applied.failure !== undefined) return console.log(applied.failure); - console.log("restored:", applied.entities.size, "entities,", await restored.run(report)); + await restored.run(readViews); + console.log("restored:", await restored.run(report), "| views", views.join(", ")); }; diff --git a/ecs/packages/ecs-persistence/src/guide/guide.test.ts b/ecs/packages/ecs-persistence/src/guide/guide.test.ts index 6bf88cb5..83ef72b0 100644 --- a/ecs/packages/ecs-persistence/src/guide/guide.test.ts +++ b/ecs/packages/ecs-persistence/src/guide/guide.test.ts @@ -6,7 +6,7 @@ const teaches: { readonly [chapter: number]: readonly string[] } = { 1: ["[file]: \"position\"", "snapshot({ world, by: file })", "encode(", "decode(bytes, { by: file })", "spawn({ world", "entities", "failure"], 2: ["snapshot({ world, by: file, root", "dropped", "entities: false", "entities", "match: [byLabel]"], 3: ["shared: true", "where: { [chunk]: [2, 0] }", "despawnWhere", "match: [byTree]"], - 4: ["snapshot({ world, by: file })", "snapshot({ world, by: settings })", "decode", "spawn"], + 4: ["snapshot({ world, by: file })", "snapshot({ world, by: settings })", "decode", "spawn", "match: [byCamera]", "update: true"], }; /** @@ -21,6 +21,7 @@ const mutations: { readonly [chapter: number]: readonly (readonly [string, strin ["[panelOpen]: { type: boolean; default: false; [settings]: \"panel open\" }", "[panelOpen]: { type: boolean; default: false; [file]: \"panel open\" }"], ["const settingsAsDocument = decode(settingsBytes, { by: file });", "const settingsAsDocument = decode(settingsBytes, { by: settings });"], ["[tool]: { type: string; default: \"select\"; [settings]: \"tool\" }", "[tool]: { type: string; default: \"select\"; [settings]: \"tool\"; [file]: \"tool\" }"], + [", update: true", ""], ], }; diff --git a/ecs/packages/ecs-persistence/src/match/match.test.ts b/ecs/packages/ecs-persistence/src/match/match.test.ts index a5351e1b..7a700caf 100644 --- a/ecs/packages/ecs-persistence/src/match/match.test.ts +++ b/ecs/packages/ecs-persistence/src/match/match.test.ts @@ -115,6 +115,23 @@ describe("spawn with match", () => { expect(spawned.entities.get(savedSteel)).toBe(existingSteel); }); + it("writes the snapshot's values onto the entities an index matches with update, through bytes too, leaving their other components", async () => { + await using from = inline(); + await from.run(build, { materials: [{ name: "steel", friction: 0.5 }, { name: "wood", friction: 0.3 }], bodies: [{ label: "box", material: "steel" }] }); + await using into = inline(); + await into.run(build, { materials: [{ name: "steel", friction: 0.9 }], bodies: [] }); + const existingSteel = (await read(into)).find(item => item.material === "steel")?.entity ?? Entity.none; + await into.run(System.create(({ commands }) => { commands.add(existingSteel, tag, "kept"); })); + const decoded = decode(encode(await taken(from)), { by: saved }); + if (decoded.failure !== undefined) throw new Error(decoded.failure); + const spawned = await spawn({ world: into, snapshot: decoded, match: [materialByName], update: true }); + if (spawned.failure !== undefined) throw new Error(spawned.failure); + expect(await described(into)).toEqual({ materials: { steel: 0.5, wood: 0.3 }, bodies: { box: "steel 0.5" } }); + const tags: string[] = []; + await into.run(System.create({ readonly: true, components: { with: [tag] } }, row => { tags.push(row[tag]); })); + expect([tags, (await read(into)).filter(item => item.material === "steel").map(item => item.entity)]).toEqual([["kept"], [existingSteel]]); + }); + it("merges through bytes, as a snapshot encoded and decoded", async () => { await using from = inline(); await from.run(build, { materials: [{ name: "steel", friction: 0.5 }], bodies: [{ label: "box", material: "steel" }] }); diff --git a/ecs/packages/ecs-persistence/src/spawn.ts b/ecs/packages/ecs-persistence/src/spawn.ts index 2831239e..b9c7c4f1 100644 --- a/ecs/packages/ecs-persistence/src/spawn.ts +++ b/ecs/packages/ecs-persistence/src/spawn.ts @@ -26,10 +26,13 @@ const isEntities = (value: unknown): value is ReadonlyMap => { return true; }; -/** Spawns its input's tables and sets its resources, a snapshot's, rewriting their entities, those `existing` maps standing for theirs. */ -const spawnTables = System.create({}, ({ commands }, input: { readonly tables: unknown; readonly resources: unknown; readonly existing: unknown }) => { +/** + * Spawns its input's tables and sets its resources, a snapshot's, rewriting their entities, those `existing` maps + * standing for theirs, onto which `update` writes their rows. + */ +const spawnTables = System.create({}, ({ commands }, input: { readonly tables: unknown; readonly resources: unknown; readonly existing: unknown; readonly update: boolean }) => { if (!isTables(input.tables) || !isKeyed(input.resources) || !isEntities(input.existing)) throw new Error("A snapshot holds tables and resources."); - return commands.spawnTables(input.tables, { resources: input.resources, existing: input.existing }); + return commands.spawnTables(input.tables, { resources: input.resources, existing: input.existing, update: input.update }); }); /** @@ -42,6 +45,10 @@ const spawnTables = System.create({}, ({ commands }, input: { readonly tables: u * with the key of one of the snapshot's it holds, stands for it, keeping its own values, and isn't spawned again. It * fails for an index whose key, `with` or `not` names a component the snapshot's trait doesn't cover, or two of the * snapshot's entities with one key. + * - `update: true` writes the saved components of each entity `match` found onto it, as `commands.add` would: adding + * those it lacks, replacing and marking changed those it has, and leaving its other components as they are, its + * entity values rewritten as a spawned entity's are, but a reference to an entity that isn't there, which is left + * out. So an entity the application makes at startup takes the values it was saved with. * - `entities: false` leaves out the result's map of entities, which costs about 85 ms and 50 MB at a million. * * Fails, as data, for a column or resource that doesn't fit, spawning nothing. @@ -55,14 +62,17 @@ export async function spawn(input: Spawning & { readonly entities?: boolean }): for (const { persisted, value } of resources) values[persisted.key] = value; const existing = input.match === undefined || input.match.length === 0 ? new Map() : await existingFor(input.world, from, input.match); if ("failure" in existing) return existing; - const spawned = await input.world.run(spawnTables, { tables, resources: values, existing }).catch((error: unknown) => ({ failure: error instanceof Error ? error.message : String(error) })); + const spawned = await input.world.run(spawnTables, { tables, resources: values, existing, update: input.update === true }).catch((error: unknown) => ({ failure: error instanceof Error ? error.message : String(error) })); if ("failure" in spawned) return spawned; if (input.entities === false) return { dropped: spawned.dropped }; return { dropped: spawned.dropped, entities: entitiesOf(tables, spawned.entities) }; } -/** What `spawn` takes: the world spawned into, the snapshot, and the indexes saying which of its entities are there already. */ -type Spawning = { readonly world: World; readonly snapshot: Snapshot; readonly match?: readonly Matching[] }; +/** + * What `spawn` takes: the world spawned into, the snapshot, the indexes saying which of its entities are there already, + * and whether to write their saved components onto them. + */ +type Spawning = { readonly world: World; readonly snapshot: Snapshot; readonly match?: readonly Matching[]; readonly update?: boolean }; /** Each of the tables' entities to the one spawned for it, the one existing for it, or null for one that wasn't. */ const entitiesOf = (tables: readonly World.Table[], spawned: readonly Float64Array[]): ReadonlyMap => { diff --git a/ecs/packages/ecs/design.md b/ecs/packages/ecs/design.md index 104a45ed..ce140bea 100644 --- a/ecs/packages/ecs/design.md +++ b/ecs/packages/ecs/design.md @@ -43,7 +43,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Component values are immutable values,** deeply, by type convention rather than freezing. A system reads a value and writes a whole new one (`row[position] = [x + vx, y + vy, z + vz]`); values are never edited in place. A body changing something inside a value through its row or resources (`row[k].a.b = 1`, `delete row[k].a`) fails the system's creation when the value is data, a struct or a string, which rows and resources give as copies, so the change would be lost; inside an object, such as a DOM node a binding sets properties of, it's a change in place, read and tracked by nothing, as a method changing one is (`resources[map].set(…)`): declare objects that change with readonly types (`ReadonlyMap`) and replace them whole where other systems must see the change. A start shared by every row and by a world's resource, such as one empty `Map` per world, is never changed for the same reason. This is value semantics, as in shading languages: no value aliases another, so the compiler is free to lower a whole-value write to per-leaf stores, as a shader lowers a struct assignment. System source is ordinary TypeScript, type-checked normally. - **Kernel systems are compiled at build time:** a system with components whose body is in the kernel subset, `kernel: true` or `"gpu"`. A Vite/Rolldown plugin compiles them in its `transform` hook, after TS types are stripped and before minification, so names are never mangled and nothing is parsed at runtime. - It finds systems by resolving the import binding of the system factory. - - **Access is inferred** (`compiler/body-access.ts`, `compiler/inferred.ts`): for every `System.create`, `System.render`, `Finalizer.create` and `Finalizer.changes` call with its declaration written in place (or passed through a wrapping call) and its body a function in place or bound by name at module scope, the build reads what the body reads and writes of its row, reads, writes and reduces into of resources, reads of each lookup's rows and of its parent through `up`, by module-scope keys and namespaces' members (`Shapes.circle`). It wraps the declaration as `access$(declaration, { components, resources, lookup, up, at })`, recording that in a global `WeakMap` under `Symbol.for("@adobe/ecs/access@1")`, which the constructor takes out and joins with the declaration into the lists the world takes (`interface/system/resolve.ts`): a row's reads and writes, the rest of `with` as `has`, a lookup's reads, `up`'s reads. A kernel compiles from the same lists. A key a body uses that its declaration doesn't require fails the build, so matching never changes unseen; TypeScript rejects it too, typing rows over `with`. Where the build can't follow a system's row, the build fails at the use it couldn't follow, saying why, since taking every member as written would change the schedule unseen; `components: { whole: true }` takes the row whole on purpose, every member written but references and shared components, and a conflict naming such a system says so. A readonly system's or a finalizer's row it can't follow is taken as read, and where it can't follow a lookup's rows, all it requires is taken as read, since that is what their declarations say; a body whose resources or `up` it can't follow fails at registration, since every resource would place it on every thread. An internal factory making declarations at runtime records its own access first, which the build's doesn't replace; the implementation's own systems use `createResolved` with lists already resolved. + - **Access is inferred** (`compiler/body-access.ts`, `compiler/inferred.ts`): for every `System.create`, `System.render`, `Finalizer.create` and `Finalizer.changes` call with its declaration written in place (or passed through a wrapping call) and its body a function in place or bound by name at module scope, the build reads what the body reads and writes of its row, reads, writes and reduces into of resources, reads of each lookup's rows and of its parent through `up`, by module-scope keys and namespaces' members (`Shapes.circle`). It wraps the declaration as `access$(declaration, { components, resources, lookup, up, commands, at })`, recording that in a global `WeakMap` under `Symbol.for("@adobe/ecs/access@1")`, which the constructor takes out and joins with the declaration into the lists the world takes (`interface/system/resolve.ts`): a row's reads and writes, the rest of `with` as `has`, a lookup's reads, `up`'s reads. `commands`, what its commands name, is a function the world calls when it first schedules or describes the system, so a list declared below the system is read once its module has loaded; one an import cycle leaves uninitialized then fails naming the system and the list. A kernel compiles from the same lists. A key a body uses that its declaration doesn't require fails the build, so matching never changes unseen; TypeScript rejects it too, typing rows over `with`. Where the build can't follow a system's row, the build fails at the use it couldn't follow, saying why, since taking every member as written would change the schedule unseen; `components: { whole: true }` takes the row whole on purpose, every member written but references and shared components, and a conflict naming such a system says so. A readonly system's or a finalizer's row it can't follow is taken as read, and where it can't follow a lookup's rows, all it requires is taken as read, since that is what their declarations say; a body whose resources or `up` it can't follow fails at registration, since every resource would place it on every thread. An internal factory making declarations at runtime records its own access first, which the build's doesn't replace; the implementation's own systems use `createResolved` with lists already resolved. - **The plugin is required for kernels and input.** Registering a kernel system that wasn't compiled throws, as does a system whose body takes input without the schema the plugin gives. `Compiler.plugin()` returns its two parts: input schemas, before TypeScript is stripped, and kernels and ids, after. - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name and version of the nearest `package.json` above the module that has a name, `name@version`, or `name` alone without a version, and the module's path in that package, without a query, so a workspace-linked package names no directory outside it and copies of a package at different versions never share ids; for a dependency without one, its package name and path inside `node_modules`; never an absolute path; `#default` names a default export. A module outside both that has anything to identify fails the build, so an id never depends on the build's root. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`, and sets `worker.format` to `"es"` where the config doesn't, since kernels in worker bundles are separate chunks: that applies to every worker the app bundles, its own included, which load as module workers. A second instance in one bundle does nothing. Ids identify keys across threads and reloads; nothing persists them, and persisted data will need names that survive refactors and versions. - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. It builds with Vite and `Compiler.plugin({ library: true })`, which runs only the pass before TypeScript is stripped, so its `dist` ships the registrations and input schemas its types give, which an application's build can't read from JavaScript, while its kernels stay source and its keys get their ids from the application's plugin. A library built by `tsc` alone, and declarations in `.d.ts` files, register nothing. Non-kernel systems run as written on the main thread, through accessor rows over the same storage. @@ -74,7 +74,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Public types are only those a consumer writes:** the interfaces to augment (`Components`, `Resources`, `Traits`, `Threads`), `Entity`, `Schema`, the math types, `Component`, `Component.Key` and `Component.With`, `Resource` and `Resource.Key`, `System`, `System.Commands`, `Finalizer` and `World`. `Rule`, as `Gpu`, names only a namespace. The rest are inferred from the values the API returns: a thread's name is `keyof Threads`, the cadence's type `Resource`, a rule `ReturnType`, and an archetype is a list of components declared `as const`. Every type those values carry is named from the package root, so declarations a library emits for them name no internal module. - **Components and resources:** - Each is identified by an exported key, a plain `Symbol()`, which TypeScript types as a `unique symbol`. Its traits are declared once, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component` is that type. The plugin's pass before TypeScript is stripped (`compiler/augmentations.ts`) records each record right after its key, and after any binding the record names, a side effect of loading the key's module: the schema of `type`, its `default`, and its other traits as declared. Nothing restates a declaration, so nothing can disagree with it. A key is a top-level `Symbol()` constant of the module declaring it, so importing a key registers it and every declared key has an id; a module declaring a key it imports, or anything else, fails the build, naming both. The block itself does nothing at runtime, so code between a key and a block below it sees the key registered. The build also checks the built-in traits' values as far as their literals and types show (`derived` is `true`, `reference` an entity's `"owner"` or `"weak"`, `within` `typeof` a key, `transfer` a Transferable class, `reduce` an operator on a resource, `thread` a name on a value that isn't a struct or string), and `World.create` checks every declaration's traits against `Traits`, a default against its type among them: while one doesn't hold, each `World.create` call fails to compile, once, on the call: its `this` must then be `{ "declarations don't hold": { [key]: { [member]: problem } } }`, which tsc prints whole, naming each such key, its members by their bindings and what's wrong with each (`"isn't allowed on this key"`, `"isn't of the declared type"`, `"isn't a trait"`), and the world it returns type-checks as any other (`exports.test.ts`). That check is one mapped type over the declarations, each key's members checked against its own `Traits`. The package's own built-in keys register themselves, so the pass leaves its library modules alone. Emitted code imports nothing and consumers have no way to register a key themselves: a function appended to the module sets each key's `{ kind, declaration }` in a global `Map` under `Symbol.for("@adobe/ecs/declarations@1")`, which every copy of the package in the program reads, in the order modules recorded them, at the first read of a key after more were recorded, and none changes. A declaration that fails is recorded for its own key alone: a read of another key never throws for it, and every use of its key fails, each time, giving it as the reason, so a system using it fails each run. A library's shipped build records its keys the same way. The check of declarations at `World.create` holds only where it's called as the package's `World.create`: assigned first to a variable typed `() => World`, it's no longer checked, a limit of checking through `this`; registration still checks what values show. - - **A type's schema** is read from its syntax, through aliases, interfaces, namespaces and re-exports of the module and those it imports, into the package's sources, which it ships, or a JavaScript package's declarations (`compiler/type-schemas.ts`); typed input reads its types the same way. `number`, `string`, `boolean`, `null`, literals, unions, arrays, one-type tuples, objects, `ReadonlySet` and `ReadonlyMap` have their schemas, and `Entity` its own. A type named by `Schema.Type`, which TypeScript resolves to `Schema.ToType`, a plain type, has the schema `S`, written as a literal type or `typeof` an `as const` constant: `type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>`. The package's `F32`, `I32`, `U32` and vectors are declared so, so their precision and width come from their declarations, and a consumer's types the same. A class, a function, `unknown`, or a `Set` or `Map`, which changes in place, isn't data: the value gets no schema. Any other type fails the build at its declaration. + - **A type's schema** is read from its syntax, through aliases, interfaces, namespaces and re-exports of the module and those it imports, into the package's sources, which it ships, or a JavaScript package's declarations (`compiler/type-schemas.ts`); typed input reads its types the same way. `number`, `string`, `boolean`, `null`, literals, unions, arrays, one-type tuples, objects, `ReadonlySet` and `ReadonlyMap` have their schemas, and `Entity` its own. A type named by `Schema.Type`, which TypeScript resolves to `Schema.ToType`, a plain type, has the schema `S`, written as a literal type or `typeof` an `as const` constant: `type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>`. The package's `F32`, `I32`, `U32` and vectors are declared so, so their precision and width come from their declarations, and a consumer's types the same. A class, a function, `unknown`, a `symbol` or a key's type (`Resource.Key`, `Component.Key`, any `keyof` or `unique symbol`), which can't be cloned to another thread or saved, a `Set` or `Map`, which changes in place, or a `ReadonlySet` or `ReadonlyMap` holding any of them isn't data: the value gets no schema. Any other type fails the build at its declaration. - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), keyed by a symbol too, which the build registers, in the registry components and resources share, before the components of its block. A trait's value is declared as a literal type or `typeof` a value, which the registration passes. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. The build checks the built-in traits' literals, registration what values tell, and `World.create` every trait's value against `Traits`, a trait of your own's included. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only. - **Entities are branded numbers** (`Entity`), and every entity-typed value, nested fields included, uses `Entity.schema`, so persistence and replication find references by schema. Index 0 is never an entity: `Entity.none`, zeroed memory and entity defaults name none, and a lookup of it returns null. - **A resource always has a value,** and so does a derived component. A schema never holds a default, which isn't part of what values are: the start is the declaration's. The first of these applies: @@ -83,9 +83,9 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. 3. for a reduced resource, its operator's identity, leaf by leaf (`+` 0, `*` 1, `min` its largest value or +∞, `max` its smallest or −∞, `&` every bit of its column, all ones for a `u32` and −1 otherwise, or `true`, `|` and `^` 0 or `false`), as a fold of no contributions leaves it, taken only where it's one of the leaf's values, else the rules below; it outranks the package's own types' defaults, never a namespace of yours, at any depth; 4. `null`, when its type allows it; 5. its zero value: `0`, `false`, `""`, item by item for a tuple and field by field for a struct, and a union's literal that is one of those; - 6. a fresh empty `Map` or `Set` of each world's own, for a `ReadonlyMap` or `ReadonlySet`; a `Map` or `Set` type gets none. + 6. a fresh empty `Map` or `Set` of each world's own, for a `ReadonlyMap` or `ReadonlySet`, whether or not what it holds is data, which takes no `default`; a `Map` or `Set` type gets none. Otherwise the build fails, saying how to give one: for a union of literals none of which is a zero value, a range without 0, a class, or a `Map` or `Set` that isn't null. A component's literal or namespace's default is its start the same way, which commands that give it no value add; a struct component without a literal needs none. The registration gives the start beside the schema, and checks it's one of the schema's values. A literal default is written into the registration, so it's a copy; a start that must be one object, as an object a joined thread fills is matched by identity, is a namespace's, read where it's declared, never copied. - - **Values that aren't data** (functions, objects with methods, such as a `Blob`, an `HTMLElement` or a `GPUDevice`, a `Set` or `Map`, which changes in place, and objects holding any of them) are declared by their type alone, and registered without a schema; a DOM `Node`, by its class or one extending it, needs `thread: "main"`, which the build requires. The world stores such values as they are, so only main-thread systems use them, and persistence and replication skip them. + - **Values that aren't data** (functions, objects with methods, such as a `Blob`, an `HTMLElement` or a `GPUDevice`, symbols, a `Set` or `Map`, which changes in place, and objects, maps and sets holding any of them) are declared by their type alone, and registered without a schema; a DOM `Node`, by its class or one extending it, needs `thread: "main"`, which the build requires. The world stores such values as they are, so only main-thread systems use them, and persistence and replication skip them. - **Schemas** are JSON Schema, of the forms the ECS reads, and two JSON lacks: `{ type: "set", items }`, a `ReadonlySet`, and `{ type: "map", keys, values }`, a `ReadonlyMap`. Neither `"set"` nor `"map"` is a JSON Schema type, so every other schema keeps its meaning. A set or map is data, never a struct: it's stored as an object, on its thread, kernels can't read it, it crosses to other threads by structured clone, and registration compares defaults holding one by their entries. - **Import and use:** code that references a component or resource always imports its key, and the key's module declares it and registers it right after the key, so importing a key registers it, on every thread. A module may not declare a key it imports: the build rejects it, naming both. - **Keys are plain symbols, compared by identity,** so no two separately declared components, resources or traits can collide, and a symbol needs no description. A key is one kind across components, resources and traits, because a resource may be stored as a component on a single entity. Registration rejects a string, a `Symbol.for` symbol, which any module could claim, and a well-known symbol such as `Symbol.iterator`; the build rejects a declaration keyed by anything but a `Symbol()` constant. A key registered again as the same kind with the same declaration is accepted; as another kind, or with another declaration, it throws. A module evaluated again makes new symbols, which share its keys' ids (see the plugin). @@ -148,9 +148,9 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Bodies are `(subject, context, input)`:** `(row, context, input)` for a system with `components`, `(context, input)` for a system without, `input` left out by a body that takes none; `(row, context)` for a finalizer, `(changes, context)` for a changes finalizer, and `(gpu, context)` for a render system. - `row`: `{ readonly entity }`, plus the values of its `with`, `added` and `changed`, writable but references, and read-only in a readonly system or render system. No component may be named `entity`. - `context` holds each kind under its own key: `resources` (every resource, assignable but reduced and world-written ones), `lookup` (functions by name, when declared), `reduce` (contribution functions by reduced resource) and `up` (with `up` declared). It also has `commands`, except in GPU kernels and render systems. Keys of different kinds never share a namespace, so nothing can collide. - - `commands`: `spawn(components, constant?)` returns the world's spawner of exactly those components, the same function for a world, set of components and equal constants each time while every shared value among them is held, and on a joined thread for a list and one constants object, which takes the values `constant` doesn't give, or nothing once none is required, and returns a real `Entity`; each component's value comes from one of the two calls, one given in both failing the system, and every shared component's from `constant`; `despawn`; `despawnWhere({ with, not?, where? })`, despawning as a `despawn` of each would every entity that, once the commands issued before it apply, has each component of a non-empty `with`, derived ones included, none of `not`, and each value `where` gives a shared component, as a system's `where` of values, with every entity spawned at the same sync point that does, never the resource entity, and only on the world's thread; `add(entity, component, value)`, the value left out for a tag, or `add(entity, components, values)`, adding or replacing and marking the components changed; and `remove(entity, component)` or `remove(entity, components)`. A spawn's or list add's values are exact against its components: a tag needs no value, a component whose type allows `null` may be left out to start at `null`, every other one is required, and leaving one out or giving `undefined` never leaves the component out; a list that isn't a tuple takes any of its components' values. The two forms of `add` and `remove` are told apart by whether the second argument is a symbol. They apply in the order they were issued. They take only components that aren't derived, but `despawnWhere`, which only selects, which is checked in types and at runtime, where a key that isn't a component commands may name, or a list naming one twice, throws at first use before anything is queued. Values are read only by the components' keys; a string key in the values of a spawn, or of an add whose components are a list as written, an array literal or a module constant of one, fails the build; an add of one component takes an object literal as its value. + - `commands`: `spawn(components, constant?)` returns the world's spawner of exactly those components, the same function for a world, set of components and equal constants each time while every shared value among them is held, and on a joined thread for a list and one constants object, which takes the values `constant` doesn't give, or nothing once none is required, and returns a real `Entity`; each component's value comes from one of the two calls, one given in both failing the system, and every shared component's from `constant`; `despawn`; `despawnWhere({ with, not?, where? })`, despawning as a `despawn` of each would every entity that, once the commands issued before it apply, has each component of a non-empty `with`, derived ones included, none of `not`, and each value `where` gives a shared component, as a system's `where` of values, of the entities alive before its sync point, as a row system over the same query despawning each row would, never one the sync point spawns nor the resource entity, and only on the world's thread; `add(entity, component, value)`, the value left out for a tag, or `add(entity, components, values)`, adding or replacing and marking the components changed; and `remove(entity, component)` or `remove(entity, components)`. A spawn's or list add's values are exact against its components: a tag needs no value, a component whose type allows `null` may be left out to start at `null`, every other one is required, and leaving one out or giving `undefined` never leaves the component out; a list that isn't a tuple takes any of its components' values. The two forms of `add` and `remove` are told apart by whether the second argument is a symbol. They apply in the order they were issued. They take only components that aren't derived, but `despawnWhere`, which only selects, which is checked in types and at runtime, where a key that isn't a component commands may name, or a list naming one twice, throws at first use before anything is queued. Values are read only by the components' keys; a string key in the values of a spawn, or of an add whose components are a list as written, an array literal or a module constant of one, fails the build; an add of one component takes an object literal as its value. - **Writes to the current row are direct and immediate.** Commands are only for structural changes and for writes to other entities. - - **Declarations take no name.** A system is labeled by its binding, which its id names, so two libraries can both declare a system `linked`. A system that declares nothing is created from its body alone, `System.create(({ commands }) => …)`, which runs once per run. Ordering constraints list systems; a system can't list itself, since it doesn't exist while it's declared. + - **Declarations take no name.** A system is labeled by its binding, which its id names, so two libraries can both declare a system `linked`. A system that declares nothing is created from its body alone, `System.create(({ commands }) => …)`, which runs once per run. Ordering constraints list systems, and a leaf's `before` and `after` resources too; a system can't list itself, since it doesn't exist while it's declared. - **Composites** flatten their members. - A composite's constraints apply to every member, and members' own constraints resolve against the flattened set. - If the same system appears twice, as one value or as values of one id (see below), it's flattened to one entry, the first reached. Systems that only share a name are different systems. @@ -159,11 +159,12 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Ticks:** `world.add(system)` adds a system to every tick from the next one on, ordered with those already added as a composite's members are; one with no ordering path to an added system it conflicts with throws, as does one with input. A tick runs the added set as one composite, on a beat of the `cadence` resource where `always` is set, `wake` is due or a run arrived since the last tick, and only while some system is added that runs in a tier. Transactions and on-demand systems are runs. - **The readonly pass:** after each run's last sync point, once its tails are done, and before `run` resolves, the world reads back what the GPU wrote that readonly systems read, or the indexes they look up through hold by, then refreshes those indexes, and runs each once: the run's own, then those added, alone or within added composites, as a later value of the list each pass starts from. A system removed meanwhile, even by an earlier body of the pass, is skipped. What the run's own throw fails the run, as does a unique index's conflict the pass's refresh found; what those added throw is reported out of band. A body returns no promise: by type, and at runtime, where returning a promise or another thenable fails its system, never awaited, so nothing holds the world's runs. `add` never throws for one and keeps it out of the composite ticks run, so adding or removing one leaves that composite as it is, and it never makes the loop tick. A run of readonly systems alone has no tiers; with nothing queued, no GPU and no joined thread it skips its sync point, and it doesn't wake the loop. After a tick the pass runs at once with the tick's values, while its tails may still run. Readonly systems on joined threads are posted to their threads in the same pass, after those here ran. A run of the world's waits for its own there to run, the post marking it owed to that run: the thread gives back what each body returned, structured-cloned, for the one the run resolves to, or what it threw, which fails the run; a value that can't be posted fails it too, and a thread gone fails it with a `"thread"` failure. What those added throw reaches the world's thread by message and is reported out of band. A joined thread's `run` of one of its own settles there, once its body ran, to what it returned or with what it threw. - **Removing:** `world.remove(system)` takes a value `add` was given off every tick. A system added alone and within an added composite stays until both are removed, and constraints naming a removed system are ignored, so a removal that leaves two systems that conflict with no ordering path between them throws, removing nothing, as `add` throws for one; a value not added does nothing, one no constructor returned throws, and so does a call on a joined thread for any system but a readonly system. Once it returns, nothing calls the removed system's body again: a tick or run in progress reads it as removed from its next tier on, and from its next row on when its own body removed it; a run queued before skips it; and a task already posted to a joined thread holding it skips it there (`threads.md`). Its memory is released: the world forgets its schedule and runner, and those of composites only it was in, releasing what the runner held once the run or tick in progress is done, and a joined thread drops what it held for it. A later `run` of it, or of a composite holding it, registers it again, from `lastRun` 0. A readonly system here whose runner holds nothing beyond its JS values (no index, order, GPU or joined thread) keeps that runner, stopped, while its value lives: added or run again once the run that removed it is done, it registers at once with that runner, restarted from `lastRun` 0, so adding it again costs no new runner, context or buffers; added again so, it runs from the next pass, even a queued run's. - - **Sync points within a run:** a run ends at a sync point, and adds one between tiers where a system follows another, by an ordering path, whose commands may change, structurally, what it reads, matches, looks up or indexes: what the build inferred the earlier one's commands name, spawned, added or removed, meets a component the later one's row, lookups, indexes, order or `up` name; one that despawns, issues commands the build couldn't follow or writes a reference, which may cascade, meets anything a later one matches; and a component a rule derives or `within` removes is met by any structural command. The schedule places the fewest that put one between every such pair, each as late as the first pair it covers allows (`syncsOf` in `registry/schedule.ts`), and none where no system depends on another's commands, so memory still moves only at sync points, never while a tier runs. Each takes a tick of its own, as every sync point does, so change filters see each change once; the tier after it refreshes its indexes from the journal, as any tier does. Tiers on a joined thread aren't fused across one. `System.describe` lists each sync point with the pairs it's for. + - **Sync points within a run:** a run ends at a sync point, and adds one between tiers where a system follows another, by an ordering path, whose commands may change, structurally, what it reads, matches, looks up or indexes: what the build inferred the earlier one's commands name, spawned, added or removed, meets a component the later one's row, lookups, indexes, order or `up` name; one that despawns, issues commands the build couldn't follow or writes a reference, which may cascade, meets anything a later one matches; and a component a rule derives or `within` removes is met by any structural command. The schedule places the fewest that put one between every such pair, each as late as the first pair it covers allows (`syncsOf` in `registry/schedule.ts`), and none where no system depends on another's commands, so memory still moves only at sync points, never while a tier runs. Each takes a tick of its own, as every sync point does, so change filters see each change once; the tier after it refreshes its indexes from the journal, as any tier does. Tiers on a joined thread aren't fused across one. One with nothing to apply, no command queued or drained, no entity reserved or carried, no rule to derive again, finalizer to place or activate, watched resource written or layout for workers, is skipped; and tiers whose systems all run on the world's thread as written, looking up through no index from the second on, run as one pass (`runPass` in `execute.ts`), with no checks between them but a failure, a runner replaced and whether a sync point between them has something to apply, which ends the pass there. `System.describe` lists each sync point with the pairs it's for. - **Describing:** `System.describe(system)` gives a system's or composite's schedule as data and as `text`: its tiers in run order and its readonly systems apart, and for each system what the build inferred it reads, writes, looks up and commands, by kind and component (`commands` null where the build couldn't follow them), whether it takes its row whole, and the systems it follows directly, with the constraint that ordered it after each and the keys both access, one writing. What a body's commands name is the build's (`body-access.ts`), kept on the system apart from its declaration, so a module evaluated again with other commands declares the same system. - Ordering comes from `before`/`after`/`during`, sorted into tiers. Systems in the same tier may run in parallel. A constraint that names a system outside the composite being run is ignored. + - **A resource in `before` or `after`** orders the system before, or after, every other leaf of the composite that uses the resource (reads, writes, reduces into, filters or is conditioned on it), but a leaf its constraints already order the other way, so a reader wanting the old value names the writer itself (`before: [applyHistory]`) with no cycle, and a leaf listing the resource in the same list, so two writers listing it still conflict. A leaf it ordered so follows it `because` `"before"` or `"after"` in `System.describe`. The scheduler orders nothing by a resource unless a system lists it. - **`during` places a system alongside another:** after what that system follows and before what follows it, both by its own constraints and by others', outside a composite's own members. No-op systems ordered one after another are phases, and systems during different phases are ordered with each other. Where no ordering path separates them, the two share a tier. - - **Unordered conflicts throw when a composite is created:** within a composite, two systems that both write a component or resource, or where one reads what the other writes, must have an ordering path between them. Contributors to one reduced resource don't conflict with each other, and every system that reads it is ordered after all of them. + - **Unordered conflicts throw when a composite is created:** within a composite, two systems that both write a component or resource, or where one reads what the other writes, must have an ordering path between them. Contributors to one reduced resource don't conflict with each other, and every system that reads it is ordered after all of them. Two systems whose rows never match one entity, one requiring a component in the other's `not`, don't conflict on a component both reach only through their rows; one they also look up, read through `up`, index or order by still conflicts. - **A lookup can't read a component its own system writes;** that throws at registration. `up` can, since it reads a parent only after the parent ran. - **`up: { through }` runs rows in depth order** through `through`, a reference component, parent first, and the context's `up` holds what the body reads of the row's parent, or null without one or for a parent lacking one of them. A parent's value is the one written earlier in the same run, so one run carries a change down a whole tree, and results don't depend on chunking. It's Flecs's depth-ordered (`cascade`) iteration, with the parent's values in the context. - `through` doesn't narrow the match: rows without it are roots. References change only through commands, so the tree changes only at sync points. @@ -183,7 +184,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Failures out of band** (`implementation/world/out-of-band.ts`): what fails where no run waits, in ticks and their tails, in readonly systems added, on joined threads (their readonly systems and finalizers, by message) and in GPU work, is raised on the world's thread as an `Error` naming what failed, its cause what was thrown: through the platform's `reportError` where it has one, as browsers and their workers do, so it's logged and dispatched as an `error` event; else thrown from a task of its own, an uncaught exception, as in node. Each is reported once per system, finalizer, index or GPU work while it keeps failing, and again once it recovered: a system once a tick or pass ran its body without its failing, a system its conditions or change filters skipped staying as it was; a finalizer once a tick's sync points ran it without its failing; an index once a tick found no conflict in it; GPU work once a run of its system gave its error scopes no error. Joined threads tell the world only of failures, so their readonly systems are reported once per placement and their finalizers once. Once the world is disposed nothing more is reported, a GPU error or a tick's tails settling after it included. A run's value is kept only for the run that resolves to it (`State.wanted`): its runner's, or a joined thread's, which posts only that one, so composites' and ticks' members return values that are never held, cloned or posted. - **Threads:** `world.share(port)` lets another thread join through a port (a `MessagePort`, a `Worker` or a worker's scope), resolving once it joined: its claims made, and the readonly systems it added as its join resolved in place, so a run started afterwards runs on the threads it claims and reaches them; the joined thread's progress and claims are the implementation's own storage. The other thread calls the implementation's `join(port, { threads })`, whose port and options are inferred from `share`, claiming the logical threads it fills, which resolves, once the world accepted the claim, to a `World` whose `run` works as here, and whose `add` and `remove` take readonly systems, which run there after the world's sync points, by the ids the plugin gives top-level systems and keys (a readonly system it adds needs none, only its keys do): a run of a system without one, or one the world never imported, rejects, and a thread claimed twice, or one with a system or finalizer it can't run, without an id, naming a key without one, declaring `up` or `order`, looking up through an index with an object key part or with `changed: true`, or not imported there, can't join, its `join` and the world's `share` rejecting with why, as both do when the world is disposed, a port is shared again while its thread lives, or the other side's thread ended first as the platform reports it: a `Worker`'s `error` before it joined, a port's `close`, a node worker's `exit`, or, once the other side heard from it, a Web Lock; a browser worker ended before the other side heard from it isn't reported, and that side stays pending ([`threads.md`](threads.md)). A joined thread adds readonly systems before awaiting anything else after `join`, so `share` resolves with them in place. The objects and object resources of the threads it claims are its own: commands elsewhere give them only their start or `null`, and systems elsewhere can't read them; the systems that read or write them run there, each tier waiting for it, and are skipped with a `"thread"` failure once it's gone, later tiers still running. A terminal one, whose body the plugin proves never reaches `commands` and which writes only those objects, reduces into nothing and looks nothing up, runs as a tail when no later system conflicts with it: after its tick's sync point, unwarned, while the next tick goes on, a writer of what it still reads first copying the rest of its reads and waiting for at most the chunk it claimed; a run waits for its tails, failing with theirs, and a tick's failures are reported out of band once its tails are done. The finalizers that read them run there too, after the sync point: the world copies each stop's declared struct reads into that thread's stop list without waiting, the thread runs them as it next replays that sync, before its next task or post of a readonly system, and the index of an entity they stopped is held until the thread acknowledges; what they throw reaches the world by message and is reported out of band. When it's gone its objects come back to the world's thread, its held indexes return, stops it never finalized are `"thread"` failures, and its systems and finalizers run here again. Once the world is disposed, a joined thread finalizes every row its finalizers match. Without cross-origin isolation both reject. - **Readonly systems are the way to read a world.** A consumer reads once by running a readonly system that copies what it reads into variables of its own, and follows changes by adding one whose `changed` filters pass only after the runs and ticks that changed what it reads; the world has no readonly systems. Derived values are computed by systems, inside the ECS. - - **Tables:** a readonly system without components declaring `tables: { components, with, not, resources }` reads, on the world's thread, `tables.archetypes`: of each archetype matching `with` and `not` that holds any of `components`, its count, its entities in row order and a copy of each of those components' columns it holds, as bytes in its layout (`stride` per row) for a linear one, values for a string or an object, and one value for a shared component or a constant; and `tables.resources`, each resource's value. What it names counts as what it reads, for readback and placement (`tables-read.ts`). A joined thread refuses one, running or adding it. `commands.spawnTables(tables, { resources?, existing? })` spawns their rows back, one batch block copy per column, and rewrites every entity value naming an entity of the tables, a reference, a struct's field or an array's item, to the one spawned for it; for entities outside them it does what despawning them would: a row owned by one, directly or through owner references among the tables, isn't spawned, a weak reference to one is left out of its row, which goes to the archetype without it, and any other value naming one is `Entity.none`. `existing` maps entities to ones alive in this world, which stand for them: a row whose entity it maps isn't spawned, and every value naming one it maps, in the tables or not, names the entity it maps to, so what a row it maps owns or points at is kept; one mapping to an entity that isn't alive fails the system first, spawning nothing. Merges by identity build on it, the policy of which entities are the same outside the core. `resources` sets each resource it names at the sync point, in a buffer of the world's own, since the sync point skips systems' commands to the resource entity: checked first, so a resource only the world writes, one on another thread, or a value its schema doesn't take fails the system, spawning nothing. It returns each table's entities, the one existing for a row `existing` maps, `Entity.none` for a row it didn't spawn, and how many those were. A column whose stride isn't its component's, whose length isn't its table's count, or that gives a shared component anything but its value fails the system. Without a value that can hold an entity, nothing is mapped (`tables.ts`). Persistence and copies between worlds build on these, outside the core (`@adobe/ecs-persistence`). + - **Tables:** a readonly system without components declaring `tables: { components, with, not, resources }` reads, on the world's thread, `tables.archetypes`: of each archetype matching `with` and `not` that holds any of `components`, its count, its entities in row order and a copy of each of those components' columns it holds, as bytes in its layout (`stride` per row) for a linear one, values for a string or an object, and one value for a shared component or a constant; and `tables.resources`, each resource's value. What it names counts as what it reads, for readback and placement (`tables-read.ts`). A joined thread refuses one, running or adding it. `commands.spawnTables(tables, { resources?, existing?, update? })` spawns their rows back, one batch block copy per column, and rewrites every entity value naming an entity of the tables, a reference, a struct's field or an array's item, to the one spawned for it; for entities outside them it does what despawning them would: a row owned by one, directly or through owner references among the tables, isn't spawned, a weak reference to one is left out of its row, which goes to the archetype without it, and any other value naming one is `Entity.none`. `existing` maps entities to ones alive in this world, which stand for them: a row whose entity it maps isn't spawned, and every value naming one it maps, in the tables or not, names the entity it maps to, so what a row it maps owns or points at is kept; one mapping to an entity that isn't alive fails the system first, spawning nothing. With `update: true`, each row it maps is written onto the entity standing for it, as a `commands.add` of its table's components queued where `spawnTables` was called: added where the entity lacks one, replaced and stamped changed where it has it, its other components left, its entity values rewritten as a spawned row's are, but a reference, weak or owner, naming an entity that isn't there, which is left out so the entity's own stands. Merges by identity build on it, the policy of which entities are the same outside the core. `resources` sets each resource it names at the sync point, in a buffer of the world's own, since the sync point skips systems' commands to the resource entity: checked first, so a resource only the world writes, one on another thread, or a value its schema doesn't take fails the system, spawning nothing. It returns each table's entities, the one existing for a row `existing` maps, `Entity.none` for a row it didn't spawn, and how many those were. A column whose stride isn't its component's, whose length isn't its table's count, or that gives a shared component anything but its value fails the system. Without a value that can hold an entity, nothing is mapped (`tables.ts`). Persistence and copies between worlds build on these, outside the core (`@adobe/ecs-persistence`). - **Declarations:** `Component.declarations()` and `Resource.declarations()` give every key its modules registered so far, with its declaration: its schema as `type`, its `default` and its traits, those of your own by their keys, so a library finds the keys a trait of its own marks. `Index.declarationOf(index)` gives an index's declaration as registered, its key, `components: { with, not }`, `sorted` and `unique`, or undefined for a value `Index.create` didn't return, so a library reads the keys of an index it's given. - **Observer factories** (`src/observe/`, internal, never exported: consumers write plain readonly systems; its own TypeScript project importing only the package root, for tests, debugging and benchmarks) give readonly systems as `Observe.resource(world, key)`, `Observe.entity(world, entity, { required, optional })` (no whole-entity form), `Observe.select(world, query)`, `Observe.count(world, query)` and `Observe.toPromise`, built on readonly systems alone. Per world, in a `WeakMap`: one readonly system per resource key and per shape of entity observer (its required and optional lists) or query (components, `where` keys, `order`, whether paged), shared by every subscriber, each `where` and page of a query an entry. A value they hold goes to a new subscriber during the subscribing call, as of the last sync point; any other waits for the flush at the end of the task, which runs what reads it (a resource's reader, a query's scan) and one delivering run, after every run called before, so cold first values are read-your-writes and come in subscribe order; a first value whose notify throws is thrown out of band and stops that subscriber, a later one's is a `"system"` failure of its run or tick. A maintenance readonly system, added first, counts passes and removes shapes the passes found idle in three, so loops of first values stay warm; a resource's reader runs before it in its own run, and its notifier, filtering on the resource's `changed`, skips subscribers that had their first value in that pass. The factories' own runs, which nothing awaits, rethrow their rejections out of band; what their systems added to the world throw the world reports itself. Entity shapes look each entity up by entity with `changed`, reading values only once changed, and with no optional components skip every entity while an index without a key on the required ones reports no change. A query's first values come from one scan, a readonly system row system run for them, ties and unordered results put in entity order; a scan of one entry with at most one test, every entity or a count, gathers in the scanner's body into typed scratch, each value made at its length; only once a subscriber outlives its first value is the shape's tracker added: a sorted index on `order` when every term goes one direction, read from its end for descending, each run of equal keys in entity order; for a count, or a selection without a page, a sorted index on its first `where` key whose values have an order, each entry looking up its range with every bound made inclusive, so it holds every row the condition passes at the precision values are kept at, then testing each row, or for a count of one condition, taking the range's length less the rows at a bound the test rejects (rows at one key share their value, so one tells for all); otherwise an index without a key, in entity order. Each notifies only when its result differs; unordered results in entity order compare as sequences. Disposed worlds run nothing, so a first value not held never comes. `observe-cases.test.ts`, `observe-ticks-cases.test.ts`, `observe-cases.browser.test.ts` and `observe-factories.test.ts` cover them, and tests read through them (`reads.test-helper.ts`); tests of storage order, or of a joined thread's objects as of the world's last sync point, read through runs of readonly systems. Benchmarks count ticks with a readonly system filtering on the clock, and read before what they time through observer runs, which leave nothing added (`perftest/observer.ts`); the factories only check results once nothing more is timed on a world, since they keep their maintenance system added there. On a joined thread's world, which the factories know by its `remove` refusing a system that isn't a readonly system, they add no maintenance or delivering system, since readonly systems run there in posts in no order with one another: a shape's cold first values come once a run of its own system resolved, a post for it having run after every run the thread called before subscribing (a resource's notifier, whose first run once added passes its filter; an entity shape's system; a query's tracker, added on first use, with no scan, since a scan's rows can't be told from a later post's there, and for an ordered selection without a page an index without a key sorted there rather than a sorted one the world's thread keeps); a pass is a turn of the thread's event loop that ran a shape's system, idle shapes swept after it; indexes they make reach the world by declaration; a system the thread can't place, as for objects of the world's thread, throws when subscribed. On the world's thread, one naming objects a joined thread fills fails to register: reported out of band for a system added, and for the factories' own runs, whose rejections on a live world are rethrown. `observe-joined-cases.test.ts` covers them on a joined thread, and the worker cases of `remote-objects` and `joining` on joined workers. - **Identity is the value, and labels are bindings:** no label is ever looked up, so systems, rules and finalizers can't collide across separately written code, as keys can't. Errors name one by its label: the binding its id names (`linked` for `lib@1.0.0/src/link.ts#linked`); for one made where no top-level binding is, the binding, function, property or method the plugin found it in (`mover` for one made in `const mover = (speed) => System.create(...)`), with no id; and else its kind numbered by creation (`"system #3"`, `"rule #1"`, `"finalizer #2"`). A label never changes as other items load, and failures give the id beside it to tell items of one label apart. Internal factories label theirs through the registry's map of labels, which the plugin's labels never override. @@ -231,7 +232,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Order:** `before` and `after` list other finalizers, then creation order. Two that both write a resource, or where one reads what the other writes, must be ordered. Every active finalizer runs at every sync point; none is in a composite, and the phase costs nothing when none is active. - **Throwing:** the plan still applies, and the run fails naming each finalizer that threw; in a tick each is reported out of band. - **`dispose()`** runs per-query finalizers for every live match, so handles are released, and no whole-plan finalizers. -- **Whole-plan finalizers** see the sync point's structural changes rather than one query's stops, for persistence, replication and structural undo: `Finalizer.changes({ watch }, (changes, context) => …)`. Changes are grouped by archetype pair (`from`, `to`, null for spawned or despawned, `added`, `removed`, `replaced`, `entities`), `replaced` listing the components an add gave a value while the entity had them before and after, so an edit committed as one add is one change, filtered to `watch`, by default every component that isn't derived or an object a joined thread fills; a derived one is rejected. `previous(entity, component)` and `next(entity, component)` give values before and after. `watch` may name resources too, none watched by default: `resources` lists each written since the last sync point's finalizers ran, `{ key, previous, next }`, its value when they last saw it and now, unless it's equal, leaf by leaf for data and by identity for objects, so a write that leaves it as it was isn't reported. The world keeps the last value it saw of each watched resource, from when it's added (`resource-changes.ts`), and takes a new one only after a write, by the resource's change tick, when its finalizers run; a watched resource written makes a sync point due, so a run writing only it still reports it, and what finalizers write is seen at the next sync point. They are ordered with per-query finalizers. +- **Whole-plan finalizers** see the sync point's structural changes rather than one query's stops, for persistence, replication and structural undo: `Finalizer.changes({ watch }, (changes, context) => …)`. Changes are grouped by archetype pair (`from`, `to`, null for spawned or despawned, `added`, `removed`, `replaced`, `entities`), `replaced` listing the components an add gave a value while the entity had them before and after, so an edit committed as one add is one change, filtered to `watch`, by default every component that isn't derived or an object a joined thread fills; a derived one is rejected. `previous(entity, component)` and `next(entity, component)` give values before and after. `watch` may name resources too, none watched by default: `resources` lists each written since the last sync point's finalizers ran, `{ key, previous, next }`, its value when they last saw it and now, unless it's equal, leaf by leaf for data and by identity for objects, so a write that leaves it as it was isn't reported. `watch` may name a trait of your own too, which watches every component and resource declaring it, each as it registers, a component by the runner's predicate over ids (`watches`, each id's declaration read once) and a resource by the world's list of those it follows, which grows between runs as the keys' version moves (`ensureProgramResources`), but derived components, objects a joined thread fills and resources on another thread, which the default leaves out too. Its object form, `{ components: true | [...], resources: [...] }`, watches every component, or those and traits listed, beside the resources and traits listed. The world keeps the last value it saw of each watched resource, from when it's added (`resource-changes.ts`), and takes a new one only after a write, by the resource's change tick, when its finalizers run; a watched resource written makes a sync point due, so a run writing only it still reports it, and what finalizers write is seen at the next sync point. They are ordered with per-query finalizers. - **Async resources** need no other API: a rule derives a slot; a system with `added: [slot]` writes a fresh token into it and starts the load; the completion calls `world.run(apply, { entity, token, value })`, which adds the ready component (`within: slot`) if the slot's token still matches and releases the value otherwise; a finalizer on the slot aborts the load, and one on the ready component releases it. Shared assets are reference-counted by the loader, since the ECS gives each user exactly one acquire and one release. - **Reductions** compute one value from many rows, in kernels across workers as well as on the main thread. diff --git a/ecs/packages/ecs/followups.md b/ecs/packages/ecs/followups.md index 41fef9a7..8ebb8a75 100644 --- a/ecs/packages/ecs/followups.md +++ b/ecs/packages/ecs/followups.md @@ -17,9 +17,6 @@ correctness, then optimization. Remove an item once it's decided or done. - **Iterable lists:** `for (const row of list)` on index lookups' lists, binding each row as the loop reaches it with no allocation (the list is its own iterator, reset per loop), lowered by kernels to the indexed loop. `list[i]` and array methods stay out: indexing can't bind lazily without a Proxy, and the methods would widen the surface. -- **`watch` by trait on changes finalizers:** `Finalizer.changes({ watch: file }, …)`, `watch` taking a trait's key as - well as a list of keys, typed as `Component.With<…>`, computed once per component id as `watches` already is. The - hook external persistence would build on. - **Readonly systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities don't work as bindings. Proposal: allow `up` in readonly systems, since it only reads; let `order` take an index over the entity a reference names. (from the todo sample) @@ -31,11 +28,6 @@ correctness, then optimization. Remove an item once it's decided or done. - **Frames in flight:** `run` resolves once its work is submitted, before the GPU finishes it, so a loop on a slow GPU queues frames without bound; the particles sample caps them at two with `onSubmittedWorkDone`. Possible: the world's loop skips a beat while a set number of submitted frames are unfinished, the number a resource. -- **A resource holding objects that aren't data can't start as an empty map:** a `ReadonlyMap` of `GPUTexture`s, say, - gets no fresh map, and a namespace's `default` holding an empty one fails the build's deep readonly check at the - objects. Such a resource allows `null` instead, which `@adobe/ecs-gpu`'s `states` resources do, and every reader - handles it. Possible: a fresh empty `ReadonlyMap` or `ReadonlySet` for one of objects too, since emptiness doesn't - depend on what it would hold. - **Pipelines built from a device a body captured go stale** when `gpuDevice` changes: the world rebuilds its own GPU objects, and a consumer's must follow `changed: [gpuDevice]`. `System.render`'s and `gpuDevice`'s docs should say so. - **Descending sorted indexes:** an ordered render reads its index front to back, so a consumer painting back to front @@ -68,7 +60,9 @@ correctness, then optimization. Remove an item once it's decided or done. rows the body only iterates. The repository has no ESLint plugin or configuration yet, so it needs deciding where such rules live, and whether it's a rule or a build warning, since the plugin sees every system and index. - **`despawnWhere` from the build:** a row system whose whole body is `commands.despawn(row.entity)` could be lowered to - `commands.despawnWhere` of its query; and `despawnWhere` on joined threads, through their command rings. + `commands.despawnWhere` of its query, which keeps its meaning while no command issued before it at its sync point + changes what the query matches, as a sync point between them ensures for a system ordered before it; and + `despawnWhere` on joined threads, through their command rings. - **A trait key a library declares** (adobe/data#239): the compiler reads a trait's key where an application's module makes it with `Symbol()`. A library whose types declare one, `declare const key: unique symbol` in a `.d.ts`, would need the compiler to take such a declaration as a key. No library declares one. diff --git a/ecs/packages/ecs/package.json b/ecs/packages/ecs/package.json index 6209e5ba..bae76e55 100644 --- a/ecs/packages/ecs/package.json +++ b/ecs/packages/ecs/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/ecs", - "version": "0.2.4", + "version": "0.3.0", "description": "Adobe ECS", "license": "MIT", "type": "module", diff --git a/ecs/packages/ecs/src/guide/01-resources.md b/ecs/packages/ecs/src/guide/01-resources.md index f3c047cc..5f3712f8 100644 --- a/ecs/packages/ecs/src/guide/01-resources.md +++ b/ecs/packages/ecs/src/guide/01-resources.md @@ -10,11 +10,12 @@ A world starts each resource at the first of these that applies: 3. its type's namespace's `default`, as [`Height`](01-resources.ts#L11-L12) gives; every world shares that value, so it's read-only; 4. a reduction's identity ([Reductions](15-reductions.md)), `null` for a nullable type, the zero value (`0`, `false`, - `""`, item by item), or an empty `ReadonlyMap` or `ReadonlySet` of each world's own. + `""`, item by item), or an empty `ReadonlyMap` or `ReadonlySet` of each world's own, whatever it holds, which takes + no `default`. Data, such as numbers, strings, tuples, structs, maps and sets, is held as a value: a system replaces it whole. A value -that isn't data, such as an `AudioContext`, is stored as it is; a change inside it is seen only once the resource is -written back. An `Entity` resource with no entity holds `Entity.none`, which is `0` rather than `null`, so it stays data +that isn't data, such as an `AudioContext`, a symbol such as a `Resource.Key`, or a map or set holding one, is stored +as it is; a change inside it is seen only once the resource is written back. An `Entity` resource with no entity holds `Entity.none`, which is `0` rather than `null`, so it stays data every thread can read. A system reads resources by key. With `readonly: true` it only reads, and runs after the world's changes apply. diff --git a/ecs/packages/ecs/src/guide/03-systems.out b/ecs/packages/ecs/src/guide/03-systems.out index 60f10242..76b7cb36 100644 --- a/ecs/packages/ecs/src/guide/03-systems.out +++ b/ecs/packages/ecs/src/guide/03-systems.out @@ -1,5 +1,5 @@ at [ 0, 1, 5 ] moving [ 'at 2', 'at 2', 'still' ] frames 1 -Error: System "fail" (@adobe/ecs@0.2.4/src/guide/03-systems.ts#fail) failed: broken +Error: System "fail" (@adobe/ecs@0.3.0/src/guide/03-systems.ts#fail) failed: broken frames 2 diff --git a/ecs/packages/ecs/src/guide/05-scheduling.md b/ecs/packages/ecs/src/guide/05-scheduling.md index 107f67e7..339a82db 100644 --- a/ecs/packages/ecs/src/guide/05-scheduling.md +++ b/ecs/packages/ecs/src/guide/05-scheduling.md @@ -1,14 +1,20 @@ # Scheduling and composites `after`, `before` and `during` order a system against others, listing the systems themselves: -[`heal`](05-scheduling.ts#L18-L20) runs `after: [hit]`. +[`heal`](05-scheduling.ts#L22-L24) runs `after: [hit]`. A *composite* is a list of systems, `System.create([heal, hit])`, run as one system. Its members run in the order -their constraints give, not the order they're listed, so [`frame`](05-scheduling.ts#L21) hits before it heals. +their constraints give, not the order they're listed, so [`frame`](05-scheduling.ts#L25) hits before it heals. Members with no order between them may run in parallel; two that write what the other reads or writes must be -ordered, or the composite is rejected. `System.create({ systems, after, ... })` declares a composite with constraints -of its own. +ordered, or the composite is rejected, unless their rows never match one entity, one's `not` naming a component the +other requires, and they use what they share only through their rows. `System.create({ systems, after, ... })` +declares a composite with constraints of its own. -A *phase* is a system that does nothing, such as [`update` and `render`](05-scheduling.ts#L28-L29). A system +A *phase* is a system that does nothing, such as [`update` and `render`](05-scheduling.ts#L32-L33). A system `during: [update]` runs after what `update` follows and before what follows `update`. Phases let systems written apart -agree on an order without naming each other: in [`phased`](05-scheduling.ts#L36), `poison` runs before `report`. +agree on an order without naming each other: in [`phased`](05-scheduling.ts#L40), `poison` runs before `report`. + +`before` and `after` also take a resource: a system listing one runs before, or after, every other system of the +composite that uses it, as [`harden`](05-scheduling.ts#L43) runs `before: [difficulty]`, so `scale` reads the +difficulty it set. One that must read the value before it's written says so of the writer itself, `before: [harden]`; +two systems listing the same resource aren't ordered with each other by it, so two writers still need an order. diff --git a/ecs/packages/ecs/src/guide/05-scheduling.out b/ecs/packages/ecs/src/guide/05-scheduling.out index 8e89e616..bb4e34b7 100644 --- a/ecs/packages/ecs/src/guide/05-scheduling.out +++ b/ecs/packages/ecs/src/guide/05-scheduling.out @@ -1,2 +1,3 @@ health 10 health as it renders 9 +damage 8 diff --git a/ecs/packages/ecs/src/guide/05-scheduling.ts b/ecs/packages/ecs/src/guide/05-scheduling.ts index 87f3c019..a3bfca03 100644 --- a/ecs/packages/ecs/src/guide/05-scheduling.ts +++ b/ecs/packages/ecs/src/guide/05-scheduling.ts @@ -3,12 +3,16 @@ import { System, World } from "../index.js"; const health = Symbol(); const damage = Symbol(); +const difficulty = Symbol(); declare module "@adobe/ecs" { interface Components { [health]: { type: number }; [damage]: { type: number }; } + interface Resources { + [difficulty]: { type: number; default: 1 }; + } } const spawn = System.create(({ commands }) => { commands.spawn([health, damage])({ [health]: 10, [damage]: 4 }); }); @@ -35,6 +39,16 @@ const report = System.create({ components: { with: [health] }, during: [render] }); const phased = System.create([report, poison, update, render]); +// Ordering by a resource: before every other system that uses it. +const harden = System.create({ before: [difficulty] }, ({ resources }) => { resources[difficulty] = 2; }); +const scale = System.create({ components: { with: [damage] } }, (row, { resources }) => { + row[damage] = 4 * resources[difficulty]; +}); +const tuned = System.create([scale, harden]); +const readDamage = System.create({ readonly: true, components: { with: [damage] } }, row => { + console.log("damage", row[damage]); +}); + export const main = async (): Promise => { await using world = World.create(); await world.run(spawn); @@ -43,4 +57,7 @@ export const main = async (): Promise => { await world.run(read); // poison, then report. await world.run(phased); + // harden, then scale, 4 * 2. + await world.run(tuned); + await world.run(readDamage); }; diff --git a/ecs/packages/ecs/src/guide/06-sync-points.md b/ecs/packages/ecs/src/guide/06-sync-points.md index 9367bd48..1d5829e3 100644 --- a/ecs/packages/ecs/src/guide/06-sync-points.md +++ b/ecs/packages/ecs/src/guide/06-sync-points.md @@ -4,18 +4,26 @@ A run applies its commands at *sync points*. - **Values** written to rows and resources apply at once. - **Commands** wait for a sync point, then apply in the order issued. `commands.spawn` returns the new entity at - once, so [later commands can name it](06-sync-points.ts#L18-L23): `commands.add` sets the hero's health to 5. An + once, so [later commands can name it](06-sync-points.ts#L20-L25): `commands.add` sets the hero's health to 5. An entity spawned and removed with `commands.despawn` before the sync point never exists. Every run ends at a sync point. Within it, the build tells which components each system's commands name, and a system that follows one whose commands could change what it reads, matches, looks up or indexes gets a sync point before its -tier. In [`frame`](06-sync-points.ts#L30), `count` runs `after: [spawn]`, so the hero exists by then and it counts it; -[`early`](06-sync-points.ts#L27-L29), ordered with nothing, runs beside `spawn`, before that sync point, and counts -none. Sync points cost a little each, so a run adds the fewest that put one between every such pair, and none where no -system depends on another's commands. +tier. In [`frame`](06-sync-points.ts#L32), `count` runs `after: [spawn]`, so the hero exists by then and it counts it; +[`early`](06-sync-points.ts#L29-L31), ordered with nothing, runs beside `spawn`, before that sync point, and counts +none. An ordering costs a little, a tier boundary, and a sync point where the earlier system's commands could change +what the later one reads, which costs more only when commands were issued: a run adds the fewest sync points that put +one between every such pair, and none where no system depends on another's commands. + +**One-frame events.** An event, such as a key going down, is an entity that lives one frame. The system making +events clears those it made before and makes this frame's in one body, as +[`press`](06-sync-points.ts#L40-L43) does: `commands.despawnWhere({ with: [keyDown] })` despawns the entities with +`keyDown` that were alive before its sync point, never one the same sync point spawns, so `handle`, `after: [press]`, +reads this frame's key and no earlier one. Events many systems make are cleared instead by one system ordered `after` +a composite of everything else, which despawns them once every system has read them. **Readonly systems.** `readonly: true` places a system after the last sync point, so it reads what the commands did. -Added to a world, [`watch`](06-sync-points.ts#L32-L34) runs after the last sync point of every run and tick, and logs +Added to a world, [`watch`](06-sync-points.ts#L34-L36) runs after the last sync point of every run and tick, and logs the hero after the run that spawned it; `count`, which isn't readonly, runs in its tier. - **Where:** a readonly system runs on the thread that added or ran it, so a [joined thread](19-threads.md) adds only diff --git a/ecs/packages/ecs/src/guide/06-sync-points.out b/ecs/packages/ecs/src/guide/06-sync-points.out index 3bf4baf6..63be7a9f 100644 --- a/ecs/packages/ecs/src/guide/06-sync-points.out +++ b/ecs/packages/ecs/src/guide/06-sync-points.out @@ -1,2 +1,4 @@ counted 1 early 0 health 5 +pressed a +pressed b diff --git a/ecs/packages/ecs/src/guide/06-sync-points.ts b/ecs/packages/ecs/src/guide/06-sync-points.ts index 3c5e7fa5..5d3c6816 100644 --- a/ecs/packages/ecs/src/guide/06-sync-points.ts +++ b/ecs/packages/ecs/src/guide/06-sync-points.ts @@ -4,10 +4,12 @@ import { System, World } from "../index.js"; const health = Symbol(); const seen = Symbol(); const seenEarly = Symbol(); +const keyDown = Symbol(); declare module "@adobe/ecs" { interface Components { [health]: { type: number }; + [keyDown]: { type: string }; } interface Resources { [seen]: { type: number }; @@ -34,6 +36,16 @@ const watch = System.create({ readonly: true, components: { with: [health] } }, }); const readSeen = System.create({ readonly: true }, ({ resources }) => [resources[seen], resources[seenEarly]]); +// One-frame events: the producer clears the events it made before, then makes this frame's. +const press = System.create({}, ({ commands }, input: { readonly key: string }) => { + commands.despawnWhere({ with: [keyDown] }); + commands.spawn([keyDown])({ [keyDown]: input.key }); +}); +const handle = System.create({ components: { with: [keyDown] }, after: [press] }, row => { + console.log("pressed", row[keyDown]); +}); +const keys = System.create([press, handle]); + export const main = async (): Promise => { await using world = World.create(); await world.run(frame); @@ -43,4 +55,7 @@ export const main = async (): Promise => { await using watched = World.create(); watched.add(watch); await watched.run(spawn); + + await world.run(keys, { key: "a" }); + await world.run(keys, { key: "b" }); }; diff --git a/ecs/packages/ecs/src/guide/10-finalizers.md b/ecs/packages/ecs/src/guide/10-finalizers.md index 82dfed57..e33b6861 100644 --- a/ecs/packages/ecs/src/guide/10-finalizers.md +++ b/ecs/packages/ecs/src/guide/10-finalizers.md @@ -11,3 +11,8 @@ closes too. A *change set* is each sync point's structural changes to the components a `Finalizer.changes` watches, grouped by how entities changed. [`changes.previous` and `changes.next`](10-finalizers.ts#L32-L36) give a component's value before and after, `null` where the entity didn't have it: what saving and undo need. + +It watches what `watch` names: components, resources, whose writes it sees as `changes.resources`, and traits of your +own, every component and resource declaring one, as each registers, so a list kept by hand never misses a key. Without +`watch`, every component and no resource; `watch: { components: true, resources: [...] }` watches every component and +the resources listed. diff --git a/ecs/packages/ecs/src/guide/18-identity.out b/ecs/packages/ecs/src/guide/18-identity.out index 32e81355..f239f80f 100644 --- a/ecs/packages/ecs/src/guide/18-identity.out +++ b/ecs/packages/ecs/src/guide/18-identity.out @@ -1 +1 @@ -Error: System "explode" (@adobe/ecs@0.2.4/src/guide/18-identity.ts#explode) failed: boom +Error: System "explode" (@adobe/ecs@0.3.0/src/guide/18-identity.ts#explode) failed: boom diff --git a/ecs/packages/ecs/src/guide/guide.test.ts b/ecs/packages/ecs/src/guide/guide.test.ts index 45a1dd17..e020698d 100644 --- a/ecs/packages/ecs/src/guide/guide.test.ts +++ b/ecs/packages/ecs/src/guide/guide.test.ts @@ -8,8 +8,8 @@ const teaches: { readonly [chapter: number]: readonly string[] } = { 2: ["Components", "type: true", "as const", "commands.spawn", "commands.remove", "with: [...Particle]"], 3: ["System.create", "with", "not", "readonly: true", "optional: [velocity]"], 4: ["clock", "delta", "cadence", "always", "resources: { changed: [elapsed] }", "wake", "reduce[wake]", "world.add", "world.remove"], - 5: ["after", "during", "System.create([heal, hit])", "during: [update]"], - 6: ["commands.spawn", "commands.add", "commands.despawn", "after: [spawn]", "readonly: true"], + 5: ["after", "during", "System.create([heal, hit])", "during: [update]", "before: [difficulty]"], + 6: ["commands.spawn", "commands.add", "commands.despawn", "after: [spawn]", "readonly: true", "commands.despawnWhere({ with: [keyDown] })"], 7: ["resources: { changed: [level] }", "changed: [score]", "resources: { is: [map], not: [paused] }", "resources[map]"], 8: ["world.run(hit, { amount: 3 })", "target: \"entity\""], 9: ["Rule.create", "derived: true"], @@ -45,8 +45,8 @@ const mutations: { readonly [chapter: number]: readonly (readonly [string, strin ["not: [frozen]", "not: []"], ["System.create([count, fail])", "System.create([fail])"], ], 4: [["world.remove(advance);", ""]], - 5: [["after: [hit] ", ""], ["during: [update] ", ""]], - 6: [["commands.add(hero, health, 5);", ""], ["after: [spawn] ", ""], ["commands.despawn(commands.spawn([health])({ [health]: 1 }));", "commands.spawn([health])({ [health]: 1 });"], ["watched.add(watch);", ""], ["const watch = System.create({ readonly: true, ", "const watch = System.create({ "]], + 5: [["after: [hit] ", ""], ["during: [update] ", ""], ["{ before: [difficulty] }, ", ""]], + 6: [["commands.add(hero, health, 5);", ""], ["after: [spawn] ", ""], ["commands.despawn(commands.spawn([health])({ [health]: 1 }));", "commands.spawn([health])({ [health]: 1 });"], ["watched.add(watch);", ""], ["const watch = System.create({ readonly: true, ", "const watch = System.create({ "], ["commands.despawnWhere({ with: [keyDown] });", ""]], 7: [["resources: { changed: [level] } ", ""], [", changed: [score]", ""], [", not: [paused]", ""], ["is: [map], ", ""]], 8: [["target: \"entity\",", ""]], 9: [["with: [mesh], not: [hidden]", "with: [mesh]"], ["Rule.create({ components: { with: [sprite], not: [hidden] }, derive: [drawn] });", ""], ["Rule.create({ components: { with: [drawn] }, derive: [layer] });", ""]], diff --git a/ecs/packages/ecs/src/implementation/compiler/augmentations.test.ts b/ecs/packages/ecs/src/implementation/compiler/augmentations.test.ts index 450f56f7..adeee58c 100644 --- a/ecs/packages/ecs/src/implementation/compiler/augmentations.test.ts +++ b/ecs/packages/ecs/src/implementation/compiler/augmentations.test.ts @@ -213,7 +213,34 @@ const Height = { default: -1 } as const;`); }); test("gives a mutable Map or Set no start of its own: null when its type allows it, else a failure", async () => { expect(await registrations(`${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Pool | null } } }\ntype Pool = Map;`)).toEqual([`declare$(pool, "resource", {});`]); - expect(await passed(`${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Pool } } }\ntype Pool = Map;`)).toContain("a ReadonlyMap or ReadonlySet starts fresh in each world, a Map or Set doesn't"); + expect(await passed(`${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Pool } } }\ntype Pool = Map;`)).toContain("or declare it a ReadonlyMap or ReadonlySet, which starts empty in each world"); + }); + test("starts a ReadonlyMap or ReadonlySet of what isn't data fresh in each world, with no schema", async () => { + const files = { "/src/pool.ts": `export class Pool {}` }; + expect(await registrations(`import { Pool } from "./pool.js";\nimport type { Resource, Component } from "@adobe/ecs";\n${keys("written", "pools", "marks", "seen", "named", "byKey")}declare module "@adobe/ecs" { + interface Resources { + [written]: { type: ReadonlyMap }; + [pools]: { type: ReadonlyMap }; + [marks]: { type: ReadonlySet }; + [seen]: { type: ReadonlySet }; + [named]: { type: Named }; + } + interface Components { [byKey]: { type: ReadonlySet } } +} +type Named = ReadonlyMap;`, files)).toEqual([ + `declare$(written, "resource", { fresh: "Map" });`, + `declare$(pools, "resource", { fresh: "Map" });`, + `declare$(marks, "resource", { fresh: "Set" });`, + `declare$(seen, "resource", { fresh: "Set" });`, + `declare$(named, "resource", { fresh: "Map" });`, + `declare$(byKey, "component", {});`, + ]); + }); + test("fails for a default of a ReadonlyMap or ReadonlySet, which starts empty in each world", async () => { + for (const type of ["ReadonlySet", "ReadonlyMap", "ReadonlySet"]) { + expect(await passed(`${keys("tags")}declare module "@adobe/ecs" { interface Resources { [tags]: { type: ${type}; default: [] } } }`)) + .toContain(`tags is a ${type.slice(0, type.indexOf("<"))}, which starts empty in each world: leave its default out`); + } }); test("gives a value that isn't data no schema, and its traits", async () => { const files = { "/src/row.ts": `export class Row extends HTMLElement {}\nexport class Pool {}` }; diff --git a/ecs/packages/ecs/src/implementation/compiler/augmentations.ts b/ecs/packages/ecs/src/implementation/compiler/augmentations.ts index 697f6d85..90ace581 100644 --- a/ecs/packages/ecs/src/implementation/compiler/augmentations.ts +++ b/ecs/packages/ecs/src/implementation/compiler/augmentations.ts @@ -819,6 +819,9 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH if (error.notData.node && !thread) fail(module, member, `${key.name} is a DOM Node, which lives on "main": declare \`thread: "main"\``); } await checkBuiltIns(key.name, kind, typeNode, schema, builtIns); + const freshType = schema !== undefined ? null : await converter.freshOf(typeNode, module); + const emptied = schema !== undefined && "type" in schema && (schema.type === "map" || schema.type === "set") ? (schema.type === "map" ? "Map" : "Set") : freshType; + if (defaultNode !== undefined && emptied !== null) fail(module, defaultNode, `${key.name} is a Readonly${emptied}, which starts empty in each world: leave its default out`); const given = defaultNode === undefined ? undefined : await literalDefault(defaultNode, key.name); if (schema !== undefined && given !== undefined && !matchesSchema(schema, given)) fail(module, defaultNode ?? member, `${key.name}'s default isn't of its declared type`); let start = given === undefined ? undefined : codeOf(given); @@ -838,7 +841,8 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH const namespaced = await namespacedDefault(typeNode, key.name, undefined, oneObject); if (namespaced !== null) read({ text: namespaced.text, locals: namespaced.local === undefined ? [] : [namespaced.local], given: true }); else if (needed && !await allowsNullType(typeNode, module, new Set())) { - fail(module, member, `${kind} ${key.name} holds a value that isn't data, so nothing gives it a start: let its type allow null, or give its type a namespace with a deeply readonly \`default\`; a ReadonlyMap or ReadonlySet starts fresh in each world, a Map or Set doesn't`); + if (freshType !== null) fresh = freshType; + else fail(module, member, `${kind} ${key.name} holds a value that isn't data, so nothing gives it a start: let its type allow null, give its type a namespace with a deeply readonly \`default\`, or declare it a ReadonlyMap or ReadonlySet, which starts empty in each world`); } } else { diff --git a/ecs/packages/ecs/src/implementation/compiler/inferred.ts b/ecs/packages/ecs/src/implementation/compiler/inferred.ts index 7e752db2..9c7837a6 100644 --- a/ecs/packages/ecs/src/implementation/compiler/inferred.ts +++ b/ecs/packages/ecs/src/implementation/compiler/inferred.ts @@ -85,8 +85,9 @@ export const accessText = (used: Used, at: string, where?: ReadonlySet | const lookupText = lookups === null ? "null" : `{ ${[...lookups].map(([name, read]) => `${JSON.stringify(name)}: ${read === null ? "null" : list(read)}`).join(", ")} }`; // The resources `where`'s predicates read, only with predicates. const whereText = where === undefined ? "" : `, where: ${where === null ? "null" : list(where)}`; - // Lists of keys stay as their bindings name them, so a list imported from another module is read where it's made. - const commandText = commands === null ? "null" : `{ spawn: ${list(commands.spawn)}, add: ${list(commands.add)}, remove: ${list(commands.remove)}, despawn: ${commands.despawn} }`; + // Lists of keys stay as their bindings name them, so a list imported from another module is read where it's made, + // and in a function, read at first need, so a list declared below the system is read once its module has loaded. + const commandText = commands === null ? "null" : `() => ({ spawn: ${list(commands.spawn)}, add: ${list(commands.add)}, remove: ${list(commands.remove)}, despawn: ${commands.despawn} })`; return `{ components: ${components}, resources: ${resourceText}, lookup: ${lookupText}, up: ${up === null ? "null" : list(up)}${whereText}, commands: ${commandText}, at: ${JSON.stringify(at)} }`; }; diff --git a/ecs/packages/ecs/src/implementation/compiler/transform.test.ts b/ecs/packages/ecs/src/implementation/compiler/transform.test.ts index 874b92b6..6179c3f0 100644 --- a/ecs/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/ecs/packages/ecs/src/implementation/compiler/transform.test.ts @@ -1436,13 +1436,13 @@ describe("inferred access", () => { test("commands by kind, keys and lists as their bindings name them, however the context reaches them", async () => { const commanded = async (body: string, declaration = "components: { with: [position] }"): Promise => - /commands: (null|\{[^}]*\})/.exec(await inferred(`const Shapes = [mass];\nconst Listed = [velocity];\n${system(declaration, body)}`))?.[1] ?? "none"; + /commands: (null|\(\) => \(\{[^}]*\}\))/.exec(await inferred(`const Shapes = [mass];\nconst Listed = [velocity];\n${system(declaration, body)}`))?.[1] ?? "none"; expect(await commanded("(row, { commands }) => { commands.spawn([mass, ...Shapes])({}); commands.add(row.entity, velocity, 1); commands.remove(row.entity, [mass]); }")) - .toBe("{ spawn: [mass, Shapes], add: [velocity], remove: [mass], despawn: false }"); + .toBe("() => ({ spawn: [mass, Shapes], add: [velocity], remove: [mass], despawn: false })"); expect(await commanded("(row, context) => { const c = context.commands; c.despawn(row.entity); context.commands.add(row.entity, Listed, {}); }")) - .toBe("{ spawn: [], add: [Listed], remove: [], despawn: true }"); + .toBe("() => ({ spawn: [], add: [Listed], remove: [], despawn: true })"); expect(await commanded("(row, context) => { const { commands } = context; commands.despawnWhere({ with: [mass] }); }")).toContain("despawn: true"); - expect(await commanded("(row) => { row[position] = 1; }")).toBe("{ spawn: [], add: [], remove: [], despawn: false }"); + expect(await commanded("(row) => { row[position] = 1; }")).toBe("() => ({ spawn: [], add: [], remove: [], despawn: false })"); for (const body of ["(row, { commands }) => { helper(commands); }", "(row, { commands }) => { commands.add(row.entity, keyOf(1), 1); }", "(row, { commands: { spawn } }) => { spawn([mass]); }", "(row, context) => { helper(context); }", "(row, { commands }) => { commands.spawnTables(tables); }"]) { diff --git a/ecs/packages/ecs/src/implementation/compiler/type-schemas.ts b/ecs/packages/ecs/src/implementation/compiler/type-schemas.ts index fd180698..e1585e6d 100644 --- a/ecs/packages/ecs/src/implementation/compiler/type-schemas.ts +++ b/ecs/packages/ecs/src/implementation/compiler/type-schemas.ts @@ -424,11 +424,15 @@ export const createConverter = (host: TypeHost, root: string) => { case "TSBooleanKeyword": return { type: "boolean" }; case "TSNullKeyword": return { type: "null" }; case "TSUnknownKeyword": return notData(use, false); + // A symbol can't be cloned to another thread or saved. + case "TSSymbolKeyword": return use === "declaration" ? notData(use, false) : fail(module, type, `${a} is ${supported}`); case "TSFunctionType": case "TSConstructorType": return use === "declaration" ? notData(use, false) : fail(module, type, `${a} is ${supported}`); case "TSParenthesizedType": return await convert(type.typeAnnotation, module, active, use); case "TSTypeOperator": - return type.operator === "readonly" ? await convert(type.typeAnnotation, module, active, use) : fail(module, type, `\`${type.operator}\` isn't ${a}: ${a} is ${supported}`); + if (type.operator === "readonly") return await convert(type.typeAnnotation, module, active, use); + // Keys, as `Resource.Key` and `unique symbol` are, may be symbols. + return use === "declaration" ? notData(use, false) : fail(module, type, `\`${type.operator}\` isn't ${a}: ${a} is ${supported}`); case "TSLiteralType": { const { literal } = type; if (literal.type === "Literal" && (typeof literal.value === "string" || typeof literal.value === "number" || typeof literal.value === "boolean")) return { const: literal.value }; @@ -677,7 +681,29 @@ export const createConverter = (host: TypeHost, root: string) => { return { anyOf: values.map(value => ({ const: value })) }; }; - return { convert, read, site, fail, typeNamed, constantNamed, returnTypeNamed, valueOfType }; + /** + * `"Map"` or `"Set"` for a declared type that is a `ReadonlyMap` or `ReadonlySet`, read through aliases, which starts + * empty in each world whatever it holds; null for any other. + */ + const freshOf = async (type: TypeNode, module: Module, seen: Set = new Set()): Promise<"Map" | "Set" | null> => { + if (type.type === "TSParenthesizedType") return await freshOf(type.typeAnnotation, module, seen); + if (type.type === "TSTypeOperator" && type.operator === "readonly") return await freshOf(type.typeAnnotation, module, seen); + if (type.type !== "TSTypeReference") return null; + const path = nameParts(type.typeName); + const [only] = path; + const count = type.typeArguments?.params.length ?? 0; + if (path.length === 1 && only !== undefined && !module.types.has(only) && !module.imports.has(only)) { + if (only === "ReadonlyMap" && count === 2) return "Map"; + if (only === "ReadonlySet" && count === 1) return "Set"; + } + if (count !== 0) return null; + const found = await typeNamed(path, module); + if (found === null || typeof found === "string" || found.declaration.type !== "TSTypeAliasDeclaration" || seen.has(found.declaration)) return null; + seen.add(found.declaration); + return await freshOf(found.declaration.typeAnnotation, found.module, seen); + }; + + return { convert, freshOf, read, site, fail, typeNamed, constantNamed, returnTypeNamed, valueOfType }; }; /** A name and the properties read from it, `Entity.none` as `["Entity", "none"]`; null for any other expression. */ diff --git a/ecs/packages/ecs/src/implementation/world/activate.ts b/ecs/packages/ecs/src/implementation/world/activate.ts index 147ee67d..2f7da9ed 100644 --- a/ecs/packages/ecs/src/implementation/world/activate.ts +++ b/ecs/packages/ecs/src/implementation/world/activate.ts @@ -2,7 +2,7 @@ import type { Finalizer } from '../../interface/finalizer/created.js'; import type { Rule } from '../../interface/rule/created.js'; import type { RuleFacts } from '../../interface/registry/strata.js'; -import { createFinalizerRunner, placeFinalizer, type FinalizerRunner } from './finalizers.js'; +import { createFinalizerRunner, followResources, placeFinalizer, watchedResourcesOf, type FinalizerRunner } from './finalizers.js'; import { watchResource } from './resource-changes.js'; import { finalizerKeysOf, type TaskChannel } from './remote-systems.js'; import { programFinalizers, programRules, programVersion } from '../../interface/registry/program.js'; @@ -10,7 +10,7 @@ import { triggersOf } from './program.js'; import { ensureResources, findResource } from './resources.js'; import { idOf, metIdOf, type Key, type State } from './state.js'; import { restratify } from './structure.js'; -import { idOfItem, idOfKey, kindOf, labelOf, threadOf } from '../../interface/registry/index.js'; +import { idOfItem, idOfKey, keysVersion, labelOf, threadOf } from '../../interface/registry/index.js'; /** * Why a joined thread that imported the items of ids `imported` can't claim `threads`: a program finalizer on one that @@ -71,19 +71,31 @@ export const placeFinalizers = (state: State): void => { /** The resources a finalizer reads, writes or, for a changes finalizer, watches. */ const resourcesOf = (finalizer: Finalizer): readonly Key[] => [ ...finalizer.declaration.resources?.read ?? [], ...finalizer.declaration.resources?.write ?? [], - ...finalizer.kind === "changes" ? (finalizer.declaration.watch ?? []).filter(key => kindOf(key) === "resource") : [], + ...finalizer.kind === "changes" ? watchedResourcesOf(finalizer.declaration.watch) : [], ]; +/** + * What a world ensured of the program's resources: the program's version then, and the keys' while a changes finalizer + * watches resources by a trait, which a resource registering later may declare. + */ +export type Ensured = { version: number; keys: number; traits: boolean }; + +/** Whether the program's finalizers may use a resource the world hasn't ensured. */ +export const programResourcesChanged = (ensured: Ensured): boolean => ensured.version !== programVersion() || (ensured.traits && ensured.keys !== keysVersion()); + /** Between runs: adds the resources of every finalizer in the program, so one can activate at any sync point. */ -export const ensureProgramResources = (state: State, ensured: { version: number }): void => { - const version = programVersion(); - if (ensured.version === version) return; - ensured.version = version; +export const ensureProgramResources = (state: State, ensured: Ensured): void => { + if (!programResourcesChanged(ensured)) return; + ensured.version = programVersion(); + ensured.keys = keysVersion(); + ensured.traits = programFinalizers().some(finalizer => finalizer.kind === "changes" && finalizer.declaration.watch.resourceTraits.length > 0); ensureResources(state, new Set(programFinalizers().flatMap(resourcesOf))); // A watched resource is followed from now, before its finalizer activates, so writes before then are seen. for (const finalizer of programFinalizers()) { - if (finalizer.kind === "changes") for (const key of finalizer.declaration.watch ?? []) if (kindOf(key) === "resource") watchResource(state, idOf(state, key)); + if (finalizer.kind === "changes") for (const key of watchedResourcesOf(finalizer.declaration.watch)) watchResource(state, idOf(state, key)); } + // Active finalizers watching resources by a trait follow those registered since. + for (const runner of state.finalizers) if (runner.finalizer.kind === "changes") followResources(state, runner.finalizer.declaration.watch, runner.resourceWatches); // Finalizers waiting for these can activate at the next sync point. state.activation.met = true; }; @@ -99,11 +111,12 @@ const ready = (state: State, item: Rule | Finalizer): boolean => { * resolves its components' ids, which may make more ready. */ export const activate = (state: State): void => { - const { activation } = state; - if (!activation.met && activation.version === programVersion()) return; - activateNow(state); + if (activationPending(state)) activateNow(state); }; +/** Whether a rule or finalizer of the program may be ready to activate: a component met, or the program changed. */ +export const activationPending = (state: State): boolean => state.activation.met || state.activation.version !== programVersion(); + /** `activate` once something may be ready: apart, so a sync point with nothing to activate allocates no closure context. */ const activateNow = (state: State): void => { const { activation, activated, structure } = state; diff --git a/ecs/packages/ecs/src/implementation/world/commands.ts b/ecs/packages/ecs/src/implementation/world/commands.ts index bde1d5c4..0e90e861 100644 --- a/ecs/packages/ecs/src/implementation/world/commands.ts +++ b/ecs/packages/ecs/src/implementation/world/commands.ts @@ -151,9 +151,10 @@ export const createCommands = (state: State, target: { readonly buffer: CommandB }, spawnTables: (tables, options) => { const resources = options?.resources; - if (resources === undefined) return spawnTables(state, tables, options?.existing); + const updates = options?.update === true ? target.buffer : null; + if (resources === undefined) return spawnTables(state, tables, options?.existing, updates); checkResources(resources); - const spawned = spawnTables(state, tables, options?.existing); + const spawned = spawnTables(state, tables, options?.existing, updates); queueResources(state, resources); return spawned; }, diff --git a/ecs/packages/ecs/src/implementation/world/create.ts b/ecs/packages/ecs/src/implementation/world/create.ts index 1878076d..e12d00a7 100644 --- a/ecs/packages/ecs/src/implementation/world/create.ts +++ b/ecs/packages/ecs/src/implementation/world/create.ts @@ -33,8 +33,8 @@ import { refreshDirectories } from './directories.js'; import { createRegistrar, type Registration } from './register.js'; import type { Schedule } from '../../interface/registry/schedule.js'; import { ensureResources, findResource, initialOf, setResource } from './resources.js'; -import { ensureProgramResources, activate, finalizerCrossing, placeFinalizers } from './activate.js'; -import { isObserver, isWorldWritten, kindOf, programVersion, scheduleOf, type Composite } from '../../interface/registry/index.js'; +import { ensureProgramResources, activate, finalizerCrossing, placeFinalizers, programResourcesChanged, type Ensured } from './activate.js'; +import { isObserver, isWorldWritten, kindOf, scheduleOf, type Composite } from '../../interface/registry/index.js'; import { always } from '../../interface/resources/always.js'; import { cadence, type Cadence } from '../../interface/resources/cadence.js'; import { clock } from '../../interface/resources/clock.js'; @@ -284,7 +284,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti if (state.observer.posts.length !== 0) await settlePosts(state, true); }; let disposed = false; - const ensured = { version: -1 }; + const ensured: Ensured = { version: -1, keys: -1, traits: false }; const minimumChunk = options.minimumChunk ?? defaultMinimumChunk; @@ -545,7 +545,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti /** Follows what changed since the last run: program resources, change watches, workers and the device; null when nothing did. */ const prepareRun = (): Promise | null => { // Resources added between runs move the resource entity, whose structs a tail may still read. - if (ensured.version !== programVersion() && (state.channels.length > 0 || state.observer.posts.length > 0)) return drainThenPrepare(); + if (programResourcesChanged(ensured) && (state.channels.length > 0 || state.observer.posts.length > 0)) return drainThenPrepare(); ensureProgramResources(state, ensured); if (startWatches(state)) return followChanges(true, false); if (workersChanged()) return followChanges(false, true); diff --git a/ecs/packages/ecs/src/implementation/world/defaults.test.ts b/ecs/packages/ecs/src/implementation/world/defaults.test.ts index 6306b000..5ee1d72a 100644 --- a/ecs/packages/ecs/src/implementation/world/defaults.test.ts +++ b/ecs/packages/ecs/src/implementation/world/defaults.test.ts @@ -4,7 +4,7 @@ import { Rule } from "../../interface/rule/rule.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "../index.js"; import { readResource } from "./reads.test-helper.js"; -import { Entity, F32, I32, Mat4x4, Quat, U32, Vec2, Vec3, Vec4 } from "../../index.js"; +import { Entity, F32, I32, Mat4x4, Quat, U32, Vec2, Vec3, Vec4, type Resource } from "../../index.js"; const pool = Symbol(); const names = Symbol(); @@ -14,6 +14,8 @@ const index = Symbol(); const turn = Symbol(); const least = Symbol(); const all = Symbol(); +const written = Symbol(); +const keys = Symbol(); /** A pool declared through an alias of ReadonlyMap. */ type Pool = ReadonlyMap; @@ -30,6 +32,8 @@ declare module "@adobe/ecs" { [turn]: { type: Quat }; [least]: { type: F32; reduce: "min" }; [all]: { type: boolean; reduce: "&" }; + [written]: { type: ReadonlyMap }; + [keys]: { type: ReadonlySet }; } } @@ -49,6 +53,25 @@ describe("a resource declared without a default", () => { }); }); +describe("a ReadonlyMap or ReadonlySet of what isn't data", () => { + it("starts at an empty one of each world's own, which a system replaces whole, keys and values kept as they are", async () => { + await using first = createWorld(); + await using second = createWorld(); + const [firstWritten, secondWritten] = [await readResource(first, written), await readResource(second, written)]; + expect([firstWritten, secondWritten, await readResource(first, keys)]).toEqual([new Map(), new Map(), new Set()]); + expect(firstWritten).not.toBe(secondWritten); + const value = { at: 1 }; + await first.run(System.create(({ resources }) => { + resources[written] = new Map([[count, value]]); + resources[keys] = new Set([count]); + })); + const read = await readResource(first, written); + expect([read.get(count), await readResource(first, keys)]).toEqual([value, new Set([count])]); + expect(read.get(count)).toBe(value); + expect(await readResource(second, written)).toBe(secondWritten); + }); +}); + describe("a derived component declared without a default", () => { it("starts every row of a world at one empty Map of its own, since values are replaced whole", async () => { const spawn = System.create(({ commands }) => { diff --git a/ecs/packages/ecs/src/implementation/world/despawn-where.test.ts b/ecs/packages/ecs/src/implementation/world/despawn-where.test.ts index 446dcd40..6ea959ae 100644 --- a/ecs/packages/ecs/src/implementation/world/despawn-where.test.ts +++ b/ecs/packages/ecs/src/implementation/world/despawn-where.test.ts @@ -119,16 +119,36 @@ describe("despawnWhere", () => { await world.dispose(); }); - it("despawns matching entities spawned at the same sync point, whenever spawned, since spawns commit first", async () => { + it("keeps what the same sync point spawns, whenever spawned and whatever earlier commands give it", async () => { const { world, run } = withCommands(); + let alive = Entity.none; + await run(commands => { alive = commands.spawn([mark, value])({ [value]: 0 }); }); const spawned: Entity[] = []; await run(commands => { spawned.push(commands.spawn([mark, value])({ [value]: 1 })); + const tagged = commands.spawn([value])({ [value]: 2 }); + commands.add(tagged, mark); + spawned.push(tagged); commands.despawnWhere({ with: [mark] }); - spawned.push(commands.spawn([mark, value])({ [value]: 2 })); - spawned.push(commands.spawn([value])({ [value]: 3 })); + spawned.push(commands.spawn([mark, value])({ [value]: 3 })); }); - expect(await aliveEach(world, spawned)).toEqual([false, false, true]); + expect([await aliveEach(world, [alive]), await aliveEach(world, spawned)]).toEqual([[false], [true, true, true]]); + await world.dispose(); + }); + + it("clears the events a frame made before, keeping those it makes, in one system", async () => { + const { world, run } = withCommands(); + let first = Entity.none, second = Entity.none; + await run(commands => { + commands.despawnWhere({ with: [mark] }); + first = commands.spawn([mark, value])({ [value]: 1 }); + }); + expect(await aliveEach(world, [first])).toEqual([true]); + await run(commands => { + commands.despawnWhere({ with: [mark] }); + second = commands.spawn([mark, value])({ [value]: 2 }); + }); + expect(await aliveEach(world, [first, second])).toEqual([false, true]); await world.dispose(); }); @@ -256,18 +276,21 @@ describe("despawnWhere", () => { await world.dispose(); }); - it("despawns by where the entities spawned at the same sync point, and none for a value no archetype holds", async () => { + it("despawns by where what was alive before the sync point, keeping what it spawns, and none for a value no archetype holds", async () => { const { world, run } = withCommands(); const spawned: Entity[] = []; await run(commands => { spawned.push(commands.spawn([mark, chunk], { [chunk]: 1 })()); - commands.despawnWhere({ with: [mark], where: { [chunk]: 1 } }); spawned.push(commands.spawn([mark, chunk], { [chunk]: 2 })()); + }); + await run(commands => { + spawned.push(commands.spawn([mark, chunk], { [chunk]: 1 })()); + commands.despawnWhere({ with: [mark], where: { [chunk]: 1 } }); commands.despawnWhere({ with: [mark], where: { [chunk]: 3 } }); }); - expect(await aliveEach(world, spawned)).toEqual([false, true]); + expect(await aliveEach(world, spawned)).toEqual([false, true, true]); await run(commands => commands.despawnWhere({ with: [mark], where: {} })); - expect(await aliveEach(world, spawned)).toEqual([false, false]); + expect(await aliveEach(world, spawned)).toEqual([false, false, false]); await world.dispose(); }); diff --git a/ecs/packages/ecs/src/implementation/world/execute.ts b/ecs/packages/ecs/src/implementation/world/execute.ts index a265cbde..48e5a57d 100644 --- a/ecs/packages/ecs/src/implementation/world/execute.ts +++ b/ecs/packages/ecs/src/implementation/world/execute.ts @@ -6,11 +6,12 @@ import type { Archetype, Storage } from './archetype.js'; import { countRan, idOf, startRan, type State } from './state.js'; import type { GpuEngine } from './gpu.js'; import { applySync, prepareSync, syncPending } from './synchronize.js'; -import { activate } from './activate.js'; +import { resourcesWritten } from './resource-changes.js'; +import { activate, activationPending } from './activate.js'; import { markRun } from './move-log.js'; import { postTails, settleAll, settleKeys, settleRunners, tailsOf, type TailOutcome } from './tails.js'; import { settlePosts, settleObserverKeys, settleObserverRunners } from './joined-observer.js'; -import { locate } from './entities.js'; +import { locate, reserved } from './entities.js'; import { clearBuffer, createBuffer } from './buffer.js'; import { drainSegment, type Segment } from './command-ring.js'; import { combine, identityOf, writeReduction, type ReductionTarget } from './reductions.js'; @@ -24,6 +25,7 @@ import { mergeUses, refreshUses, type IndexUse } from './indexes.js'; /** Shared by every report without failures or skipped systems: they're read-only, so none needs its own. */ const none: readonly never[] = []; const noChunks: readonly Chunk[] = []; +const noRunners: readonly Runner[] = []; /** The report of a run with nothing to report. */ export const clean: RunReport = { failures: none, skipped: none, dropped: 0, warnings: none }; @@ -101,6 +103,14 @@ type Prepared = { readonly remotes: number[]; /** The indexes each tier's runners look up through, refreshed before it. */ readonly uses: (readonly IndexUse[])[]; + /** + * By tier, the last tier of the pass starting there: tiers whose systems all run on the world's thread as written, + * which look up through no index from the second on, run in one pass while their sync points apply nothing. Its own + * tier for any other. + */ + readonly passes: number[]; + /** By tier starting a pass of several, the runners of the whole pass, which it counts as it ran at once. */ + readonly passRunners: (readonly Runner[])[]; /** The world's `runnersVersion` its runners were read at. */ version: number; readonly started: (readonly Chunk[])[][]; @@ -168,7 +178,8 @@ const preparedFor = (context: ExecuteContext, schedule: Schedule): Prepared => { let prepared = bySchedule.get(schedule); if (prepared === undefined) { prepared = { - runners: schedule.tiers.map(tier => tier.map(context.runnerOf)), remotes: schedule.tiers.map(() => 0), uses: schedule.tiers.map(() => []), version: -1, + runners: schedule.tiers.map(tier => tier.map(context.runnerOf)), remotes: schedule.tiers.map(() => 0), uses: schedule.tiers.map(() => []), + passes: schedule.tiers.map((_, t) => t), passRunners: schedule.tiers.map(() => noRunners), version: -1, started: schedule.tiers.map(tier => new Array(tier.length)), systems: [], tiers: [], tasks: new Map(), warnings: [], warningCount: 0, warned: null, outcome: { failures: null, skipped: null }, placement: -1, tailing: null, settling: 0, chunk: 0, error: undefined, tierFailed: false, dropped: 0, @@ -189,9 +200,31 @@ const refresh = (context: ExecuteContext, schedule: Schedule, prepared: Prepared prepared.remotes[t] = remote; prepared.uses[t] = mergeUses(runners); } + passesOf(schedule, prepared); prepared.version = context.state.runnersVersion; }; +/** Whether every runner of a tier runs on the world's thread as written: none a kernel or on a joined thread. */ +const asWritten = (runners: readonly Runner[]): boolean => { + for (let i = 0; i < runners.length; i++) if (runners[i].kernel !== null || runners[i].remote !== null) return false; + return true; +}; + +/** Finds a schedule's passes (`Prepared["passes"]`) from its runners as read now. */ +const passesOf = (schedule: Schedule, prepared: Prepared): void => { + const { runners, uses, passes, passRunners } = prepared; + let last = -1; + for (let t = 0; t < schedule.tiers.length; t++) { + if (!asWritten(runners[t])) last = t; + else if (last < t) { + last = t; + while (last + 1 < schedule.tiers.length && uses[last + 1].length === 0 && asWritten(runners[last + 1])) last++; + } + passes[t] = last; + passRunners[t] = last === t ? noRunners : runners.slice(t, last + 1).flat(); + } +}; + /** * Drains a worker's segment into a buffer of its own, queued in its place: commands, spawn blocks staged to commit in * its place, and the reservations it left unused. Fails if it doesn't start with its task's merge tag. @@ -477,11 +510,20 @@ const executeTiers = (context: ExecuteContext, whole: Schedule, args: unknown): if (building !== null) return handed = executeFrom(context, schedule, prepared, folds, args, null, t, building, null); if (state.indexFailures.length !== 0) (outcome.failures ??= []).push(...state.indexFailures.splice(0)); } - startTier(context, prepared, t, args); - const waiting = settleTier(state, folds, prepared, schedule.tiers[t], t, nothing); - if (waiting !== null) return handed = executeFrom(context, schedule, prepared, folds, args, null, t, null, waiting); - if (endTier(state, null, prepared, schedule, t)) break; - if (schedule.syncs[t] !== null) syncNow(context, prepared, false); + const last = prepared.passes[t]; + if (last > t) { + const reached = runPass(context, prepared, schedule, folds, args, t, last); + if (typeof reached !== "number") return handed = reached; + if (reached < 0) break; + t = reached; + } + else { + startTier(context, prepared, t, args); + const waiting = settleTier(state, folds, prepared, schedule.tiers[t], t, nothing); + if (waiting !== null) return handed = executeFrom(context, schedule, prepared, folds, args, null, t, null, waiting); + if (endTier(state, null, prepared, schedule, t)) break; + } + if (schedule.syncs[t] !== null && !idleSync(state)) syncNow(context, prepared, false); } } finally { @@ -490,6 +532,43 @@ const executeTiers = (context: ExecuteContext, whole: Schedule, args: unknown): return reportOf(state, prepared); }; +/** + * Runs tiers `first` to `last`, a pass, one after another with none of the checks between tiers that only a tier on + * another thread, using an index or after a sync point that applies something needs. Returns the last tier it ran, + * after which the caller applies its sync point: `last`, or one before it whose sync point applies something or whose + * bodies replaced runners; -1 when one failed, the tiers after it skipped; or what `executeFrom` returns once one waits + * for something. + */ +const runPass = (context: ExecuteContext, prepared: Prepared, schedule: Schedule, folds: Map | null, args: unknown, first: number, last: number): number | Promise => { + const { state } = context; + for (let v = first; v <= last; v++) { + startTier(context, prepared, v, args); + const waiting = settleTier(state, folds, prepared, schedule.tiers[v], v, nothing); + if (waiting !== null) { + for (let ran = first; ran < v; ran++) countRan(state, prepared.runners[ran]); + return executeFrom(context, schedule, prepared, folds, args, null, v, null, waiting); + } + if (prepared.tierFailed || (v < last && (prepared.version !== state.runnersVersion || (schedule.syncs[v] !== null && !idleSync(state))))) { + for (let ran = first; ran <= v; ran++) countRan(state, prepared.runners[ran]); + if (!prepared.tierFailed) return v; + const { outcome } = prepared; + for (let later = v + 1; later < schedule.tiers.length; later++) for (const leaf of schedule.tiers[later]) (outcome.skipped ??= []).push(leaf); + return -1; + } + } + countRan(state, prepared.passRunners[first]); + return last; +}; + +/** + * Whether a sync point within a run, with no joined thread, would apply nothing and leave nothing to do after: + * nothing queued, drained, unreserved or pending (`syncPending`), no rule or finalizer to activate, and no layout for + * workers to see. + */ +const idleSync = (state: State): boolean => state.queued.length === 0 && state.drained === 0 && state.unused.length === 0 + && state.channels.length === 0 && state.logs.length === 0 && state.bumped.length === 0 && state.counted.length === 0 && state.carried === null && !state.structure.rederive && state.unplaced.length === 0 + && (state.resourcesSeen.size === 0 || !resourcesWritten(state)) && !activationPending(state) && !reserved(state.entities); + /** * A sync point with nothing to wait for: no GPU, tails or observer posts, which `executeTiers` checked before each * tier and bodies can't add. The run's `last` lets go of strings no reader holds. diff --git a/ecs/packages/ecs/src/implementation/world/finalizers.ts b/ecs/packages/ecs/src/implementation/world/finalizers.ts index abeb101a..4d0495d4 100644 --- a/ecs/packages/ecs/src/implementation/world/finalizers.ts +++ b/ecs/packages/ecs/src/implementation/world/finalizers.ts @@ -14,7 +14,8 @@ import { idOf, type Key, type State } from './state.js'; import type { Queued } from './synchronize.js'; import type { Essential, ReadonlyType } from '../../interface/component/value.js'; import type { Leaf } from '../../interface/finalizer/created.js'; -import { newestKeyOf, threadOf } from '../../interface/registry/index.js'; +import { declaredOf, declaresTrait, newestKeyOf, threadOf, threadOfDeclaration } from '../../interface/registry/index.js'; +import type { ResolvedChanges } from '../../interface/finalizer/declaration.js'; import type { Layout } from '../../interface/registry/layout.js'; import type { TaskChannel } from './remote-systems.js'; import { resourceOffsetsOf, writeStops, type StopsOf } from './stop-lists.js'; @@ -30,8 +31,8 @@ export type FinalizerRunner = { readonly excluded: readonly number[]; /** For a changes finalizer: whether it watches a component, by id. */ readonly watches: (id: number) => boolean; - /** For a changes finalizer: the resources it watches, by id. */ - readonly resourceWatches: readonly number[]; + /** For a changes finalizer: the resources it watches, by id, which grow as resources declaring a trait it watches register. */ + readonly resourceWatches: number[]; /** By archetype id: 1 if it matches, 2 if not, 0 until known. */ readonly matches: number[]; readonly context: Context; @@ -63,19 +64,23 @@ export const createFinalizerRunner = (state: State, finalizer: Finalizer): Final const reads = query?.read ?? []; // By default every component that isn't derived, including ones a world first meets later; never resources, nor // the objects a joined thread fills, whose storage here holds only tags. - let watches = (id: number): boolean => !state.derived[id] && !state.resources.has(id) && state.remote[id] === undefined; - const watch = finalizer.kind === "changes" ? finalizer.declaration.watch : undefined; + const everyComponent = (id: number): boolean => !state.derived[id] && !state.resources.has(id) && state.remote[id] === undefined; + let watches = everyComponent; const resourceWatches: number[] = []; - if (watch !== undefined) { - // `Finalizer.changes` rejected derived components. - const watched = new Set(watch.map(key => idOf(state, key))); - for (const id of watched) { - if (!state.resources.has(id)) continue; - watched.delete(id); - resourceWatches.push(id); - watchResource(state, id); + if (finalizer.kind === "changes") { + const { watch } = finalizer.declaration; + followResources(state, watch, resourceWatches); + if (watch.components !== true) { + // `Finalizer.changes` rejected derived components. + const listed = new Set(watch.components.map(key => idOf(state, key))); + const traits = watch.componentTraits; + if (traits.length === 0) watches = id => listed.has(id); + else { + /** By id: 1 where its key declares one of the traits, 2 where it doesn't. */ + const declaring: number[] = []; + watches = id => listed.has(id) || (everyComponent(id) && (declaring[id] ??= declaresTrait(state.keys[id], traits) ? 1 : 2) === 1); + } } - watches = id => watched.has(id); } const target = { buffer: createBuffer() }; const commands = createCommands(state, target); @@ -102,6 +107,30 @@ export const createFinalizerRunner = (state: State, finalizer: Finalizer): Final }; }; +/** + * The resources a changes finalizer watches: those it lists, and those registered so far that declare a trait it + * watches, but those on another thread than the world's. + */ +export const watchedResourcesOf = (watch: ResolvedChanges["watch"]): readonly Key[] => { + if (watch.resourceTraits.length === 0) return watch.resources; + const found: Key[] = [...watch.resources]; + for (const [key, declared] of declaredOf("resource")) { + const thread = threadOfDeclaration(declared); + if ((thread === null || thread === "world") && declaresTrait(key, watch.resourceTraits)) found.push(key); + } + return found; +}; + +/** Adds to `ids` each resource a changes finalizer watches that it doesn't hold, following its changes from now. */ +export const followResources = (state: State, watch: ResolvedChanges["watch"], ids: number[]): void => { + for (const key of watchedResourcesOf(watch)) { + const id = idOf(state, key); + if (ids.includes(id)) continue; + ids.push(id); + watchResource(state, id); + } +}; + /** * Places a finalizer on a joined thread's channel, posting it there, or back on the world's thread with null. Its reads * are listed as the thread reads them: objects by their tags, linear structs by their bytes, strings by handles of diff --git a/ecs/packages/ecs/src/implementation/world/lifetime.test-helper.ts b/ecs/packages/ecs/src/implementation/world/lifetime.test-helper.ts index eae89cf8..03197a33 100644 --- a/ecs/packages/ecs/src/implementation/world/lifetime.test-helper.ts +++ b/ecs/packages/ecs/src/implementation/world/lifetime.test-helper.ts @@ -73,7 +73,7 @@ export const freshKernel = (system: System): System => { const description = kernelOf(system); if (leaf === undefined || leaf.kind === "composite" || description === undefined) throw new Error("Not a kernel system."); // What `System.create` makes of the same declaration and body, without the id a top-level binding takes. - const copy: Leaf & System = { kind: leaf.kind, name: leaf.name, declaration: leaf.declaration, input: leaf.input, body: leaf.body }; + const copy: Leaf & System = { kind: leaf.kind, name: leaf.name, declaration: leaf.declaration, input: leaf.input, body: leaf.body, commands: leaf.commands }; claimSystem(copy, undefined); return attachKernel(copy, description); }; diff --git a/ecs/packages/ecs/src/implementation/world/passes.test.ts b/ecs/packages/ecs/src/implementation/world/passes.test.ts new file mode 100644 index 00000000..0308bcc0 --- /dev/null +++ b/ecs/packages/ecs/src/implementation/world/passes.test.ts @@ -0,0 +1,61 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { System } from "../../interface/system/system.js"; +import { workers } from "../../interface/resources/workers.js"; +import { createWorld } from "./create.js"; +import { runRan, runReported } from "./outcomes.test-helper.js"; + +const seed = Symbol("passesTest/seed"); +const event = Symbol("passesTest/event"); + +declare module "@adobe/ecs" { + export interface Components { + [seed]: { type: number }; + [event]: { type: number }; + } +} + +/** What the systems below ran, in order. */ +const order: string[] = []; +let firing = false; + +const plant = System.create(({ commands }) => { commands.spawn([seed])({ [seed]: 1 }); }); +const emit = System.create({ components: { with: [seed] } }, (row, { commands }) => { + order.push("emit"); + if (firing) commands.spawn([event])({ [event]: row[seed] }); +}); +const handle = System.create({ components: { with: [event] }, after: [emit] }, row => { order.push(`handle ${row[event]}`); }); +const finish = System.create({ after: [handle] }, () => { order.push("finish"); }); +const chain = System.create([emit, handle, finish]); + +const fail = System.create({ components: { with: [seed] }, after: [emit] }, () => { + order.push("fail"); + throw new Error("passesTest: fail"); +}); +const afterFail = System.create({ after: [fail] }, () => { order.push("after fail"); }); +const failing = System.create([emit, fail, afterFail]); + +describe("tiers on the world's thread", () => { + it("run one after another past sync points that apply nothing, and apply one with commands before the tier after it", async () => { + expect(System.describe(chain).tiers.map(tier => tier.sync === null)).toEqual([false, true, true]); + await using world = createWorld({ [workers]: 0 }); + await world.run(plant); + order.length = 0; + firing = false; + expect((await runRan(world, chain)).systems).toBe(3); + firing = true; + expect((await runRan(world, chain)).systems).toBe(3); + expect(order).toEqual(["emit", "finish", "emit", "handle 1", "finish"]); + }); + + it("stop at a tier whose system fails, skipping those after it", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(plant); + order.length = 0; + firing = false; + const reported = await runReported(world, failing); + expect([reported.failures.map(failure => failure.message), reported.skipped]).toEqual([["passesTest: fail"], [afterFail.name]]); + expect(order).toEqual(["emit", "fail"]); + expect((await runRan(world, failing)).systems).toBe(2); + }); +}); diff --git a/ecs/packages/ecs/src/implementation/world/perf.md b/ecs/packages/ecs/src/implementation/world/perf.md index 2252f979..0368c484 100644 --- a/ecs/packages/ecs/src/implementation/world/perf.md +++ b/ecs/packages/ecs/src/implementation/world/perf.md @@ -229,6 +229,15 @@ Review fixes to shared components and `where`, node, alternating against the com - **A spawner's guard** against a component given in both calls, one check of each constant's key a spawn: `spawn singles shared` 323 → 323 ns, `spawn bulk shared` 18.0 → 18.1 ns. - **Reclaiming** (`reclaim.ts`): an entity walking one cell a run made an archetype, an interned value, a batch and spawner levels per cell, for good: after 20K cells 20,002 archetypes, 185 MB of heap and 9.1 MB of arena, and `one row after 100K cells walked`, a system over one row, 2.76 ms a run against 250 ns before the walk. Reclaimed and reused, after 100K cells: 11 archetypes, 9 values, 9 batches, 18 spawner levels, heap 29.7–29.9 MB from 1K cells on, arena 71,616 B throughout; `one row after 100K cells walked` 250 ns; `walk to a new cell` 10.3 → 2.8 µs a run. Waiting out empty sync points (`emptyFor`), `circle 8 cells`, round 8 cells, each emptied 7 sync points: 2.67–2.69 µs a run at 1, 2 and 4, reclaiming and reusing one archetype a run, 1.00 at 8 and 16, reclaiming nothing; `walk to a new cell` 2.69–2.73 µs at 1 to 4, 2.81 at 8, 3.06 at 16, scanning more candidates; `flicker between 2 cells` 958 ns at all, against 916 before reclaiming, the sync point's scan of its emptied archetypes. 8, so a cell left for up to 7 sync points keeps its archetype. Edges cached to an archetype of shared values are kept on it to clear, but a batch's archetype is checked by its generation instead: an edge object per spawned archetype, allocated between archetypes and their storages, made `where 1K cells moving` 10% slower, its 1K predicates walking archetypes spread over more memory; checked, +1.8%. Worlds without shared components, five rounds against the commit before: within noise (`run idle` 208 → 208 ns, `run observer` 125 → 125, `loop tick idle`, `move 1K`, `main thread 1K`, `sync point one spawn` 708 → 708, `create` +0.5%, `create flat`, `spawn bulk`, `despawn one`, `churn`, `add value`, `remote run`, `joined observer resource`, `joined observer 10K`, `handoff`, `tail prompt` +0.5% over eight rounds, `log replay`, `read run`); `run 50 idle` was +5% a system until the run's check for reclaimed archetypes kept the scan for new ones apart, out of what its runner inlines: 71.2 → 71.2 ns. +Sync points and tiers that apply nothing (adobe/data#245), node, alternating eight rounds against `244f952` with the +new benchmarks added there: eight systems over rows no entity has, each with a command it never issues. `chained +unordered`, one tier, 538 → 538 ns a run; `chained quiet`, eight tiers by `after` with no commands, 717 → 636; +`chained noting`, eight tiers whose commands no later system depends on, 723 → 636; `chained commanding`, eight tiers +and seven sync points, 859 → 713. A tier boundary on the world's thread costs about 14 ns, from 26, the tiers running +as one pass; a sync point between them with nothing to apply about 11 ns, from 20, its check of what's pending standing +for its activation, rewinds and publishing. The other node benchmarks are within noise of `244f952` over three +rounds, and `read run` (+3.7%) and `loop tick add remove again` (+3.9%), which make a schedule each run, over ten. + ## Where the gains came from Step figures marked (vitest) are from the node vitest lane: read them as relative. diff --git a/ecs/packages/ecs/src/implementation/world/program.ts b/ecs/packages/ecs/src/implementation/world/program.ts index cebf4333..843b3778 100644 --- a/ecs/packages/ecs/src/implementation/world/program.ts +++ b/ecs/packages/ecs/src/implementation/world/program.ts @@ -10,5 +10,8 @@ import type { Key } from './state.js'; export const triggersOf = (item: Rule | Finalizer): { readonly keys: readonly Key[]; readonly any: boolean } => { if (item.kind === "rule") return { keys: item.declaration.components.has ?? [], any: false }; if (item.kind === "finalizer") return { keys: [...item.declaration.components.read ?? [], ...item.declaration.components.has ?? []], any: false }; - return { keys: item.declaration.watch ?? [], any: true }; + const { watch } = item.declaration; + // Every component, or those declaring a trait, may be met at any sync point. + if (watch.components === true || watch.componentTraits.length + watch.resourceTraits.length > 0) return { keys: [], any: true }; + return { keys: [...watch.components, ...watch.resources], any: true }; }; diff --git a/ecs/packages/ecs/src/implementation/world/schedule.test.ts b/ecs/packages/ecs/src/implementation/world/schedule.test.ts index 831fec52..a5c32193 100644 --- a/ecs/packages/ecs/src/implementation/world/schedule.test.ts +++ b/ecs/packages/ecs/src/implementation/world/schedule.test.ts @@ -8,17 +8,25 @@ import { scheduleOf } from "../../interface/registry/schedule.js"; const value = Symbol("scheduleTest/value"); const other = Symbol("scheduleTest/other"); +const voxel = Symbol("scheduleTest/voxel"); +const mark = Symbol("scheduleTest/mark"); +const size = Symbol("scheduleTest/size"); declare module "@adobe/ecs" { export interface Components { [value]: { type: number }; [other]: { type: number }; + [voxel]: { type: true }; + [mark]: { type: true }; + } + export interface Resources { + [size]: { type: number }; } } /** Systems built as values, not registered, so each test's are its own. */ -const leaf = (declaration: Resolved & { readonly name: string }): Leaf => ({ kind: "system", name: declaration.name, declaration, input: undefined, body: () => {} }); +const leaf = (declaration: Resolved & { readonly name: string }): Leaf => ({ kind: "system", name: declaration.name, declaration, input: undefined, body: () => {}, commands: () => undefined }); const composite = (declaration: Composite["declaration"] & { readonly name: string }): Composite => ({ kind: "composite", name: declaration.name, declaration }); const names = (system: Definition): string[][] => scheduleOf(system).tiers.map(tier => tier.map(leaf => leaf.name)); @@ -113,6 +121,39 @@ describe("scheduleOf", () => { .toEqual([["a", "c"], ["b"]]); }); + it("doesn't order two systems on a key both reach through rows that never match one entity", () => { + const voxels = leaf({ name: "voxels", components: { write: [value], has: [voxel], not: [mark] } }); + const marks = leaf({ name: "marks", components: { write: [value], has: [mark] } }); + const watching = leaf({ name: "watching", components: { read: [value], changed: [value], has: [mark] } }); + expect(names(composite({ name: "root", systems: [voxels, marks] }))).toEqual([["voxels", "marks"]]); + expect(names(composite({ name: "root", systems: [voxels, watching] }))).toEqual([["voxels", "watching"]]); + }); + it("orders two systems whose rows may match one entity, or that reach the key other than through their rows", () => { + const voxels = leaf({ name: "voxels", components: { write: [value], has: [voxel] } }); + const marks = leaf({ name: "marks", components: { write: [value], has: [mark] } }); + expect(() => scheduleOf(composite({ name: "root", systems: [voxels, marks] }))) + .toThrow(`Systems "voxels" and "marks" both access "scheduleTest/value" and one writes it; order them with before or after, or, if they never match one entity, add a component of one to the other's not.`); + const apart = leaf({ name: "apart", components: { write: [value], has: [voxel], not: [mark] } }); + const looking = leaf({ name: "looking", components: { has: [mark] }, lookup: { others: { read: [value] } } }); + expect(() => scheduleOf(composite({ name: "root", systems: [apart, looking] }))) + .toThrow(`Systems "apart" and "looking" both access "scheduleTest/value" and one writes it; order them with before or after.`); + // An optional component is read where an entity has it, so it excludes nothing it doesn't require. + const optional = leaf({ name: "optional", components: { read: [value, mark], optional: [mark] } }); + expect(() => scheduleOf(composite({ name: "root", systems: [apart, optional] }))).toThrow('"apart" and "optional"'); + }); + + it("orders a system before or after every other that uses a resource it lists, but those already ordered and those listing it too", () => { + const resize = leaf({ name: "resize", resources: { write: [size] }, before: [size] }); + const frame = leaf({ name: "frame", resources: { read: [size] } }); + const pick = leaf({ name: "pick", components: { read: [value] }, resources: { is: [size] } }); + const old = leaf({ name: "old", resources: { read: [size] }, before: [resize] }); + expect(names(composite({ name: "root", systems: [frame, pick, resize, old] }))).toEqual([["old"], ["resize"], ["frame", "pick"]]); + const last = leaf({ name: "last", resources: { read: [size] }, after: [size] }); + expect(names(composite({ name: "root", systems: [last, frame, resize] }))).toEqual([["resize"], ["frame"], ["last"]]); + const again = leaf({ name: "again", resources: { write: [size] }, before: [size] }); + expect(() => scheduleOf(composite({ name: "root", systems: [resize, again, frame] }))).toThrow(`Systems "resize" and "again" both access "scheduleTest/size"`); + }); + it("schedules two systems of one name as two, and one listed twice as one", () => { const a = leaf({ name: "a", components: { write: [value] } }); const copy = leaf({ name: "a", components: { write: [value] }, after: [a] }); diff --git a/ecs/packages/ecs/src/implementation/world/sync-points.test.ts b/ecs/packages/ecs/src/implementation/world/sync-points.test.ts index d53cc40f..856a248d 100644 --- a/ecs/packages/ecs/src/implementation/world/sync-points.test.ts +++ b/ecs/packages/ecs/src/implementation/world/sync-points.test.ts @@ -79,6 +79,11 @@ const tally = System.create({ components: { with: [grown] }, after: [sprout] }, const garden = System.create([plant, sprout, tally]); const readChildren = System.create({ readonly: true }, ({ resources }) => resources[children]); +const spawnSeedlings = System.create(({ commands }) => { commands.spawn(Seedling)({ [seed]: 1, [grown]: 2 }); }); +const countSeedlings = System.create({ components: { with: [seed, grown] }, after: [spawnSeedlings] }, (row, { resources }) => { resources[children] += row[grown]; }); +/** Declared below the systems whose commands name it. */ +const Seedling = [seed, grown] as const; + describe("sync points within a run", () => { it("apply commands before a later system that depends on what they change, which then sees them in the same run", async () => { const world = createWorld({ [workers]: 0 }); @@ -122,6 +127,13 @@ describe("sync points within a run", () => { } }); + it("follow commands naming an archetype declared below their system", async () => { + expect(System.describe(System.create([spawnSeedlings, countSeedlings])).tiers.map(tier => tier.sync?.map(reason => reason.keys) ?? null)).toEqual([[[seed, grown]], null]); + await using world = createWorld({ [workers]: 0 }); + await world.run(System.create([spawnSeedlings, countSeedlings])); + expect(await world.run(readChildren)).toBe(2); + }); + it("commit a kernel's staged spawns at a sync point within the run, for a later tier", async () => { expect(System.describe(garden).tiers.map(tier => tier.sync === null)).toEqual([false, false, true]); await using world = createWorld({ [workers]: 0 }); diff --git a/ecs/packages/ecs/src/implementation/world/synchronize.ts b/ecs/packages/ecs/src/implementation/world/synchronize.ts index d9fc7d0d..8341c55c 100644 --- a/ecs/packages/ecs/src/implementation/world/synchronize.ts +++ b/ecs/packages/ecs/src/implementation/world/synchronize.ts @@ -147,10 +147,11 @@ const closedOf = (state: State, archetype: Archetype): Archetype => { }; /** - * Despawns each entity a `despawnWhere` command selects given the plan so far: those planned, by where they're going, - * then the rest, by the archetype they're in. Never the resource entity. + * Despawns each entity alive before the sync point that a `despawnWhere` command selects given the plan so far: those + * planned, by where they're going, then the rest, by the archetype they're in. Never the resource entity, nor an + * entity the sync point spawns: `born` holds the first row each archetype gained at it. */ -const despawnWhere = (state: State, buffer: CommandBuffer, command: number, within: boolean): void => { +const despawnWhere = (state: State, buffer: CommandBuffer, command: number, within: boolean, born: ReadonlyMap): void => { const { plan, archetypes } = state; const { ids } = buffer; const start = buffer.starts[command]; @@ -159,8 +160,12 @@ const despawnWhere = (state: State, buffer: CommandBuffer, command: number, with const where = ids[last - 1] === despawnWhereHolds ? buffer.values[last - 1] : null; const holds = typeof where === "function" ? where : null; const end = holds === null ? last : last - 1; + const { row } = state.entities; for (let entry = 0; entry < plan.count; entry++) { - if (plan.despawned[entry] === 1 || plan.entity[entry] === state.resourceEntity) continue; + const from = plan.from[entry]; + if (plan.despawned[entry] === 1 || plan.entity[entry] === state.resourceEntity || from < 0) continue; + const first = born.get(from); + if (first !== undefined && row[indexOf(plan.entity[entry])] >= first) continue; const to = archetypes[plan.to[entry]]; if (matches(within && plan.to[entry] !== plan.from[entry] ? closedOf(state, to) : to, ids, start, middle, end, holds)) plan.despawned[entry] = 1; } @@ -171,8 +176,9 @@ const despawnWhere = (state: State, buffer: CommandBuffer, command: number, with if (archetype.resource || archetype.count === 0 || !matches(archetype, ids, start, middle, end, holds)) continue; ensurePlanCapacity(state, plan.count + archetype.count); const { entities } = archetype; - for (let row = 0; row < archetype.count; row++) { - const entity = entities[row]; + const alive = born.get(id) ?? archetype.count; + for (let at = 0; at < alive; at++) { + const entity = entities[at]; const index = indexOf(entity); if (slots[index] < 0) plan.despawned[addPlanned(state, entity, index, id, id)] = 1; } @@ -183,7 +189,7 @@ const despawnWhere = (state: State, buffer: CommandBuffer, command: number, with * Plan: the net change for each entity, in issue order. Commands against dead entities do nothing. Reference values * written by commands go to `written`, to check once despawns are known. */ -const planOf = (state: State, queued: readonly Queued[], written: Written): void => { +const planOf = (state: State, queued: readonly Queued[], written: Written, born: ReadonlyMap): void => { const { plan, structure } = state; if (plan.slots.length < state.entities.length) plan.slots = new Int32Array(Math.max(state.entities.length, plan.slots.length * 2)).fill(-1); const { slots } = plan; @@ -199,7 +205,7 @@ const planOf = (state: State, queued: readonly Queued[], written: Written): void for (let command = 0; command < buffer.count; command++) { const kind = kinds[command]; if (kind === despawnWhereCommand) { - despawnWhere(state, buffer, command, within); + despawnWhere(state, buffer, command, within, born); ensurePlanCapacity(state, plan.count + buffer.count - command); continue; } @@ -742,7 +748,7 @@ const preparePending = (state: State, queued: readonly Queued[], isolated: boole if (!isolated) plan.spawned += commitBatches(state, tick, born, deriving ? close : null); plan.spawned += commitStaged(state, queued, tick, born, deriving ? close : null); const written: Written = []; - planOf(state, queued, written); + planOf(state, queued, written, born); if (structure.rederive && !isolated) rederive(state); if (deriving) for (let entry = 0; entry < plan.count; entry++) if (plan.despawned[entry] === 0) plan.to[entry] = close(state.archetypes[plan.to[entry]]).id; let index: IndexChanges | null = null; diff --git a/ecs/packages/ecs/src/implementation/world/tables.test.ts b/ecs/packages/ecs/src/implementation/world/tables.test.ts index 5721ffae..096db2c6 100644 --- a/ecs/packages/ecs/src/implementation/world/tables.test.ts +++ b/ecs/packages/ecs/src/implementation/world/tables.test.ts @@ -46,18 +46,20 @@ const tablesOf = (components: readonly Component.Key[], resources: readonly Reso System.create({ readonly: true, tables: { components, resources, not } }, ({ tables }): Tables => tables); const spawnedBy: { spawned: Table.Spawned } = { spawned: { entities: [], dropped: 0 } }; -const given: { tables: readonly Table[]; resources?: { readonly [key: symbol]: unknown }; existing?: ReadonlyMap } = { tables: [] }; +const given: { tables: readonly Table[]; resources?: { readonly [key: symbol]: unknown }; existing?: ReadonlyMap; update?: boolean } = { tables: [] }; const spawnGiven = System.create(({ commands }) => { - spawnedBy.spawned = commands.spawnTables(given.tables, { resources: given.resources, existing: given.existing }); + spawnedBy.spawned = commands.spawnTables(given.tables, { resources: given.resources, existing: given.existing, update: given.update }); }); -const spawnTables = async (world: ReturnType, tables: readonly Table[], existing?: ReadonlyMap): Promise => { +const spawnTables = async (world: ReturnType, tables: readonly Table[], existing?: ReadonlyMap, update?: boolean): Promise => { given.tables = tables; given.existing = existing; + given.update = update; try { await world.run(spawnGiven); } finally { given.existing = undefined; + given.update = undefined; } return spawnedBy.spawned; }; @@ -247,6 +249,37 @@ describe("tables", () => { expect((await readAll(other)).find(entry => entry.label === "owned")).toMatchObject({ parent: existingSteel, target: existingSteel }); }); + it("write a row existing maps onto its entity with update, as an add would, entity values rewritten as a spawned row's", async () => { + await using world = inline(); + let steel = Entity.none; + await world.run(System.create(({ commands }) => { + const inside = commands.spawn([label])({ [label]: "inside" }); + const outside = commands.spawn([pinned])(); + steel = commands.spawn([label, position, buddy, target, link, team], { [team]: "red" })({ [label]: "steel", [position]: [1, 2, 3], [buddy]: inside, [target]: outside, [link]: { to: inside, weight: 4 } }); + })); + const { archetypes } = await world.run(tablesOf([label, position, buddy, target, link, team])); + await using other = inline(); + let mine = Entity.none, kept = Entity.none; + await other.run(System.create(({ commands }) => { + kept = commands.spawn([label])({ [label]: "kept" }); + mine = commands.spawn([label, hidden, target, team], { [team]: "blue" })({ [label]: "mine", [target]: kept }); + })); + const changed: string[] = []; + const watch = System.create({ components: { with: [label], changed: [label] } }, row => { changed.push(row[label]); }); + await other.run(watch); + changed.length = 0; + const { entities, dropped } = await spawnTables(other, archetypes, new Map([[steel, mine]]), true); + expect([dropped, entities.flatMap(list => [...list]).includes(mine)]).toEqual([0, true]); + const read = await readAll(other); + const inside = read.find(entry => entry.label === "inside")?.entity; + // The weak reference to an entity outside the tables is left out, so the entity's own stands. + expect(read.find(entry => entry.entity === mine)).toMatchObject({ label: "steel", position: [1, 2, 3], buddy: inside, target: kept, link: { to: inside, weight: 4 } }); + const teams = await other.run(System.create({ readonly: true, tables: { components: [team, hidden] } }, ({ tables }) => tables.archetypes.map(table => [table.columns[team], table.columns[hidden] !== undefined]))); + expect(teams).toEqual([[{ kind: "value", value: "red" }, true]]); + await other.run(watch); + expect(changed.sort()).toEqual(["inside", "steel"]); + }); + it("refuse existing mapping to an entity that isn't alive, spawning nothing", async () => { await using world = inline(); const gone = (await spawnLabels(world, ["gone"])).get("gone") ?? Entity.none; diff --git a/ecs/packages/ecs/src/implementation/world/tables.ts b/ecs/packages/ecs/src/implementation/world/tables.ts index 05c1a64a..be84ef4b 100644 --- a/ecs/packages/ecs/src/implementation/world/tables.ts +++ b/ecs/packages/ecs/src/implementation/world/tables.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Layout, Path } from '../../interface/registry/layout.js'; +import { viewsOf, type Layout, type Path } from '../../interface/registry/layout.js'; import { declarationOf, labelOf } from '../../interface/registry/names.js'; import { isEntity } from '../../interface/registry/schemas.js'; import type { Schema } from '../../interface/schema/schema.js'; @@ -8,7 +8,8 @@ import { batchOf } from './batches.js'; import { indexOf, locate, reserveRun } from './entities.js'; import { internShared } from './shared.js'; import { claimRows, openRows, writerOf, type Column as BatchColumn } from './staging.js'; -import { commandIdOf, type Key, type State } from './state.js'; +import { commandIdOf, storedShared, type Key, type State } from './state.js'; +import { addCommand, pushCommand, pushComponent, type CommandBuffer } from './buffer.js'; /** Whether a value of `schema` may hold an entity anywhere in it. */ const holdsEntity = (schema: Schema): boolean => { @@ -209,6 +210,45 @@ const spawnRows = (state: State, { table, keys, ids, shared }: Planned, rows: re claimRows(batch, entities); }; +/** A linear value read out of a table's bytes, copied to row 0 here first; grown for larger layouts. */ +let scratch = viewsOf(new ArrayBuffer(256), 0, 256); + +/** A table's value of a component in a row: its column's one value, its value there, or one read out of its bytes. */ +const valueIn = (layout: Layout, column: Table.Column, row: number): unknown => { + if (column.kind === "value") return column.value; + if (column.kind === "values") return column.values[row]; + if (layout.kind !== "linear") return undefined; + const { stride } = layout; + if (stride > scratch.u8.length) scratch = viewsOf(new ArrayBuffer(stride * 2), 0, stride * 2); + scratch.u8.set(column.bytes.subarray(row * stride, (row + 1) * stride)); + return layout.read(scratch, 0); +}; + +/** + * Queues an add of each of its table's components onto the entity standing for a row `existing` maps, its entity + * values remapped, but a reference whose target isn't there. + */ +const updateRows = (state: State, { table, keys, ids }: Planned, rows: readonly number[], standing: Float64Array, buffer: CommandBuffer, remap: Remap): void => { + const columns: Table.Column[] = keys.map(key => Reflect.get(table.columns, key)); + const schemas = keys.map(key => declarationOf(key)?.type); + const references = keys.map(key => declarationOf(key)?.reference !== undefined); + for (const row of rows) { + pushCommand(buffer, addCommand, standing[row]); + for (let k = 0; k < keys.length; k++) { + const id = ids[k]; + const schema = schemas[k]; + let value = valueIn(state.layouts[id], columns[k], row); + if (references[k]) { + const to = remap(Number(value)); + if (to === 0 && value !== 0) continue; + value = to; + } + else if (schema !== undefined && holdsEntity(schema)) value = remapValue(schema, value, remap); + pushComponent(buffer, id, state.shared[id] === undefined ? value : storedShared(state, id, value)); + } + } +}; + /** Throws for an entity `existing` maps to that isn't alive in this world. */ const checkExisting = (state: State, existing: ReadonlyMap): void => { for (const [source, target] of existing) { @@ -223,7 +263,7 @@ const checkExisting = (state: State, existing: ReadonlyMap): voi * value naming an entity spawned names the one spawned for it. A row whose entity `existing` maps isn't spawned: the * entity it maps to stands for it, as it does for an entity outside the tables that `existing` maps. */ -export const spawnTables = (state: State, tables: readonly Table[], existing: ReadonlyMap = new Map()): Table.Spawned => { +export const spawnTables = (state: State, tables: readonly Table[], existing: ReadonlyMap = new Map(), updates: CommandBuffer | null = null): Table.Spawned => { const plans = tables.map(table => planOf(state, table)); checkExisting(state, existing); // Entities are mapped only where a value of the tables may hold one, or a row is one that exists. @@ -342,6 +382,11 @@ export const spawnTables = (state: State, tables: readonly Table[], existing: Re const target = targetAt(row); return target !== 0 && !isSpawned(target); }); + if (updates !== null && !wholly[t]) { + const mapped: number[] = []; + for (let row = 0; row < table.count; row++) if (existingAt[first + row] !== 0) mapped.push(row); + if (mapped.length > 0) updateRows(state, plan, mapped, entities[t], updates, remap); + } let whole = wholly[t]; if (weak.length > 0) for (let row = 0; row < table.count && whole; row++) if (loses(row)) whole = false; if (whole) return spawnRows(state, plan, null, none, entities[t], remap); diff --git a/ecs/packages/ecs/src/implementation/world/watch-traits-late.test-helper.ts b/ecs/packages/ecs/src/implementation/world/watch-traits-late.test-helper.ts new file mode 100644 index 00000000..f2a7dd30 --- /dev/null +++ b/ecs/packages/ecs/src/implementation/world/watch-traits-late.test-helper.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { System } from "../../interface/system/system.js"; +import { inDocument } from "./watch-traits.test-helper.js"; + +/** A resource of the document's, registered once this module is imported. */ +export const margin = Symbol("watchTraits/margin"); + +declare module "@adobe/ecs" { + export interface Resources { + [margin]: { type: number; [inDocument]: true }; + } +} + +export const widen = System.create(({ resources }) => { resources[margin] = 5; }); diff --git a/ecs/packages/ecs/src/implementation/world/watch-traits.test-helper.ts b/ecs/packages/ecs/src/implementation/world/watch-traits.test-helper.ts new file mode 100644 index 00000000..5829ddc6 --- /dev/null +++ b/ecs/packages/ecs/src/implementation/world/watch-traits.test-helper.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +/** A trait of the document's: what changes finalizers watching it see. */ +export const inDocument = Symbol("watchTraits/inDocument"); + +declare module "@adobe/ecs" { + export interface Traits { + readonly [inDocument]: N extends unknown ? true : never; + } +} diff --git a/ecs/packages/ecs/src/implementation/world/watch-traits.test.ts b/ecs/packages/ecs/src/implementation/world/watch-traits.test.ts new file mode 100644 index 00000000..3c05cd4f --- /dev/null +++ b/ecs/packages/ecs/src/implementation/world/watch-traits.test.ts @@ -0,0 +1,62 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { Finalizer } from "../../interface/finalizer/finalizer.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "../index.js"; +import { readResource } from "./reads.test-helper.js"; +import { inDocument } from "./watch-traits.test-helper.js"; + +const title = Symbol("watchTraits/title"); +const cursor = Symbol("watchTraits/cursor"); +const zoom = Symbol("watchTraits/zoom"); +const theme = Symbol("watchTraits/theme"); +const saved = Symbol("watchTraits/saved"); +const recorded = Symbol("watchTraits/recorded"); + +declare module "@adobe/ecs" { + interface Components { + [title]: { type: string; [inDocument]: true }; + [cursor]: { type: number }; + } + interface Resources { + [zoom]: { type: number; default: 1; [inDocument]: true }; + [theme]: { type: string }; + [saved]: { type: readonly string[]; default: [] }; + [recorded]: { type: readonly string[]; default: [] }; + } +} + +/** What a change set holds, as lines: each group's components added and removed, and each resource written. */ +const linesOf = (changes: { readonly groups: readonly { readonly added: readonly symbol[]; readonly removed: readonly symbol[] }[]; readonly resources: readonly { readonly key: symbol; readonly previous: unknown; readonly next: unknown }[] }): string[] => [ + ...changes.groups.map(group => [...group.added.map(key => `+${key.description}`), ...group.removed.map(key => `-${key.description}`)].join(" ")), + ...changes.resources.map(change => `${change.key.description} ${String(change.previous)} → ${String(change.next)}`), +]; + +Finalizer.changes({ watch: [inDocument] }, (changes, { resources }) => { + resources[saved] = [...resources[saved], ...linesOf(changes)]; +}); +Finalizer.changes({ watch: { components: true, resources: [theme] } }, (changes, { resources }) => { + resources[recorded] = [...resources[recorded], ...linesOf(changes)]; +}); + +const spawn = System.create(({ commands }) => { commands.spawn([title, cursor])({ [title]: "notes", [cursor]: 0 }); }); +const edit = System.create(({ resources }) => { + resources[zoom] = 2; + resources[theme] = "dark"; +}); + +it("watches each component and resource declaring a trait, and every component with the resources chosen", async () => { + await using world = createWorld(); + await world.run(spawn); + await world.run(edit); + expect(await readResource(world, saved)).toEqual(["+watchTraits/title", "watchTraits/zoom 1 → 2"]); + expect(await readResource(world, recorded)).toEqual(["+watchTraits/title +watchTraits/cursor", "watchTraits/theme → dark"]); +}); + +it("watches a resource declaring a trait it watches that registers after the world started", async () => { + await using world = createWorld(); + await world.run(spawn); + const { widen } = await import("./watch-traits-late.test-helper.js"); + await world.run(widen); + expect(await readResource(world, saved)).toEqual(["+watchTraits/title", "watchTraits/margin 0 → 5"]); +}); diff --git a/ecs/packages/ecs/src/interface/finalizer/body.ts b/ecs/packages/ecs/src/interface/finalizer/body.ts index 6015bf9a..5cf1ec2d 100644 --- a/ecs/packages/ecs/src/interface/finalizer/body.ts +++ b/ecs/packages/ecs/src/interface/finalizer/body.ts @@ -5,9 +5,11 @@ import type { Commands } from '../system/commands.js'; import type { Listed, Property } from '../system/property.js'; import type { LookupFunction } from '../system/values.js'; import type { WriteResources } from '../system/body.js'; -import type { ChangesDeclaration, Declaration } from './declaration.js'; +import type { ChangesDeclaration, Declaration, OwnTrait } from './declaration.js'; import type { Essential, ReadonlyType } from '../component/value.js'; import type { Resource } from '../resource/resource.js'; +import type { Resources } from '../resource/types.js'; +import type { Components } from '../component/types.js'; import type { ReadonlyType as ResourceType } from '../resource/value.js'; import type { Elsewhere, ThreadsOf } from '../system/threads.js'; @@ -30,11 +32,28 @@ export type Context = }) & (true extends Elsewhere> ? {} : { readonly commands: Commands }); +/** The components declaring any of the traits `T`. */ +type ComponentsDeclaring = { [K in Component.Key]: [Extract] extends [never] ? never : K }[Component.Key]; + +/** The resources declaring any of the traits `T`. */ +type ResourcesDeclaring = { [K in Resource.Key]: [Extract] extends [never] ? never : K }[Resource.Key]; + +/** The components keys `L` name: each, and each declaring a trait among them. */ +type ComponentsOf = Extract | Extract>, Essential>; + +/** The resources keys `L` name: each, and each declaring a trait among them. */ +type ResourcesOf = Extract | ResourcesDeclaring>; + +/** The keys a changes finalizer's `watch` lists, by kind: those of its list, or of its object's lists. */ +export type WatchListed = Property extends readonly (infer E)[] ? E + : Property extends { readonly [P in Of]: readonly (infer E)[] } ? E : never; + /** The components a changes finalizer sees. */ -export type Watched = Property extends readonly unknown[] ? Listed & Essential : Essential; +export type Watched = Property extends readonly unknown[] | { readonly components: readonly unknown[] } + ? ComponentsOf> : Essential; /** The resources a changes finalizer sees: those it watches. */ -export type WatchedResources = Listed & Resource.Key; +export type WatchedResources = ResourcesOf>; /** Entities whose components changed the same way: `from` and `to` are null for those spawned and despawned. */ export type Group = { diff --git a/ecs/packages/ecs/src/interface/finalizer/create.test.ts b/ecs/packages/ecs/src/interface/finalizer/create.test.ts index 9be14d65..bd9327b5 100644 --- a/ecs/packages/ecs/src/interface/finalizer/create.test.ts +++ b/ecs/packages/ecs/src/interface/finalizer/create.test.ts @@ -75,7 +75,10 @@ describe("Finalizer.create and Finalizer.changes", () => { expect(() => untyped(Finalizer.create, { components: {} }, { resources: { reduce: [total] } })).toThrow("only systems contribute to a reduction"); expect(() => untyped(Finalizer.create, { components: { with: [node, tags] } }, { read: [node, tags] })).toThrow('uses "typesFinalizerTest/node" on "main" and "typesFinalizerTest/tags" on "world"'); expect(() => untyped(Finalizer.changes, { name: "derived", watch: [slot] })).toThrow("which is derived"); - expect(() => untyped(Finalizer.changes, { name: "unregistered", watch: [Symbol("typesFinalizerTest/unregistered")] })).toThrow("which isn't a registered component or resource"); + expect(() => untyped(Finalizer.changes, { name: "unregistered", watch: [Symbol("typesFinalizerTest/unregistered")] })).toThrow("which isn't a registered component, resource or trait"); + expect(() => untyped(Finalizer.changes, { watch: { components: [time], resources: [] } })).toThrow(`watches "typesFinalizerTest/time" among its components, which isn't a registered component or trait`); + expect(() => untyped(Finalizer.changes, { watch: { components: true, resources: [label] } })).toThrow(`watches "typesFinalizerTest/label" among its resources, which isn't a registered resource or trait`); + expect(() => untyped(Finalizer.changes, { watch: { components: [slot], resources: [] } })).toThrow("which is derived"); expect(() => untyped(Finalizer.create, { components: { with: [time] } })).toThrow(`uses component "typesFinalizerTest/time", which isn't a registered component`); expect(() => untyped(Finalizer.create, { components: { with: [label] }, lookup: { others: { not: [Symbol("typesFinalizerTest/unregistered")] } } }, { read: [label] })).toThrow("which isn't a registered component"); expect(programFinalizers()).toHaveLength(7); diff --git a/ecs/packages/ecs/src/interface/finalizer/create.ts b/ecs/packages/ecs/src/interface/finalizer/create.ts index a1d335b1..04a5e160 100644 --- a/ecs/packages/ecs/src/interface/finalizer/create.ts +++ b/ecs/packages/ecs/src/interface/finalizer/create.ts @@ -6,13 +6,13 @@ import { addFinalizer, programFinalizers } from '../registry/program.js'; import { requireRegistered, threadOf } from '../registry/threads.js'; import { checkWorldWritten } from '../registry/written.js'; import { checkLookup } from '../registry/indexes.js'; -import type { Listed, Property } from '../system/property.js'; +import type { Property } from '../system/property.js'; import type { ThreadOfComponent } from '../component/value.js'; import type { ThreadOfResource } from '../resource/value.js'; import type { Resource } from '../resource/resource.js'; import type { Component } from '../component/component.js'; import type { Elsewhere, OneThread, ThreadedBy, ThreadsOf } from '../system/threads.js'; -import type { ChangeSet, Context, Row, Watched, WatchedResources } from './body.js'; +import type { ChangeSet, Context, Row, Watched, WatchedResources, WatchListed } from './body.js'; import type { Changes, Leaf } from './created.js'; import type { ChangesDeclaration, Declaration, Resolved, ResolvedChanges } from './declaration.js'; import { accessOf, compact, lookupsLists, resourceUses, type Access } from '../system/resolve.js'; @@ -28,7 +28,7 @@ type Remote = true extends Elsewhere> : {}; /** A changes finalizer runs on the world's thread, in the sync point: it watches and reads no other thread's objects. */ -type OnWorld = true extends Elsewhere | ThreadOfComponent & Component.Key> | ThreadOfResource & Resource.Key>> +type OnWorld = true extends Elsewhere | ThreadOfComponent, Component.Key>> | ThreadOfResource, Resource.Key>>> ? { readonly "error: a changes finalizer watches and reads only the world's thread's objects": true } : {}; @@ -127,6 +127,39 @@ export const create = ( return addFinalizer(finalizer, id); }; +const isList = (watch: NonNullable): watch is Extract => Array.isArray(watch); + +/** What a changes finalizer watches, sorted by kind. Throws for a key that's neither of the kinds it's listed as, or derived. */ +const watchOf = (name: string, watch: ChangesDeclaration["watch"]): ResolvedChanges["watch"] => { + const components: symbol[] = []; + const componentTraits: symbol[] = []; + const resources: symbol[] = []; + const resourceTraits: symbol[] = []; + const sort = (key: unknown, kinds: readonly ("component" | "resource")[]): void => { + const kind = kindOf(key); + if (typeof key !== "symbol" || kind === undefined || (kind !== "trait" && !kinds.includes(kind))) { + const among = kinds.length === 2 ? "" : ` among its ${kinds[0]}s`; + throw new Error(`Finalizer "${name}" watches "${labelOf(key)}"${among}, which isn't a registered ${kinds.length === 2 ? "component, resource" : kinds[0]} or trait: ${whyUnregistered(key)}.`); + } + if (kind === "trait") { + if (kinds.includes("component")) componentTraits.push(key); + if (kinds.includes("resource")) resourceTraits.push(key); + return; + } + if (declarationOf(key)?.derived === true) throw new Error(`Finalizer "${name}" watches "${labelOf(key)}", which is derived; it sees only components user code adds and removes.`); + (kind === "component" ? components : resources).push(key); + }; + if (watch === undefined) return { components: true, componentTraits, resources, resourceTraits }; + if (isList(watch)) { + for (const key of watch) sort(key, ["component", "resource"]); + return { components, componentTraits, resources, resourceTraits }; + } + const every = watch.components === true; + if (!every) for (const key of watch.components) sort(key, ["component"]); + for (const key of watch.resources) sort(key, ["resource"]); + return { components: every ? true : components, componentTraits: every ? [] : componentTraits, resources, resourceTraits }; +}; + /** * Declares a finalizer of each sync point's structural changes, which applies in every world, and returns it. It runs * on the world's thread. Throws as `create` does, for a watched component that isn't registered or is derived, a @@ -139,7 +172,7 @@ export const changes = ( const id = takeIdOfDeclaration(declared); const name = nameOf(declared, id, "finalizer"); const what = `Finalizer "${name}"`; - const declaration: ResolvedChanges = { ...resolveShared(what, declared, accessOf(what, declared)), ...declared.watch === undefined ? {} : { watch: declared.watch } }; + const declaration: ResolvedChanges = { ...resolveShared(what, declared, accessOf(what, declared)), watch: watchOf(name, declared.watch) }; const finalizer: Changes = { kind: "changes", name, declaration, body }; checkResources(name, declaration.resources); requireRegistered("Finalizer", name, "resource", declaration.resources?.read); @@ -149,14 +182,9 @@ export const changes = ( checkLookup("Finalizer", name, lookupName, lookup); for (const keys of [lookup.read, "has" in lookup ? lookup.has : undefined, "not" in lookup ? lookup.not : undefined]) requireRegistered("Finalizer", name, "component", keys); } - for (const key of declaration.watch ?? []) { - const kind = kindOf(key); - if (kind !== "component" && kind !== "resource") throw new Error(`Finalizer "${name}" watches "${labelOf(key)}", which isn't a registered component or resource: ${whyUnregistered(key)}.`); - if (declarationOf(key)?.derived === true) throw new Error(`Finalizer "${name}" watches "${labelOf(key)}", which is derived; it sees only components user code adds and removes.`); - } - const watched = (kind: "component" | "resource"): readonly symbol[] => (declaration.watch ?? []).filter(key => kindOf(key) === kind); - const read = [...declaration.resources?.read ?? [], ...watched("resource")]; - const thread = threadOf({ components: { read: watched("component") }, resources: { ...declaration.resources, read }, lookup: declaration.lookup }, "Finalizer", name); + const { watch } = declaration; + const read = [...declaration.resources?.read ?? [], ...watch.resources]; + const thread = threadOf({ components: { read: watch.components === true ? [] : watch.components }, resources: { ...declaration.resources, read }, lookup: declaration.lookup }, "Finalizer", name); if (thread !== null && thread !== "world") throw new Error(`Finalizer "${name}" watches or reads values on thread "${thread}"; a changes finalizer runs on the world's thread, in the sync point.`); checkOrder(name, "world", declaration); return addFinalizer(finalizer, id); diff --git a/ecs/packages/ecs/src/interface/finalizer/declaration.ts b/ecs/packages/ecs/src/interface/finalizer/declaration.ts index 3d29acc0..294044d4 100644 --- a/ecs/packages/ecs/src/interface/finalizer/declaration.ts +++ b/ecs/packages/ecs/src/interface/finalizer/declaration.ts @@ -5,6 +5,8 @@ import type { Lookup } from '../system/access.js'; import type { Finalizer } from './finalizer.js'; import type { Essential } from '../component/value.js'; import type { Resource } from '../resource/resource.js'; +import type { Traits } from '../trait/types.js'; +import type { BuiltIn } from '../trait/key.js'; /** * What every finalizer declares. What its body reads of its row and lookups, and reads and writes of resources, the @@ -25,12 +27,23 @@ export type Declaration = Shared & { readonly components: { readonly with?: readonly Component.Key[]; readonly optional?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; }; +/** A trait of your own, which watches every component and resource declaring it. */ +export type OwnTrait = Exclude, BuiltIn>; + /** - * A finalizer that sees each sync point's structural changes to the components it watches: by default, every one that - * isn't derived or an object a joined thread fills; and the resources it watches, none by default, written since the - * last sync point's finalizers ran. + * A finalizer that sees each sync point's structural changes to the components it watches, and the resources it + * watches written since the last sync point's finalizers ran. By default it watches every component that isn't derived + * or an object a joined thread fills, and no resource. `watch` lists components, resources and traits of your own, or, + * as an object, every component (`components: true`) or those listed, beside the resources listed. A trait watches each + * component and resource declaring it, as each registers, but those the default leaves out and resources on another + * thread. */ -export type ChangesDeclaration = Shared & { readonly watch?: readonly (Essential | Resource.Key)[] }; +export type ChangesDeclaration = Shared & { + readonly watch?: readonly (Essential | Resource.Key | OwnTrait)[] | { + readonly components: true | readonly (Essential | OwnTrait)[]; + readonly resources: readonly (Resource.Key | OwnTrait)[]; + }; +}; type Key = symbol; @@ -47,4 +60,16 @@ export type Resolved = ResolvedShared & { readonly components: { readonly read?: readonly Key[]; readonly has?: readonly Key[]; readonly optional?: readonly Key[]; readonly not?: readonly Key[] }; }; -export type ResolvedChanges = ResolvedShared & { readonly watch?: readonly Key[] }; +/** + * A changes finalizer's declaration as the world takes it. It watches the components listed, or every one (`true`), + * and those declaring a trait of `componentTraits`; the resources listed, and those declaring a trait of + * `resourceTraits`. + */ +export type ResolvedChanges = ResolvedShared & { + readonly watch: { + readonly components: true | readonly Key[]; + readonly componentTraits: readonly Key[]; + readonly resources: readonly Key[]; + readonly resourceTraits: readonly Key[]; + }; +}; diff --git a/ecs/packages/ecs/src/interface/registry/index.ts b/ecs/packages/ecs/src/interface/registry/index.ts index c4a19401..664b5ef4 100644 --- a/ecs/packages/ecs/src/interface/registry/index.ts +++ b/ecs/packages/ecs/src/interface/registry/index.ts @@ -3,7 +3,7 @@ export type { AnyDeclaration } from './declarations.js'; export { schemaOf, threadOfDeclaration } from './declarations.js'; export type { Kind } from './names.js'; -export { declarationOf, firstKeyOf, keyOfId, kindOf, labelOf, newestKeyOf, whyUnregistered } from './names.js'; +export { declarationOf, declaresTrait, declaredOf, firstKeyOf, keyOfId, keysVersion, kindOf, labelOf, newestKeyOf, whyUnregistered } from './names.js'; export type { Item } from './program.js'; export { definitionOf, programFinalizers, programRules, programVersion, requireDefinition } from './program.js'; export type { Schedule } from './schedule.js'; diff --git a/ecs/packages/ecs/src/interface/registry/names.ts b/ecs/packages/ecs/src/interface/registry/names.ts index 11e6a2db..ff62176a 100644 --- a/ecs/packages/ecs/src/interface/registry/names.ts +++ b/ecs/packages/ecs/src/interface/registry/names.ts @@ -22,6 +22,9 @@ type Entry = { /** Every component, resource and user trait, by symbol identity: symbols of one id share an entry. */ const entries = new Map(); +/** How many keys were registered, counting a symbol registered under another's id. */ +let registered = 0; + /** Entries by the ids the plugin gave their keys. */ const identified = new Map(); @@ -155,12 +158,28 @@ export const claimKey = (key: unknown, kind: Kind, declaration?: AnyDeclaration) } named.newest = symbol; entries.set(symbol, named); + registered++; return; } const description = descriptionOf(symbol); const entry: Entry = { kind, declaration, description, id, first: symbol, newest: symbol }; if (id !== undefined) identified.set(id, entry); entries.set(symbol, entry); + registered++; +}; + +/** How many keys are registered so far, so a world can tell when one registered since it last looked. */ +export const keysVersion = (): number => { + catchUp(); + return registered; +}; + +/** Whether a component's or resource's declaration declares one of `traits`, keys of traits of your own. */ +export const declaresTrait = (key: unknown, traits: readonly symbol[]): boolean => { + const declaration = declarationOf(key); + if (declaration === undefined) return false; + for (const declared of Object.getOwnPropertySymbols(declaration)) for (const trait of traits) if (sameKey(declared, trait)) return true; + return false; }; /** The symbol last registered under an id, by which a world gives values, or undefined where none was. */ diff --git a/ecs/packages/ecs/src/interface/registry/schedule.ts b/ecs/packages/ecs/src/interface/registry/schedule.ts index 6614478b..b70ebc20 100644 --- a/ecs/packages/ecs/src/interface/registry/schedule.ts +++ b/ecs/packages/ecs/src/interface/registry/schedule.ts @@ -53,8 +53,8 @@ type Graph = { readonly successors: readonly Set[]; /** Each unit's members, by unit: none for a leaf. */ readonly members: readonly (readonly number[])[]; - /** The units a list of systems names, readonly systems left out. */ - readonly resolve: (systems: readonly object[] | undefined) => readonly number[]; + /** The units a list of systems names, readonly systems and resources left out. */ + readonly resolve: (systems: readonly unknown[] | undefined) => readonly number[]; }; /** @@ -79,8 +79,9 @@ const graphOf = (root: Definition): Graph => { visit(root); const successors = units.flatMap(() => [new Set(), new Set()]); // A readonly system is ordered with nothing, so constraints naming one are ignored. - const resolve = (systems: readonly object[] | undefined): readonly number[] => + const resolve = (systems: readonly unknown[] | undefined): readonly number[] => (systems ?? []).flatMap(system => { + if (typeof system !== "object" || system === null) return []; const unit = indexOf.get(firstItemOf(system)); return unit === undefined || isObserver(units[unit]) ? [] : [unit]; }); @@ -149,7 +150,7 @@ const depthsOf = ({ units, successors }: Graph, order: readonly number[]): numbe return depth; }; -type Access = { readonly reads: ReadonlySet; readonly writes: ReadonlySet; readonly reduces: ReadonlySet }; +type Access = { readonly reads: ReadonlySet; readonly writes: ReadonlySet; readonly reduces: ReadonlySet; readonly leaf: Leaf }; /** The resources a leaf reduces into. */ export const reducedBy = (leaf: Leaf): readonly Key[] => { @@ -168,21 +169,53 @@ export const accessOf = (leaf: Leaf): Access => { for (const key of orderKeysOf(leaf.declaration)) reads.add(firstKeyOf(key)); if (up !== undefined) for (const key of [up.through, ...up.read ?? []]) reads.add(firstKeyOf(key)); const writes = new Set([...components?.write ?? [], ...resources?.write ?? []].map(firstKeyOf)); - return { reads, writes, reduces: new Set(reducedBy(leaf).map(firstKeyOf)) }; + return { reads, writes, reduces: new Set(reducedBy(leaf).map(firstKeyOf)), leaf }; }; -/** Contributors to one resource don't conflict with each other: their fold doesn't depend on which runs first. */ +const names = (list: readonly Key[] | undefined, key: Key): boolean => list !== undefined && list.some(item => firstKeyOf(item) === key); + +/** Whether a leaf reaches a key only through its rows: neither looked up, read through `up`, indexed nor ordered by. */ +const onlyThroughRows = (leaf: Leaf, key: Key): boolean => { + const { components, lookup, up } = leaf.declaration; + if (!names(components?.read, key) && !names(components?.write, key) && !names(components?.added, key) && !names(components?.changed, key)) return false; + for (const name in lookup) if (names(lookup[name].read, key) || names(lookupKeysOf(lookup[name]), key)) return false; + if (names(orderKeysOf(leaf.declaration), key)) return false; + return up === undefined || (firstKeyOf(up.through) !== key && !names(up.read, key)); +}; + +/** Whether no entity can match both leaves' rows: one requires a component the other's `not` lists. */ +const exclusive = (a: Leaf, b: Leaf): boolean => { + const requiresExcluded = (one: Leaf, other: Leaf): boolean => { + const { components } = one.declaration; + const not = other.declaration.components?.not; + if (components === undefined || not === undefined || not.length === 0) return false; + for (const list of [components.read, components.write, components.has, components.added, components.changed]) { + for (const key of list ?? []) if (!names(components.optional, firstKeyOf(key)) && names(not, firstKeyOf(key))) return true; + } + return false; + }; + return requiresExcluded(a, b) || requiresExcluded(b, a); +}; + +/** Whether a conflict on `key` is waived: both reach it only through rows that never match one entity. */ +const waived = (a: Access, b: Access, key: Key): boolean => onlyThroughRows(a.leaf, key) && onlyThroughRows(b.leaf, key) && exclusive(a.leaf, b.leaf); + +/** + * The first key two accesses conflict on, one writing it, or undefined. Contributors to one resource don't conflict + * with each other: their fold doesn't depend on which runs first. Nor do two whose rows never match one entity on a key + * both reach only through their rows. + */ const conflictOf = (a: Access, b: Access): Key | undefined => { - for (const key of a.writes) if (b.writes.has(key) || b.reads.has(key) || b.reduces.has(key)) return key; - for (const key of b.writes) if (a.reads.has(key) || a.reduces.has(key)) return key; + for (const key of a.writes) if ((b.writes.has(key) || b.reads.has(key) || b.reduces.has(key)) && !waived(a, b, key)) return key; + for (const key of b.writes) if ((a.reads.has(key) || a.reduces.has(key)) && !waived(a, b, key)) return key; return undefined; }; -/** Every key two accesses conflict on. */ +/** Every key two accesses conflict on, as `conflictOf` finds the first. */ const conflictsOf = (a: Access, b: Access): readonly Key[] => [...new Set([ ...[...a.writes].filter(key => b.writes.has(key) || b.reads.has(key) || b.reduces.has(key)), ...[...b.writes].filter(key => a.reads.has(key) || a.reduces.has(key)), -])]; +])].filter(key => !waived(a, b, key)); /** A system's leaves in its tiers, with their units and accesses, and its graph with readers of folds after their contributors. */ const orderedOf = (system: Definition) => { @@ -199,7 +232,46 @@ const orderedOf = (system: Definition) => { }); } }); - return { graph, leaves, accesses, reductions }; + return { graph, leaves, accesses, reductions, keyed: orderByResources(graph, leaves, accesses) }; +}; + +const noneKeyed: ReadonlyMap = new Map(); + +const listsResource = (list: readonly unknown[] | undefined): boolean => list !== undefined && list.some(item => typeof item === "symbol"); + +/** The resources a list of `before` or `after` names, each by its first symbol. */ +const resourcesIn = (list: readonly unknown[] | undefined): readonly Key[] => + (list ?? []).flatMap(item => typeof item === "symbol" ? [firstKeyOf(item)] : []); + +/** + * Orders each leaf naming a resource in `before` or `after` before or after every other leaf using it, adding the edges + * to `graph`: but one its constraints already order the other way, and one naming the resource in the same list. By + * unit, as `" "`, the list that ordered each pair. + */ +const orderByResources = (graph: Graph, leaves: readonly { readonly leaf: Leaf; readonly unit: number }[], accesses: readonly Access[]): ReadonlyMap => { + if (!leaves.some(({ leaf }) => listsResource(leaf.declaration.before) || listsResource(leaf.declaration.after))) return noneKeyed; + const keyed = new Map(); + const lists = leaves.map(({ leaf }) => ({ before: resourcesIn(leaf.declaration.before), after: resourcesIn(leaf.declaration.after) })); + const uses = leaves.map(({ leaf }, index) => new Set([...accesses[index].reads, ...accesses[index].writes, ...accesses[index].reduces, + ...[...leaf.declaration.resources?.is ?? [], ...leaf.declaration.resources?.not ?? []].map(firstKeyOf)])); + const reach = reachability(topologicalOrder(graph), graph.successors); + const edges: (readonly [number, number, "before" | "after"])[] = []; + leaves.forEach(({ unit }, index) => { + for (const list of ["before", "after"] as const) { + for (const key of lists[index][list]) { + leaves.forEach((other, o) => { + if (o === index || !uses[o].has(key) || lists[o][list].includes(key)) return; + const [earlier, later] = list === "before" ? [unit, other.unit] : [other.unit, unit]; + if (!reaches(reach, end(later), start(earlier))) edges.push([earlier, later, list]); + }); + } + } + }); + for (const [earlier, later, list] of edges) { + graph.successors[end(earlier)].add(start(later)); + keyed.set(`${earlier} ${later}`, list); + } + return keyed; }; /** @@ -214,7 +286,7 @@ export type Placement = { readonly leaf: Leaf; readonly because: "after" | "befo * leaf of its tiers. */ export const placementsOf = (system: Definition): ReadonlyMap => { - const { graph, leaves, accesses } = orderedOf(system); + const { graph, leaves, accesses, keyed } = orderedOf(system); const reach = reachability(topologicalOrder(graph), graph.successors); const ancestors = graph.units.map(() => [] as number[]); graph.members.forEach((members, unit) => { @@ -225,7 +297,7 @@ export const placementsOf = (system: Definition): ReadonlyMap { + const names = (systems: readonly unknown[] | undefined, unit: number): boolean => { const named = graph.resolve(systems); return named.includes(unit) || ancestors[unit].some(holder => named.includes(holder)); }; @@ -239,7 +311,7 @@ export const placementsOf = (system: Definition): ReadonlyMap accesses[b].reads.has(key)) ? "reduction" : "during"; + : keyed.get(`${other.unit} ${unit}`) ?? ([...accesses[a].reduces].some(key => accesses[b].reads.has(key)) ? "reduction" : "during"); return { leaf: other.leaf, because, conflicts: conflictsOf(accesses[a], accesses[b]) }; })); }); @@ -271,7 +343,8 @@ const createSchedule = (system: Definition): Schedule => { if (key !== undefined && !ordered(reach, leaves[a].unit, leaves[b].unit)) { const whole = [leaves[a].leaf, leaves[b].leaf].filter(leaf => leaf.declaration.components?.whole === true) .map(leaf => ` "${leaf.name}" takes its row whole (components.whole), so it writes every member.`).join(""); - throw new Error(`Systems "${leaves[a].leaf.name}" and "${leaves[b].leaf.name}" both access "${labelOf(key)}" and one writes it; order them with before or after.${whole}`); + const rows = onlyThroughRows(leaves[a].leaf, key) && onlyThroughRows(leaves[b].leaf, key) ? ", or, if they never match one entity, add a component of one to the other's not" : ""; + throw new Error(`Systems "${leaves[a].leaf.name}" and "${leaves[b].leaf.name}" both access "${labelOf(key)}" and one writes it; order them with before or after${rows}.${whole}`); } } } @@ -305,7 +378,7 @@ export type SyncReason = { readonly from: Leaf; readonly to: Leaf; readonly keys /** The components a leaf's commands may add or remove, by spawn, add or remove; `"any"` once it despawns or isn't followed. */ const structuralOf = (leaf: Leaf): ReadonlySet | "any" | null => { - const { commands } = leaf; + const commands = leaf.commands(); if (commands === undefined) return null; if (commands === null || commands.despawn === true) return "any"; const keys = new Set([...commands.spawn ?? [], ...commands.add ?? [], ...commands.remove ?? []].map(firstKeyOf)); diff --git a/ecs/packages/ecs/src/interface/rule/rule.type-test.ts b/ecs/packages/ecs/src/interface/rule/rule.type-test.ts index e5597f1c..ae644beb 100644 --- a/ecs/packages/ecs/src/interface/rule/rule.type-test.ts +++ b/ecs/packages/ecs/src/interface/rule/rule.type-test.ts @@ -7,7 +7,7 @@ import { System } from '../system/system.js'; import type { Create } from '../world/create.js'; import type { World } from '../world/world.js'; import { Rule } from './rule.js'; -import type { Writable } from '../component/value.js'; +import type { Essential, Writable } from '../component/value.js'; import type { ChangeSet } from '../finalizer/body.js'; type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; @@ -18,6 +18,9 @@ const hidden = Symbol("ruleTypeTest/hidden"); const slot = Symbol("ruleTypeTest/slot"); const parent = Symbol("ruleTypeTest/parent"); const time = Symbol("ruleTypeTest/time"); +const inDocument = Symbol("ruleTypeTest/inDocument"); +const kept = Symbol("ruleTypeTest/kept"); +const saved = Symbol("ruleTypeTest/saved"); declare module '@adobe/ecs' { export interface Components { @@ -25,9 +28,14 @@ declare module '@adobe/ecs' { [hidden]: { type: true }; [slot]: { type: number; derived: true }; [parent]: { type: Entity; reference: "owner" }; + [kept]: { type: number; [inDocument]: true }; } export interface Resources { [time]: { type: number }; + [saved]: { type: number; [inDocument]: true }; + } + export interface Traits { + readonly [inDocument]: N extends unknown ? true : never; } } @@ -81,6 +89,20 @@ Finalizer.changes({ watch: [source, parent] }, (changes, { commands }) => { // @ts-expect-error changes finalizers don't watch derived components Finalizer.changes({ watch: [slot] }, () => {}); +// A trait of your own watches every component and resource declaring it. +Finalizer.changes({ watch: [inDocument, source] }, changes => { + check>>(); +}); +// Every component, and the resources chosen, by key or trait. +Finalizer.changes({ watch: { components: true, resources: [time, inDocument] } }, changes => { + check>>(); +}); +Finalizer.changes({ watch: { components: [inDocument], resources: [] } }, changes => { + check>>(); +}); +// @ts-expect-error components lists components and traits, resources resources and traits +Finalizer.changes({ watch: { components: [time], resources: [source] } }, () => {}); + // References change only through commands, so systems can't write them. System.create({ components: { with: [parent, source] } }, row => { row[source] = row[parent]; }); System.create({ components: { with: [parent] } }, row => { diff --git a/ecs/packages/ecs/src/interface/system/check.ts b/ecs/packages/ecs/src/interface/system/check.ts index d9c689a1..128aeb57 100644 --- a/ecs/packages/ecs/src/interface/system/check.ts +++ b/ecs/packages/ecs/src/interface/system/check.ts @@ -220,6 +220,8 @@ export const checkLeaf = (leaf: Leaf): void => { threadOf(declaration, "System", name); requireRegistered("System", name, "component", components?.has); requireRegistered("System", name, "component", components?.not); + if (declaration.before !== undefined) requireRegistered("System", name, "resource", declaration.before.filter(item => typeof item === "symbol")); + if (declaration.after !== undefined) requireRegistered("System", name, "resource", declaration.after.filter(item => typeof item === "symbol")); for (const lookupName in lookup) { const found = lookup[lookupName]; requireRegistered("System", name, "component", "has" in found ? found.has : undefined); diff --git a/ecs/packages/ecs/src/interface/system/commands.ts b/ecs/packages/ecs/src/interface/system/commands.ts index 5d3c04f9..3cbe4664 100644 --- a/ecs/packages/ecs/src/interface/system/commands.ts +++ b/ecs/packages/ecs/src/interface/system/commands.ts @@ -65,9 +65,9 @@ export type Commands = { ): (...values: SpawnValues) => Entity; despawn(entity: Entity): void; /** - * Despawns, as a `despawn` of each would, every entity that has each component of `with` and none of `not` once the - * commands issued before it apply, and every entity spawned at the same sync point that does, whenever spawned. - * Derived components may be named. `where` keeps it to the archetypes holding these values of shared components, + * Despawns, as a `despawn` of each would, every entity alive before its sync point that has each component of + * `with` and none of `not` once the commands issued before it apply, as a row system over the same query despawning + * each of its rows would; never one the sync point spawns, whenever spawned. Derived components may be named. `where` keeps it to the archetypes holding these values of shared components, * each one required as `with` requires it, as a system's `where` of values does. Never the resource entity. Only on * the world's thread: a joined thread's throws. */ @@ -94,11 +94,16 @@ export type Commands = { * - `existing` maps entities to ones already alive in this world, which stand for them: a row whose entity it maps * isn't spawned, its entry in the result is the entity it maps to, and every value naming one it maps names that * entity, whether or not it's in the tables. Fails for one that isn't alive. + * - `update` writes each row `existing` maps onto the entity standing for it, as `commands.add` of its table's + * components would: adding those the entity lacks, replacing and marking changed those it has, and leaving its + * other components as they are. Its entity values are rewritten as a spawned row's are, but a reference naming an + * entity that isn't there, weak or owner, is left out, so the entity's own stands. * - `resources` sets each resource it names, by key, at the sync point, as commands apply, each value checked * against its resource's schema; not one only the world writes, nor one living on another thread. */ spawnTables(tables: readonly Table[], options?: { readonly resources?: { readonly [key: symbol]: unknown }; readonly existing?: ReadonlyMap; + readonly update?: boolean; }): Table.Spawned; }; diff --git a/ecs/packages/ecs/src/interface/system/create-resolved.ts b/ecs/packages/ecs/src/interface/system/create-resolved.ts index f09b04e6..9fb5ff2c 100644 --- a/ecs/packages/ecs/src/interface/system/create-resolved.ts +++ b/ecs/packages/ecs/src/interface/system/create-resolved.ts @@ -3,6 +3,7 @@ import { nameOf } from '../registry/items.js'; import { claimSystem } from '../registry/program.js'; import { checkLeaf, inputSchemaOf } from './check.js'; import type { Resolved } from './declaration.js'; +import { noCommands } from './resolve.js'; import type { Leaf } from './leaf.js'; import type { System } from './system.js'; @@ -14,7 +15,7 @@ import type { System } from './system.js'; export const createResolved = (declaration: Resolved, body: Leaf["body"], input?: unknown): System => { const name = nameOf(declaration, undefined, "system"); const schema = inputSchemaOf(name, body, declaration.components === undefined ? 1 : 2, input); - const system: Leaf & System = { kind: "system", name, declaration, input: schema, body, commands: undefined }; + const system: Leaf & System = { kind: "system", name, declaration, input: schema, body, commands: noCommands }; checkLeaf(system); claimSystem(system, undefined); return system; diff --git a/ecs/packages/ecs/src/interface/system/create.test.ts b/ecs/packages/ecs/src/interface/system/create.test.ts index a87a14b4..fd2a6ec7 100644 --- a/ecs/packages/ecs/src/interface/system/create.test.ts +++ b/ecs/packages/ecs/src/interface/system/create.test.ts @@ -49,7 +49,7 @@ describe("System.create", () => { const body = (): void => {}; // A body the build doesn't see is given what it uses. const move = System.create(giveAccess({ components: { with: [position] } }, { write: [position], resources: { read: [time] } }), body); - expect(definitionOf(move)).toEqual({ kind: "system", name: "move", declaration: { components: { write: [position] }, resources: { read: [time] } }, body }); + expect(definitionOf(move)).toEqual({ kind: "system", name: "move", declaration: { components: { write: [position] }, resources: { read: [time] } }, body, commands: expect.any(Function) }); expect(move.name).toBe("move"); expect(definitionOf({ name: "move" })).toBe(undefined); }); @@ -71,7 +71,7 @@ describe("System.create", () => { it("labels a system made with neither an id nor a binding by its kind numbered", () => { const body = ({ commands }: { readonly commands: System.Commands }): void => { commands.spawn([position])({ [position]: 0 }); }; const [alone] = [System.create(giveAccess(identify(body, "setup.ts#spawnOnce")))]; - expect(definitionOf(alone)).toEqual({ kind: "system", name: "spawnOnce", declaration: {}, body }); + expect(definitionOf(alone)).toEqual({ kind: "system", name: "spawnOnce", declaration: {}, body, commands: expect.any(Function) }); const [first, second] = [System.create(() => {}), System.create({ systems: [alone] })].map(system => system.name); const number = Number(first?.slice("system #".length)); expect([first, second]).toEqual([`system #${number}`, `system #${number + 1}`]); @@ -177,7 +177,7 @@ describe("System.create of a composite", () => { const reader = System.create({ components: { with: [position] } }, row => { void row[position]; }); const definition = definitionOf(whole); expect(definition?.kind === "system" ? definition.declaration.components : null).toEqual({ read: [parent], write: [position], whole: true }); - expect(() => System.create([whole, reader])).toThrow('both access "typesSystemTest/position" and one writes it; order them with before or after. "whole" takes its row whole (components.whole), so it writes every member.'); + expect(() => System.create([whole, reader])).toThrow(`both access "typesSystemTest/position" and one writes it; order them with before or after, or, if they never match one entity, add a component of one to the other's not. "whole" takes its row whole (components.whole), so it writes every member.`); }); it("runs readonly members after its sync point, in no tier, never conflicting and ignoring constraints naming them", () => { const write = System.create({ components: { with: [position] } }, row => { row[position] += 1; }); diff --git a/ecs/packages/ecs/src/interface/system/create.ts b/ecs/packages/ecs/src/interface/system/create.ts index c1cf5553..2b876395 100644 --- a/ecs/packages/ecs/src/interface/system/create.ts +++ b/ecs/packages/ecs/src/interface/system/create.ts @@ -18,7 +18,7 @@ import type { OneThread, ThreadedBy, ThreadsOf } from './threads.js'; import type { HeldBy } from './values.js'; import type { SharedOnly } from './shared-only.js'; import type { Shared } from '../component/value.js'; -import { accessOf, commandLists, compact, componentLists, lookupsLists, resourceUses, upReads, whereReadsOf } from './resolve.js'; +import { accessOf, commandListsOf, compact, componentLists, lookupsLists, resourceUses, upReads, whereReadsOf } from './resolve.js'; type Lookups = Property; @@ -181,7 +181,7 @@ export function create(first: Declaration | Leaf["body"] | readonly System = { kind: "system", name, declaration, input: schema, body, commands: commandLists(access) }; + const system: Leaf & System = { kind: "system", name, declaration, input: schema, body, commands: commandListsOf(`System "${name}"`, access) }; checkLeaf(system); claimSystem(system, id); return system; diff --git a/ecs/packages/ecs/src/interface/system/declaration.ts b/ecs/packages/ecs/src/interface/system/declaration.ts index db0145e1..09ee8aa9 100644 --- a/ecs/packages/ecs/src/interface/system/declaration.ts +++ b/ecs/packages/ecs/src/interface/system/declaration.ts @@ -52,10 +52,16 @@ export type Declaration = { readonly target?: string; /** Makes this a render system (see `render`): its body records GPU work over the GPU copy of the components it names. */ readonly shader?: Shader; - /** Hard constraint. */ - readonly before?: readonly System[]; - /** Hard constraint. */ - readonly after?: readonly System[]; + /** + * Hard constraint: runs before each system listed, and, for a resource listed, before every other system of the + * composite that uses it, but those its constraints already order before it and those listing it in `before` too. + */ + readonly before?: readonly (System | Resource.Key)[]; + /** + * Hard constraint: runs after each system listed, and, for a resource listed, after every other system of the + * composite that uses it, but those its constraints already order after it and those listing it in `after` too. + */ + readonly after?: readonly (System | Resource.Key)[]; /** Runs inside the span of the systems listed: after what they follow and before what follows them. */ readonly during?: readonly System[]; /** diff --git a/ecs/packages/ecs/src/interface/system/describe.test.ts b/ecs/packages/ecs/src/interface/system/describe.test.ts index 84e162df..5f9d04d3 100644 --- a/ecs/packages/ecs/src/interface/system/describe.test.ts +++ b/ecs/packages/ecs/src/interface/system/describe.test.ts @@ -56,6 +56,25 @@ describe("System.describe", () => { const tally = System.create({ after: [count] }, ({ resources }) => { resources[hover] = resources[speed]; }); expect(System.describe(System.create([clear, count, tally])).tiers.map(tier => tier.sync)).toEqual([[{ from: clear, to: count, keys: [] }], null, null]); }); + it("lists no conflict on a key two systems reach through rows that never match one entity", () => { + const placeCells = System.create({ components: { with: [position, cell] } }, row => { row[cell] = row[position]; }); + const clearCells = System.create({ components: { with: [cell], not: [position] }, after: [placeCells] }, row => { row[cell] = 0; }); + expect(System.describe(System.create([placeCells, clearCells])).tiers[1]?.systems[0]?.after).toEqual([{ system: placeCells, name: "placeCells", because: "after", conflicts: [] }]); + }); + it("says a system follows another by a resource one lists in before or after", () => { + const setSpeed = System.create({ before: [speed] }, ({ resources }) => { resources[speed] = 2; }); + const readSpeed = System.create({ components: { with: [position] } }, (row, { resources }) => { row[position] = resources[speed]; }); + const readHover = System.create({ after: [hover] }, ({ resources }) => resources[hover] + resources[speed]); + const setHover = System.create({ after: [setSpeed] }, ({ resources }) => { resources[hover] = 1; }); + const described = System.describe(System.create([readHover, readSpeed, setHover, setSpeed])); + expect(described.tiers.map(tier => tier.systems.map(system => [system.name, system.after.map(placement => [placement.name, placement.because])]))).toEqual([ + [["setSpeed", []]], + [["readSpeed", [["setSpeed", "before"]]], ["setHover", [["setSpeed", "after"]]]], + [["readHover", [["setHover", "after"]]]], + ]); + // @ts-expect-error ordering takes resources, not components + expect(() => System.create({ after: [position] }, () => {})).toThrow(`uses resource "describeTest/position", which isn't a registered resource`); + }); it("gives a text form to read", () => { expect(System.describe(frame).text).toBe([ "tier 0 spawnCells commands spawn describeTest/position", diff --git a/ecs/packages/ecs/src/interface/system/describe.ts b/ecs/packages/ecs/src/interface/system/describe.ts index f90854b6..70b03973 100644 --- a/ecs/packages/ecs/src/interface/system/describe.ts +++ b/ecs/packages/ecs/src/interface/system/describe.ts @@ -16,7 +16,7 @@ const asSystem = (leaf: Leaf): System => leaf; /** What the build inferred one system uses, and what placed it, as plain data. */ const describeLeaf = (leaf: Leaf, placements: readonly Placement[]) => { const { components, resources, lookup, up, order } = leaf.declaration; - const { commands } = leaf; + const commands = leaf.commands(); return { /** The system, the value its constructor returned. */ system: asSystem(leaf), diff --git a/ecs/packages/ecs/src/interface/system/leaf.ts b/ecs/packages/ecs/src/interface/system/leaf.ts index 6d6a2b74..3194ff5a 100644 --- a/ecs/packages/ecs/src/interface/system/leaf.ts +++ b/ecs/packages/ecs/src/interface/system/leaf.ts @@ -19,8 +19,9 @@ export type Leaf = { readonly input: InputSchema | undefined; readonly body: (...parameters: never[]) => void; /** - * What its body's commands name, as the build inferred them: null where it couldn't follow them, undefined for - * none. Apart from its declaration, so a module evaluated again with other commands declares the same system. + * What its body's commands name, as the build inferred them, read at first need: null where it couldn't follow + * them, undefined for none. Apart from its declaration, so a module evaluated again with other commands declares the + * same system. */ - readonly commands?: CommandLists | null; + readonly commands: () => CommandLists | null | undefined; }; diff --git a/ecs/packages/ecs/src/interface/system/render.ts b/ecs/packages/ecs/src/interface/system/render.ts index d2857f83..6094f3df 100644 --- a/ecs/packages/ecs/src/interface/system/render.ts +++ b/ecs/packages/ecs/src/interface/system/render.ts @@ -11,7 +11,7 @@ import type { Body } from './body.js'; import type { ReturnsNoPromise } from './input.js'; import { accessorsOf } from '../gpu/accessors.js'; import { checkLeaf } from './check.js'; -import { accessOf, compact, resourceUses } from './resolve.js'; +import { accessOf, compact, noCommands, resourceUses } from './resolve.js'; import type { Leaf } from './leaf.js'; import type { StructComponent } from './struct.js'; import type { Shared } from '../component/value.js'; @@ -96,7 +96,7 @@ export const render = >( }, input: undefined, body, - commands: undefined, + commands: noCommands, }; if (uses.write.length > 0 || uses.reduce.length > 0) throw new Error(`${what} writes or reduces into resources; a render system only reads.`); checkLeaf(system); diff --git a/ecs/packages/ecs/src/interface/system/resolve.test.ts b/ecs/packages/ecs/src/interface/system/resolve.test.ts index 80a5f3b8..b9f17798 100644 --- a/ecs/packages/ecs/src/interface/system/resolve.test.ts +++ b/ecs/packages/ecs/src/interface/system/resolve.test.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import "../registry/declarations.js"; -import { componentLists, lookupsLists, resourceUses, type Access } from "./resolve.js"; +import { commandListsOf, componentLists, lookupsLists, resourceUses, type Access } from "./resolve.js"; const mass = Symbol("resolveTest/mass"); const pool = Symbol("resolveTest/pool"); @@ -47,3 +47,19 @@ describe("resourceUses", () => { expect(() => resourceUses("System \"s\"", changing(pool), undefined)).not.toThrow(); }); }); + +describe("commandListsOf", () => { + const commanding = (commands: () => unknown): Access => ({ components: null, resources: null, lookup: null, up: null, commands, at: "a test" }); + it("reads what the commands name at the first call, once", () => { + let reads = 0; + const lists = commandListsOf("System \"s\"", commanding(() => { reads++; return { spawn: [[mass, position]], add: [mass], remove: [], despawn: false }; })); + expect(reads).toBe(0); + expect([lists(), lists(), reads]).toEqual([{ spawn: [mass, position], add: [mass] }, { spawn: [mass, position], add: [mass] }, 1]); + }); + it("names the system and the declaration its commands name before it's initialized", () => { + for (const message of ["Cannot access 'standard' before initialization", "can't access lexical declaration 'standard' before initialization"]) { + const lists = commandListsOf("System \"s\"", commanding(() => { throw new ReferenceError(message); })); + expect(lists).toThrow(`System "s" names "standard" in its commands before it's initialized: declare it above the system.`); + } + }); +}); diff --git a/ecs/packages/ecs/src/interface/system/resolve.ts b/ecs/packages/ecs/src/interface/system/resolve.ts index dfaba1da..a2dfb4ad 100644 --- a/ecs/packages/ecs/src/interface/system/resolve.ts +++ b/ecs/packages/ecs/src/interface/system/resolve.ts @@ -23,7 +23,10 @@ export type Access = { readonly up: readonly Key[] | null; /** The resources the predicates of its declaration's `where` read; absent without predicates. */ readonly where?: readonly Key[] | null; - /** What its commands name, keys or lists of them, by kind, and whether it despawns; absent where no build said. */ + /** + * What its commands name, keys or lists of them, by kind, and whether it despawns, or a function giving that; absent + * where no build said. + */ readonly commands?: unknown; readonly at: string; }; @@ -35,7 +38,7 @@ const isKeys = (value: unknown): value is readonly Key[] => Array.isArray(value) const isNamed = (value: unknown): value is Named => Array.isArray(value) && value.every(item => typeof item === "symbol" || isKeys(item)); -/** A binding a command named that holds neither a key nor a list of them, as one declared below its system, isn't followed. */ +/** A binding a command named that holds neither a key nor a list of them isn't followed. */ const isCommanded = (value: unknown): value is Commanded => typeof value === "object" && value !== null && isNamed(Reflect.get(value, "spawn")) && isNamed(Reflect.get(value, "add")) && isNamed(Reflect.get(value, "remove")) && typeof Reflect.get(value, "despawn") === "boolean"; @@ -173,9 +176,28 @@ export const whereReadsOf = (what: string, components: ComponentAccess | undefin return reads; }; -/** What a body's commands name, as the world takes it: null where the build couldn't follow them, nothing for none. */ -export const commandLists = (access: Access): CommandLists | null | undefined => { - const { commands } = access; +/** + * What a body's commands name, as the world takes it, read at the first call and kept: null where the build couldn't + * follow them, nothing for none. The build gives them as a function, so a list declared below the system is read once + * its module has loaded; one still uninitialized then, as an import cycle leaves it, throws naming it. + */ +export const commandListsOf = (what: string, access: Access): (() => CommandLists | null | undefined) => { + let read = false; + let lists: CommandLists | null | undefined; + return () => { + if (!read) { + lists = listsOf(what, access.commands); + read = true; + } + return lists; + }; +}; + +/** The command lists of a system whose body issues none. */ +export const noCommands = (): undefined => undefined; + +const listsOf = (what: string, given: unknown): CommandLists | null | undefined => { + const commands = typeof given === "function" ? evaluated(what, () => Reflect.apply(given, undefined, [])) : given; if (commands === undefined) return undefined; if (!isCommanded(commands)) return null; if (!commands.despawn && commands.spawn.length + commands.add.length + commands.remove.length === 0) return undefined; @@ -184,5 +206,18 @@ export const commandLists = (access: Access): CommandLists | null | undefined => return Object.keys(lists).length === 0 ? undefined : lists; }; +/** What the build's function of a body's commands gives. */ +const evaluated = (what: string, commands: () => unknown): unknown => { + try { + return commands(); + } catch (error) { + if (!(error instanceof ReferenceError)) throw error; + // Engines quote the binding in the message: "Cannot access 'standard' before initialization", or "can't access + // lexical declaration 'standard' before initialization". + const binding = /'([A-Za-z_$][\w$]*)'/.exec(error.message)?.[1]; + throw new Error(`${what} names ${binding === undefined ? "a declaration" : `"${binding}"`} in its commands before it's initialized: declare it above the system.`, { cause: error }); + } +}; + /** What a body reads of its parent's values. */ export const upReads = (what: string, access: Access): readonly Key[] => followed(what, access.up, "its parent's values", access); diff --git a/ecs/packages/ecs/src/interface/system/system.type-test.ts b/ecs/packages/ecs/src/interface/system/system.type-test.ts index 893129e8..48ac6752 100644 --- a/ecs/packages/ecs/src/interface/system/system.type-test.ts +++ b/ecs/packages/ecs/src/interface/system/system.type-test.ts @@ -154,6 +154,12 @@ System.create({ // @ts-expect-error ordering takes systems after: ["test/unregistered"], }, () => {}); +// Ordering by a resource: before or after every other system of the composite that uses it. +System.create({ before: [time], after: [integrate, score] }, ({ resources }) => { resources[time] = resources[score]; }); +System.create({ + // @ts-expect-error ordering takes systems and resources, not components + after: [position], +}, () => {}); // @ts-expect-error a system with input can't use change filters System.create({ components: { changed: [position] } }, (_row, _context, input: { readonly distance: number }) => { void input; }); diff --git a/ecs/packages/ecs/src/perftest/benchmarks.ts b/ecs/packages/ecs/src/perftest/benchmarks.ts index 6fcd9c36..8cb85054 100644 --- a/ecs/packages/ecs/src/perftest/benchmarks.ts +++ b/ecs/packages/ecs/src/perftest/benchmarks.ts @@ -36,6 +36,7 @@ import { entitiesWith } from "./observer.js"; import { invariantBenchmarks } from "./invariants.js"; import { journalBenchmarks } from "./journal.js"; import { tableBenchmarks } from "./tables.js"; +import { chainedBenchmarks } from "./chained.js"; /** A world whose kernels run on this thread, which the benchmarks without a placement measure; `workers` ones use the pool. */ const inline = (): World => createWorld({ [workers]: 0 }); @@ -723,4 +724,5 @@ export const benchmarks: readonly Benchmark[] = [ ...invariantBenchmarks, ...journalBenchmarks, ...tableBenchmarks, + ...chainedBenchmarks, ]; diff --git a/ecs/packages/ecs/src/perftest/chained.ts b/ecs/packages/ecs/src/perftest/chained.ts new file mode 100644 index 00000000..3eb287f7 --- /dev/null +++ b/ecs/packages/ecs/src/perftest/chained.ts @@ -0,0 +1,108 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import { createWorld } from "../implementation/index.js"; +import type { Benchmark } from "./benchmark.js"; +import { fastest } from "./suite.js"; + +// Eight systems over rows no entity has, each with a command its body never issues, run alone or ordered one after +// another: what orderings and the sync points they need cost a frame whose commands don't fire. + +const event = Symbol("chained/event"); +const note = Symbol("chained/note"); +declare module "@adobe/ecs" { + export interface Components { + [event]: { type: true }; + [note]: { type: true }; + } +} + +/** Never true, so no body issues its command. */ +let firing = false; +/** Systems in each composite. */ +const length = 8; +/** Runs in each timed sample. */ +const runs = 100; + +/** A system spawning, when firing, what each of the composite's systems matches. */ +const commanding = (after: readonly System[]): System => System.create({ components: { with: [event] }, after }, (_row, { commands }) => { + if (firing) commands.spawn([event])(); +}); +/** A system spawning, when firing, what none of them matches. */ +const noting = (after: readonly System[]): System => System.create({ components: { with: [event] }, after }, (_row, { commands }) => { + if (firing) commands.spawn([note])(); +}); +const quiet = (after: readonly System[]): System => System.create({ components: { with: [event] }, after }, () => {}); + +/** `length` systems made by `make`, each after the one before when `ordered`. */ +const composite = (make: (after: readonly System[]) => System, ordered: boolean): System => { + const systems: System[] = []; + for (let k = 0; k < length; k++) { + const previous = systems[k - 1]; + systems.push(make(ordered && previous !== undefined ? [previous] : [])); + } + return System.create(systems); +}; + +const unordered = composite(commanding, false); +const chainedQuiet = composite(quiet, true); +const chainedNoting = composite(noting, true); +const chainedCommanding = composite(commanding, true); + +const check = (condition: boolean, message: string): void => { + if (!condition) throw new Error(message); +}; + +const timed = (system: System, tiers: number, syncs: number) => async (): Promise => { + const { tiers: scheduled } = System.describe(system); + check(scheduled.length === tiers && scheduled.filter(tier => tier.sync !== null).length === syncs, `chained: ${scheduled.length} tiers and ${scheduled.filter(tier => tier.sync !== null).length} sync points`); + firing = false; + const world = createWorld({ [workers]: 0 }); + try { + return await fastest(async () => { for (let k = 0; k < runs; k++) await world.run(system); }); + } + finally { + await world.dispose(); + } +}; + +const runBest = { bytes: 64, basis: "A run reads at least one cache line of its system's state." }; + +export const chainedBenchmarks: readonly Benchmark[] = [ + { + name: "chained unordered", + operation: "Running eight systems no entity matches, each with a command it never issues, unordered: one tier, and a sync point at the run's end that applies nothing.", + per: "runs", + n: runs, + typical: [1, 1], + best: runBest, + run: timed(unordered, 1, 0), + }, + { + name: "chained quiet", + operation: "Running eight systems no entity matches, without commands, each after the one before: eight tiers and no sync point between them, as orderings that only settle conflicts make.", + per: "runs", + n: runs, + typical: [1, 1], + best: runBest, + run: timed(chainedQuiet, length, 0), + }, + { + name: "chained noting", + operation: "Running eight systems no entity matches, each after the one before, each with a command it never issues of what none of them matches: eight tiers, no sync point between them.", + per: "runs", + n: runs, + typical: [1, 1], + best: runBest, + run: timed(chainedNoting, length, 0), + }, + { + name: "chained commanding", + operation: "Running eight systems no entity matches, each after the one before, each with a command it never issues of what the next matches: eight tiers and seven sync points that apply nothing.", + per: "runs", + n: runs, + typical: [1, 1], + best: runBest, + run: timed(chainedCommanding, length, length - 1), + }, +];