@adobe/ecs 0.2.4 → 0.3.0

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 (120) hide show
  1. package/dist/implementation/compiler/augmentations.js +8 -1
  2. package/dist/implementation/compiler/augmentations.js.map +1 -1
  3. package/dist/implementation/compiler/inferred.js +3 -2
  4. package/dist/implementation/compiler/inferred.js.map +1 -1
  5. package/dist/implementation/compiler/type-schemas.d.ts +1 -0
  6. package/dist/implementation/compiler/type-schemas.js +35 -2
  7. package/dist/implementation/compiler/type-schemas.js.map +1 -1
  8. package/dist/implementation/world/activate.d.ts +14 -3
  9. package/dist/implementation/world/activate.js +19 -13
  10. package/dist/implementation/world/activate.js.map +1 -1
  11. package/dist/implementation/world/commands.js +3 -2
  12. package/dist/implementation/world/commands.js.map +1 -1
  13. package/dist/implementation/world/create.js +4 -4
  14. package/dist/implementation/world/create.js.map +1 -1
  15. package/dist/implementation/world/execute.js +97 -11
  16. package/dist/implementation/world/execute.js.map +1 -1
  17. package/dist/implementation/world/finalizers.d.ts +10 -2
  18. package/dist/implementation/world/finalizers.js +42 -13
  19. package/dist/implementation/world/finalizers.js.map +1 -1
  20. package/dist/implementation/world/program.js +5 -1
  21. package/dist/implementation/world/program.js.map +1 -1
  22. package/dist/implementation/world/state.d.ts +2 -0
  23. package/dist/implementation/world/state.js +9 -0
  24. package/dist/implementation/world/state.js.map +1 -1
  25. package/dist/implementation/world/synchronize.js +16 -9
  26. package/dist/implementation/world/synchronize.js.map +1 -1
  27. package/dist/implementation/world/tables.d.ts +2 -1
  28. package/dist/implementation/world/tables.js +55 -2
  29. package/dist/implementation/world/tables.js.map +1 -1
  30. package/dist/interface/finalizer/body.d.ts +24 -3
  31. package/dist/interface/finalizer/create.d.ts +3 -3
  32. package/dist/interface/finalizer/create.js +43 -11
  33. package/dist/interface/finalizer/create.js.map +1 -1
  34. package/dist/interface/finalizer/declaration.d.ts +25 -5
  35. package/dist/interface/registry/index.d.ts +1 -1
  36. package/dist/interface/registry/index.js +1 -1
  37. package/dist/interface/registry/index.js.map +1 -1
  38. package/dist/interface/registry/names.d.ts +4 -0
  39. package/dist/interface/registry/names.js +20 -0
  40. package/dist/interface/registry/names.js.map +1 -1
  41. package/dist/interface/registry/schedule.d.ts +1 -0
  42. package/dist/interface/registry/schedule.js +86 -11
  43. package/dist/interface/registry/schedule.js.map +1 -1
  44. package/dist/interface/system/check.js +4 -0
  45. package/dist/interface/system/check.js.map +1 -1
  46. package/dist/interface/system/commands.d.ts +8 -3
  47. package/dist/interface/system/create-resolved.js +2 -1
  48. package/dist/interface/system/create-resolved.js.map +1 -1
  49. package/dist/interface/system/create.js +2 -2
  50. package/dist/interface/system/create.js.map +1 -1
  51. package/dist/interface/system/declaration.d.ts +11 -4
  52. package/dist/interface/system/describe.js +1 -1
  53. package/dist/interface/system/describe.js.map +1 -1
  54. package/dist/interface/system/leaf.d.ts +4 -3
  55. package/dist/interface/system/render.js +2 -2
  56. package/dist/interface/system/render.js.map +1 -1
  57. package/dist/interface/system/resolve.d.ts +12 -3
  58. package/dist/interface/system/resolve.js +35 -4
  59. package/dist/interface/system/resolve.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/guide/01-resources.md +4 -3
  62. package/src/guide/03-systems.out +1 -1
  63. package/src/guide/05-scheduling.md +12 -6
  64. package/src/guide/05-scheduling.out +1 -0
  65. package/src/guide/05-scheduling.ts +17 -0
  66. package/src/guide/06-sync-points.md +14 -6
  67. package/src/guide/06-sync-points.out +2 -0
  68. package/src/guide/06-sync-points.ts +15 -0
  69. package/src/guide/10-finalizers.md +5 -0
  70. package/src/guide/18-identity.out +1 -1
  71. package/src/guide/guide.test.ts +4 -4
  72. package/src/implementation/compiler/augmentations.test.ts +28 -1
  73. package/src/implementation/compiler/augmentations.ts +5 -1
  74. package/src/implementation/compiler/inferred.ts +3 -2
  75. package/src/implementation/compiler/transform.test.ts +4 -4
  76. package/src/implementation/compiler/type-schemas.ts +28 -2
  77. package/src/implementation/world/activate.ts +24 -11
  78. package/src/implementation/world/commands.ts +3 -2
  79. package/src/implementation/world/create.ts +4 -4
  80. package/src/implementation/world/defaults.test.ts +24 -1
  81. package/src/implementation/world/despawn-where.test.ts +31 -8
  82. package/src/implementation/world/execute.ts +98 -9
  83. package/src/implementation/world/finalizers.ts +43 -14
  84. package/src/implementation/world/lifetime.test-helper.ts +1 -1
  85. package/src/implementation/world/passes.test.ts +61 -0
  86. package/src/implementation/world/perf.md +9 -0
  87. package/src/implementation/world/program.ts +4 -1
  88. package/src/implementation/world/run-results.test.ts +17 -0
  89. package/src/implementation/world/schedule.test.ts +42 -1
  90. package/src/implementation/world/state.ts +8 -0
  91. package/src/implementation/world/sync-points.test.ts +12 -0
  92. package/src/implementation/world/synchronize.ts +15 -9
  93. package/src/implementation/world/tables.test.ts +36 -3
  94. package/src/implementation/world/tables.ts +48 -3
  95. package/src/implementation/world/watch-traits-late.test-helper.ts +14 -0
  96. package/src/implementation/world/watch-traits.test-helper.ts +10 -0
  97. package/src/implementation/world/watch-traits.test.ts +62 -0
  98. package/src/interface/finalizer/body.ts +22 -3
  99. package/src/interface/finalizer/create.test.ts +4 -1
  100. package/src/interface/finalizer/create.ts +40 -12
  101. package/src/interface/finalizer/declaration.ts +30 -5
  102. package/src/interface/registry/index.ts +1 -1
  103. package/src/interface/registry/names.ts +19 -0
  104. package/src/interface/registry/schedule.ts +89 -16
  105. package/src/interface/rule/rule.type-test.ts +23 -1
  106. package/src/interface/system/check.ts +2 -0
  107. package/src/interface/system/commands.ts +8 -3
  108. package/src/interface/system/create-resolved.ts +2 -1
  109. package/src/interface/system/create.test.ts +3 -3
  110. package/src/interface/system/create.ts +2 -2
  111. package/src/interface/system/declaration.ts +10 -4
  112. package/src/interface/system/describe.test.ts +19 -0
  113. package/src/interface/system/describe.ts +1 -1
  114. package/src/interface/system/leaf.ts +4 -3
  115. package/src/interface/system/render.ts +2 -2
  116. package/src/interface/system/resolve.test.ts +17 -1
  117. package/src/interface/system/resolve.ts +40 -5
  118. package/src/interface/system/system.type-test.ts +6 -0
  119. package/src/perftest/benchmarks.ts +2 -0
  120. package/src/perftest/chained.ts +108 -0
