@driftengine/entities 3.61.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.
@@ -0,0 +1,161 @@
1
+ import { BoundSystem } from './system.js';
2
+ /**
3
+ * Order the systems, refusing what cannot be ordered.
4
+ *
5
+ * Deterministic: the same definitions in the same order produce the same schedule on every machine
6
+ * and every run, which is what a replay of a recorded session depends on.
7
+ */
8
+ export function buildSchedule(systems) {
9
+ const byName = new Map();
10
+ systems.forEach((system, index) => {
11
+ if (byName.has(system.name)) {
12
+ throw new Error(`\`${system.name}\` is declared more than once. \`after\` addresses a system by name, so ` +
13
+ 'two of a name is an ordering constraint nobody can write.');
14
+ }
15
+ byName.set(system.name, index);
16
+ });
17
+ for (const system of systems) {
18
+ for (const name of system.after ?? []) {
19
+ if (!byName.has(name)) {
20
+ throw new Error(`\`${system.name}\` says it runs after \`${name}\`, which no system declares. A ` +
21
+ 'constraint naming nothing is a constraint that silently does nothing.');
22
+ }
23
+ }
24
+ }
25
+ /*
26
+ * Kahn's algorithm over the `after` edges, taking the **lowest-numbered** ready system each time.
27
+ * That is the tie-break: among systems whose constraints are all satisfied, declaration order
28
+ * decides, so the result is a total order rather than whichever the set happened to yield.
29
+ */
30
+ const order = [];
31
+ const done = new Set();
32
+ for (let placed = 0; placed < systems.length; placed += 1) {
33
+ let chosen = -1;
34
+ for (let i = 0; i < systems.length; i += 1) {
35
+ const system = systems[i];
36
+ if (done.has(system.name))
37
+ continue;
38
+ const waiting = (system.after ?? []).some((name) => !done.has(name));
39
+ if (waiting)
40
+ continue;
41
+ chosen = i;
42
+ break;
43
+ }
44
+ if (chosen < 0) {
45
+ const stuck = systems.filter((system) => !done.has(system.name)).map((s) => s.name);
46
+ throw new Error(`these systems each say they run after another of them, so none can go first: ` +
47
+ `${stuck.join(', ')}. One of the \`after\` clauses has to give.`);
48
+ }
49
+ const system = systems[chosen];
50
+ done.add(system.name);
51
+ order.push(system);
52
+ }
53
+ return { order, groups: group(order) };
54
+ }
55
+ /** Which of a component type's ids a system touches at all. */
56
+ function touched(system) {
57
+ const ids = new Set();
58
+ for (const type of system.reads ?? [])
59
+ ids.add(type.id);
60
+ for (const type of system.writes ?? [])
61
+ ids.add(type.id);
62
+ return ids;
63
+ }
64
+ /** Whether two systems must not run at the same time: one writes what the other touches. */
65
+ function conflicts(a, b) {
66
+ const bTouched = touched(b);
67
+ for (const type of a.writes ?? [])
68
+ if (bTouched.has(type.id))
69
+ return true;
70
+ const aTouched = touched(a);
71
+ for (const type of b.writes ?? [])
72
+ if (aTouched.has(type.id))
73
+ return true;
74
+ return false;
75
+ }
76
+ function group(order) {
77
+ const groups = [];
78
+ let current = [];
79
+ for (const system of order) {
80
+ if (current.length > 0 && current.some((other) => conflicts(other, system))) {
81
+ groups.push(current);
82
+ current = [];
83
+ }
84
+ current.push(system);
85
+ }
86
+ if (current.length > 0)
87
+ groups.push(current);
88
+ return groups;
89
+ }
90
+ /** Systems bound to a world, made once and reused for the life of it. */
91
+ const bound = new WeakMap();
92
+ /**
93
+ * Systems whose failure has already been reported, per world.
94
+ *
95
+ * A declaration error repeats every step by construction, so the message is said once. Keyed by
96
+ * definition rather than counted, because what a reader needs is the name of each broken system
97
+ * and not how many times it has failed.
98
+ */
99
+ const reported = new WeakMap();
100
+ /**
101
+ * Run one fixed step of a schedule.
102
+ *
103
+ * `tick` is the simulation's step count and is what `everyTicks` strides over — so a system at
104
+ * stride 60 runs on tick 0, 60, 120, in every replay of the same input.
105
+ *
106
+ * **A system that throws does not take the rest of the tick with it, since 2026-08-28.** It used
107
+ * to: the throw left this function, so every system scheduled after the failing one never ran —
108
+ * once per tick, for as long as the game was up. Reported from outside, and the way it was found is
109
+ * the argument for containing it. A system queried a component it had not declared, which throws by
110
+ * design; the system that stopped was three places later and was the one stepping a vehicle; the
111
+ * symptom was *a car moving in jerks*, and the only evidence was a console line no headless check
112
+ * reads. A broken system must not be able to silently starve its neighbours.
113
+ *
114
+ * So each system's failure is caught, reported **once** naming the system and the reason, and the
115
+ * schedule carries on. Nothing is disabled: a declaration error will throw again next tick and be
116
+ * caught again, which costs a throw per tick and keeps a transient failure recoverable — and this
117
+ * function no longer throws, which is what `AGENTS.md` requires of anything the frame loop calls.
118
+ *
119
+ * **What it gives up** is the stack reaching a debugger's uncaught-exception break, which is how
120
+ * somebody working on a system would rather meet it. **What would change it** is a consumer wanting
121
+ * failures fatal in development; the answer then is a reporter this takes as a parameter rather than
122
+ * a rule chosen here for everybody.
123
+ */
124
+ export function runSchedule(world, schedule, tick) {
125
+ let forWorld = bound.get(world);
126
+ if (forWorld === undefined) {
127
+ forWorld = new Map();
128
+ bound.set(world, forWorld);
129
+ }
130
+ for (const definition of schedule.order) {
131
+ const stride = definition.everyTicks ?? 1;
132
+ if (stride > 1 && tick % stride !== 0)
133
+ continue;
134
+ let system = forWorld.get(definition);
135
+ if (system === undefined) {
136
+ system = new BoundSystem(world, definition);
137
+ forWorld.set(definition, system);
138
+ }
139
+ try {
140
+ system.run();
141
+ }
142
+ catch (error) {
143
+ announce(world, definition, error);
144
+ }
145
+ }
146
+ }
147
+ /** Say what failed, once per system per world. */
148
+ function announce(world, definition, error) {
149
+ let seen = reported.get(world);
150
+ if (seen === undefined) {
151
+ seen = new Set();
152
+ reported.set(world, seen);
153
+ }
154
+ if (seen.has(definition))
155
+ return;
156
+ seen.add(definition);
157
+ const reason = error instanceof Error ? error.message : String(error);
158
+ console.error(`[driftengine] the system \`${definition.name}\` threw and was skipped; every other system ` +
159
+ `in the schedule still ran. This is said once, and it will keep happening every tick until ` +
160
+ `it is fixed: ${reason}`);
161
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * A component type, and the sparse set that holds one.
3
+ *
4
+ * **Sparse sets rather than archetypes, and the reason is churn.** An archetype table iterates a
5
+ * multi-component query faster because the components are already adjacent — but adding or removing
6
+ * one component moves the entity between tables, which copies every component it has. A game that
7
+ * adds and removes a marker component per frame pays that copy per frame, and a marker is exactly
8
+ * the pattern queries are built for. Here, add and remove are constant time and move nothing else.
9
+ *
10
+ * The cost is that a multi-component query walks one dense array and tests membership in the others,
11
+ * so the data is not adjacent. **That is to be measured rather than assumed**, and the measurement
12
+ * is a deliverable of this track — in a language where every object field is already an indirection
13
+ * the gap is not the one a systems language would see.
14
+ *
15
+ * ---
16
+ *
17
+ * ## The schema is DriftScript's, and there is no second description
18
+ *
19
+ * A component's fields, their types and their **stable ids** are a `Schema` — the same structure
20
+ * Track N's Phase 5 built for state migration. Storage layout reads it, serialization reads its
21
+ * ids, loading a scene across a field change goes through `migrate` unchanged, and an inspector
22
+ * will read it when Track K exists. Four readers, one description, nothing to keep in sync.
23
+ *
24
+ * A field id is `(declaring module, declaring record, field name)` and never a position, so a field
25
+ * inserted in the middle moves nothing and a scene saved before it loads afterwards.
26
+ *
27
+ * ## Numeric fields get typed arrays; everything else gets a plain one
28
+ *
29
+ * `AGENTS.md`'s bulk-data rule where it belongs. **What it gives up** is that a component is not a
30
+ * value a caller can hold — reading one means reading its fields — which is deliberate, because a
31
+ * component object handed out is an object allocated per read, sixty times a second.
32
+ */
33
+ import type { Schema } from 'driftscript';
34
+ import { type Entity } from './entity.ts';
35
+ import { type ComponentView } from './view.ts';
36
+ /**
37
+ * The presence column's name for an optional field.
38
+ *
39
+ * A suffix rather than a parallel map, because `grow`, `remove` and the view all already walk
40
+ * `this.columns` — and a second map would be one more thing each of them has to remember. What it
41
+ * gives up is a field literally named `of$present`, which the language refuses at declaration and
42
+ * which `$` makes unwritable in a `.drs` file anyway. **What would make it wrong** is a component
43
+ * that is mostly optional, where a bitset over all of its optional fields beats a byte each — a
44
+ * change to how presence is stored, not to what an option is, so it moves nothing a save holds.
45
+ */
46
+ export declare const presenceColumn: (field: string) => string;
47
+ /**
48
+ * `option:f64` → `f64`, and `undefined` for a type that is not an option.
49
+ *
50
+ * A prefix rather than a structured type because a schema's field type is a *key* — one string
51
+ * compared for equality by a migration — and parsing it here is cheaper than carrying a second
52
+ * representation that could disagree with it.
53
+ */
54
+ export declare function optionInner(type: string): string | undefined;
55
+ export interface ComponentType {
56
+ readonly name: string;
57
+ readonly schema: Schema;
58
+ /**
59
+ * A small dense integer, for indexing by component type in a query or a schedule.
60
+ *
61
+ * Allocated in declaration order at module load, so it is the same on every run of the same
62
+ * code — and it is deliberately **not** what a scene is written with. A scene uses field ids,
63
+ * which survive a reorder; this survives nothing and is never stored.
64
+ */
65
+ readonly id: number;
66
+ /** Fields typed `Entity`, which a scene load has to rewrite through its id map. */
67
+ readonly entityFields: readonly string[];
68
+ }
69
+ export declare function defineComponent(schema: Schema): ComponentType;
70
+ export declare function defineComponent(name: string, fields: Readonly<Record<string, string>>): ComponentType;
71
+ /**
72
+ * One store's contents, for a rewind. Reused between saves; see `AllocatorSnapshot`.
73
+ *
74
+ * **`sparse` is absent and that is a decision.** It is derivable from `dense` — position *i* holds
75
+ * the entity whose sparse entry is *i* — and its length grows with the highest entity index the
76
+ * world has ever reached rather than with the live set. A world of forty entities that once held
77
+ * forty thousand would copy 160 KB per store per tick to save an array it can rebuild in forty
78
+ * writes. `loadFrom` rebuilds it.
79
+ */
80
+ export interface StoreSnapshot {
81
+ count: number;
82
+ dense: Float64Array;
83
+ /** Field name to its column's contents, mirroring `columns`. Grown to match, never shrunk. */
84
+ columns: Map<string, NumericColumn | unknown[]>;
85
+ }
86
+ export declare function createStoreSnapshot(): StoreSnapshot;
87
+ export interface ComponentStoreOptions {
88
+ /** Initial column length. Present for the growth test; a consumer has no reason to set it. */
89
+ readonly capacity?: number;
90
+ }
91
+ /** A mutable numeric column, which the readonly `ArrayLike` in `COLUMNS` does not describe. */
92
+ type NumericColumn = Float32Array | Float64Array | Int8Array | Int16Array | Int32Array | Uint8Array | Uint16Array | Uint32Array;
93
+ export declare class ComponentStore {
94
+ readonly type: ComponentType;
95
+ /**
96
+ * The entities that have this component, in insertion order modified by swap-removal.
97
+ *
98
+ * **Deterministic given the same history, and not a stable property of the set** — two worlds
99
+ * holding the same entities in a different removal order iterate differently. That is what replay
100
+ * needs and is not what a consumer wanting a stable order gets; they sort by entity, and the fact
101
+ * that it is a choice is why this says so.
102
+ */
103
+ private denseArray;
104
+ /** Entity index → position in `dense`, or -1. */
105
+ private sparse;
106
+ private readonly columns;
107
+ private count;
108
+ /**
109
+ * The live view of these columns, made on first ask.
110
+ *
111
+ * Lazy rather than built in the constructor, because a store that nobody indexes directly should
112
+ * not carry an object per field name it will never hand out.
113
+ */
114
+ private viewObject;
115
+ constructor(type: ComponentType, options?: ComponentStoreOptions);
116
+ get size(): number;
117
+ /** The entities this store holds, oldest first. Valid up to `size`; the rest is capacity. */
118
+ get dense(): Float64Array;
119
+ /**
120
+ * The live columns of this store, addressable by field name.
121
+ *
122
+ * The same object every call, refreshed after every reallocation — see `view.ts` for why that is
123
+ * the contract rather than a convenience.
124
+ */
125
+ view(): ComponentView;
126
+ /** Point the view at the arrays that exist now. Called after either kind of growth. */
127
+ private refresh;
128
+ /** One field's storage, for a caller iterating it directly rather than through `read`. */
129
+ column(field: string): NumericColumn | unknown[];
130
+ has(entity: Entity): boolean;
131
+ add(entity: Entity, values?: Readonly<Record<string, unknown>>): void;
132
+ /**
133
+ * Drop an entity's component. `false` when it never had one, which is not an error.
134
+ *
135
+ * The last entry is swapped into the hole and **its** sparse entry is fixed — the fix-up being
136
+ * the whole of what makes a constant-time removal correct.
137
+ *
138
+ * **The order of the two sparse writes is load-bearing**, and it is the only thing that is. When
139
+ * the entry being removed *is* the last, the swap is with itself and `moved` is `entity`: writing
140
+ * the position first and then the -1 leaves it absent, which is right; the other way round leaves
141
+ * it pointing into the space past `size`.
142
+ *
143
+ * There was a `position < size` guard in `positionOf` as well, which made the order not matter —
144
+ * and made a perturbation of the order pass every test. Two defences for one case is one defence
145
+ * and one comment that cannot be checked, so the guard is gone and the order is what the test
146
+ * for removing the last entry actually holds.
147
+ */
148
+ remove(entity: Entity): boolean;
149
+ read(entity: Entity, field: string): unknown;
150
+ write(entity: Entity, field: string, value: unknown): void;
151
+ /**
152
+ * Where an entity sits, or -1.
153
+ *
154
+ * **The dense array holds the whole handle**, so the generation is compared for free: a stale
155
+ * handle whose slot is live finds a different number and is refused. A sparse array alone could
156
+ * only compare indices, which is the silent-reuse failure one level down.
157
+ */
158
+ private positionOf;
159
+ /**
160
+ * Copy this store's contents into a slot, growing the slot to match if it is short.
161
+ *
162
+ * **`count` bounds every copy, not capacity.** A store that once held ten thousand entities and
163
+ * now holds four has ten thousand slots of stale numbers behind the live four, and copying them
164
+ * would make a snapshot's cost depend on the world's history instead of its size.
165
+ *
166
+ * A boxed column copies references. That is correct rather than a shortcut: the one boxed field
167
+ * type is `String`, and a string cannot be mutated behind the snapshot's back.
168
+ */
169
+ saveInto(slot: StoreSnapshot): void;
170
+ /**
171
+ * Put this store back to a saved slot, rebuilding `sparse` from the restored `dense`.
172
+ *
173
+ * **The order of the two sparse passes is load-bearing.** The entries this store's *current* live
174
+ * set occupies are cleared first, while `dense` still says which they are; overwriting `dense`
175
+ * first loses that record and leaves stale positions behind, and a stale position is worse than a
176
+ * missing one because `positionOf` would find a live slot holding a different handle.
177
+ */
178
+ loadFrom(slot: StoreSnapshot): void;
179
+ private noField;
180
+ private grow;
181
+ private growSparse;
182
+ }
183
+ export {};