@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.
- package/dist/implementation/compiler/augmentations.js +8 -1
- package/dist/implementation/compiler/augmentations.js.map +1 -1
- package/dist/implementation/compiler/inferred.js +3 -2
- package/dist/implementation/compiler/inferred.js.map +1 -1
- package/dist/implementation/compiler/type-schemas.d.ts +1 -0
- package/dist/implementation/compiler/type-schemas.js +35 -2
- package/dist/implementation/compiler/type-schemas.js.map +1 -1
- package/dist/implementation/world/activate.d.ts +14 -3
- package/dist/implementation/world/activate.js +19 -13
- package/dist/implementation/world/activate.js.map +1 -1
- package/dist/implementation/world/commands.js +3 -2
- package/dist/implementation/world/commands.js.map +1 -1
- package/dist/implementation/world/create.js +4 -4
- package/dist/implementation/world/create.js.map +1 -1
- package/dist/implementation/world/execute.js +97 -11
- package/dist/implementation/world/execute.js.map +1 -1
- package/dist/implementation/world/finalizers.d.ts +10 -2
- package/dist/implementation/world/finalizers.js +42 -13
- package/dist/implementation/world/finalizers.js.map +1 -1
- package/dist/implementation/world/program.js +5 -1
- package/dist/implementation/world/program.js.map +1 -1
- package/dist/implementation/world/state.d.ts +2 -0
- package/dist/implementation/world/state.js +9 -0
- package/dist/implementation/world/state.js.map +1 -1
- package/dist/implementation/world/synchronize.js +16 -9
- package/dist/implementation/world/synchronize.js.map +1 -1
- package/dist/implementation/world/tables.d.ts +2 -1
- package/dist/implementation/world/tables.js +55 -2
- package/dist/implementation/world/tables.js.map +1 -1
- package/dist/interface/finalizer/body.d.ts +24 -3
- package/dist/interface/finalizer/create.d.ts +3 -3
- package/dist/interface/finalizer/create.js +43 -11
- package/dist/interface/finalizer/create.js.map +1 -1
- package/dist/interface/finalizer/declaration.d.ts +25 -5
- package/dist/interface/registry/index.d.ts +1 -1
- package/dist/interface/registry/index.js +1 -1
- package/dist/interface/registry/index.js.map +1 -1
- package/dist/interface/registry/names.d.ts +4 -0
- package/dist/interface/registry/names.js +20 -0
- package/dist/interface/registry/names.js.map +1 -1
- package/dist/interface/registry/schedule.d.ts +1 -0
- package/dist/interface/registry/schedule.js +86 -11
- package/dist/interface/registry/schedule.js.map +1 -1
- package/dist/interface/system/check.js +4 -0
- package/dist/interface/system/check.js.map +1 -1
- package/dist/interface/system/commands.d.ts +8 -3
- package/dist/interface/system/create-resolved.js +2 -1
- package/dist/interface/system/create-resolved.js.map +1 -1
- package/dist/interface/system/create.js +2 -2
- package/dist/interface/system/create.js.map +1 -1
- package/dist/interface/system/declaration.d.ts +11 -4
- package/dist/interface/system/describe.js +1 -1
- package/dist/interface/system/describe.js.map +1 -1
- package/dist/interface/system/leaf.d.ts +4 -3
- package/dist/interface/system/render.js +2 -2
- package/dist/interface/system/render.js.map +1 -1
- package/dist/interface/system/resolve.d.ts +12 -3
- package/dist/interface/system/resolve.js +35 -4
- package/dist/interface/system/resolve.js.map +1 -1
- package/package.json +1 -1
- package/src/guide/01-resources.md +4 -3
- package/src/guide/03-systems.out +1 -1
- package/src/guide/05-scheduling.md +12 -6
- package/src/guide/05-scheduling.out +1 -0
- package/src/guide/05-scheduling.ts +17 -0
- package/src/guide/06-sync-points.md +14 -6
- package/src/guide/06-sync-points.out +2 -0
- package/src/guide/06-sync-points.ts +15 -0
- package/src/guide/10-finalizers.md +5 -0
- package/src/guide/18-identity.out +1 -1
- package/src/guide/guide.test.ts +4 -4
- package/src/implementation/compiler/augmentations.test.ts +28 -1
- package/src/implementation/compiler/augmentations.ts +5 -1
- package/src/implementation/compiler/inferred.ts +3 -2
- package/src/implementation/compiler/transform.test.ts +4 -4
- package/src/implementation/compiler/type-schemas.ts +28 -2
- package/src/implementation/world/activate.ts +24 -11
- package/src/implementation/world/commands.ts +3 -2
- package/src/implementation/world/create.ts +4 -4
- package/src/implementation/world/defaults.test.ts +24 -1
- package/src/implementation/world/despawn-where.test.ts +31 -8
- package/src/implementation/world/execute.ts +98 -9
- package/src/implementation/world/finalizers.ts +43 -14
- package/src/implementation/world/lifetime.test-helper.ts +1 -1
- package/src/implementation/world/passes.test.ts +61 -0
- package/src/implementation/world/perf.md +9 -0
- package/src/implementation/world/program.ts +4 -1
- package/src/implementation/world/run-results.test.ts +17 -0
- package/src/implementation/world/schedule.test.ts +42 -1
- package/src/implementation/world/state.ts +8 -0
- package/src/implementation/world/sync-points.test.ts +12 -0
- package/src/implementation/world/synchronize.ts +15 -9
- package/src/implementation/world/tables.test.ts +36 -3
- package/src/implementation/world/tables.ts +48 -3
- package/src/implementation/world/watch-traits-late.test-helper.ts +14 -0
- package/src/implementation/world/watch-traits.test-helper.ts +10 -0
- package/src/implementation/world/watch-traits.test.ts +62 -0
- package/src/interface/finalizer/body.ts +22 -3
- package/src/interface/finalizer/create.test.ts +4 -1
- package/src/interface/finalizer/create.ts +40 -12
- package/src/interface/finalizer/declaration.ts +30 -5
- package/src/interface/registry/index.ts +1 -1
- package/src/interface/registry/names.ts +19 -0
- package/src/interface/registry/schedule.ts +89 -16
- package/src/interface/rule/rule.type-test.ts +23 -1
- package/src/interface/system/check.ts +2 -0
- package/src/interface/system/commands.ts +8 -3
- package/src/interface/system/create-resolved.ts +2 -1
- package/src/interface/system/create.test.ts +3 -3
- package/src/interface/system/create.ts +2 -2
- package/src/interface/system/declaration.ts +10 -4
- package/src/interface/system/describe.test.ts +19 -0
- package/src/interface/system/describe.ts +1 -1
- package/src/interface/system/leaf.ts +4 -3
- package/src/interface/system/render.ts +2 -2
- package/src/interface/system/resolve.test.ts +17 -1
- package/src/interface/system/resolve.ts +40 -5
- package/src/interface/system/system.type-test.ts +6 -0
- package/src/perftest/benchmarks.ts +2 -0
- package/src/perftest/chained.ts +108 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { System } from "../../interface/system/system.js";
|
|
4
|
+
import { workers } from "../../interface/resources/workers.js";
|
|
5
|
+
import { createWorld } from "./create.js";
|
|
6
|
+
import { runRan, runReported } from "./outcomes.test-helper.js";
|
|
7
|
+
|
|
8
|
+
const seed = Symbol("passesTest/seed");
|
|
9
|
+
const event = Symbol("passesTest/event");
|
|
10
|
+
|
|
11
|
+
declare module "@adobe/ecs" {
|
|
12
|
+
export interface Components {
|
|
13
|
+
[seed]: { type: number };
|
|
14
|
+
[event]: { type: number };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What the systems below ran, in order. */
|
|
19
|
+
const order: string[] = [];
|
|
20
|
+
let firing = false;
|
|
21
|
+
|
|
22
|
+
const plant = System.create(({ commands }) => { commands.spawn([seed])({ [seed]: 1 }); });
|
|
23
|
+
const emit = System.create({ components: { with: [seed] } }, (row, { commands }) => {
|
|
24
|
+
order.push("emit");
|
|
25
|
+
if (firing) commands.spawn([event])({ [event]: row[seed] });
|
|
26
|
+
});
|
|
27
|
+
const handle = System.create({ components: { with: [event] }, after: [emit] }, row => { order.push(`handle ${row[event]}`); });
|
|
28
|
+
const finish = System.create({ after: [handle] }, () => { order.push("finish"); });
|
|
29
|
+
const chain = System.create([emit, handle, finish]);
|
|
30
|
+
|
|
31
|
+
const fail = System.create({ components: { with: [seed] }, after: [emit] }, () => {
|
|
32
|
+
order.push("fail");
|
|
33
|
+
throw new Error("passesTest: fail");
|
|
34
|
+
});
|
|
35
|
+
const afterFail = System.create({ after: [fail] }, () => { order.push("after fail"); });
|
|
36
|
+
const failing = System.create([emit, fail, afterFail]);
|
|
37
|
+
|
|
38
|
+
describe("tiers on the world's thread", () => {
|
|
39
|
+
it("run one after another past sync points that apply nothing, and apply one with commands before the tier after it", async () => {
|
|
40
|
+
expect(System.describe(chain).tiers.map(tier => tier.sync === null)).toEqual([false, true, true]);
|
|
41
|
+
await using world = createWorld({ [workers]: 0 });
|
|
42
|
+
await world.run(plant);
|
|
43
|
+
order.length = 0;
|
|
44
|
+
firing = false;
|
|
45
|
+
expect((await runRan(world, chain)).systems).toBe(3);
|
|
46
|
+
firing = true;
|
|
47
|
+
expect((await runRan(world, chain)).systems).toBe(3);
|
|
48
|
+
expect(order).toEqual(["emit", "finish", "emit", "handle 1", "finish"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("stop at a tier whose system fails, skipping those after it", async () => {
|
|
52
|
+
await using world = createWorld({ [workers]: 0 });
|
|
53
|
+
await world.run(plant);
|
|
54
|
+
order.length = 0;
|
|
55
|
+
firing = false;
|
|
56
|
+
const reported = await runReported(world, failing);
|
|
57
|
+
expect([reported.failures.map(failure => failure.message), reported.skipped]).toEqual([["passesTest: fail"], [afterFail.name]]);
|
|
58
|
+
expect(order).toEqual(["emit", "fail"]);
|
|
59
|
+
expect((await runRan(world, failing)).systems).toBe(2);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -229,6 +229,15 @@ Review fixes to shared components and `where`, node, alternating against the com
|
|
|
229
229
|
- **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.
|
|
230
230
|
- **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.
|
|
231
231
|
|
|
232
|
+
Sync points and tiers that apply nothing (adobe/data#245), node, alternating eight rounds against `244f952` with the
|
|
233
|
+
new benchmarks added there: eight systems over rows no entity has, each with a command it never issues. `chained
|
|
234
|
+
unordered`, one tier, 538 → 538 ns a run; `chained quiet`, eight tiers by `after` with no commands, 717 → 636;
|
|
235
|
+
`chained noting`, eight tiers whose commands no later system depends on, 723 → 636; `chained commanding`, eight tiers
|
|
236
|
+
and seven sync points, 859 → 713. A tier boundary on the world's thread costs about 14 ns, from 26, the tiers running
|
|
237
|
+
as one pass; a sync point between them with nothing to apply about 11 ns, from 20, its check of what's pending standing
|
|
238
|
+
for its activation, rewinds and publishing. The other node benchmarks are within noise of `244f952` over three
|
|
239
|
+
rounds, and `read run` (+3.7%) and `loop tick add remove again` (+3.9%), which make a schedule each run, over ten.
|
|
240
|
+
|
|
232
241
|
## Where the gains came from
|
|
233
242
|
|
|
234
243
|
Step figures marked (vitest) are from the node vitest lane: read them as relative.
|
|
@@ -10,5 +10,8 @@ import type { Key } from './state.js';
|
|
|
10
10
|
export const triggersOf = (item: Rule | Finalizer): { readonly keys: readonly Key[]; readonly any: boolean } => {
|
|
11
11
|
if (item.kind === "rule") return { keys: item.declaration.components.has ?? [], any: false };
|
|
12
12
|
if (item.kind === "finalizer") return { keys: [...item.declaration.components.read ?? [], ...item.declaration.components.has ?? []], any: false };
|
|
13
|
-
|
|
13
|
+
const { watch } = item.declaration;
|
|
14
|
+
// Every component, or those declaring a trait, may be met at any sync point.
|
|
15
|
+
if (watch.components === true || watch.componentTraits.length + watch.resourceTraits.length > 0) return { keys: [], any: true };
|
|
16
|
+
return { keys: [...watch.components, ...watch.resources], any: true };
|
|
14
17
|
};
|
|
@@ -354,6 +354,23 @@ describe("what a run gathers for tests", () => {
|
|
|
354
354
|
expect(await runRan(world, System.create([nothing, raise]))).toEqual({ systems: 2, workers: 0, joined: 0, main: true, gpu: false });
|
|
355
355
|
expect(await runRan(world, readLevel)).toEqual({ systems: 0, workers: 0, joined: 0, main: false, gpu: false });
|
|
356
356
|
});
|
|
357
|
+
|
|
358
|
+
it("counts each system of a chain once, run whole or entered after a sync point that applies something", async () => {
|
|
359
|
+
await using world = createWorld({ [workers]: 0 });
|
|
360
|
+
/** Systems each after the one before, the first after `first`. */
|
|
361
|
+
const chainOf = (length: number, first: readonly System<unknown>[]): System<unknown>[] => {
|
|
362
|
+
const links: System<unknown>[] = [];
|
|
363
|
+
for (let k = 0; k < length; k++) links.push(System.create({ after: links.length === 0 ? first : links.slice(-1) }, ({ resources }) => {
|
|
364
|
+
resources[frames] = k;
|
|
365
|
+
}));
|
|
366
|
+
return links;
|
|
367
|
+
};
|
|
368
|
+
expect(await runRan(world, System.create(chainOf(64, [])))).toEqual({ systems: 64, workers: 0, joined: 0, main: true, gpu: false });
|
|
369
|
+
const reads = System.create({ after: [spawn], components: { with: [amount] } }, row => {
|
|
370
|
+
counted += row[amount];
|
|
371
|
+
});
|
|
372
|
+
expect(await runRan(world, System.create([spawn, reads, ...chainOf(8, [reads])]))).toEqual({ systems: 10, workers: 0, joined: 0, main: true, gpu: false });
|
|
373
|
+
});
|
|
357
374
|
});
|
|
358
375
|
|
|
359
376
|
describe("a value a body returned", () => {
|
|
@@ -8,17 +8,25 @@ import { scheduleOf } from "../../interface/registry/schedule.js";
|
|
|
8
8
|
|
|
9
9
|
const value = Symbol("scheduleTest/value");
|
|
10
10
|
const other = Symbol("scheduleTest/other");
|
|
11
|
+
const voxel = Symbol("scheduleTest/voxel");
|
|
12
|
+
const mark = Symbol("scheduleTest/mark");
|
|
13
|
+
const size = Symbol("scheduleTest/size");
|
|
11
14
|
|
|
12
15
|
declare module "@adobe/ecs" {
|
|
13
16
|
export interface Components {
|
|
14
17
|
[value]: { type: number };
|
|
15
18
|
[other]: { type: number };
|
|
19
|
+
[voxel]: { type: true };
|
|
20
|
+
[mark]: { type: true };
|
|
21
|
+
}
|
|
22
|
+
export interface Resources {
|
|
23
|
+
[size]: { type: number };
|
|
16
24
|
}
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
|
|
20
28
|
/** Systems built as values, not registered, so each test's are its own. */
|
|
21
|
-
const leaf = (declaration: Resolved & { readonly name: string }): Leaf => ({ kind: "system", name: declaration.name, declaration, input: undefined, body: () => {} });
|
|
29
|
+
const leaf = (declaration: Resolved & { readonly name: string }): Leaf => ({ kind: "system", name: declaration.name, declaration, input: undefined, body: () => {}, commands: () => undefined });
|
|
22
30
|
const composite = (declaration: Composite["declaration"] & { readonly name: string }): Composite => ({ kind: "composite", name: declaration.name, declaration });
|
|
23
31
|
|
|
24
32
|
const names = (system: Definition): string[][] => scheduleOf(system).tiers.map(tier => tier.map(leaf => leaf.name));
|
|
@@ -113,6 +121,39 @@ describe("scheduleOf", () => {
|
|
|
113
121
|
.toEqual([["a", "c"], ["b"]]);
|
|
114
122
|
});
|
|
115
123
|
|
|
124
|
+
it("doesn't order two systems on a key both reach through rows that never match one entity", () => {
|
|
125
|
+
const voxels = leaf({ name: "voxels", components: { write: [value], has: [voxel], not: [mark] } });
|
|
126
|
+
const marks = leaf({ name: "marks", components: { write: [value], has: [mark] } });
|
|
127
|
+
const watching = leaf({ name: "watching", components: { read: [value], changed: [value], has: [mark] } });
|
|
128
|
+
expect(names(composite({ name: "root", systems: [voxels, marks] }))).toEqual([["voxels", "marks"]]);
|
|
129
|
+
expect(names(composite({ name: "root", systems: [voxels, watching] }))).toEqual([["voxels", "watching"]]);
|
|
130
|
+
});
|
|
131
|
+
it("orders two systems whose rows may match one entity, or that reach the key other than through their rows", () => {
|
|
132
|
+
const voxels = leaf({ name: "voxels", components: { write: [value], has: [voxel] } });
|
|
133
|
+
const marks = leaf({ name: "marks", components: { write: [value], has: [mark] } });
|
|
134
|
+
expect(() => scheduleOf(composite({ name: "root", systems: [voxels, marks] })))
|
|
135
|
+
.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.`);
|
|
136
|
+
const apart = leaf({ name: "apart", components: { write: [value], has: [voxel], not: [mark] } });
|
|
137
|
+
const looking = leaf({ name: "looking", components: { has: [mark] }, lookup: { others: { read: [value] } } });
|
|
138
|
+
expect(() => scheduleOf(composite({ name: "root", systems: [apart, looking] })))
|
|
139
|
+
.toThrow(`Systems "apart" and "looking" both access "scheduleTest/value" and one writes it; order them with before or after.`);
|
|
140
|
+
// An optional component is read where an entity has it, so it excludes nothing it doesn't require.
|
|
141
|
+
const optional = leaf({ name: "optional", components: { read: [value, mark], optional: [mark] } });
|
|
142
|
+
expect(() => scheduleOf(composite({ name: "root", systems: [apart, optional] }))).toThrow('"apart" and "optional"');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("orders a system before or after every other that uses a resource it lists, but those already ordered and those listing it too", () => {
|
|
146
|
+
const resize = leaf({ name: "resize", resources: { write: [size] }, before: [size] });
|
|
147
|
+
const frame = leaf({ name: "frame", resources: { read: [size] } });
|
|
148
|
+
const pick = leaf({ name: "pick", components: { read: [value] }, resources: { is: [size] } });
|
|
149
|
+
const old = leaf({ name: "old", resources: { read: [size] }, before: [resize] });
|
|
150
|
+
expect(names(composite({ name: "root", systems: [frame, pick, resize, old] }))).toEqual([["old"], ["resize"], ["frame", "pick"]]);
|
|
151
|
+
const last = leaf({ name: "last", resources: { read: [size] }, after: [size] });
|
|
152
|
+
expect(names(composite({ name: "root", systems: [last, frame, resize] }))).toEqual([["resize"], ["frame"], ["last"]]);
|
|
153
|
+
const again = leaf({ name: "again", resources: { write: [size] }, before: [size] });
|
|
154
|
+
expect(() => scheduleOf(composite({ name: "root", systems: [resize, again, frame] }))).toThrow(`Systems "resize" and "again" both access "scheduleTest/size"`);
|
|
155
|
+
});
|
|
156
|
+
|
|
116
157
|
it("schedules two systems of one name as two, and one listed twice as one", () => {
|
|
117
158
|
const a = leaf({ name: "a", components: { write: [value] } });
|
|
118
159
|
const copy = leaf({ name: "a", components: { write: [value] }, after: [a] });
|
|
@@ -78,6 +78,14 @@ export const countRan = (state: State, runners: readonly RanRunner[]): void => {
|
|
|
78
78
|
for (let k = 0; k < tier.logs.length; k++) ran.joined.add(tier.logs[k]);
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
+
/** Counts runners into `ran` that all ran here, none on a joined thread: on `main` unless a joined thread claims it. */
|
|
82
|
+
export const countRanHere = (state: State, systems: number, gpu: boolean): void => {
|
|
83
|
+
const { ran } = state;
|
|
84
|
+
ran.systems += systems;
|
|
85
|
+
if (systems !== 0 && state.claims.get("main") === undefined) ran.main = true;
|
|
86
|
+
if (gpu) ran.gpu = true;
|
|
87
|
+
};
|
|
88
|
+
|
|
81
89
|
/** Starts gathering what a run ran. */
|
|
82
90
|
export const startRan = (ran: Ran): void => {
|
|
83
91
|
ran.systems = 0;
|
|
@@ -79,6 +79,11 @@ const tally = System.create({ components: { with: [grown] }, after: [sprout] },
|
|
|
79
79
|
const garden = System.create([plant, sprout, tally]);
|
|
80
80
|
const readChildren = System.create({ readonly: true }, ({ resources }) => resources[children]);
|
|
81
81
|
|
|
82
|
+
const spawnSeedlings = System.create(({ commands }) => { commands.spawn(Seedling)({ [seed]: 1, [grown]: 2 }); });
|
|
83
|
+
const countSeedlings = System.create({ components: { with: [seed, grown] }, after: [spawnSeedlings] }, (row, { resources }) => { resources[children] += row[grown]; });
|
|
84
|
+
/** Declared below the systems whose commands name it. */
|
|
85
|
+
const Seedling = [seed, grown] as const;
|
|
86
|
+
|
|
82
87
|
describe("sync points within a run", () => {
|
|
83
88
|
it("apply commands before a later system that depends on what they change, which then sees them in the same run", async () => {
|
|
84
89
|
const world = createWorld({ [workers]: 0 });
|
|
@@ -122,6 +127,13 @@ describe("sync points within a run", () => {
|
|
|
122
127
|
}
|
|
123
128
|
});
|
|
124
129
|
|
|
130
|
+
it("follow commands naming an archetype declared below their system", async () => {
|
|
131
|
+
expect(System.describe(System.create([spawnSeedlings, countSeedlings])).tiers.map(tier => tier.sync?.map(reason => reason.keys) ?? null)).toEqual([[[seed, grown]], null]);
|
|
132
|
+
await using world = createWorld({ [workers]: 0 });
|
|
133
|
+
await world.run(System.create([spawnSeedlings, countSeedlings]));
|
|
134
|
+
expect(await world.run(readChildren)).toBe(2);
|
|
135
|
+
});
|
|
136
|
+
|
|
125
137
|
it("commit a kernel's staged spawns at a sync point within the run, for a later tier", async () => {
|
|
126
138
|
expect(System.describe(garden).tiers.map(tier => tier.sync === null)).toEqual([false, false, true]);
|
|
127
139
|
await using world = createWorld({ [workers]: 0 });
|
|
@@ -147,10 +147,11 @@ const closedOf = (state: State, archetype: Archetype): Archetype => {
|
|
|
147
147
|
};
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
|
-
* Despawns each entity a `despawnWhere` command selects given the plan so far: those
|
|
151
|
-
* then the rest, by the archetype they're in. Never the resource entity
|
|
150
|
+
* Despawns each entity alive before the sync point that a `despawnWhere` command selects given the plan so far: those
|
|
151
|
+
* planned, by where they're going, then the rest, by the archetype they're in. Never the resource entity, nor an
|
|
152
|
+
* entity the sync point spawns: `born` holds the first row each archetype gained at it.
|
|
152
153
|
*/
|
|
153
|
-
const despawnWhere = (state: State, buffer: CommandBuffer, command: number, within: boolean): void => {
|
|
154
|
+
const despawnWhere = (state: State, buffer: CommandBuffer, command: number, within: boolean, born: ReadonlyMap<number, number>): void => {
|
|
154
155
|
const { plan, archetypes } = state;
|
|
155
156
|
const { ids } = buffer;
|
|
156
157
|
const start = buffer.starts[command];
|
|
@@ -159,8 +160,12 @@ const despawnWhere = (state: State, buffer: CommandBuffer, command: number, with
|
|
|
159
160
|
const where = ids[last - 1] === despawnWhereHolds ? buffer.values[last - 1] : null;
|
|
160
161
|
const holds = typeof where === "function" ? where : null;
|
|
161
162
|
const end = holds === null ? last : last - 1;
|
|
163
|
+
const { row } = state.entities;
|
|
162
164
|
for (let entry = 0; entry < plan.count; entry++) {
|
|
163
|
-
|
|
165
|
+
const from = plan.from[entry];
|
|
166
|
+
if (plan.despawned[entry] === 1 || plan.entity[entry] === state.resourceEntity || from < 0) continue;
|
|
167
|
+
const first = born.get(from);
|
|
168
|
+
if (first !== undefined && row[indexOf(plan.entity[entry])] >= first) continue;
|
|
164
169
|
const to = archetypes[plan.to[entry]];
|
|
165
170
|
if (matches(within && plan.to[entry] !== plan.from[entry] ? closedOf(state, to) : to, ids, start, middle, end, holds)) plan.despawned[entry] = 1;
|
|
166
171
|
}
|
|
@@ -171,8 +176,9 @@ const despawnWhere = (state: State, buffer: CommandBuffer, command: number, with
|
|
|
171
176
|
if (archetype.resource || archetype.count === 0 || !matches(archetype, ids, start, middle, end, holds)) continue;
|
|
172
177
|
ensurePlanCapacity(state, plan.count + archetype.count);
|
|
173
178
|
const { entities } = archetype;
|
|
174
|
-
|
|
175
|
-
|
|
179
|
+
const alive = born.get(id) ?? archetype.count;
|
|
180
|
+
for (let at = 0; at < alive; at++) {
|
|
181
|
+
const entity = entities[at];
|
|
176
182
|
const index = indexOf(entity);
|
|
177
183
|
if (slots[index] < 0) plan.despawned[addPlanned(state, entity, index, id, id)] = 1;
|
|
178
184
|
}
|
|
@@ -183,7 +189,7 @@ const despawnWhere = (state: State, buffer: CommandBuffer, command: number, with
|
|
|
183
189
|
* Plan: the net change for each entity, in issue order. Commands against dead entities do nothing. Reference values
|
|
184
190
|
* written by commands go to `written`, to check once despawns are known.
|
|
185
191
|
*/
|
|
186
|
-
const planOf = (state: State, queued: readonly Queued[], written: Written): void => {
|
|
192
|
+
const planOf = (state: State, queued: readonly Queued[], written: Written, born: ReadonlyMap<number, number>): void => {
|
|
187
193
|
const { plan, structure } = state;
|
|
188
194
|
if (plan.slots.length < state.entities.length) plan.slots = new Int32Array(Math.max(state.entities.length, plan.slots.length * 2)).fill(-1);
|
|
189
195
|
const { slots } = plan;
|
|
@@ -199,7 +205,7 @@ const planOf = (state: State, queued: readonly Queued[], written: Written): void
|
|
|
199
205
|
for (let command = 0; command < buffer.count; command++) {
|
|
200
206
|
const kind = kinds[command];
|
|
201
207
|
if (kind === despawnWhereCommand) {
|
|
202
|
-
despawnWhere(state, buffer, command, within);
|
|
208
|
+
despawnWhere(state, buffer, command, within, born);
|
|
203
209
|
ensurePlanCapacity(state, plan.count + buffer.count - command);
|
|
204
210
|
continue;
|
|
205
211
|
}
|
|
@@ -742,7 +748,7 @@ const preparePending = (state: State, queued: readonly Queued[], isolated: boole
|
|
|
742
748
|
if (!isolated) plan.spawned += commitBatches(state, tick, born, deriving ? close : null);
|
|
743
749
|
plan.spawned += commitStaged(state, queued, tick, born, deriving ? close : null);
|
|
744
750
|
const written: Written = [];
|
|
745
|
-
planOf(state, queued, written);
|
|
751
|
+
planOf(state, queued, written, born);
|
|
746
752
|
if (structure.rederive && !isolated) rederive(state);
|
|
747
753
|
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;
|
|
748
754
|
let index: IndexChanges | null = null;
|
|
@@ -46,18 +46,20 @@ const tablesOf = (components: readonly Component.Key[], resources: readonly Reso
|
|
|
46
46
|
System.create({ readonly: true, tables: { components, resources, not } }, ({ tables }): Tables => tables);
|
|
47
47
|
|
|
48
48
|
const spawnedBy: { spawned: Table.Spawned } = { spawned: { entities: [], dropped: 0 } };
|
|
49
|
-
const given: { tables: readonly Table[]; resources?: { readonly [key: symbol]: unknown }; existing?: ReadonlyMap<Entity, Entity
|
|
49
|
+
const given: { tables: readonly Table[]; resources?: { readonly [key: symbol]: unknown }; existing?: ReadonlyMap<Entity, Entity>; update?: boolean } = { tables: [] };
|
|
50
50
|
const spawnGiven = System.create(({ commands }) => {
|
|
51
|
-
spawnedBy.spawned = commands.spawnTables(given.tables, { resources: given.resources, existing: given.existing });
|
|
51
|
+
spawnedBy.spawned = commands.spawnTables(given.tables, { resources: given.resources, existing: given.existing, update: given.update });
|
|
52
52
|
});
|
|
53
|
-
const spawnTables = async (world: ReturnType<typeof inline>, tables: readonly Table[], existing?: ReadonlyMap<Entity, Entity
|
|
53
|
+
const spawnTables = async (world: ReturnType<typeof inline>, tables: readonly Table[], existing?: ReadonlyMap<Entity, Entity>, update?: boolean): Promise<Table.Spawned> => {
|
|
54
54
|
given.tables = tables;
|
|
55
55
|
given.existing = existing;
|
|
56
|
+
given.update = update;
|
|
56
57
|
try {
|
|
57
58
|
await world.run(spawnGiven);
|
|
58
59
|
}
|
|
59
60
|
finally {
|
|
60
61
|
given.existing = undefined;
|
|
62
|
+
given.update = undefined;
|
|
61
63
|
}
|
|
62
64
|
return spawnedBy.spawned;
|
|
63
65
|
};
|
|
@@ -247,6 +249,37 @@ describe("tables", () => {
|
|
|
247
249
|
expect((await readAll(other)).find(entry => entry.label === "owned")).toMatchObject({ parent: existingSteel, target: existingSteel });
|
|
248
250
|
});
|
|
249
251
|
|
|
252
|
+
it("write a row existing maps onto its entity with update, as an add would, entity values rewritten as a spawned row's", async () => {
|
|
253
|
+
await using world = inline();
|
|
254
|
+
let steel = Entity.none;
|
|
255
|
+
await world.run(System.create(({ commands }) => {
|
|
256
|
+
const inside = commands.spawn([label])({ [label]: "inside" });
|
|
257
|
+
const outside = commands.spawn([pinned])();
|
|
258
|
+
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 } });
|
|
259
|
+
}));
|
|
260
|
+
const { archetypes } = await world.run(tablesOf([label, position, buddy, target, link, team]));
|
|
261
|
+
await using other = inline();
|
|
262
|
+
let mine = Entity.none, kept = Entity.none;
|
|
263
|
+
await other.run(System.create(({ commands }) => {
|
|
264
|
+
kept = commands.spawn([label])({ [label]: "kept" });
|
|
265
|
+
mine = commands.spawn([label, hidden, target, team], { [team]: "blue" })({ [label]: "mine", [target]: kept });
|
|
266
|
+
}));
|
|
267
|
+
const changed: string[] = [];
|
|
268
|
+
const watch = System.create({ components: { with: [label], changed: [label] } }, row => { changed.push(row[label]); });
|
|
269
|
+
await other.run(watch);
|
|
270
|
+
changed.length = 0;
|
|
271
|
+
const { entities, dropped } = await spawnTables(other, archetypes, new Map([[steel, mine]]), true);
|
|
272
|
+
expect([dropped, entities.flatMap(list => [...list]).includes(mine)]).toEqual([0, true]);
|
|
273
|
+
const read = await readAll(other);
|
|
274
|
+
const inside = read.find(entry => entry.label === "inside")?.entity;
|
|
275
|
+
// The weak reference to an entity outside the tables is left out, so the entity's own stands.
|
|
276
|
+
expect(read.find(entry => entry.entity === mine)).toMatchObject({ label: "steel", position: [1, 2, 3], buddy: inside, target: kept, link: { to: inside, weight: 4 } });
|
|
277
|
+
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])));
|
|
278
|
+
expect(teams).toEqual([[{ kind: "value", value: "red" }, true]]);
|
|
279
|
+
await other.run(watch);
|
|
280
|
+
expect(changed.sort()).toEqual(["inside", "steel"]);
|
|
281
|
+
});
|
|
282
|
+
|
|
250
283
|
it("refuse existing mapping to an entity that isn't alive, spawning nothing", async () => {
|
|
251
284
|
await using world = inline();
|
|
252
285
|
const gone = (await spawnLabels(world, ["gone"])).get("gone") ?? Entity.none;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
-
import type
|
|
2
|
+
import { viewsOf, type Layout, type Path } from '../../interface/registry/layout.js';
|
|
3
3
|
import { declarationOf, labelOf } from '../../interface/registry/names.js';
|
|
4
4
|
import { isEntity } from '../../interface/registry/schemas.js';
|
|
5
5
|
import type { Schema } from '../../interface/schema/schema.js';
|
|
@@ -8,7 +8,8 @@ import { batchOf } from './batches.js';
|
|
|
8
8
|
import { indexOf, locate, reserveRun } from './entities.js';
|
|
9
9
|
import { internShared } from './shared.js';
|
|
10
10
|
import { claimRows, openRows, writerOf, type Column as BatchColumn } from './staging.js';
|
|
11
|
-
import { commandIdOf, type Key, type State } from './state.js';
|
|
11
|
+
import { commandIdOf, storedShared, type Key, type State } from './state.js';
|
|
12
|
+
import { addCommand, pushCommand, pushComponent, type CommandBuffer } from './buffer.js';
|
|
12
13
|
|
|
13
14
|
/** Whether a value of `schema` may hold an entity anywhere in it. */
|
|
14
15
|
const holdsEntity = (schema: Schema): boolean => {
|
|
@@ -209,6 +210,45 @@ const spawnRows = (state: State, { table, keys, ids, shared }: Planned, rows: re
|
|
|
209
210
|
claimRows(batch, entities);
|
|
210
211
|
};
|
|
211
212
|
|
|
213
|
+
/** A linear value read out of a table's bytes, copied to row 0 here first; grown for larger layouts. */
|
|
214
|
+
let scratch = viewsOf(new ArrayBuffer(256), 0, 256);
|
|
215
|
+
|
|
216
|
+
/** 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. */
|
|
217
|
+
const valueIn = (layout: Layout, column: Table.Column, row: number): unknown => {
|
|
218
|
+
if (column.kind === "value") return column.value;
|
|
219
|
+
if (column.kind === "values") return column.values[row];
|
|
220
|
+
if (layout.kind !== "linear") return undefined;
|
|
221
|
+
const { stride } = layout;
|
|
222
|
+
if (stride > scratch.u8.length) scratch = viewsOf(new ArrayBuffer(stride * 2), 0, stride * 2);
|
|
223
|
+
scratch.u8.set(column.bytes.subarray(row * stride, (row + 1) * stride));
|
|
224
|
+
return layout.read(scratch, 0);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Queues an add of each of its table's components onto the entity standing for a row `existing` maps, its entity
|
|
229
|
+
* values remapped, but a reference whose target isn't there.
|
|
230
|
+
*/
|
|
231
|
+
const updateRows = (state: State, { table, keys, ids }: Planned, rows: readonly number[], standing: Float64Array, buffer: CommandBuffer, remap: Remap): void => {
|
|
232
|
+
const columns: Table.Column[] = keys.map(key => Reflect.get(table.columns, key));
|
|
233
|
+
const schemas = keys.map(key => declarationOf(key)?.type);
|
|
234
|
+
const references = keys.map(key => declarationOf(key)?.reference !== undefined);
|
|
235
|
+
for (const row of rows) {
|
|
236
|
+
pushCommand(buffer, addCommand, standing[row]);
|
|
237
|
+
for (let k = 0; k < keys.length; k++) {
|
|
238
|
+
const id = ids[k];
|
|
239
|
+
const schema = schemas[k];
|
|
240
|
+
let value = valueIn(state.layouts[id], columns[k], row);
|
|
241
|
+
if (references[k]) {
|
|
242
|
+
const to = remap(Number(value));
|
|
243
|
+
if (to === 0 && value !== 0) continue;
|
|
244
|
+
value = to;
|
|
245
|
+
}
|
|
246
|
+
else if (schema !== undefined && holdsEntity(schema)) value = remapValue(schema, value, remap);
|
|
247
|
+
pushComponent(buffer, id, state.shared[id] === undefined ? value : storedShared(state, id, value));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
212
252
|
/** Throws for an entity `existing` maps to that isn't alive in this world. */
|
|
213
253
|
const checkExisting = (state: State, existing: ReadonlyMap<number, number>): void => {
|
|
214
254
|
for (const [source, target] of existing) {
|
|
@@ -223,7 +263,7 @@ const checkExisting = (state: State, existing: ReadonlyMap<number, number>): voi
|
|
|
223
263
|
* value naming an entity spawned names the one spawned for it. A row whose entity `existing` maps isn't spawned: the
|
|
224
264
|
* entity it maps to stands for it, as it does for an entity outside the tables that `existing` maps.
|
|
225
265
|
*/
|
|
226
|
-
export const spawnTables = (state: State, tables: readonly Table[], existing: ReadonlyMap<number, number> = new Map()): Table.Spawned => {
|
|
266
|
+
export const spawnTables = (state: State, tables: readonly Table[], existing: ReadonlyMap<number, number> = new Map(), updates: CommandBuffer | null = null): Table.Spawned => {
|
|
227
267
|
const plans = tables.map(table => planOf(state, table));
|
|
228
268
|
checkExisting(state, existing);
|
|
229
269
|
// 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
|
|
|
342
382
|
const target = targetAt(row);
|
|
343
383
|
return target !== 0 && !isSpawned(target);
|
|
344
384
|
});
|
|
385
|
+
if (updates !== null && !wholly[t]) {
|
|
386
|
+
const mapped: number[] = [];
|
|
387
|
+
for (let row = 0; row < table.count; row++) if (existingAt[first + row] !== 0) mapped.push(row);
|
|
388
|
+
if (mapped.length > 0) updateRows(state, plan, mapped, entities[t], updates, remap);
|
|
389
|
+
}
|
|
345
390
|
let whole = wholly[t];
|
|
346
391
|
if (weak.length > 0) for (let row = 0; row < table.count && whole; row++) if (loses(row)) whole = false;
|
|
347
392
|
if (whole) return spawnRows(state, plan, null, none, entities[t], remap);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import { System } from "../../interface/system/system.js";
|
|
3
|
+
import { inDocument } from "./watch-traits.test-helper.js";
|
|
4
|
+
|
|
5
|
+
/** A resource of the document's, registered once this module is imported. */
|
|
6
|
+
export const margin = Symbol("watchTraits/margin");
|
|
7
|
+
|
|
8
|
+
declare module "@adobe/ecs" {
|
|
9
|
+
export interface Resources {
|
|
10
|
+
[margin]: { type: number; [inDocument]: true };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const widen = System.create(({ resources }) => { resources[margin] = 5; });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
|
|
3
|
+
/** A trait of the document's: what changes finalizers watching it see. */
|
|
4
|
+
export const inDocument = Symbol("watchTraits/inDocument");
|
|
5
|
+
|
|
6
|
+
declare module "@adobe/ecs" {
|
|
7
|
+
export interface Traits<N> {
|
|
8
|
+
readonly [inDocument]: N extends unknown ? true : never;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
import { expect, it } from "vitest";
|
|
3
|
+
import { Finalizer } from "../../interface/finalizer/finalizer.js";
|
|
4
|
+
import { System } from "../../interface/system/system.js";
|
|
5
|
+
import { createWorld } from "../index.js";
|
|
6
|
+
import { readResource } from "./reads.test-helper.js";
|
|
7
|
+
import { inDocument } from "./watch-traits.test-helper.js";
|
|
8
|
+
|
|
9
|
+
const title = Symbol("watchTraits/title");
|
|
10
|
+
const cursor = Symbol("watchTraits/cursor");
|
|
11
|
+
const zoom = Symbol("watchTraits/zoom");
|
|
12
|
+
const theme = Symbol("watchTraits/theme");
|
|
13
|
+
const saved = Symbol("watchTraits/saved");
|
|
14
|
+
const recorded = Symbol("watchTraits/recorded");
|
|
15
|
+
|
|
16
|
+
declare module "@adobe/ecs" {
|
|
17
|
+
interface Components {
|
|
18
|
+
[title]: { type: string; [inDocument]: true };
|
|
19
|
+
[cursor]: { type: number };
|
|
20
|
+
}
|
|
21
|
+
interface Resources {
|
|
22
|
+
[zoom]: { type: number; default: 1; [inDocument]: true };
|
|
23
|
+
[theme]: { type: string };
|
|
24
|
+
[saved]: { type: readonly string[]; default: [] };
|
|
25
|
+
[recorded]: { type: readonly string[]; default: [] };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What a change set holds, as lines: each group's components added and removed, and each resource written. */
|
|
30
|
+
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[] => [
|
|
31
|
+
...changes.groups.map(group => [...group.added.map(key => `+${key.description}`), ...group.removed.map(key => `-${key.description}`)].join(" ")),
|
|
32
|
+
...changes.resources.map(change => `${change.key.description} ${String(change.previous)} → ${String(change.next)}`),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
Finalizer.changes({ watch: [inDocument] }, (changes, { resources }) => {
|
|
36
|
+
resources[saved] = [...resources[saved], ...linesOf(changes)];
|
|
37
|
+
});
|
|
38
|
+
Finalizer.changes({ watch: { components: true, resources: [theme] } }, (changes, { resources }) => {
|
|
39
|
+
resources[recorded] = [...resources[recorded], ...linesOf(changes)];
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const spawn = System.create(({ commands }) => { commands.spawn([title, cursor])({ [title]: "notes", [cursor]: 0 }); });
|
|
43
|
+
const edit = System.create(({ resources }) => {
|
|
44
|
+
resources[zoom] = 2;
|
|
45
|
+
resources[theme] = "dark";
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("watches each component and resource declaring a trait, and every component with the resources chosen", async () => {
|
|
49
|
+
await using world = createWorld();
|
|
50
|
+
await world.run(spawn);
|
|
51
|
+
await world.run(edit);
|
|
52
|
+
expect(await readResource(world, saved)).toEqual(["+watchTraits/title", "watchTraits/zoom 1 → 2"]);
|
|
53
|
+
expect(await readResource(world, recorded)).toEqual(["+watchTraits/title +watchTraits/cursor", "watchTraits/theme → dark"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("watches a resource declaring a trait it watches that registers after the world started", async () => {
|
|
57
|
+
await using world = createWorld();
|
|
58
|
+
await world.run(spawn);
|
|
59
|
+
const { widen } = await import("./watch-traits-late.test-helper.js");
|
|
60
|
+
await world.run(widen);
|
|
61
|
+
expect(await readResource(world, saved)).toEqual(["+watchTraits/title", "watchTraits/margin 0 → 5"]);
|
|
62
|
+
});
|
|
@@ -5,9 +5,11 @@ import type { Commands } from '../system/commands.js';
|
|
|
5
5
|
import type { Listed, Property } from '../system/property.js';
|
|
6
6
|
import type { LookupFunction } from '../system/values.js';
|
|
7
7
|
import type { WriteResources } from '../system/body.js';
|
|
8
|
-
import type { ChangesDeclaration, Declaration } from './declaration.js';
|
|
8
|
+
import type { ChangesDeclaration, Declaration, OwnTrait } from './declaration.js';
|
|
9
9
|
import type { Essential, ReadonlyType } from '../component/value.js';
|
|
10
10
|
import type { Resource } from '../resource/resource.js';
|
|
11
|
+
import type { Resources } from '../resource/types.js';
|
|
12
|
+
import type { Components } from '../component/types.js';
|
|
11
13
|
import type { ReadonlyType as ResourceType } from '../resource/value.js';
|
|
12
14
|
import type { Elsewhere, ThreadsOf } from '../system/threads.js';
|
|
13
15
|
|
|
@@ -30,11 +32,28 @@ export type Context<D extends Declaration | ChangesDeclaration> =
|
|
|
30
32
|
})
|
|
31
33
|
& (true extends Elsewhere<ThreadsOf<D>> ? {} : { readonly commands: Commands });
|
|
32
34
|
|
|
35
|
+
/** The components declaring any of the traits `T`. */
|
|
36
|
+
type ComponentsDeclaring<T> = { [K in Component.Key]: [Extract<keyof Components[K], T>] extends [never] ? never : K }[Component.Key];
|
|
37
|
+
|
|
38
|
+
/** The resources declaring any of the traits `T`. */
|
|
39
|
+
type ResourcesDeclaring<T> = { [K in Resource.Key]: [Extract<keyof Resources[K], T>] extends [never] ? never : K }[Resource.Key];
|
|
40
|
+
|
|
41
|
+
/** The components keys `L` name: each, and each declaring a trait among them. */
|
|
42
|
+
type ComponentsOf<L> = Extract<L, Essential> | Extract<ComponentsDeclaring<Extract<L, OwnTrait>>, Essential>;
|
|
43
|
+
|
|
44
|
+
/** The resources keys `L` name: each, and each declaring a trait among them. */
|
|
45
|
+
type ResourcesOf<L> = Extract<L, Resource.Key> | ResourcesDeclaring<Extract<L, OwnTrait>>;
|
|
46
|
+
|
|
47
|
+
/** The keys a changes finalizer's `watch` lists, by kind: those of its list, or of its object's lists. */
|
|
48
|
+
export type WatchListed<D extends ChangesDeclaration, Of extends "components" | "resources"> = Property<D, "watch"> extends readonly (infer E)[] ? E
|
|
49
|
+
: Property<D, "watch"> extends { readonly [P in Of]: readonly (infer E)[] } ? E : never;
|
|
50
|
+
|
|
33
51
|
/** The components a changes finalizer sees. */
|
|
34
|
-
export type Watched<D extends ChangesDeclaration> = Property<D, "watch"> extends readonly unknown[]
|
|
52
|
+
export type Watched<D extends ChangesDeclaration> = Property<D, "watch"> extends readonly unknown[] | { readonly components: readonly unknown[] }
|
|
53
|
+
? ComponentsOf<WatchListed<D, "components">> : Essential;
|
|
35
54
|
|
|
36
55
|
/** The resources a changes finalizer sees: those it watches. */
|
|
37
|
-
export type WatchedResources<D extends ChangesDeclaration> =
|
|
56
|
+
export type WatchedResources<D extends ChangesDeclaration> = ResourcesOf<WatchListed<D, "resources">>;
|
|
38
57
|
|
|
39
58
|
/** Entities whose components changed the same way: `from` and `to` are null for those spawned and despawned. */
|
|
40
59
|
export type Group<C extends Component.Key> = {
|
|
@@ -75,7 +75,10 @@ describe("Finalizer.create and Finalizer.changes", () => {
|
|
|
75
75
|
expect(() => untyped(Finalizer.create, { components: {} }, { resources: { reduce: [total] } })).toThrow("only systems contribute to a reduction");
|
|
76
76
|
expect(() => untyped(Finalizer.create, { components: { with: [node, tags] } }, { read: [node, tags] })).toThrow('uses "typesFinalizerTest/node" on "main" and "typesFinalizerTest/tags" on "world"');
|
|
77
77
|
expect(() => untyped(Finalizer.changes, { name: "derived", watch: [slot] })).toThrow("which is derived");
|
|
78
|
-
expect(() => untyped(Finalizer.changes, { name: "unregistered", watch: [Symbol("typesFinalizerTest/unregistered")] })).toThrow("which isn't a registered component or
|
|
78
|
+
expect(() => untyped(Finalizer.changes, { name: "unregistered", watch: [Symbol("typesFinalizerTest/unregistered")] })).toThrow("which isn't a registered component, resource or trait");
|
|
79
|
+
expect(() => untyped(Finalizer.changes, { watch: { components: [time], resources: [] } })).toThrow(`watches "typesFinalizerTest/time" among its components, which isn't a registered component or trait`);
|
|
80
|
+
expect(() => untyped(Finalizer.changes, { watch: { components: true, resources: [label] } })).toThrow(`watches "typesFinalizerTest/label" among its resources, which isn't a registered resource or trait`);
|
|
81
|
+
expect(() => untyped(Finalizer.changes, { watch: { components: [slot], resources: [] } })).toThrow("which is derived");
|
|
79
82
|
expect(() => untyped(Finalizer.create, { components: { with: [time] } })).toThrow(`uses component "typesFinalizerTest/time", which isn't a registered component`);
|
|
80
83
|
expect(() => untyped(Finalizer.create, { components: { with: [label] }, lookup: { others: { not: [Symbol("typesFinalizerTest/unregistered")] } } }, { read: [label] })).toThrow("which isn't a registered component");
|
|
81
84
|
expect(programFinalizers()).toHaveLength(7);
|