@codehz/ecs 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/examples/advanced-scheduling.ts +96 -0
  2. package/examples/collision-detection.ts +229 -0
  3. package/examples/inventory-system-relations.ts +108 -0
  4. package/examples/parent-child-hierarchy.ts +206 -0
  5. package/examples/serialization.ts +337 -0
  6. package/examples/simple.ts +96 -0
  7. package/examples/spatial-grid.ts +276 -0
  8. package/examples/state-machine.ts +273 -0
  9. package/examples/tag-filtering.ts +266 -0
  10. package/package.json +60 -12
  11. package/src/__tests__/commands/buffer-limits.test.ts +72 -0
  12. package/src/__tests__/commands/buffer.test.ts +195 -0
  13. package/src/__tests__/component/singleton.test.ts +148 -0
  14. package/src/__tests__/core/archetype.test.ts +247 -0
  15. package/src/__tests__/core/bitset.test.ts +171 -0
  16. package/src/__tests__/core/changeset.test.ts +254 -0
  17. package/src/__tests__/core/multi-map.test.ts +74 -0
  18. package/src/__tests__/entity/component-registry.test.ts +66 -0
  19. package/src/__tests__/entity/entity.test.ts +520 -0
  20. package/src/__tests__/entity/id-manager.test.ts +157 -0
  21. package/src/__tests__/entity/id-system.test.ts +260 -0
  22. package/src/__tests__/perf/comprehensive.perf.test.ts +300 -0
  23. package/src/__tests__/perf/sync-hotpath.perf.test.ts +79 -0
  24. package/src/__tests__/query/basic.test.ts +341 -0
  25. package/src/__tests__/query/caching.test.ts +112 -0
  26. package/src/__tests__/query/filter.test.ts +111 -0
  27. package/src/__tests__/query/optional.test.ts +231 -0
  28. package/src/__tests__/query/perf.test.ts +99 -0
  29. package/src/__tests__/relations/dont-fragment/basic.test.ts +496 -0
  30. package/src/__tests__/relations/dont-fragment/query-notification.test.ts +125 -0
  31. package/src/__tests__/relations/wildcard.test.ts +179 -0
  32. package/src/__tests__/serialization/bounds.test.ts +237 -0
  33. package/src/__tests__/testing/assertions.test.ts +224 -0
  34. package/src/__tests__/testing/entity-builder.test.ts +84 -0
  35. package/src/__tests__/testing/snapshot.test.ts +150 -0
  36. package/src/__tests__/testing/world-fixture.test.ts +73 -0
  37. package/src/__tests__/world/component-hooks.test.ts +185 -0
  38. package/src/__tests__/world/component-management.test.ts +447 -0
  39. package/src/__tests__/world/entity-management.test.ts +86 -0
  40. package/src/__tests__/world/get-optional.test.ts +96 -0
  41. package/src/__tests__/world/multi-component-hooks.test.ts +502 -0
  42. package/src/__tests__/world/perf.test.ts +93 -0
  43. package/src/__tests__/world/query.test.ts +223 -0
  44. package/src/__tests__/world/serialize.test.ts +83 -0
  45. package/src/__tests__/world/wildcard-relation-hooks.test.ts +332 -0
  46. package/src/archetype/archetype.ts +472 -0
  47. package/src/archetype/helpers.ts +186 -0
  48. package/src/archetype/store.ts +33 -0
  49. package/src/commands/buffer.ts +110 -0
  50. package/src/commands/changeset.ts +104 -0
  51. package/src/component/entity-store.ts +223 -0
  52. package/src/component/registry.ts +657 -0
  53. package/src/component/type-utils.ts +9 -0
  54. package/src/entity/index.ts +63 -0
  55. package/src/entity/manager.ts +115 -0
  56. package/src/entity/relation.ts +319 -0
  57. package/src/entity/types.ts +135 -0
  58. package/src/index.ts +41 -0
  59. package/src/query/filter.ts +75 -0
  60. package/src/query/query.ts +313 -0
  61. package/src/query/registry.ts +101 -0
  62. package/src/storage/serialization.ts +130 -0
  63. package/src/testing/index.ts +634 -0
  64. package/src/types/index.ts +99 -0
  65. package/src/utils/bit-set.ts +133 -0
  66. package/src/utils/multi-map.ts +96 -0
  67. package/src/utils/utils.ts +19 -0
  68. package/src/world/builder.ts +100 -0
  69. package/src/world/commands.ts +378 -0
  70. package/src/world/hooks.ts +358 -0
  71. package/src/world/references.ts +38 -0
  72. package/src/world/serialization.ts +122 -0
  73. package/src/world/world.ts +1201 -0
  74. /package/{builder.d.mts → dist/builder.d.mts} +0 -0
  75. /package/{index.d.mts → dist/index.d.mts} +0 -0
  76. /package/{index.mjs → dist/index.mjs} +0 -0
  77. /package/{testing.d.mts → dist/testing.d.mts} +0 -0
  78. /package/{testing.mjs → dist/testing.mjs} +0 -0
  79. /package/{testing.mjs.map → dist/testing.mjs.map} +0 -0
  80. /package/{world.mjs → dist/world.mjs} +0 -0
  81. /package/{world.mjs.map → dist/world.mjs.map} +0 -0