@@ -819,6 +819,9 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH
819
819
  if (error.notData.node && !thread) fail(module, member, `${key.name} is a DOM Node, which lives on "main": declare \`thread: "main"\``);
820
820
  }
821
821
  await checkBuiltIns(key.name, kind, typeNode, schema, builtIns);
822
+ const freshType = schema !== undefined ? null : await converter.freshOf(typeNode, module);
823
+ const emptied = schema !== undefined && "type" in schema && (schema.type === "map" || schema.type === "set") ? (schema.type === "map" ? "Map" : "Set") : freshType;
824
+ if (defaultNode !== undefined && emptied !== null) fail(module, defaultNode, `${key.name} is a Readonly${emptied}, which starts empty in each world: leave its default out`);
822
825
  const given = defaultNode === undefined ? undefined : await literalDefault(defaultNode, key.name);
823
826
  if (schema !== undefined && given !== undefined && !matchesSchema(schema, given)) fail(module, defaultNode ?? member, `${key.name}'s default isn't of its declared type`);
824
827
  let start = given === undefined ? undefined : codeOf(given);
@@ -838,7 +841,8 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH
838
841
  const namespaced = await namespacedDefault(typeNode, key.name, undefined, oneObject);
839
842
  if (namespaced !== null) read({ text: namespaced.text, locals: namespaced.local === undefined ? [] : [namespaced.local], given: true });
840
843
  else if (needed && !await allowsNullType(typeNode, module, new Set())) {
841
- 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`);
844
+ if (freshType !== null) fresh = freshType;
845
+ 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`);
842
846
  }
843
847
  }
