@adobe/ecs 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/dist/implementation/compiler/body-access.d.ts +18 -1
  2. package/dist/implementation/compiler/body-access.js +104 -15
  3. package/dist/implementation/compiler/body-access.js.map +1 -1
  4. package/dist/implementation/compiler/estree.d.ts +5 -2
  5. package/dist/implementation/compiler/estree.js +8 -5
  6. package/dist/implementation/compiler/estree.js.map +1 -1
  7. package/dist/implementation/compiler/inferred.js +4 -2
  8. package/dist/implementation/compiler/inferred.js.map +1 -1
  9. package/dist/implementation/compiler/transform.js +5 -4
  10. package/dist/implementation/compiler/transform.js.map +1 -1
  11. package/dist/implementation/world/activate.js +14 -2
  12. package/dist/implementation/world/activate.js.map +1 -1
  13. package/dist/implementation/world/buffer.d.ts +7 -0
  14. package/dist/implementation/world/buffer.js +7 -0
  15. package/dist/implementation/world/buffer.js.map +1 -1
  16. package/dist/implementation/world/cascade.d.ts +10 -3
  17. package/dist/implementation/world/cascade.js +48 -23
  18. package/dist/implementation/world/cascade.js.map +1 -1
  19. package/dist/implementation/world/commands.js +40 -5
  20. package/dist/implementation/world/commands.js.map +1 -1
  21. package/dist/implementation/world/create.js +53 -2
  22. package/dist/implementation/world/create.js.map +1 -1
  23. package/dist/implementation/world/execute.d.ts +4 -4
  24. package/dist/implementation/world/execute.js +132 -83
  25. package/dist/implementation/world/execute.js.map +1 -1
  26. package/dist/implementation/world/finalizers.d.ts +2 -0
  27. package/dist/implementation/world/finalizers.js +24 -3
  28. package/dist/implementation/world/finalizers.js.map +1 -1
  29. package/dist/implementation/world/join.js +12 -2
  30. package/dist/implementation/world/join.js.map +1 -1
  31. package/dist/implementation/world/resource-changes.d.ts +18 -0
  32. package/dist/implementation/world/resource-changes.js +66 -0
  33. package/dist/implementation/world/resource-changes.js.map +1 -0
  34. package/dist/implementation/world/rows.js +21 -16
  35. package/dist/implementation/world/rows.js.map +1 -1
  36. package/dist/implementation/world/run-changes.d.ts +38 -0
  37. package/dist/implementation/world/run-changes.js +138 -0
  38. package/dist/implementation/world/run-changes.js.map +1 -0
  39. package/dist/implementation/world/runner.js +16 -12
  40. package/dist/implementation/world/runner.js.map +1 -1
  41. package/dist/implementation/world/state.d.ts +13 -0
  42. package/dist/implementation/world/state.js +13 -1
  43. package/dist/implementation/world/state.js.map +1 -1
  44. package/dist/implementation/world/synchronize.js +72 -22
  45. package/dist/implementation/world/synchronize.js.map +1 -1
  46. package/dist/implementation/world/tables-read.d.ts +5 -3
  47. package/dist/implementation/world/tables-read.js +16 -4
  48. package/dist/implementation/world/tables-read.js.map +1 -1
  49. package/dist/implementation/world/tables.d.ts +3 -2
  50. package/dist/implementation/world/tables.js +83 -37
  51. package/dist/implementation/world/tables.js.map +1 -1
  52. package/dist/implementation/world/thread-observer.js +1 -1
  53. package/dist/implementation/world/thread-observer.js.map +1 -1
  54. package/dist/implementation/world/thread-systems.js +10 -6
  55. package/dist/implementation/world/thread-systems.js.map +1 -1
  56. package/dist/implementation/world/up.js +7 -1
  57. package/dist/implementation/world/up.js.map +1 -1
  58. package/dist/implementation/world/where.d.ts +8 -0
  59. package/dist/implementation/world/where.js +40 -11
  60. package/dist/implementation/world/where.js.map +1 -1
  61. package/dist/interface/finalizer/body.d.ts +18 -3
  62. package/dist/interface/finalizer/create.d.ts +7 -5
  63. package/dist/interface/finalizer/create.js +8 -5
  64. package/dist/interface/finalizer/create.js.map +1 -1
  65. package/dist/interface/finalizer/declaration.d.ts +4 -2
  66. package/dist/interface/indexes/declaration-of.d.ts +17 -0
  67. package/dist/interface/indexes/declaration-of.js +20 -0
  68. package/dist/interface/indexes/declaration-of.js.map +1 -0
  69. package/dist/interface/indexes/index.d.ts +1 -0
  70. package/dist/interface/indexes/index.js +1 -0
  71. package/dist/interface/indexes/index.js.map +1 -1
  72. package/dist/interface/registry/schedule.d.ts +28 -1
  73. package/dist/interface/registry/schedule.js +182 -21
  74. package/dist/interface/registry/schedule.js.map +1 -1
  75. package/dist/interface/system/access.d.ts +15 -0
  76. package/dist/interface/system/check.d.ts +7 -0
  77. package/dist/interface/system/check.js +17 -3
  78. package/dist/interface/system/check.js.map +1 -1
  79. package/dist/interface/system/commands.d.ts +26 -5
  80. package/dist/interface/system/create-resolved.js +1 -1
  81. package/dist/interface/system/create-resolved.js.map +1 -1
  82. package/dist/interface/system/create.d.ts +10 -6
  83. package/dist/interface/system/create.js +5 -4
  84. package/dist/interface/system/create.js.map +1 -1
  85. package/dist/interface/system/describe.d.ts +120 -0
  86. package/dist/interface/system/describe.js +116 -0
  87. package/dist/interface/system/describe.js.map +1 -0
  88. package/dist/interface/system/index.d.ts +1 -0
  89. package/dist/interface/system/index.js +1 -0
  90. package/dist/interface/system/index.js.map +1 -1
  91. package/dist/interface/system/leaf.d.ts +6 -0
  92. package/dist/interface/system/render.js +1 -0
  93. package/dist/interface/system/render.js.map +1 -1
  94. package/dist/interface/system/resolve.d.ts +5 -1
  95. package/dist/interface/system/resolve.js +19 -1
  96. package/dist/interface/system/resolve.js.map +1 -1
  97. package/dist/interface/system/shared-only.d.ts +7 -0
  98. package/dist/interface/system/shared-only.js +2 -0
  99. package/dist/interface/system/shared-only.js.map +1 -0
  100. package/dist/interface/system/tables.d.ts +9 -1
  101. package/dist/interface/world/table/index.d.ts +3 -2
  102. package/dist/interface/world/world.d.ts +15 -0
  103. package/package.json +11 -2
  104. package/src/guide/03-systems.out +1 -1
  105. package/src/guide/06-sync-points.md +12 -8
  106. package/src/guide/06-sync-points.out +1 -2
  107. package/src/guide/06-sync-points.ts +9 -5
  108. package/src/guide/18-identity.out +1 -1
  109. package/src/guide/guide.test.ts +3 -150
  110. package/src/implementation/compiler/body-access.ts +102 -12
  111. package/src/implementation/compiler/estree.ts +9 -6
  112. package/src/implementation/compiler/inferred.test.ts +1 -1
  113. package/src/implementation/compiler/inferred.ts +4 -2
  114. package/src/implementation/compiler/transform.test.ts +41 -21
  115. package/src/implementation/compiler/transform.ts +5 -3
  116. package/src/implementation/world/activate.ts +11 -2
  117. package/src/implementation/world/batches.test.ts +3 -3
  118. package/src/implementation/world/buffer.ts +7 -0
  119. package/src/implementation/world/cascade.ts +46 -22
  120. package/src/implementation/world/commands.ts +36 -5
  121. package/src/implementation/world/create.test.ts +8 -8
  122. package/src/implementation/world/create.ts +57 -2
  123. package/src/implementation/world/despawn-where.test.ts +287 -0
  124. package/src/implementation/world/execute.ts +125 -78
  125. package/src/implementation/world/finalizers.ts +27 -4
  126. package/src/implementation/world/ids.test.ts +1 -1
  127. package/src/implementation/world/indexes.test.ts +2 -1
  128. package/src/implementation/world/join.test.ts +19 -0
  129. package/src/implementation/world/join.ts +16 -2
  130. package/src/implementation/world/names.test.ts +1 -2
  131. package/src/implementation/world/perf.md +3 -1
  132. package/src/implementation/world/resource-changes.test.ts +61 -0
  133. package/src/implementation/world/resource-changes.ts +67 -0
  134. package/src/implementation/world/rows.ts +21 -14
  135. package/src/implementation/world/rules.test.ts +37 -0
  136. package/src/implementation/world/run-changes.test.ts +88 -0
  137. package/src/implementation/world/run-changes.ts +160 -0
  138. package/src/implementation/world/runner.ts +17 -13
  139. package/src/implementation/world/shared.test.ts +2 -2
  140. package/src/implementation/world/state.ts +19 -0
  141. package/src/implementation/world/sync-points.test.ts +135 -0
  142. package/src/implementation/world/synchronize.ts +65 -18
  143. package/src/implementation/world/tables-read.ts +16 -5
  144. package/src/implementation/world/tables.test.ts +106 -4
  145. package/src/implementation/world/tables.ts +71 -33
  146. package/src/implementation/world/thread-observer.ts +1 -1
  147. package/src/implementation/world/thread-systems.ts +10 -6
  148. package/src/implementation/world/up.test.ts +13 -1
  149. package/src/implementation/world/up.ts +6 -1
  150. package/src/implementation/world/where.ts +38 -10
  151. package/src/interface/finalizer/body.ts +13 -3
  152. package/src/interface/finalizer/create.test.ts +3 -1
  153. package/src/interface/finalizer/create.ts +12 -7
  154. package/src/interface/finalizer/declaration.ts +4 -2
  155. package/src/interface/indexes/create.test.ts +10 -0
  156. package/src/interface/indexes/declaration-of.ts +34 -0
  157. package/src/interface/indexes/index.ts +1 -0
  158. package/src/interface/registry/schedule.ts +196 -23
  159. package/src/interface/system/access.ts +16 -0
  160. package/src/interface/system/check.ts +15 -2
  161. package/src/interface/system/commands.ts +23 -4
  162. package/src/interface/system/commands.type-test.ts +31 -0
  163. package/src/interface/system/create-resolved.ts +1 -1
  164. package/src/interface/system/create.test.ts +8 -0
  165. package/src/interface/system/create.ts +9 -5
  166. package/src/interface/system/describe.test.ts +79 -0
  167. package/src/interface/system/describe.ts +122 -0
  168. package/src/interface/system/index.ts +1 -0
  169. package/src/interface/system/leaf.ts +6 -0
  170. package/src/interface/system/render.ts +1 -0
  171. package/src/interface/system/resolve.ts +25 -2
  172. package/src/interface/system/shared-only.ts +5 -0
  173. package/src/interface/system/tables.ts +7 -1
  174. package/src/interface/world/run.type-test.ts +11 -1
  175. package/src/interface/world/table/index.ts +3 -2
  176. package/src/interface/world/world.ts +13 -0
  177. package/src/perftest/benchmarks.ts +18 -0
  178. package/src/perftest/structural.ts +20 -0
  179. package/src/perftest/tables.ts +107 -0