@@ -0,0 +1,657 @@
1
+ import { ComponentIdAllocator } from "../entity/manager";
2
+ import { decodeRelationRaw } from "../entity/relation";
3
+ import type { ComponentId, EntityId } from "../entity/types";
4
+ import {
5
+ COMPONENT_ID_MAX,
6
+ ENTITY_ID_START,
7
+ isComponentId,
8
+ isValidComponentId,
9
+ WILDCARD_TARGET_ID,
10
+ } from "../entity/types";
11
+ import { BitSet } from "../utils/bit-set";
12
+
13
+ const globalComponentIdAllocator = new ComponentIdAllocator();
14
+
15
+ const ComponentIdForNames: Map<string, ComponentId<any>> = new Map();
16
+
17
+ /**
18
+ * Merge function type for combining repeated `set()` values within a single sync batch.
19
+ *
20
+ * When `world.set(entity, componentType, value)` is called **multiple times** for the
21
+ * same entity and same component type **before** the next `world.sync()`, the merge
22
+ * callback is invoked to combine the values instead of simply overwriting. This allows
23
+ * additive or custom composition of component data in a single frame.
24
+ *
25
+ * @typeParam T - The component's value type.
26
+ *
27
+ * @param prev - The value from the **previous** `set()` call (or the merged result of
28
+ * earlier calls) for this entity/componentType pair within the current sync batch.
29
+ * @param next - The value from the **current** `set()` call being processed.
30
+ *
31
+ * @returns The merged value to be stored. This becomes `prev` if another `set()` for
32
+ * the same entity and componentType is encountered later in the same batch.
33
+ *
34
+ * @remarks
35
+ * **Idempotency**: Merge functions **must be idempotent**. The ECS does not guarantee
36
+ * that `world.sync()` won't be called multiple times in edge cases (e.g., intermediate
37
+ * syncs during pipeline execution), so the merge result should not depend on call
38
+ * count or non-deterministic state.
39
+ *
40
+ * **Single-batch scope**: Merging only applies to `set()` calls within the **same sync
41
+ * batch** (i.e., between two `world.sync()` calls). After `world.sync()`, the component
42
+ * value is committed to storage, and the next `set()` starts with a fresh `prev` value.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * // Accumulate damage events in a single frame
47
+ * const DamageEvents = component<DamageEvent[]>({
48
+ * merge: (prev, next) => [...prev, ...next],
49
+ * });
50
+ *
51
+ * world.set(player, DamageEvents, [{ source: "fire", amount: 10 }]);
52
+ * world.set(player, DamageEvents, [{ source: "ice", amount: 5 }]);
53
+ * // After sync: player has [{ source: "fire", amount: 10 }, { source: "ice", amount: 5 }]
54
+ * ```
55
+ */
56
+ type ComponentMerge<T = any> = (prev: T, next: T) => T;
57
+
58
+ /**
59
+ * Component options that define intrinsic properties
60
+ */
61
+ export interface ComponentOptions<T = any> {
62
+ /**
63
+ * An optional human-readable name for the component, used for debugging and
64
+ * serialization.
65
+ *
66
+ * While `name` is **optional** at registration time, omitting it can cause
67
+ * problems when serializing and later deserializing the world:
68
+ *
69
+ * 1. **Cross-session portability**: Without a name, the component is
70
+ * serialized as a raw numeric ID. Component IDs are allocated sequentially
71
+ * at registration time, so if the order of `component()` calls changes
72
+ * between sessions (e.g. due to code refactoring, lazy-loading, or
73
+ * tree-shaking), those numeric IDs will no longer point to the same
74
+ * component type, leading to **silent data corruption** on restore.
75
+ *
76
+ * 2. **Runtime warnings**: `encodeEntityId` logs a `console.warn` for every
77
+ * unnamed component it encounters during `world.serialize()`, which can be
78
+ * noisy in production when serialization is used for save-games or
79
+ * snapshots.
80
+ *
81
+ * 3. **Debugging ergonomics**: Named components make serialized snapshots
82
+ * human-readable (e.g. `"Position"` instead of `42`), which is invaluable
83
+ * when inspecting save files or network dumps.
84
+ *
85
+ * **Recommendation**: Always provide a `name` for any component that may
86
+ * appear in a serialized world — even if it's just the same string as the
87
+ * variable name.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * // ✅ Good: explicit name ensures stable serialization
92
+ * const Position = component<{ x: number; y: number }>({ name: "Position" });
93
+ *
94
+ * // ⚠️ Risky: no name — serialization falls back to numeric ID
95
+ * const Velocity = component<{ dx: number; dy: number }>();
96
+ * ```
97
+ */
98
+ name?: string;
99
+ /**
100
+ * If `true`, an entity can have **at most one** relation per base component type.
101
+ * When a new relation with the same base component is added, any existing relations
102
+ * with that base component are **automatically removed** before the new one is applied.
103
+ *
104
+ * **Only applicable to relation components** — components used via
105
+ * `relation(componentId, target)`. Regular (non-relation) components ignore this flag.
106
+ *
107
+ * ## Behavior
108
+ *
109
+ * Exclusive relations enforce a **one-to-one** constraint at the entity level:
110
+ * each entity can hold at most one relation of a given exclusive component type.
111
+ *
112
+ * - **Same base component, different targets**: `set(entity, relation(Comp, A))`
113
+ * followed by `set(entity, relation(Comp, B))` results in only `(Comp, B)` —
114
+ * the `(Comp, A)` relation is automatically removed.
115
+ * - **Same base component, same target**: Re-setting the same relation target
116
+ * simply updates the component value (no extra removal overhead).
117
+ * - **Different exclusive components**: Independent — `exclusive` on `CompA` does
118
+ * not affect relations using `CompB`.
119
+ *
120
+ * The removal happens **during `world.sync()`**, as part of the command buffer
121
+ * processing, so it respects the same deferred execution model as other structural
122
+ * changes.
123
+ *
124
+ * ## Use cases
125
+ *
126
+ * - **Ownership**: An entity can only be owned by one parent at a time
127
+ * (`ChildOf` with `exclusive: true`).
128
+ * - **Equipment slots**: An item can only be in one slot at a time
129
+ * (`EquippedBy` with `exclusive: true`).
130
+ * - **Targeting**: An AI agent can only track one target at a time
131
+ * (`Targeting` with `exclusive: true`).
132
+ * - **State machines**: An entity can only have one active state from a set
133
+ * (`ActiveState` with `exclusive: true`).
134
+ *
135
+ * ## Interaction with other options
136
+ *
137
+ * - **`cascadeDelete`**: Compatible. When an exclusive relation uses
138
+ * `cascadeDelete`, deleting the target entity will both (a) delete the
139
+ * referencing entity, and (b) the exclusivity constraint prevents the
140
+ * entity from having multiple cascade-delete relations of the same type.
141
+ * - **`dontFragment`**: Compatible. Exclusivity is enforced at the data level
142
+ * regardless of whether the archetype is fragmented.
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * // Without exclusive: entity can have multiple ChildOf relations
147
+ * const ChildOf = component();
148
+ * world.set(child, relation(ChildOf, parentA));
149
+ * world.set(child, relation(ChildOf, parentB));
150
+ * world.sync();
151
+ * // child now has TWO ChildOf relations (parentA and parentB)
152
+ * ```
153
+ *
154
+ * @example
155
+ * ```ts
156
+ * // With exclusive: only the last relation survives
157
+ * const ChildOf = component({ exclusive: true });
158
+ * world.set(child, relation(ChildOf, parentA));
159
+ * world.set(child, relation(ChildOf, parentB));
160
+ * world.sync();
161
+ * // child has only (ChildOf, parentB); (ChildOf, parentA) was auto-removed
162
+ * ```
163
+ */
164
+ exclusive?: boolean;
165
+ /**
166
+ * If true, when a relation target entity is deleted, all entities that reference
167
+ * it through this component will also be deleted (cascade delete).
168
+ *
169
+ * Only applicable to entity-relation components.
170
+ *
171
+ * **Important distinction from default cleanup**:
172
+ * By default, the ECS library **always** cleans up relation components that point
173
+ * to a deleted entity — the relation component is removed from the referencing
174
+ * entity, but the referencing entity itself **survives**. When `cascadeDelete` is
175
+ * enabled, the **entire referencing entity** is deleted, not just the relation
176
+ * component. This deletion is transitive: if entity C references entity B (which
177
+ * is cascade-deleted), entity C will also be deleted, and so on.
178
+ *
179
+ * @example
180
+ * // Without cascadeDelete (default behavior):
181
+ * const ChildOf = component(); // no cascadeDelete
182
+ * world.set(child, relation(ChildOf, parent));
183
+ * world.sync();
184
+ * world.delete(parent);
185
+ * world.sync();
186
+ * // child still exists, but the ChildOf relation is cleaned up
187
+ *
188
+ * @example
189
+ * // With cascadeDelete:
190
+ * const ChildOf = component({ cascadeDelete: true });
191
+ * world.set(child, relation(ChildOf, parent));
192
+ * world.sync();
193
+ * world.delete(parent);
194
+ * world.sync();
195
+ * // child is also deleted (entity deleted, not just relation cleaned up)
196
+ */
197
+ cascadeDelete?: boolean;
198
+ /**
199
+ * If true, relations with this component will not cause archetype fragmentation.
200
+ *
201
+ * **Problem it solves**: By default, each unique relation pair `(component, target)`
202
+ * creates a **separate archetype**. If 100 entities each have a `ChildOf` relation
203
+ * to a different parent, you get 100 archetypes — this is **archetype fragmentation**.
204
+ * Queries that iterate over all entities with a `ChildOf` relation must check all
205
+ * 100 archetypes, which degrades iteration performance and increases memory overhead.
206
+ *
207
+ * **How it works**: When `dontFragment` is enabled, the relation's target does **not**
208
+ * contribute to the archetype signature. Entities with different targets for the same
209
+ * relation component share a **single archetype**, and the per-entity target data is
210
+ * stored in a separate `DontFragmentStore` (a `Map<EntityId, Map<EntityId, any>>`).
211
+ * A wildcard relation marker (`relation(Comp, "*")`) is placed in the archetype
212
+ * component list so queries can still discover matching archetypes.
213
+ *
214
+ * **Use cases**:
215
+ * - **Hierarchy/ownership**: `ChildOf` relations where thousands of entities each
216
+ * point to different parent entities.
217
+ * - **Dynamic targeting**: Relations where targets change frequently (e.g., AI
218
+ * targeting, inventory slots) — without `dontFragment`, each target change would
219
+ * cause an archetype migration, which is expensive.
220
+ * - **High-cardinality relations**: Any relation where the number of unique targets
221
+ * is large compared to the number of entities.
222
+ *
223
+ * **Performance implications**:
224
+ * - **Without `dontFragment`**: Archetype count grows linearly with unique targets.
225
+ * Each archetype migration (changing a relation target) requires moving the entity's
226
+ * data between component arrays.
227
+ * - **With `dontFragment`**: Archetype count stays constant regardless of target
228
+ * diversity. Changing a relation target is an O(1) update in the `DontFragmentStore`.
229
+ * The trade-off is an extra map lookup when accessing the relation data.
230
+ *
231
+ * **Constraints**:
232
+ * - Only applicable to **relation components** (components used with `relation()`).
233
+ * - Wildcard queries (e.g., `relation(Comp, "*")`) still work correctly — the
234
+ * archetype carries a wildcard marker so queries can discover it.
235
+ * - Works with `exclusive` and `cascadeDelete` simultaneously.
236
+ *
237
+ * @example
238
+ * ```ts
239
+ * // Without dontFragment: 100 entities with different parents = 100 archetypes
240
+ * const ChildOf = component(); // default: fragmentation happens
241
+ *
242
+ * // With dontFragment: 100 entities with different parents = 1 archetype
243
+ * const ChildOf = component({ dontFragment: true });
244
+ *
245
+ * for (let i = 0; i < 100; i++) {
246
+ * const parent = world.new();
247
+ * const child = world.new();
248
+ * world.set(child, Position);
249
+ * world.set(child, relation(ChildOf, parent));
250
+ * }
251
+ * world.sync();
252
+ * // dontFragment: 1 archetype for all 100 entities
253
+ * // without: 100 archetypes, one per unique parent
254
+ * ```
255
+ *
256
+ * Inspired by Flecs' `DontFragment` trait.
257
+ */
258
+ dontFragment?: boolean;
259
+ /**
260
+ * Custom merge behavior for repeated `set()` of the same component type on the
261
+ * same entity within a single sync batch.
262
+ *
263
+ * By default, calling `world.set(entity, comp, value)` multiple times for the same
264
+ * entity and component before `world.sync()` simply overwrites the previous value —
265
+ * the last `set()` wins. When `merge` is provided, the values are combined using
266
+ * your function instead.
267
+ *
268
+ * @remarks
269
+ * **Use cases**:
270
+ * - **Accumulation**: Collecting events, tags, or modifiers that multiple systems
271
+ * contribute to within the same frame.
272
+ * - **Composition**: Merging partial updates into a single component value (e.g.,
273
+ * applying multiple `Vec3` deltas to a position).
274
+ * - **Conflict resolution**: Choosing the max/min/latest value when multiple
275
+ * systems want to set the same component.
276
+ *
277
+ * **Scope**: This only affects `set()` calls on the **same entity** with the **same
278
+ * component type** within **one sync batch** (i.e., between `world.sync()` calls).
279
+ * It does NOT merge values across different entities or across sync boundaries.
280
+ *
281
+ * **Relation support**: If the component is used as a relation (via
282
+ * `relation(componentId, target)`), the merge function also applies per-target.
283
+ * `set(entity, relation(Comp, A), v1)` and `set(entity, relation(Comp, A), v2)`
284
+ * will be merged, but `set(entity, relation(Comp, B), v)` is independent.
285
+ *
286
+ * **Idempotency required**: Your merge function should be idempotent — calling it
287
+ * multiple times with the same inputs must produce the same result. The ECS
288
+ * runtime does not guarantee exactly-once `sync()` execution in all scenarios.
289
+ *
290
+ * **Return value**: The function **must return** the merged value. It should not
291
+ * mutate `prev` or `next` in place unless you intentionally want shared mutable
292
+ * state (which is discouraged).
293
+ *
294
+ * @example
295
+ * ```ts
296
+ * // Collect tags from multiple systems in one frame
297
+ * const Tags = component<string[]>({
298
+ * merge: (prev, next) => [...prev, ...next],
299
+ * });
300
+ * ```
301
+ *
302
+ * @example
303
+ * ```ts
304
+ * // Only keep the highest priority value
305
+ * const Alert = component<{ level: number; msg: string }>({
306
+ * merge: (prev, next) => prev.level >= next.level ? prev : next,
307
+ * });
308
+ * ```
309
+ *
310
+ * @example
311
+ * ```ts
312
+ * // Accumulate numeric deltas (e.g., for movement)
313
+ * const Velocity = component<{ x: number; y: number }>({
314
+ * merge: (prev, next) => ({ x: prev.x + next.x, y: prev.y + next.y }),
315
+ * });
316
+ * ```
317
+ */
318
+ merge?: ComponentMerge<T>;
319
+ }
320
+
321
+ // Array for component names (Component ID range: 1-1023)
322
+ const componentNames: (string | undefined)[] = new Array(COMPONENT_ID_MAX + 1);
323
+
324
+ // BitSets for fast component option checks (Component ID range: 1-1023)
325
+ const exclusiveFlags = new BitSet(COMPONENT_ID_MAX + 1);
326
+ const cascadeDeleteFlags = new BitSet(COMPONENT_ID_MAX + 1);
327
+ const dontFragmentFlags = new BitSet(COMPONENT_ID_MAX + 1);
328
+ const componentMerges: (ComponentMerge<any> | undefined)[] = new Array(COMPONENT_ID_MAX + 1);
329
+
330
+ /**
331
+ * Allocate a new component ID from the global allocator.
332
+ * @param nameOrOptions Optional name for the component (for serialization/debugging) or options object
333
+ * @returns The allocated component ID
334
+ * @example
335
+ * // Just a name
336
+ * const Position = component<Position>("Position");
337
+ *
338
+ * // With options
339
+ * const ChildOf = component({ exclusive: true, cascadeDelete: true });
340
+ *
341
+ * // With name and options
342
+ * const ChildOf = component({ name: "ChildOf", exclusive: true });
343
+ */
344
+ export function component<T = void>(nameOrOptions?: string | ComponentOptions<T>): ComponentId<T> {
345
+ const id = globalComponentIdAllocator.allocate<T>();
346
+
347
+ let name: string | undefined;
348
+ let options: ComponentOptions<T> | undefined;
349
+
350
+ // Parse the parameter
351
+ if (typeof nameOrOptions === "string") {
352
+ name = nameOrOptions;
353
+ } else if (typeof nameOrOptions === "object" && nameOrOptions !== null) {
354
+ options = nameOrOptions;
355
+ name = options.name;
356
+ }
357
+
358
+ // Register name if provided
359
+ if (name) {
360
+ if (ComponentIdForNames.has(name)) {
361
+ throw new Error(`Component name "${name}" is already registered`);
362
+ }
363
+
364
+ componentNames[id] = name;
365
+ ComponentIdForNames.set(name, id);
366
+ }
367
+
368
+ // Register options if provided
369
+ if (options) {
370
+ // Set bitset flags for fast lookup
371
+ if (options.exclusive) exclusiveFlags.set(id);
372
+ if (options.cascadeDelete) cascadeDeleteFlags.set(id);
373
+ if (options.dontFragment) dontFragmentFlags.set(id);
374
+ if (options.merge) componentMerges[id] = options.merge;
375
+ }
376
+
377
+ return id;
378
+ }
379
+
380
+ /**
381
+ * Get a component ID by its registered name
382
+ * @param name The component name
383
+ * @returns The component ID if found, undefined otherwise
384
+ */
385
+ export function getComponentIdByName(name: string): ComponentId<any> | undefined {
386
+ return ComponentIdForNames.get(name);
387
+ }
388
+
389
+ /** Get a component name by its ID
390
+ * @param id The component ID
391
+ * @returns The component name if found, undefined otherwise
392
+ */
393
+ export function getComponentNameById(id: ComponentId<any>): string | undefined {
394
+ return componentNames[id];
395
+ }
396
+
397
+ /**
398
+ * Get component options by its ID
399
+ * @param id The component ID
400
+ * @returns The component options
401
+ */
402
+ export function getComponentOptions<T = any>(id: ComponentId<T>): ComponentOptions<T> {
403
+ if (!isComponentId(id)) {
404
+ throw new Error("Invalid component ID");
405
+ }
406
+ const hasName = componentNames[id] !== undefined;
407
+ const hasExclusive = exclusiveFlags.has(id);
408
+ const hasCascadeDelete = cascadeDeleteFlags.has(id);
409
+ const hasDontFragment = dontFragmentFlags.has(id);
410
+ return {
411
+ name: hasName ? componentNames[id] : undefined,
412
+ exclusive: hasExclusive ? true : undefined,
413
+ cascadeDelete: hasCascadeDelete ? true : undefined,
414
+ dontFragment: hasDontFragment ? true : undefined,
415
+ merge: componentMerges[id] as ComponentMerge<T> | undefined,
416
+ };
417
+ }
418
+
419
+ function getBaseComponentId(componentType: EntityId<any>): ComponentId<any> | undefined {
420
+ if (isComponentId(componentType)) {
421
+ return componentType;
422
+ }
423
+
424
+ const decoded = decodeRelationRaw(componentType);
425
+ if (decoded === null) return undefined;
426
+ return isValidComponentId(decoded.componentId) ? (decoded.componentId as ComponentId<any>) : undefined;
427
+ }
428
+
429
+ /**
430
+ * Get the merge callback for a component type (including relation component types).
431
+ *
432
+ * Looks up the base component's merge function, resolving through relation wrappers.
433
+ * For example, if `ChildOf` has a merge function and you pass `relation(ChildOf, parent)`,
434
+ * the same merge function is returned.
435
+ *
436
+ * @param componentType - A raw component ID or a relation-wrapped component type
437
+ * (e.g., `relation(MyComp, targetEntity)`).
438
+ * @returns The merge callback if one was registered via {@link ComponentOptions.merge},
439
+ * or `undefined` if no merge was configured for the base component.
440
+ */
441
+ export function getComponentMerge<T = any>(componentType: EntityId<any>): ComponentMerge<T> | undefined {
442
+ const baseComponentId = getBaseComponentId(componentType);
443
+ if (baseComponentId === undefined) return undefined;
444
+ return componentMerges[baseComponentId] as ComponentMerge<T> | undefined;
445
+ }
446
+
447
+ /**
448
+ * Check if a component was created with `exclusive: true`.
449
+ *
450
+ * This is a fast O(1) bitset lookup that determines whether the component enforces
451
+ * the one-to-one relation constraint — an entity can have at most one relation of
452
+ * this component type, and setting a new relation target automatically removes the
453
+ * previous one.
454
+ *
455
+ * **Note**: This only checks the component's intrinsic property, not whether a
456
+ * specific entity/relation ID is actually an exclusive relation. For checking
457
+ * runtime relation IDs (including wildcards), use {@link isExclusiveRelation}
458
+ * or {@link isExclusiveWildcard}.
459
+ *
460
+ * @param id - The component ID to check. Must be a plain component ID (1–1023),
461
+ * not a relation-wrapped ID.
462
+ * @returns `true` if the component was created with `exclusive: true`.
463
+ *
464
+ * @see {@link ComponentOptions.exclusive} for the full explanation of exclusive
465
+ * relation behavior.
466
+ * @see {@link isExclusiveRelation} for checking specific-target exclusive relations.
467
+ * @see {@link isExclusiveWildcard} for checking wildcard exclusive relations.
468
+ */
469
+ export function isExclusiveComponent(id: ComponentId<any>): boolean {
470
+ return exclusiveFlags.has(id);
471
+ }
472
+
473
+ /**
474
+ * Check if a component is marked as cascade delete.
475
+ *
476
+ * When enabled, deleting the target entity of an entity-relation with this
477
+ * component will cause the **entire referencing entity** to be deleted (not
478
+ * just cleanup of the relation component, which happens by default for all
479
+ * relations).
480
+ *
481
+ * @param id The component ID
482
+ * @returns true if the component is cascade delete, false otherwise
483
+ * @see {@link ComponentOptions.cascadeDelete}
484
+ */
485
+ export function isCascadeDeleteComponent(id: ComponentId<any>): boolean {
486
+ return cascadeDeleteFlags.has(id);
487
+ }
488
+
489
+ /**
490
+ * Check if a component is marked as `dontFragment`.
491
+ *
492
+ * When a component has `dontFragment: true`, relations using it do not cause
493
+ * archetype fragmentation — entities with different relation targets can share
494
+ * the same archetype. This is a fast O(1) bitset lookup.
495
+ *
496
+ * @param id - The component ID to check.
497
+ * @returns `true` if the component was created with `dontFragment: true`.
498
+ *
499
+ * @see {@link ComponentOptions.dontFragment} for the full explanation of how
500
+ * `dontFragment` prevents archetype fragmentation.
501
+ */
502
+ export function isDontFragmentComponent(id: ComponentId<any>): boolean {
503
+ return dontFragmentFlags.has(id);
504
+ }
505
+
506
+ /**
507
+ * Generic optimized function to check whether a relation ID's base component
508
+ * has a specific flag in a bitset.
509
+ *
510
+ * Avoids the overhead of `getDetailedIdType` by directly decoding the relation
511
+ * ID and checking: (1) the ID is a valid relation, (2) the component ID is in the
512
+ * valid range, (3) the target satisfies the condition, and (4) the flag bit is set.
513
+ *
514
+ * Used as the fast-path implementation for `isDontFragmentRelation`,
515
+ * `isDontFragmentWildcard`, `isExclusiveRelation`, `isExclusiveWildcard`,
516
+ * and `isCascadeDeleteRelation`.
517
+ *
518
+ * @param id - The entity/relation ID to check.
519
+ * @param flagBitSet - The bitset tracking which component IDs have the flag.
520
+ * @param targetCondition - Predicate on the target ID (e.g., check for wildcard
521
+ * vs. specific entity target).
522
+ * @returns `true` if the relation's base component has the flag and the target
523
+ * condition is met.
524
+ */
525
+ function checkRelationFlag(
526
+ id: EntityId<any>,
527
+ flagBitSet: BitSet,
528
+ targetCondition: (targetId: number) => boolean,
529
+ ): boolean {
530
+ const decoded = decodeRelationRaw(id);
531
+ if (decoded === null) return false;
532
+ const { componentId, targetId } = decoded;
533
+ return isValidComponentId(componentId) && targetCondition(targetId) && flagBitSet.has(componentId);
534
+ }
535
+
536
+ /**
537
+ * Check if an ID is a specific (non-wildcard) relation backed by a `dontFragment`
538
+ * component.
539
+ *
540
+ * This is used in hot paths (archetype resolution, command processing) to determine
541
+ * whether a relation should be excluded from the archetype signature. Relations with
542
+ * `dontFragment` components are stored in the shared {@link DontFragmentStore} instead
543
+ * of being part of the archetype's component type list.
544
+ *
545
+ * This is an optimized function that avoids the overhead of `getDetailedIdType`
546
+ * by directly decoding and checking the relation's component ID against the
547
+ * `dontFragment` bitset.
548
+ *
549
+ * @param id - The entity/relation ID to check (must be a relation ID, not a plain
550
+ * component ID).
551
+ * @returns `true` if this is a specific-target relation (not wildcard) whose base
552
+ * component was created with `dontFragment: true`.
553
+ *
554
+ * @see {@link isDontFragmentWildcard} for the wildcard variant.
555
+ * @see {@link ComponentOptions.dontFragment} for the full explanation.
556
+ */
557
+ export function isDontFragmentRelation(id: EntityId<any>): boolean {
558
+ return checkRelationFlag(id, dontFragmentFlags, (targetId) => targetId !== WILDCARD_TARGET_ID);
559
+ }
560
+
561
+ /**
562
+ * Check if an ID is a wildcard relation (`relation(Comp, "*")`) backed by a
563
+ * `dontFragment` component.
564
+ *
565
+ * Wildcard markers for `dontFragment` components are placed in the archetype
566
+ * component list so that queries can discover archetypes containing entities
567
+ * with that relation type. This function is used in `filterRegularComponentTypes`
568
+ * to **keep** these wildcard markers in the archetype signature while stripping
569
+ * out specific-target `dontFragment` relations.
570
+ *
571
+ * This is an optimized function that avoids the overhead of `getDetailedIdType`
572
+ * by directly decoding and checking the relation's component ID against the
573
+ * `dontFragment` bitset.
574
+ *
575
+ * @param id - The entity/relation ID to check.
576
+ * @returns `true` if this is a wildcard relation (`"*"` target) whose base
577
+ * component was created with `dontFragment: true`.
578
+ *
579
+ * @see {@link isDontFragmentRelation} for the specific-target variant.
580
+ * @see {@link ComponentOptions.dontFragment} for the full explanation.
581
+ */
582
+ export function isDontFragmentWildcard(id: EntityId<any>): boolean {
583
+ return checkRelationFlag(id, dontFragmentFlags, (targetId) => targetId === WILDCARD_TARGET_ID);
584
+ }
585
+
586
+ /**
587
+ * Check if an ID is a specific (non-wildcard) relation backed by an `exclusive`
588
+ * component.
589
+ *
590
+ * This is used in hot paths (command buffer processing, relation management) to
591
+ * determine whether setting this relation should trigger automatic removal of
592
+ * other relations with the same base component on the same entity.
593
+ *
594
+ * This is an optimized function that avoids the overhead of `getDetailedIdType`
595
+ * by directly decoding and checking the relation's component ID against the
596
+ * `exclusive` bitset.
597
+ *
598
+ * @param id - The entity/relation ID to check (must be a relation ID, not a plain
599
+ * component ID).
600
+ * @returns `true` if this is a specific-target relation (not wildcard) whose base
601
+ * component was created with `exclusive: true`.
602
+ *
603
+ * @see {@link isExclusiveWildcard} for the wildcard variant.
604
+ * @see {@link ComponentOptions.exclusive} for the full explanation of exclusive
605
+ * relation behavior.
606
+ */
607
+ export function isExclusiveRelation(id: EntityId<any>): boolean {
608
+ return checkRelationFlag(id, exclusiveFlags, (targetId) => targetId !== WILDCARD_TARGET_ID);
609
+ }
610
+
611
+ /**
612
+ * Check if an ID is a wildcard relation (`relation(Comp, "*")`) backed by an
613
+ * `exclusive` component.
614
+ *
615
+ * Wildcard markers for exclusive components are used to detect that an archetype
616
+ * may contain exclusive relations, so the runtime can apply exclusivity enforcement
617
+ * when processing relation commands.
618
+ *
619
+ * This is an optimized function that avoids the overhead of `getDetailedIdType`
620
+ * by directly decoding and checking the relation's component ID against the
621
+ * `exclusive` bitset.
622
+ *
623
+ * @param id - The entity/relation ID to check.
624
+ * @returns `true` if this is a wildcard relation (`"*"` target) whose base
625
+ * component was created with `exclusive: true`.
626
+ *
627
+ * @see {@link isExclusiveRelation} for the specific-target variant.
628
+ * @see {@link ComponentOptions.exclusive} for the full explanation of exclusive
629
+ * relation behavior.
630
+ */
631
+ export function isExclusiveWildcard(id: EntityId<any>): boolean {
632
+ return checkRelationFlag(id, exclusiveFlags, (targetId) => targetId === WILDCARD_TARGET_ID);
633
+ }
634
+
635
+ /**
636
+ * Check if a relation ID is a cascade delete entity-relation.
637
+ *
638
+ * This is an optimized function that avoids the overhead of getDetailedIdType.
639
+ *
640
+ * Cascade delete only applies to entity-relations (not component-relations or
641
+ * wildcards). When a cascade-delete-marked relation's target entity is deleted,
642
+ * the **entire source entity** (the one holding the relation) is deleted — not
643
+ * just the relation component. Without cascade delete, the relation component
644
+ * is simply removed (which is the default cleanup for all relations when their
645
+ * target is deleted).
646
+ *
647
+ * @param id The entity/relation ID to check
648
+ * @returns true if this is an entity-relation with cascade delete, false otherwise
649
+ * @see {@link ComponentOptions.cascadeDelete}
650
+ */
651
+ export function isCascadeDeleteRelation(id: EntityId<any>): boolean {
652
+ return checkRelationFlag(
653
+ id,
654
+ cascadeDeleteFlags,
655
+ (targetId) => targetId !== WILDCARD_TARGET_ID && targetId >= ENTITY_ID_START,
656
+ );
657
+ }
@@ -0,0 +1,9 @@
1
+ import type { EntityId } from "../entity";
2
+
3
+ /**
4
+ * Normalize component type collections into a stable ascending order.
5
+ * This keeps cache keys and archetype signatures deterministic.
6
+ */
7
+ export function normalizeComponentTypes(componentTypes: Iterable<EntityId<any>>): EntityId<any>[] {
8
+ return [...componentTypes].sort((a, b) => a - b);
9
+ }