844
848
  else {
@@ -85,8 +85,9 @@ export const accessText = (used: Used, at: string, where?: ReadonlySet<string> |
85
85
  const lookupText = lookups === null ? "null" : `{ ${[...lookups].map(([name, read]) => `${JSON.stringify(name)}: ${read === null ? "null" : list(read)}`).join(", ")} }`;
86
86
  // The resources `where`'s predicates read, only with predicates.
87
87
  const whereText = where === undefined ? "" : `, where: ${where === null ? "null" : list(where)}`;
88
- // Lists of keys stay as their bindings name them, so a list imported from another module is read where it's made.
89
- const commandText = commands === null ? "null" : `{ spawn: ${list(commands.spawn)}, add: ${list(commands.add)}, remove: ${list(commands.remove)}, despawn: ${commands.despawn} }`;
88
+ // Lists of keys stay as their bindings name them, so a list imported from another module is read where it's made,
89
+ // and in a function, read at first need, so a list declared below the system is read once its module has loaded.
90
+ const commandText = commands === null ? "null" : `() => ({ spawn: ${list(commands.spawn)}, add: ${list(commands.add)}, remove: ${list(commands.remove)}, despawn: ${commands.despawn} })`;
90
91
  return `{ components: ${components}, resources: ${resourceText}, lookup: ${lookupText}, up: ${up === null ? "null" : list(up)}${whereText}, commands: ${commandText}, at: ${JSON.stringify(at)} }`;
91
92
  };
92
93
 
@@ -1436,13 +1436,13 @@ describe("inferred access", () => {
1436
1436
 
1437
1437
  test("commands by kind, keys and lists as their bindings name them, however the context reaches them", async () => {
1438
1438
  const commanded = async (body: string, declaration = "components: { with: [position] }"): Promise<string> =>
1439
- /commands: (null|\{[^}]*\})/.exec(await inferred(`const Shapes = [mass];\nconst Listed = [velocity];\n${system(declaration, body)}`))?.[1] ?? "none";
1439
+ /commands: (null|\(\) => \(\{[^}]*\}\))/.exec(await inferred(`const Shapes = [mass];\nconst Listed = [velocity];\n${system(declaration, body)}`))?.[1] ?? "none";
1440
1440
  expect(await commanded("(row, { commands }) => { commands.spawn([mass, ...Shapes])({}); commands.add(row.entity, velocity, 1); commands.remove(row.entity, [mass]); }"))
1441
- .toBe("{ spawn: [mass, Shapes], add: [velocity], remove: [mass], despawn: false }");
1441
+ .toBe("() => ({ spawn: [mass, Shapes], add: [velocity], remove: [mass], despawn: false })");
1442
1442
  expect(await commanded("(row, context) => { const c = context.commands; c.despawn(row.entity); context.commands.add(row.entity, Listed, {}); }"))
1443
- .toBe("{ spawn: [], add: [Listed], remove: [], despawn: true }");
1443
+ .toBe("() => ({ spawn: [], add: [Listed], remove: [], despawn: true })");
1444
1444
  expect(await commanded("(row, context) => { const { commands } = context; commands.despawnWhere({ with: [mass] }); }")).toContain("despawn: true");
1445
- expect(await commanded("(row) => { row[position] = 1; }")).toBe("{ spawn: [], add: [], remove: [], despawn: false }");
1445
+ expect(await commanded("(row) => { row[position] = 1; }")).toBe("() => ({ spawn: [], add: [], remove: [], despawn: false })");
1446
1446
  for (const body of ["(row, { commands }) => { helper(commands); }", "(row, { commands }) => { commands.add(row.entity, keyOf(1), 1); }",
1447
1447
  "(row, { commands: { spawn } }) => { spawn([mass]); }", "(row, context) => { helper(context); }",
1448
1448
  "(row, { commands }) => { commands.spawnTables(tables); }"]) {
@@ -424,11 +424,15 @@ export const createConverter = (host: TypeHost, root: string) => {
424
424
  case "TSBooleanKeyword": return { type: "boolean" };
425
425
  case "TSNullKeyword": return { type: "null" };
426
426
  case "TSUnknownKeyword": return notData(use, false);
427
+ // A symbol can't be cloned to another thread or saved.
428
+ case "TSSymbolKeyword": return use === "declaration" ? notData(use, false) : fail(module, type, `${a} is ${supported}`);
427
429
  case "TSFunctionType": case "TSConstructorType":
428
430
  return use === "declaration" ? notData(use, false) : fail(module, type, `${a} is ${supported}`);
429
431
  case "TSParenthesizedType": return await convert(type.typeAnnotation, module, active, use);
430
432
  case "TSTypeOperator":
431
- return type.operator === "readonly" ? await convert(type.typeAnnotation, module, active, use) : fail(module, type, `\`${type.operator}\` isn't ${a}: ${a} is ${supported}`);
433
+ if (type.operator === "readonly") return await convert(type.typeAnnotation, module, active, use);
434
+ // Keys, as `Resource.Key` and `unique symbol` are, may be symbols.
435
+ return use === "declaration" ? notData(use, false) : fail(module, type, `\`${type.operator}\` isn't ${a}: ${a} is ${supported}`);
432
436
  case "TSLiteralType": {
433
437
  const { literal } = type;
434
438
  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) => {
677
681
  return { anyOf: values.map(value => ({ const: value })) };
678
682
  };
679
683
 
680
- return { convert, read, site, fail, typeNamed, constantNamed, returnTypeNamed, valueOfType };
684
+ /**
685
+ * `"Map"` or `"Set"` for a declared type that is a `ReadonlyMap` or `ReadonlySet`, read through aliases, which starts
686
+ * empty in each world whatever it holds; null for any other.
687
+ */
688
+ const freshOf = async (type: TypeNode, module: Module, seen: Set<Declared> = new Set()): Promise<"Map" | "Set" | null> => {
689
+ if (type.type === "TSParenthesizedType") return await freshOf(type.typeAnnotation, module, seen);
690
+ if (type.type === "TSTypeOperator" && type.operator === "readonly") return await freshOf(type.typeAnnotation, module, seen);
691
+ if (type.type !== "TSTypeReference") return null;
692
+ const path = nameParts(type.typeName);
693
+ const [only] = path;
694
+ const count = type.typeArguments?.params.length ?? 0;
695
+ if (path.length === 1 && only !== undefined && !module.types.has(only) && !module.imports.has(only)) {
696
+ if (only === "ReadonlyMap" && count === 2) return "Map";
697
+ if (only === "ReadonlySet" && count === 1) return "Set";
698
+ }
699
+ if (count !== 0) return null;
700
+ const found = await typeNamed(path, module);
701
+ if (found === null || typeof found === "string" || found.declaration.type !== "TSTypeAliasDeclaration" || seen.has(found.declaration)) return null;
702
+ seen.add(found.declaration);
703
+ return await freshOf(found.declaration.typeAnnotation, found.module, seen);
704
+ };
705
+
706
+ return { convert, freshOf, read, site, fail, typeNamed, constantNamed, returnTypeNamed, valueOfType };
681
707
  };
682
708
 
683
709
  /** A name and the properties read from it, `Entity.none` as `["Entity", "none"]`; null for any other expression. */
@@ -2,7 +2,7 @@
2
2
  import type { Finalizer } from '../../interface/finalizer/created.js';
3
3
  import type { Rule } from '../../interface/rule/created.js';
4
4
  import type { RuleFacts } from '../../interface/registry/strata.js';
5
- import { createFinalizerRunner, placeFinalizer, type FinalizerRunner } from './finalizers.js';
5
+ import { createFinalizerRunner, followResources, placeFinalizer, watchedResourcesOf, type FinalizerRunner } from './finalizers.js';
6
6
  import { watchResource } from './resource-changes.js';
7
7
  import { finalizerKeysOf, type TaskChannel } from './remote-systems.js';
8
8
  import { programFinalizers, programRules, programVersion } from '../../interface/registry/program.js';
@@ -10,7 +10,7 @@ import { triggersOf } from './program.js';
10
10
  import { ensureResources, findResource } from './resources.js';
11
11
  import { idOf, metIdOf, type Key, type State } from './state.js';
12
12
  import { restratify } from './structure.js';
13
- import { idOfItem, idOfKey, kindOf, labelOf, threadOf } from '../../interface/registry/index.js';
13
+ import { idOfItem, idOfKey, keysVersion, labelOf, threadOf } from '../../interface/registry/index.js';
14
14
 
15
15
  /**
16
16
  * 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 => {
71
71
  /** The resources a finalizer reads, writes or, for a changes finalizer, watches. */
72
72
  const resourcesOf = (finalizer: Finalizer): readonly Key[] => [
73
73
  ...finalizer.declaration.resources?.read ?? [], ...finalizer.declaration.resources?.write ?? [],
74
- ...finalizer.kind === "changes" ? (finalizer.declaration.watch ?? []).filter(key => kindOf(key) === "resource") : [],
74
+ ...finalizer.kind === "changes" ? watchedResourcesOf(finalizer.declaration.watch) : [],
75
75
  ];
76
76
 
77
+ /**
78
+ * What a world ensured of the program's resources: the program's version then, and the keys' while a changes finalizer
79
+ * watches resources by a trait, which a resource registering later may declare.
80
+ */
81
+ export type Ensured = { version: number; keys: number; traits: boolean };
82
+
83
+ /** Whether the program's finalizers may use a resource the world hasn't ensured. */
84
+ export const programResourcesChanged = (ensured: Ensured): boolean => ensured.version !== programVersion() || (ensured.traits && ensured.keys !== keysVersion());
85
+
77
86
  /** Between runs: adds the resources of every finalizer in the program, so one can activate at any sync point. */
78
- export const ensureProgramResources = (state: State, ensured: { version: number }): void => {
79
- const version = programVersion();
80
- if (ensured.version === version) return;
81
- ensured.version = version;
87
+ export const ensureProgramResources = (state: State, ensured: Ensured): void => {
88
+ if (!programResourcesChanged(ensured)) return;
89
+ ensured.version = programVersion();
90
+ ensured.keys = keysVersion();
91
+ ensured.traits = programFinalizers().some(finalizer => finalizer.kind === "changes" && finalizer.declaration.watch.resourceTraits.length > 0);
82
92
  ensureResources(state, new Set(programFinalizers().flatMap(resourcesOf)));
83
93
  // A watched resource is followed from now, before its finalizer activates, so writes before then are seen.
84
94
  for (const finalizer of programFinalizers()) {
85
- if (finalizer.kind === "changes") for (const key of finalizer.declaration.watch ?? []) if (kindOf(key) === "resource") watchResource(state, idOf(state, key));
95
+ if (finalizer.kind === "changes") for (const key of watchedResourcesOf(finalizer.declaration.watch)) watchResource(state, idOf(state, key));
86
96
  }
97
+ // Active finalizers watching resources by a trait follow those registered since.
98
+ for (const runner of state.finalizers) if (runner.finalizer.kind === "changes") followResources(state, runner.finalizer.declaration.watch, runner.resourceWatches);
87
99
  // Finalizers waiting for these can activate at the next sync point.
88
100
  state.activation.met = true;
89
101
  };
@@ -99,11 +111,12 @@ const ready = (state: State, item: Rule | Finalizer): boolean => {
99
111
  * resolves its components' ids, which may make more ready.
100
112
  */
101
113
  export const activate = (state: State): void => {
102
- const { activation } = state;
103
- if (!activation.met && activation.version === programVersion()) return;
104
- activateNow(state);
114
+ if (activationPending(state)) activateNow(state);
105
115
  };
106
116
 
117
+ /** Whether a rule or finalizer of the program may be ready to activate: a component met, or the program changed. */
118
+ export const activationPending = (state: State): boolean => state.activation.met || state.activation.version !== programVersion();
119
+
107
120
  /** `activate` once something may be ready: apart, so a sync point with nothing to activate allocates no closure context. */
108
121
  const activateNow = (state: State): void => {
109
122
  const { activation, activated, structure } = state;
@@ -151,9 +151,10 @@ export const createCommands = (state: State, target: { readonly buffer: CommandB
151
151
  },
152
152
  spawnTables: (tables, options) => {
153
153
  const resources = options?.resources;
154
- if (resources === undefined) return spawnTables(state, tables, options?.existing);
154
+ const updates = options?.update === true ? target.buffer : null;
155
+ if (resources === undefined) return spawnTables(state, tables, options?.existing, updates);
155
156
  checkResources(resources);
156
- const spawned = spawnTables(state, tables, options?.existing);
157
+ const spawned = spawnTables(state, tables, options?.existing, updates);
157
158
  queueResources(state, resources);
158
159
  return spawned;
159
160
  },
@@ -33,8 +33,8 @@ import { refreshDirectories } from './directories.js';
33
33
  import { createRegistrar, type Registration } from './register.js';
34
34
  import type { Schedule } from '../../interface/registry/schedule.js';
35
35
  import { ensureResources, findResource, initialOf, setResource } from './resources.js';
36
- import { ensureProgramResources, activate, finalizerCrossing, placeFinalizers } from './activate.js';
37
- import { isObserver, isWorldWritten, kindOf, programVersion, scheduleOf, type Composite } from '../../interface/registry/index.js';
36
+ import { ensureProgramResources, activate, finalizerCrossing, placeFinalizers, programResourcesChanged, type Ensured } from './activate.js';
37
+ import { isObserver, isWorldWritten, kindOf, scheduleOf, type Composite } from '../../interface/registry/index.js';
38
38
  import { always } from '../../interface/resources/always.js';
39
39
  import { cadence, type Cadence } from '../../interface/resources/cadence.js';
40
40
  import { clock } from '../../interface/resources/clock.js';
@@ -284,7 +284,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti
284
284
  if (state.observer.posts.length !== 0) await settlePosts(state, true);
285
285
  };
286
286
  let disposed = false;
287
- const ensured = { version: -1 };
287
+ const ensured: Ensured = { version: -1, keys: -1, traits: false };
288
288
 
289
289
  const minimumChunk = options.minimumChunk ?? defaultMinimumChunk;
290
290
 
@@ -545,7 +545,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti
545
545
  /** Follows what changed since the last run: program resources, change watches, workers and the device; null when nothing did. */
546
546
  const prepareRun = (): Promise<void> | null => {
547
547
  // Resources added between runs move the resource entity, whose structs a tail may still read.
548
- if (ensured.version !== programVersion() && (state.channels.length > 0 || state.observer.posts.length > 0)) return drainThenPrepare();
548
+ if (programResourcesChanged(ensured) && (state.channels.length > 0 || state.observer.posts.length > 0)) return drainThenPrepare();
549
549
  ensureProgramResources(state, ensured);
550
550
  if (startWatches(state)) return followChanges(true, false);
551
551
  if (workersChanged()) return followChanges(false, true);
@@ -4,7 +4,7 @@ import { Rule } from "../../interface/rule/rule.js";
4
4
  import { System } from "../../interface/system/system.js";
5
5
  import { createWorld } from "../index.js";
6
6
  import { readResource } from "./reads.test-helper.js";
7
- import { Entity, F32, I32, Mat4x4, Quat, U32, Vec2, Vec3, Vec4 } from "../../index.js";
7
+ import { Entity, F32, I32, Mat4x4, Quat, U32, Vec2, Vec3, Vec4, type Resource } from "../../index.js";
8
8
 
9
9
  const pool = Symbol();
10
10
  const names = Symbol();
@@ -14,6 +14,8 @@ const index = Symbol();
14
14
  const turn = Symbol();
15
15
  const least = Symbol();
16
16
  const all = Symbol();
17
+ const written = Symbol();
18
+ const keys = Symbol();
17
19
 
18
20
  /** A pool declared through an alias of ReadonlyMap. */
19
21
  type Pool = ReadonlyMap<string, number>;
@@ -30,6 +32,8 @@ declare module "@adobe/ecs" {
30
32
  [turn]: { type: Quat };
31
33
  [least]: { type: F32; reduce: "min" };
32
34
  [all]: { type: boolean; reduce: "&" };
35
+ [written]: { type: ReadonlyMap<Resource.Key, unknown> };
36
+ [keys]: { type: ReadonlySet<symbol> };
33
37
  }
34
38
  }
35
39
 
@@ -49,6 +53,25 @@ describe("a resource declared without a default", () => {
49
53
  });
50
54
  });
51
55
 
56
+ describe("a ReadonlyMap or ReadonlySet of what isn't data", () => {
57
+ it("starts at an empty one of each world's own, which a system replaces whole, keys and values kept as they are", async () => {
58
+ await using first = createWorld();
59
+ await using second = createWorld();
60
+ const [firstWritten, secondWritten] = [await readResource(first, written), await readResource(second, written)];
61
+ expect([firstWritten, secondWritten, await readResource(first, keys)]).toEqual([new Map(), new Map(), new Set()]);
62
+ expect(firstWritten).not.toBe(secondWritten);
63
+ const value = { at: 1 };
64
+ await first.run(System.create(({ resources }) => {
65
+ resources[written] = new Map([[count, value]]);
66
+ resources[keys] = new Set([count]);
67
+ }));
68
+ const read = await readResource(first, written);
69
+ expect([read.get(count), await readResource(first, keys)]).toEqual([value, new Set([count])]);
70
+ expect(read.get(count)).toBe(value);
71
+ expect(await readResource(second, written)).toBe(secondWritten);
72
+ });
73
+ });
74
+
52
75
  describe("a derived component declared without a default", () => {
53
76
  it("starts every row of a world at one empty Map of its own, since values are replaced whole", async () => {
54
77
  const spawn = System.create(({ commands }) => {
@@ -119,16 +119,36 @@ describe("despawnWhere", () => {
119
119
  await world.dispose();
120
120
  });
121
121
 
122
- it("despawns matching entities spawned at the same sync point, whenever spawned, since spawns commit first", async () => {
122
+ it("keeps what the same sync point spawns, whenever spawned and whatever earlier commands give it", async () => {
123
123
  const { world, run } = withCommands();
124
+ let alive = Entity.none;
125
+ await run(commands => { alive = commands.spawn([mark, value])({ [value]: 0 }); });
124
126
  const spawned: Entity[] = [];
125
127
  await run(commands => {
126
128
  spawned.push(commands.spawn([mark, value])({ [value]: 1 }));
129
+ const tagged = commands.spawn([value])({ [value]: 2 });
130
+ commands.add(tagged, mark);
131
+ spawned.push(tagged);
127
132
  commands.despawnWhere({ with: [mark] });
128
- spawned.push(commands.spawn([mark, value])({ [value]: 2 }));
129
- spawned.push(commands.spawn([value])({ [value]: 3 }));
133
+ spawned.push(commands.spawn([mark, value])({ [value]: 3 }));
130
134
  });
131
- expect(await aliveEach(world, spawned)).toEqual([false, false, true]);
135
+ expect([await aliveEach(world, [alive]), await aliveEach(world, spawned)]).toEqual([[false], [true, true, true]]);
136
+ await world.dispose();
137
+ });
138
+
139
+ it("clears the events a frame made before, keeping those it makes, in one system", async () => {
140
+ const { world, run } = withCommands();
141
+ let first = Entity.none, second = Entity.none;
142
+ await run(commands => {
143
+ commands.despawnWhere({ with: [mark] });
144
+ first = commands.spawn([mark, value])({ [value]: 1 });
145
+ });
146
+ expect(await aliveEach(world, [first])).toEqual([true]);
147
+ await run(commands => {
148
+ commands.despawnWhere({ with: [mark] });
149
+ second = commands.spawn([mark, value])({ [value]: 2 });
150
+ });
151
+ expect(await aliveEach(world, [first, second])).toEqual([false, true]);
132
152
  await world.dispose();
133
153
  });
134
154
 
@@ -256,18 +276,21 @@ describe("despawnWhere", () => {
256
276
  await world.dispose();
257
277
  });
258
278
 
259
- it("despawns by where the entities spawned at the same sync point, and none for a value no archetype holds", async () => {
279
+ it("despawns by where what was alive before the sync point, keeping what it spawns, and none for a value no archetype holds", async () => {
260
280
  const { world, run } = withCommands();
261
281
  const spawned: Entity[] = [];
262
282
  await run(commands => {
263
283
  spawned.push(commands.spawn([mark, chunk], { [chunk]: 1 })());
264
- commands.despawnWhere({ with: [mark], where: { [chunk]: 1 } });
265
284
  spawned.push(commands.spawn([mark, chunk], { [chunk]: 2 })());
285
+ });
286
+ await run(commands => {
287
+ spawned.push(commands.spawn([mark, chunk], { [chunk]: 1 })());
288
+ commands.despawnWhere({ with: [mark], where: { [chunk]: 1 } });
266
289
  commands.despawnWhere({ with: [mark], where: { [chunk]: 3 } });
267
290
  });
268
- expect(await aliveEach(world, spawned)).toEqual([false, true]);
291
+ expect(await aliveEach(world, spawned)).toEqual([false, true, true]);
269
292
  await run(commands => commands.despawnWhere({ with: [mark], where: {} }));
270
- expect(await aliveEach(world, spawned)).toEqual([false, false]);
293
+ expect(await aliveEach(world, spawned)).toEqual([false, false, false]);
271
294
  await world.dispose();
272
295
  });
273
296
 
@@ -3,14 +3,15 @@ import type { Leaf } from '../../interface/system/leaf.js';
3
3
  import type { Chunk, ChunkResult, Runner, Workers } from './runner.js';
4
4
  import type { Schedule } from '../../interface/registry/schedule.js';
5
5
  import type { Archetype, Storage } from './archetype.js';
6
- import { countRan, idOf, startRan, type State } from './state.js';
6
+ import { countRan, countRanHere, idOf, startRan, type State } from './state.js';
7
7
  import type { GpuEngine } from './gpu.js';
8
8
  import { applySync, prepareSync, syncPending } from './synchronize.js';
9
- import { activate } from './activate.js';
9
+ import { resourcesWritten } from './resource-changes.js';
10
+ import { activate, activationPending } from './activate.js';
10
11
  import { markRun } from './move-log.js';
11
12
  import { postTails, settleAll, settleKeys, settleRunners, tailsOf, type TailOutcome } from './tails.js';
12
13
  import { settlePosts, settleObserverKeys, settleObserverRunners } from './joined-observer.js';
13
- import { locate } from './entities.js';
14
+ import { locate, reserved } from './entities.js';
14
15
  import { clearBuffer, createBuffer } from './buffer.js';
15
16
  import { drainSegment, type Segment } from './command-ring.js';
16
17
  import { combine, identityOf, writeReduction, type ReductionTarget } from './reductions.js';
@@ -101,6 +102,18 @@ type Prepared = {
101
102
  readonly remotes: number[];
102
103
  /** The indexes each tier's runners look up through, refreshed before it. */
103
104
  readonly uses: (readonly IndexUse[])[];
105
+ /**
106
+ * By tier, the last tier of the pass starting there: tiers whose systems all run on the world's thread as written,
107
+ * which look up through no index from the second on, run in one pass while their sync points apply nothing. Its own
108
+ * tier for any other.
109
+ */
110
+ readonly passes: number[];
111
+ /**
112
+ * By tier within a pass of several, how many runners it and the pass's later tiers hold, and whether any runs on the
113
+ * GPU: what a pass entered there counts once it ran to its end.
114
+ */
115
+ readonly passSystems: number[];
116
+ readonly passGpu: boolean[];
104
117
  /** The world's `runnersVersion` its runners were read at. */
105
118
  version: number;
106
119
  readonly started: (readonly Chunk[])[][];
@@ -168,7 +181,8 @@ const preparedFor = (context: ExecuteContext, schedule: Schedule): Prepared => {
168
181
  let prepared = bySchedule.get(schedule);
169
182
  if (prepared === undefined) {
170
183
  prepared = {
171
- runners: schedule.tiers.map(tier => tier.map(context.runnerOf)), remotes: schedule.tiers.map(() => 0), uses: schedule.tiers.map(() => []), version: -1,
184
+ runners: schedule.tiers.map(tier => tier.map(context.runnerOf)), remotes: schedule.tiers.map(() => 0), uses: schedule.tiers.map(() => []),
185
+ passes: schedule.tiers.map((_, t) => t), passSystems: schedule.tiers.map(() => 0), passGpu: schedule.tiers.map(() => false), version: -1,
172
186
  started: schedule.tiers.map(tier => new Array<readonly Chunk[]>(tier.length)),
173
187
  systems: [], tiers: [], tasks: new Map(), warnings: [], warningCount: 0, warned: null, outcome: { failures: null, skipped: null },
174
188
  placement: -1, tailing: null, settling: 0, chunk: 0, error: undefined, tierFailed: false, dropped: 0,
@@ -189,9 +203,38 @@ const refresh = (context: ExecuteContext, schedule: Schedule, prepared: Prepared
189
203
  prepared.remotes[t] = remote;
190
204
  prepared.uses[t] = mergeUses(runners);
191
205
  }
206
+ passesOf(schedule, prepared);
192
207
  prepared.version = context.state.runnersVersion;
193
208
  };
194
209
 
210
+ /** Whether every runner of a tier runs on the world's thread as written: none a kernel or on a joined thread. */
211
+ const asWritten = (runners: readonly Runner[]): boolean => {
212
+ for (let i = 0; i < runners.length; i++) if (runners[i].kernel !== null || runners[i].remote !== null) return false;
213
+ return true;
214
+ };
215
+
216
+ /** Finds a schedule's passes (`Prepared["passes"]`) from its runners as read now. */
217
+ const passesOf = (schedule: Schedule, prepared: Prepared): void => {
218
+ const { runners, uses, passes, passSystems, passGpu } = prepared;
219
+ let last = -1;
220
+ for (let t = 0; t < schedule.tiers.length; t++) {
221
+ if (!asWritten(runners[t])) last = t;
222
+ else if (last < t) {
223
+ last = t;
224
+ while (last + 1 < schedule.tiers.length && uses[last + 1].length === 0 && asWritten(runners[last + 1])) last++;
225
+ }
226
+ passes[t] = last;
227
+ }
228
+ for (let t = schedule.tiers.length - 1; t >= 0; t--) {
229
+ const tier = runners[t];
230
+ const continues = passes[t] > t;
231
+ let gpu = continues && passGpu[t + 1];
232
+ for (let i = 0; i < tier.length; i++) if (tier[i].placement !== "cpu") gpu = true;
233
+ passSystems[t] = tier.length + (continues ? passSystems[t + 1] : 0);
234
+ passGpu[t] = gpu;
235
+ }
236
+ };
237
+
195
238
  /**
196
239
  * Drains a worker's segment into a buffer of its own, queued in its place: commands, spawn blocks staged to commit in
197
240
  * its place, and the reservations it left unused. Fails if it doesn't start with its task's merge tag.
@@ -477,11 +520,20 @@ const executeTiers = (context: ExecuteContext, whole: Schedule, args: unknown):
477
520
  if (building !== null) return handed = executeFrom(context, schedule, prepared, folds, args, null, t, building, null);
478
521
  if (state.indexFailures.length !== 0) (outcome.failures ??= []).push(...state.indexFailures.splice(0));
479
522
  }
480
- startTier(context, prepared, t, args);
481
- const waiting = settleTier(state, folds, prepared, schedule.tiers[t], t, nothing);
482
- if (waiting !== null) return handed = executeFrom(context, schedule, prepared, folds, args, null, t, null, waiting);
483
- if (endTier(state, null, prepared, schedule, t)) break;
484
- if (schedule.syncs[t] !== null) syncNow(context, prepared, false);
523
+ const last = prepared.passes[t];
524
+ if (last > t) {
525
+ const reached = runPass(context, prepared, schedule, folds, args, t, last);
526
+ if (typeof reached !== "number") return handed = reached;
527
+ if (reached < 0) break;
528
+ t = reached;
529
+ }
530
+ else {
531
+ startTier(context, prepared, t, args);
532
+ const waiting = settleTier(state, folds, prepared, schedule.tiers[t], t, nothing);
533
+ if (waiting !== null) return handed = executeFrom(context, schedule, prepared, folds, args, null, t, null, waiting);
534
+ if (endTier(state, null, prepared, schedule, t)) break;
535
+ }
536
+ if (schedule.syncs[t] !== null && !idleSync(state)) syncNow(context, prepared, false);
485
537
  }
486
538
  }
487
539
  finally {
@@ -490,6 +542,43 @@ const executeTiers = (context: ExecuteContext, whole: Schedule, args: unknown):
490
542
  return reportOf(state, prepared);
491
543
  };
492
544
 
545
+ /**
546
+ * Runs tiers `first` to `last`, a pass, one after another with none of the checks between tiers that only a tier on
547
+ * another thread, using an index or after a sync point that applies something needs. Returns the last tier it ran,
548
+ * after which the caller applies its sync point: `last`, or one before it whose sync point applies something or whose
549
+ * bodies replaced runners; -1 when one failed, the tiers after it skipped; or what `executeFrom` returns once one waits
550
+ * for something.
551
+ */
552
+ const runPass = (context: ExecuteContext, prepared: Prepared, schedule: Schedule, folds: Map<unknown, Fold> | null, args: unknown, first: number, last: number): number | Promise<RunReport> => {
553
+ const { state } = context;
554
+ for (let v = first; v <= last; v++) {
555
+ startTier(context, prepared, v, args);
556
+ const waiting = settleTier(state, folds, prepared, schedule.tiers[v], v, nothing);
557
+ if (waiting !== null) {
558
+ for (let ran = first; ran < v; ran++) countRan(state, prepared.runners[ran]);
559
+ return executeFrom(context, schedule, prepared, folds, args, null, v, null, waiting);
560
+ }
561
+ if (prepared.tierFailed || (v < last && (prepared.version !== state.runnersVersion || (schedule.syncs[v] !== null && !idleSync(state))))) {
562
+ for (let ran = first; ran <= v; ran++) countRan(state, prepared.runners[ran]);
563
+ if (!prepared.tierFailed) return v;
564
+ const { outcome } = prepared;
565
+ for (let later = v + 1; later < schedule.tiers.length; later++) for (const leaf of schedule.tiers[later]) (outcome.skipped ??= []).push(leaf);
566
+ return -1;
567
+ }
568
+ }
569
+ countRanHere(state, prepared.passSystems[first], prepared.passGpu[first]);
570
+ return last;
571
+ };
572
+
573
+ /**
574
+ * Whether a sync point within a run, with no joined thread, would apply nothing and leave nothing to do after:
575
+ * nothing queued, drained, unreserved or pending (`syncPending`), no rule or finalizer to activate, and no layout for
576
+ * workers to see.
577
+ */
578
+ const idleSync = (state: State): boolean => state.queued.length === 0 && state.drained === 0 && state.unused.length === 0
579
+ && 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
580
+ && (state.resourcesSeen.size === 0 || !resourcesWritten(state)) && !activationPending(state) && !reserved(state.entities);
581
+
493
582
  /**
494
583
  * A sync point with nothing to wait for: no GPU, tails or observer posts, which `executeTiers` checked before each
495
584
  * tier and bodies can't add. The run's `last` lets go of strings no reader holds.
@@ -14,7 +14,8 @@ import { idOf, type Key, type State } from './state.js';
14
14
  import type { Queued } from './synchronize.js';
15
15
  import type { Essential, ReadonlyType } from '../../interface/component/value.js';
16
16
  import type { Leaf } from '../../interface/finalizer/created.js';
17
- import { newestKeyOf, threadOf } from '../../interface/registry/index.js';
17
+ import { declaredOf, declaresTrait, newestKeyOf, threadOf, threadOfDeclaration } from '../../interface/registry/index.js';
18
+ import type { ResolvedChanges } from '../../interface/finalizer/declaration.js';
18
19
  import type { Layout } from '../../interface/registry/layout.js';
19
20
  import type { TaskChannel } from './remote-systems.js';
20
21
  import { resourceOffsetsOf, writeStops, type StopsOf } from './stop-lists.js';
@@ -30,8 +31,8 @@ export type FinalizerRunner = {
30
31
  readonly excluded: readonly number[];
31
32
  /** For a changes finalizer: whether it watches a component, by id. */
32
33
  readonly watches: (id: number) => boolean;
33
- /** For a changes finalizer: the resources it watches, by id. */
34
- readonly resourceWatches: readonly number[];
34
+ /** For a changes finalizer: the resources it watches, by id, which grow as resources declaring a trait it watches register. */
35
+ readonly resourceWatches: number[];
35
36
  /** By archetype id: 1 if it matches, 2 if not, 0 until known. */
36
37
  readonly matches: number[];
37
38
  readonly context: Context;
@@ -63,19 +64,23 @@ export const createFinalizerRunner = (state: State, finalizer: Finalizer): Final
63
64
  const reads = query?.read ?? [];
64
65
  // By default every component that isn't derived, including ones a world first meets later; never resources, nor
65
66
  // the objects a joined thread fills, whose storage here holds only tags.
66
- let watches = (id: number): boolean => !state.derived[id] && !state.resources.has(id) && state.remote[id] === undefined;
67
- const watch = finalizer.kind === "changes" ? finalizer.declaration.watch : undefined;
67
+ const everyComponent = (id: number): boolean => !state.derived[id] && !state.resources.has(id) && state.remote[id] === undefined;
68
+ let watches = everyComponent;
68
69
  const resourceWatches: number[] = [];
69
- if (watch !== undefined) {
70
- // `Finalizer.changes` rejected derived components.
71
- const watched = new Set(watch.map(key => idOf(state, key)));
72
- for (const id of watched) {
73
- if (!state.resources.has(id)) continue;
74
- watched.delete(id);
75
- resourceWatches.push(id);
76
- watchResource(state, id);
70
+ if (finalizer.kind === "changes") {
71
+ const { watch } = finalizer.declaration;
72
+ followResources(state, watch, resourceWatches);
73
+ if (watch.components !== true) {
74
+ // `Finalizer.changes` rejected derived components.
75
+ const listed = new Set(watch.components.map(key => idOf(state, key)));
76
+ const traits = watch.componentTraits;
77
+ if (traits.length === 0) watches = id => listed.has(id);
78
+ else {
79
+ /** By id: 1 where its key declares one of the traits, 2 where it doesn't. */
80
+ const declaring: number[] = [];
81
+ watches = id => listed.has(id) || (everyComponent(id) && (declaring[id] ??= declaresTrait(state.keys[id], traits) ? 1 : 2) === 1);
82
+ }
77
83
  }
78
- watches = id => watched.has(id);
79
84
  }
80
85
  const target = { buffer: createBuffer() };
81
86
  const commands = createCommands(state, target);
@@ -102,6 +107,30 @@ export const createFinalizerRunner = (state: State, finalizer: Finalizer): Final
102
107
  };
103
108
  };
104
109
 
110
+ /**
111
+ * The resources a changes finalizer watches: those it lists, and those registered so far that declare a trait it
112
+ * watches, but those on another thread than the world's.
113
+ */
114
+ export const watchedResourcesOf = (watch: ResolvedChanges["watch"]): readonly Key[] => {
115
+ if (watch.resourceTraits.length === 0) return watch.resources;
116
+ const found: Key[] = [...watch.resources];
117
+ for (const [key, declared] of declaredOf("resource")) {
118
+ const thread = threadOfDeclaration(declared);
119
+ if ((thread === null || thread === "world") && declaresTrait(key, watch.resourceTraits)) found.push(key);
120
+ }
121
+ return found;
122
+ };
123
+
124
+ /** Adds to `ids` each resource a changes finalizer watches that it doesn't hold, following its changes from now. */
125
+ export const followResources = (state: State, watch: ResolvedChanges["watch"], ids: number[]): void => {
126
+ for (const key of watchedResourcesOf(watch)) {
127
+ const id = idOf(state, key);
128
+ if (ids.includes(id)) continue;
129
+ ids.push(id);
130
+ watchResource(state, id);
131
+ }
132
+ };
133
+
105
134
  /**
106
135
  * Places a finalizer on a joined thread's channel, posting it there, or back on the world's thread with null. Its reads
107
136
  * are listed as the thread reads them: objects by their tags, linear structs by their bytes, strings by handles of
@@ -73,7 +73,7 @@ export const freshKernel = (system: System): System => {
73
73
  const description = kernelOf(system);
74
74
  if (leaf === undefined || leaf.kind === "composite" || description === undefined) throw new Error("Not a kernel system.");
75
75
  // What `System.create` makes of the same declaration and body, without the id a top-level binding takes.
76
- const copy: Leaf & System = { kind: leaf.kind, name: leaf.name, declaration: leaf.declaration, input: leaf.input, body: leaf.body };
76
+ const copy: Leaf & System = { kind: leaf.kind, name: leaf.name, declaration: leaf.declaration, input: leaf.input, body: leaf.body, commands: leaf.commands };
77
77
  claimSystem(copy, undefined);
78
78
  return attachKernel(copy, description);
79
79
  };