@@ -29,7 +29,7 @@ declare module "@adobe/ecs" {
29
29
  }
30
30
 
31
31
  describe("staged spawns", () => {
32
- it("are seen once by `added` observers in earlier, the same and later tiers, and not before the sync point", async () => {
32
+ it("are seen once by `added` observers in earlier, the same and later tiers, from the sync point after them", async () => {
33
33
  const seen = { early: 0, same: 0, late: 0 };
34
34
  const spawned: Entity[] = [];
35
35
  const early = System.create({ components: { added: [score] } }, () => { seen.early++; });
@@ -42,8 +42,8 @@ describe("staged spawns", () => {
42
42
  const frame = System.create({ systems: [early, spawn, same, late] });
43
43
  const world = createWorld({ [workers]: 0 });
44
44
  await world.run(frame);
45
- // Nothing saw the spawns during their own run.
46
- expect(seen).toEqual({ early: 0, same: 0, late: 0 });
45
+ // Only what follows the spawn saw them in their own run, after the sync point it needs.
46
+ expect(seen).toEqual({ early: 0, same: 0, late: 100 });
47
47
  await world.run(frame);
48
48
  await world.run(frame);
49
49
  expect(seen).toEqual({ early: 100, same: 100, late: 100 });
@@ -7,6 +7,13 @@ export const spawnCommand = 0;
7
7
  export const despawnCommand = 1;
8
8
  export const addCommand = 2;
9
9
  export const removeCommand = 3;
10
+ /**
11
+ * Names the ids of its query's `with`, then of its `not`, and with a `where`, last, `despawnWhereHolds`, whose value is
12
+ * whether an archetype holds the `where`'s values; its entity is how many are of `with`.
13
+ */
14
+ export const despawnWhereCommand = 4;
15
+ /** The id a `despawnWhere` names its `where` by, which no component has. */
16
+ export const despawnWhereHolds = -1;
10
17
 
11
18
  /** Rows `start` to `end` of a kernel's spawn batch, committed in its buffer's place in merge order. */
12
19
  export type Staged = { batch: SpawnBatch; start: number; end: number };
@@ -1,7 +1,7 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
  import type { Archetype } from './archetype.js';
3
3
  import { readCell } from './cells.js';
4
- import { indexOf, locate } from './entities.js';
4
+ import { generationOf, indexOf, locate } from './entities.js';
5
5
  import { commandValue, isMissing } from './finalizers.js';
6
6
  import { createHolders, fitHolders, forEachHolder, linkHolder, unlinkHolder } from './holders.js';
7
7
  import { closureOf, owner } from './structure.js';
@@ -9,8 +9,8 @@ import { archetypeOf, withoutComponent, type State } from './state.js';
9
9
  import type { Queued } from './synchronize.js';
10
10
 
11
11
  /**
12
- * A reference value written in this sync point: by spawn or add commands, or a batch spawn. `holder`, `component`
13
- * and `target` triples, checked once the plan's despawns are known.
12
+ * A reference value written in this sync point to check: by spawn or add commands, or of a row spawned that `linkBorn`
13
+ * leaves to the cascade. `holder`, `component` and `target` triples, checked once the plan's despawns are known.
14
14
  */
15
15
  export type Written = number[];
16
16
 
@@ -41,6 +41,48 @@ const aliveAfter = (state: State, target: number): boolean => {
41
41
  return locate(entities, target) >= 0;
42
42
  };
43
43
 
44
+ /**
45
+ * Links each reference the rows spawned in this sync point hold to its target, before the cascade, so a target it
46
+ * despawns finds them as it finds any holder. One naming an entity that won't be alive isn't linked, and goes to
47
+ * `written` to cascade, as does every reference of a row a command touched, which the command may point elsewhere.
48
+ * The rows spawned into an archetype are those from `first`, by archetype id; an entity is one f64.
49
+ */
50
+ export const linkBorn = (state: State, born: ReadonlyMap<number, number>, written: Written, tick: number): void => {
51
+ const { plan, structure, archetypes, entities, referencesWritten } = state;
52
+ const { slots } = plan;
53
+ for (const [id, first] of born) {
54
+ const archetype = archetypes[id];
55
+ const { count } = archetype;
56
+ if (first >= count) continue;
57
+ const rows = archetype.entities;
58
+ for (const component of structure.referenceIds) {
59
+ const storage = archetype.storageById[component];
60
+ if (storage === undefined) continue;
61
+ referencesWritten[component] = tick;
62
+ const lists = structure.holders[component] ??= createHolders();
63
+ fitHolders(lists, entities.length);
64
+ const { unitStride, unitOffset } = storage;
65
+ const values = storage.views.f64;
66
+ // `aliveAfter`, through locals, since it runs for each row spawned.
67
+ const { archetype: archetypeOfIndex, generation, length } = entities;
68
+ const { entity: planned, despawned } = plan;
69
+ for (let row = first; row < count; row++) {
70
+ const target = values[row * unitStride + unitOffset];
71
+ if (target === 0) continue;
72
+ const holder = rows[row];
73
+ const index = indexOf(holder);
74
+ const commanded = index < slots.length && slots[index] >= 0;
75
+ const into = indexOf(target);
76
+ const entry = into < slots.length ? slots[into] : -1;
77
+ const alive = entry >= 0 && planned[entry] === target ? despawned[entry] === 0
78
+ : into < length && archetypeOfIndex[into] >= 0 && generation[into] === generationOf(target);
79
+ if (alive) linkHolder(lists, target, holder);
80
+ if (commanded || !alive) written.push(holder, component, target);
81
+ }
82
+ }
83
+ }
84
+ };
85
+
44
86
  /**
45
87
  * Despawns the holders that own a reference to an entity that is despawned or never lives, and removes `weak`
46
88
  * references to one, transitively, before rules close the plan. A holder acts only if its value once the plan applies
@@ -91,7 +133,7 @@ export const cascadeReferences = (state: State, queued: readonly Queued[], writt
91
133
  */
92
134
  export type IndexChanges = { readonly remove: number[]; readonly add: number[] };
93
135
 
94
- export const indexChangesOf = (state: State, queued: readonly Queued[], fromOf: (entry: number) => Archetype | null, bornRows: readonly (readonly [Archetype, number])[]): IndexChanges => {
136
+ export const indexChangesOf = (state: State, queued: readonly Queued[], fromOf: (entry: number) => Archetype | null): IndexChanges => {
95
137
  const { plan, structure, entities } = state;
96
138
  const changes: IndexChanges = { remove: [], add: [] };
97
139
  for (let entry = 0; entry < plan.count; entry++) {
@@ -106,24 +148,6 @@ export const indexChangesOf = (state: State, queued: readonly Queued[], fromOf:
106
148
  if (typeof after === "number" && after !== 0) changes.add.push(after, holder, component);
107
149
  }
108
150
  }
109
- // Batch spawns no command touched still hold the references they were spawned with. An entity is one f64.
110
- const { slots } = plan;
111
- for (const [archetype, first] of bornRows) {
112
- for (const component of structure.referenceIds) {
113
- const storage = archetype.storageById[component];
114
- if (storage === undefined) continue;
115
- const { unitStride, unitOffset } = storage;
116
- const values = storage.views.f64;
117
- const rows = archetype.entities;
118
- for (let row = first; row < archetype.count; row++) {
119
- const holder = rows[row];
120
- const index = indexOf(holder);
121
- if (index < slots.length && slots[index] >= 0) continue;
122
- const value = values[row * unitStride + unitOffset];
123
- if (value !== 0) changes.add.push(value, holder, component);
124
- }
125
- }
126
- }
127
151
  return changes;
128
152
  };
129
153
 
@@ -1,10 +1,13 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
  import type { Commands } from '../../interface/system/commands.js';
3
- import { addCommand, createBuffer, despawnCommand, pushCommand, pushComponent, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js';
3
+ import { addCommand, createBuffer, despawnCommand, despawnWhereCommand, despawnWhereHolds, pushCommand, pushComponent, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js';
4
+ import type { Archetype } from './archetype.js';
4
5
  import { reserve } from './entities.js';
5
6
  import { listShapeOf, presetSpawnerOf, spawnerOf, type ListShape } from './lists.js';
6
- import { commandIdOf, idOf, storedShared, type Key, type State } from './state.js';
7
+ import { commandIdOf, idOf, queryIdOf, storedShared, type Key, type State } from './state.js';
7
8
  import { spawnTables } from './tables.js';
9
+ import { holdsValuesOf } from './where.js';
10
+ import { checkWhereValues } from '../../interface/system/check.js';
8
11
  import { declarationOf, isWorldWritten, kindOf, labelOf, threadOfDeclaration, whyUnregistered } from '../../interface/registry/index.js';
9
12
  import { matchesSchema } from '../../interface/registry/schemas.js';
10
13
 
@@ -83,6 +86,32 @@ const queueResources = (state: State, resources: object): void => {
83
86
  /** Values of a list add given none, which only an untyped caller does. */
84
87
  const empty = {};
85
88
 
89
+ const noKeys: readonly unknown[] = [];
90
+
91
+ /**
92
+ * Queues a `despawnWhere`, its ids resolved and its `where` checked first, so a key that isn't a component, or a `where`
93
+ * that isn't of shared values, queues nothing.
94
+ */
95
+ const pushDespawnWhere = (state: State, buffer: CommandBuffer, query: Parameters<Commands["despawnWhere"]>[0]): void => {
96
+ const including: readonly unknown[] = query.with;
97
+ const excluding: readonly unknown[] = query.not ?? noKeys;
98
+ const { where } = query;
99
+ if (including.length === 0) throw new Error("commands.despawnWhere's `with` names at least one component.");
100
+ for (let k = 0; k < including.length; k++) queryIdOf(state, including[k]);
101
+ for (let k = 0; k < excluding.length; k++) queryIdOf(state, excluding[k]);
102
+ let holds: ((archetype: Archetype) => boolean) | null = null;
103
+ if (where !== undefined && Object.getOwnPropertySymbols(where).length > 0) {
104
+ checkWhereValues("commands.despawnWhere", "where", "a despawn", where, false);
105
+ const found = holdsValuesOf(state, where, false);
106
+ if (typeof found === "symbol") throw new Error(`commands.despawnWhere: where names "${labelOf(found)}", which isn't a shared component.`);
107
+ holds = found;
108
+ }
109
+ pushCommand(buffer, despawnWhereCommand, including.length);
110
+ for (let k = 0; k < including.length; k++) pushComponent(buffer, queryIdOf(state, including[k]), undefined);
111
+ for (let k = 0; k < excluding.length; k++) pushComponent(buffer, queryIdOf(state, excluding[k]), undefined);
112
+ if (holds !== null) pushComponent(buffer, despawnWhereHolds, holds);
113
+ };
114
+
86
115
  /**
87
116
  * Commands that queue for the next sync point. A key that isn't a component commands may name throws here, inside the
88
117
  * issuing system, before anything is queued. Values are immutable by convention, so they aren't copied.
@@ -97,6 +126,7 @@ export const createCommands = (state: State, target: { readonly buffer: CommandB
97
126
  despawn: entity => {
98
127
  pushCommand(target.buffer, despawnCommand, entity);
99
128
  },
129
+ despawnWhere: query => pushDespawnWhere(state, target.buffer, query),
100
130
  add: (entity: number, components: symbol | readonly symbol[], values?: unknown) => {
101
131
  const { buffer } = target;
102
132
  if (typeof components === "symbol") {
@@ -119,10 +149,11 @@ export const createCommands = (state: State, target: { readonly buffer: CommandB
119
149
  pushComponent(buffer, ids[k], value === undefined ? starts[k] : value);
120
150
  }
121
151
  },
122
- spawnTables: (tables, resources) => {
123
- if (resources === undefined) return spawnTables(state, tables);
152
+ spawnTables: (tables, options) => {
153
+ const resources = options?.resources;
154
+ if (resources === undefined) return spawnTables(state, tables, options?.existing);
124
155
  checkResources(resources);
125
- const spawned = spawnTables(state, tables);
156
+ const spawned = spawnTables(state, tables, options?.existing);
126
157
  queueResources(state, resources);
127
158
  return spawned;
128
159
  },
@@ -71,28 +71,28 @@ const positions = async (world: World): Promise<Vec2[]> =>
71
71
  (await readEach(world, await select(world, { required: [position], order: { [position]: true } }), position)).map(value => value ?? [NaN, NaN]);
72
72
 
73
73
  describe("World.create", () => {
74
- it("spawns once from a changed resource, then integrates", async () => {
74
+ it("spawns once from a changed resource, then integrates, from the run that spawns", async () => {
75
75
  const world = createWorld();
76
76
  await world.run(main);
77
- expect(await positions(world)).toEqual([[0, 0], [1, 0], [2, 0]]);
78
- await world.run(main);
79
77
  expect(await positions(world)).toEqual([[0, 1], [1, 1], [2, 1]]);
78
+ await world.run(main);
79
+ expect(await positions(world)).toEqual([[0, 2], [1, 2], [2, 2]]);
80
80
  expect(await readResource(world, spawnCount)).toBe(3);
81
81
  });
82
82
 
83
83
  it("reports each change to a system exactly once", async () => {
84
84
  const world = createWorld();
85
85
  await world.run(main);
86
- // Spawned at the sync point, after watch ran: seen on the next run.
87
- expect(await readResource(world, seen)).toBe(0);
88
- await world.run(main);
86
+ // Spawned at the sync point before move, which watch follows: seen in the same run.
89
87
  expect(await readResource(world, seen)).toBe(3);
88
+ await world.run(main);
89
+ expect(await readResource(world, seen)).toBe(6);
90
90
  // Moved before watch in the last run, so already reported.
91
91
  await world.run(watch);
92
- expect(await readResource(world, seen)).toBe(3);
92
+ expect(await readResource(world, seen)).toBe(6);
93
93
  await world.run(move);
94
94
  await world.run(watch);
95
- expect(await readResource(world, seen)).toBe(6);
95
+ expect(await readResource(world, seen)).toBe(9);
96
96
  });
97
97
 
98
98
  it("applies the net structural change of a sync point", async () => {
@@ -1,7 +1,7 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
  import type { Finalizer } from '../../interface/finalizer/created.js';
3
3
  import type { Rule } from '../../interface/rule/created.js';
4
- import { requireDefinition, type Definition } from '../../interface/registry/index.js';
4
+ import { definitionOf, requireDefinition, type Definition } from '../../interface/registry/index.js';
5
5
  import type { World } from '../../interface/world/world.js';
6
6
  import type { Input } from '../../interface/system/input.js';
7
7
  import type { System } from '../../interface/system/system.js';
@@ -43,6 +43,10 @@ import { stampChanged } from './archetype.js';
43
43
  import { createLoop, platformFrame, platformTimer, type Clock, type LoopPlatform } from './loop.js';
44
44
  import { presetLevelsOf } from './lists.js';
45
45
  import { createState, defaultOf, idOf, reside, startTicking, type Key } from './state.js';
46
+ import { asksForChanges, changesOf, resourcesWrittenBy, startRecording, takesInput } from './run-changes.js';
47
+ import type { ChangeSet } from '../../interface/finalizer/body.js';
48
+ import type { Essential } from '../../interface/component/value.js';
49
+ import type { Resource } from '../../interface/resource/resource.js';
46
50
  import { indexOf } from './entities.js';
47
51
  import { touchedHeld } from './up.js';
48
52
  import { isHeld } from './transfers.js';
@@ -53,6 +57,9 @@ type Queue = <T>(task: () => T | Promise<T>) => Promise<T>;
53
57
 
54
58
  /** A task's failure as the queue's own chain carries it, which never rejects: made only when a task fails. */
55
59
  type Carried = { readonly thrown: unknown };
60
+ /** A schedule of nothing, whose run is its sync point alone. */
61
+ const noTiers: Schedule = { tiers: [], reductions: new Map(), observer: [], syncs: [] };
62
+
56
63
  const thrownValues = new WeakSet<object>();
57
64
  const thrownOf = (error: unknown): Carried => {
58
65
  const wrapped = { thrown: error };
@@ -956,9 +963,57 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti
956
963
  },
957
964
  });
958
965
 
966
+ /**
967
+ * Runs a system or composite, recording its own changes, which it resolves to: what earlier finalizers queued
968
+ * applies first, at a sync point of its own, and the recording covers the run's sync points alone.
969
+ */
970
+ const runChanging = (definition: Definition, value: unknown): Promise<unknown> => {
971
+ const started = enqueue(() => startChanges(definition));
972
+ const ran = runWith(definition, value);
973
+ const taken = enqueue(takeChanges);
974
+ return Promise.all([started, ran, taken]).then(([, , changes]) => changes);
975
+ };
976
+ const startChanges = (definition: Definition): unknown => {
977
+ checkLive();
978
+ const flushed = execution(definition, undefined, noTiers);
979
+ if (flushed instanceof Promise) return flushed.then(report => startRecordingOf(definition, report));
980
+ startRecordingOf(definition, flushed);
981
+ return undefined;
982
+ };
983
+ const startRecordingOf = (definition: Definition, flushed: RunReport): void => {
984
+ outOfBand.reportAll(flushed.failures);
985
+ state.recording = startRecording(state, resourcesWrittenBy(definition));
986
+ };
987
+ const takeChanges = (): unknown => {
988
+ const { recording } = state;
989
+ state.recording = null;
990
+ if (recording === null) throw new Error("The run's changes weren't recorded.");
991
+ return changesOf(state, recording);
992
+ };
993
+
994
+ function run<I, R>(system: System<I, R>, ...input: Input<NoInfer<I>>): Promise<R>;
995
+ function run<I, R>(system: System<I, R>, ...input: [...Input<NoInfer<I>>, options: { readonly changes: true }]): Promise<ChangeSet<Essential, Resource.Key>>;
996
+ function run(system: System<unknown, unknown>, ...args: readonly unknown[]): Promise<unknown> {
997
+ // Options come last, so a run not asking for changes reads one value more.
998
+ const { length } = args;
999
+ if (length === 0 || !asksForChanges(args[length - 1])) return runWith(system, args[0]);
1000
+ const definition = definitionOf(system);
1001
+ let input = false;
1002
+ try {
1003
+ input = definition !== undefined && takesInput(definition);
1004
+ }
1005
+ catch {
1006
+ // A composite whose schedule can't be made fails its run, as `runWith` finds.
1007
+ return runWith(system, args[0]);
1008
+ }
1009
+ // A system taking input whose input is all it was given runs with it.
1010
+ if (definition === undefined || (input && args.length < 2)) return runWith(system, args[0]);
1011
+ return runChanging(definition, input ? args[0] : undefined);
1012
+ }
1013
+
959
1014
  const world: CreatedWorld = {
960
1015
  // A run gives what its system's body returned, `R` by the system's type.
961
- run: <I, R>(system: System<I, R>, ...input: Input<NoInfer<I>>) => runWith(system, input[0]) as Promise<R>,
1016
+ run,
962
1017
  share: port => {
963
1018
  if (disposed) return Promise.reject(new Error("The world is disposed."));
964
1019
  if (!state.memory.shared) return Promise.reject(new Error("Without cross-origin isolation memory can't be shared, so no thread can join."));
@@ -0,0 +1,287 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { describe, expect, it } from "vitest";
3
+ import { Entity } from "../../interface/entity/entity.js";
4
+ import { Finalizer } from "../../interface/finalizer/finalizer.js";
5
+ import { Rule } from "../../interface/rule/rule.js";
6
+ import type { Commands } from "../../interface/system/commands.js";
7
+ import { System } from "../../interface/system/system.js";
8
+ import type { World } from "../../interface/world/world.js";
9
+ import { workers } from "../../interface/resources/workers.js";
10
+ import { Observe } from "../../observe/index.js";
11
+ import { createWorld } from "./create.js";
12
+ import { runReported } from "./outcomes.test-helper.js";
13
+ import { count, read, readResource } from "./reads.test-helper.js";
14
+
15
+ const mark = Symbol("despawnWhereTest/mark");
16
+ const kept = Symbol("despawnWhereTest/kept");
17
+ const value = Symbol("despawnWhereTest/value");
18
+ const extra = Symbol("despawnWhereTest/extra");
19
+ const host = Symbol("despawnWhereTest/host");
20
+ const derivedFrom = Symbol("despawnWhereTest/derivedFrom");
21
+ const slot = Symbol("despawnWhereTest/slot");
22
+ const parent = Symbol("despawnWhereTest/parent");
23
+ const finalized = Symbol("despawnWhereTest/finalized");
24
+ const watched = Symbol("despawnWhereTest/watched");
25
+ const trigger = Symbol("despawnWhereTest/trigger");
26
+ const level = Symbol("despawnWhereTest/level");
27
+ const journal = Symbol("despawnWhereTest/journal");
28
+ const chunk = Symbol("despawnWhereTest/chunk");
29
+ // Never declared, so never registered.
30
+ const unregistered = Symbol("despawnWhereTest/unregistered");
31
+
32
+ declare module "@adobe/ecs" {
33
+ export interface Components {
34
+ [mark]: { type: true };
35
+ [kept]: { type: true };
36
+ [value]: { type: number };
37
+ [extra]: { type: true };
38
+ [host]: { type: true };
39
+ [derivedFrom]: { type: true };
40
+ [slot]: { type: number; derived: true };
41
+ [parent]: { type: Entity; reference: "owner" };
42
+ [finalized]: { type: number };
43
+ [watched]: { type: number };
44
+ [trigger]: { type: true };
45
+ [chunk]: { type: number; shared: true };
46
+ }
47
+ export interface Resources {
48
+ [level]: { type: number };
49
+ [journal]: { type: readonly string[]; default: [] };
50
+ }
51
+ }
52
+
53
+ Rule.create({ components: { with: [host, derivedFrom] }, derive: [slot] });
54
+
55
+ const seen: number[] = [];
56
+ Finalizer.create({ components: { with: [finalized] } }, row => { seen.push(row[finalized]); });
57
+
58
+ Finalizer.changes({ watch: [watched, mark] }, (changes, { resources }) => {
59
+ const lines = changes.groups.map(group => `${group.entities.map(entity => changes.previous(entity, watched)).join(",")}: +${group.added.length} -${group.removed.length}`);
60
+ resources[journal] = [...resources[journal], ...lines];
61
+ });
62
+
63
+ /** A world, and a function running `body` once in a system with the world's commands, rejecting with what it threw. */
64
+ const withCommands = () => {
65
+ const world = createWorld({ [workers]: 0 });
66
+ let body: (commands: Commands) => void = () => {};
67
+ const issue = System.create({}, ({ commands }) => body(commands));
68
+ const run = async (next: (commands: Commands) => void): Promise<void> => {
69
+ body = next;
70
+ const [failure] = (await runReported(world, issue)).failures;
71
+ if (failure !== undefined) throw new Error(failure.message);
72
+ };
73
+ return { world, run };
74
+ };
75
+
76
+ const alive = async (world: World, entity: Entity): Promise<boolean> =>
77
+ (await Observe.toPromise(Observe.entity(world, entity, { optional: [mark] }))) !== null;
78
+
79
+ const aliveEach = (world: World, entities: readonly Entity[]): Promise<boolean[]> => Promise.all(entities.map(entity => alive(world, entity)));
80
+
81
+ describe("despawnWhere", () => {
82
+ it("despawns every entity with each `with` component and no `not` one, across archetypes", async () => {
83
+ const { world, run } = withCommands();
84
+ const spawned: Entity[] = [];
85
+ await run(commands => {
86
+ for (let i = 0; i < 3; i++) {
87
+ spawned.push(commands.spawn([mark])());
88
+ spawned.push(commands.spawn([mark, value])({ [value]: i }));
89
+ spawned.push(commands.spawn([mark, value, extra])({ [value]: i }));
90
+ spawned.push(commands.spawn([value])({ [value]: i }));
91
+ spawned.push(commands.spawn([mark, kept])());
92
+ }
93
+ });
94
+ await run(commands => commands.despawnWhere({ with: [mark], not: [kept] }));
95
+ expect(await aliveEach(world, spawned)).toEqual([0, 1, 2].flatMap(() => [false, false, false, true, true]));
96
+ await run(commands => commands.despawnWhere({ with: [value, mark] }));
97
+ expect(await count(world, { required: [value] })).toBe(3);
98
+ await world.dispose();
99
+ });
100
+
101
+ it("selects by the commands issued before it at the sync point; later ones to a despawned entity do nothing", async () => {
102
+ const { world, run } = withCommands();
103
+ let gains = Entity.none, loses = Entity.none, gainsAfter = Entity.none, changedAfter = Entity.none;
104
+ await run(commands => {
105
+ gains = commands.spawn([value])({ [value]: 1 });
106
+ loses = commands.spawn([mark, value])({ [value]: 2 });
107
+ gainsAfter = commands.spawn([value])({ [value]: 3 });
108
+ changedAfter = commands.spawn([mark, value])({ [value]: 4 });
109
+ });
110
+ await run(commands => {
111
+ commands.add(gains, mark);
112
+ commands.remove(loses, mark);
113
+ commands.despawnWhere({ with: [mark] });
114
+ commands.add(gainsAfter, mark);
115
+ commands.add(changedAfter, extra);
116
+ });
117
+ expect(await aliveEach(world, [gains, loses, gainsAfter, changedAfter])).toEqual([false, true, true, false]);
118
+ expect(await read(world, gainsAfter, mark)).toBe(true);
119
+ await world.dispose();
120
+ });
121
+
122
+ it("despawns matching entities spawned at the same sync point, whenever spawned, since spawns commit first", async () => {
123
+ const { world, run } = withCommands();
124
+ const spawned: Entity[] = [];
125
+ await run(commands => {
126
+ spawned.push(commands.spawn([mark, value])({ [value]: 1 }));
127
+ commands.despawnWhere({ with: [mark] });
128
+ spawned.push(commands.spawn([mark, value])({ [value]: 2 }));
129
+ spawned.push(commands.spawn([value])({ [value]: 3 }));
130
+ });
131
+ expect(await aliveEach(world, spawned)).toEqual([false, false, true]);
132
+ await world.dispose();
133
+ });
134
+
135
+ it("matches derived components, by where earlier commands leave an entity once derived", async () => {
136
+ const { world, run } = withCommands();
137
+ let derived = Entity.none, deriving = Entity.none, underived = Entity.none;
138
+ await run(commands => {
139
+ derived = commands.spawn([host, derivedFrom])();
140
+ deriving = commands.spawn([host])();
141
+ underived = commands.spawn([host, derivedFrom])();
142
+ });
143
+ expect(await read(world, derived, slot)).not.toBeNull();
144
+ await run(commands => {
145
+ commands.add(deriving, derivedFrom);
146
+ commands.remove(underived, derivedFrom);
147
+ commands.despawnWhere({ with: [slot] });
148
+ });
149
+ expect(await aliveEach(world, [derived, deriving, underived])).toEqual([false, false, true]);
150
+ await world.dispose();
151
+ });
152
+
153
+ it("runs finalizers for each entity it despawns, reading its values", async () => {
154
+ const { world, run } = withCommands();
155
+ seen.length = 0;
156
+ await run(commands => {
157
+ for (let i = 0; i < 4; i++) commands.spawn(i % 2 === 0 ? [finalized, mark] : [finalized])({ [finalized]: i });
158
+ });
159
+ await run(commands => commands.despawnWhere({ with: [mark, finalized] }));
160
+ expect(seen.sort()).toEqual([0, 2]);
161
+ await world.dispose();
162
+ seen.length = 0;
163
+ });
164
+
165
+ it("gives a changes finalizer the same changes as a despawn of each", async () => {
166
+ const journals: (readonly string[])[] = [];
167
+ for (const despawn of [
168
+ (commands: Commands, entities: readonly Entity[]) => { for (const entity of entities) commands.despawn(entity); },
169
+ (commands: Commands) => commands.despawnWhere({ with: [mark] }),
170
+ ]) {
171
+ const { world, run } = withCommands();
172
+ const entities: Entity[] = [];
173
+ await run(commands => {
174
+ for (let i = 0; i < 3; i++) entities.push(commands.spawn([watched, mark])({ [watched]: i }));
175
+ entities.push(commands.spawn([watched, mark, extra])({ [watched]: 9 }));
176
+ commands.spawn([watched])({ [watched]: 5 });
177
+ });
178
+ await run(commands => despawn(commands, entities));
179
+ journals.push(await readResource(world, journal));
180
+ await world.dispose();
181
+ }
182
+ expect(journals[1]).toEqual(journals[0]);
183
+ expect(journals[1]).toContain("0,1,2: +0 -2");
184
+ });
185
+
186
+ it("cascades owner references as a despawn does", async () => {
187
+ const { world, run } = withCommands();
188
+ let root = Entity.none, child = Entity.none, other = Entity.none;
189
+ await run(commands => {
190
+ root = commands.spawn([mark])();
191
+ child = commands.spawn([parent, value])({ [parent]: root, [value]: 1 });
192
+ other = commands.spawn([value])({ [value]: 2 });
193
+ });
194
+ await run(commands => commands.despawnWhere({ with: [mark] }));
195
+ expect(await aliveEach(world, [root, child, other])).toEqual([false, false, true]);
196
+ await world.dispose();
197
+ });
198
+
199
+ it("never despawns the resource entity", async () => {
200
+ const { world, run } = withCommands();
201
+ const set = System.create({}, ({ resources, commands }) => {
202
+ resources[level] = 3;
203
+ commands.spawn([mark])();
204
+ commands.despawnWhere({ with: [mark] });
205
+ });
206
+ await world.run(set);
207
+ await run(commands => commands.despawnWhere({ with: [mark] }));
208
+ expect(await readResource(world, level)).toBe(3);
209
+ expect(await count(world, { required: [mark] })).toBe(0);
210
+ await world.dispose();
211
+ });
212
+
213
+ it("throws in the issuing system, queuing nothing, for an empty `with` or a key that isn't a component", async () => {
214
+ const { world, run } = withCommands();
215
+ let entity = Entity.none;
216
+ await run(commands => { entity = commands.spawn([mark])(); });
217
+ // @ts-expect-error `with` names at least one component
218
+ await expect(run(commands => commands.despawnWhere({ with: [] }))).rejects.toThrow("names at least one component");
219
+ // @ts-expect-error never registered
220
+ await expect(run(commands => commands.despawnWhere({ with: [mark], not: [unregistered] }))).rejects.toThrow("no registered schema");
221
+ // @ts-expect-error a resource
222
+ await expect(run(commands => commands.despawnWhere({ with: [level] }))).rejects.toThrow("is a resource");
223
+ expect(await alive(world, entity)).toBe(true);
224
+ await world.dispose();
225
+ });
226
+
227
+ it("runs in a system over rows, which isn't a kernel", async () => {
228
+ const { world, run } = withCommands();
229
+ const clear = System.create({ components: { with: [trigger] } }, (_row, { commands }) => {
230
+ commands.despawnWhere({ with: [mark] });
231
+ });
232
+ await run(commands => {
233
+ for (let i = 0; i < 5; i++) commands.spawn([mark, value])({ [value]: i });
234
+ commands.spawn([trigger])();
235
+ commands.spawn([trigger])();
236
+ });
237
+ const { failures } = await runReported(world, clear);
238
+ expect(failures).toEqual([]);
239
+ expect(await count(world, { required: [mark] })).toBe(0);
240
+ expect(await count(world, { required: [trigger] })).toBe(2);
241
+ await world.dispose();
242
+ });
243
+
244
+ it("keeps to the archetypes holding where's shared values, each required as with requires it", async () => {
245
+ const { world, run } = withCommands();
246
+ const spawned: Entity[] = [];
247
+ await run(commands => {
248
+ spawned.push(commands.spawn([mark, chunk], { [chunk]: 1 })());
249
+ spawned.push(commands.spawn([mark, value, chunk], { [chunk]: 1 })({ [value]: 1 }));
250
+ spawned.push(commands.spawn([mark, chunk], { [chunk]: 2 })());
251
+ spawned.push(commands.spawn([mark])());
252
+ spawned.push(commands.spawn([value, chunk], { [chunk]: 1 })({ [value]: 2 }));
253
+ });
254
+ await run(commands => commands.despawnWhere({ with: [mark], where: { [chunk]: 1 } }));
255
+ expect(await aliveEach(world, spawned)).toEqual([false, false, true, true, true]);
256
+ await world.dispose();
257
+ });
258
+
259
+ it("despawns by where the entities spawned at the same sync point, and none for a value no archetype holds", async () => {
260
+ const { world, run } = withCommands();
261
+ const spawned: Entity[] = [];
262
+ await run(commands => {
263
+ spawned.push(commands.spawn([mark, chunk], { [chunk]: 1 })());
264
+ commands.despawnWhere({ with: [mark], where: { [chunk]: 1 } });
265
+ spawned.push(commands.spawn([mark, chunk], { [chunk]: 2 })());
266
+ commands.despawnWhere({ with: [mark], where: { [chunk]: 3 } });
267
+ });
268
+ expect(await aliveEach(world, spawned)).toEqual([false, true]);
269
+ await run(commands => commands.despawnWhere({ with: [mark], where: {} }));
270
+ expect(await aliveEach(world, spawned)).toEqual([false, false]);
271
+ await world.dispose();
272
+ });
273
+
274
+ it("throws for a where that isn't values of shared components, queuing nothing", async () => {
275
+ const { world, run } = withCommands();
276
+ let entity = Entity.none;
277
+ await run(commands => { entity = commands.spawn([mark, chunk], { [chunk]: 1 })(); });
278
+ // @ts-expect-error where names only shared components
279
+ await expect(run(commands => commands.despawnWhere({ with: [mark], where: { [value]: 1 } }))).rejects.toThrow(/where names "despawnWhereTest\/value", which isn't a shared component/);
280
+ // @ts-expect-error where takes values, not predicates
281
+ await expect(run(commands => commands.despawnWhere({ with: [mark], where: { [chunk]: (given: number) => given === 1 } }))).rejects.toThrow(/gives "despawnWhereTest\/chunk" a predicate/);
282
+ // @ts-expect-error a value of the component's type
283
+ await expect(run(commands => commands.despawnWhere({ with: [mark], where: { [chunk]: "1" } }))).rejects.toThrow(/isn't one of its type's values/);
284
+ expect(await alive(world, entity)).toBe(true);
285
+ await world.dispose();
286
+ });
287
+ });