@codehz/ecs 0.7.1 → 0.7.3

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 (82) hide show
  1. package/{builder.d.mts → dist/builder.d.mts} +4 -2
  2. package/{world.mjs → dist/world.mjs} +9 -30
  3. package/dist/world.mjs.map +1 -0
  4. package/examples/advanced-scheduling.ts +96 -0
  5. package/examples/collision-detection.ts +229 -0
  6. package/examples/inventory-system-relations.ts +108 -0
  7. package/examples/parent-child-hierarchy.ts +206 -0
  8. package/examples/serialization.ts +337 -0
  9. package/examples/simple.ts +96 -0
  10. package/examples/spatial-grid.ts +276 -0
  11. package/examples/state-machine.ts +273 -0
  12. package/examples/tag-filtering.ts +266 -0
  13. package/package.json +58 -12
  14. package/src/__tests__/commands/buffer-limits.test.ts +72 -0
  15. package/src/__tests__/commands/buffer.test.ts +195 -0
  16. package/src/__tests__/component/singleton.test.ts +148 -0
  17. package/src/__tests__/core/archetype.test.ts +247 -0
  18. package/src/__tests__/core/bitset.test.ts +171 -0
  19. package/src/__tests__/core/changeset.test.ts +254 -0
  20. package/src/__tests__/core/multi-map.test.ts +74 -0
  21. package/src/__tests__/entity/component-registry.test.ts +66 -0
  22. package/src/__tests__/entity/entity.test.ts +520 -0
  23. package/src/__tests__/entity/id-manager.test.ts +157 -0
  24. package/src/__tests__/entity/id-system.test.ts +260 -0
  25. package/src/__tests__/perf/comprehensive.perf.test.ts +300 -0
  26. package/src/__tests__/perf/sync-hotpath.perf.test.ts +79 -0
  27. package/src/__tests__/query/basic.test.ts +341 -0
  28. package/src/__tests__/query/caching.test.ts +112 -0
  29. package/src/__tests__/query/filter.test.ts +111 -0
  30. package/src/__tests__/query/optional.test.ts +231 -0
  31. package/src/__tests__/query/perf.test.ts +99 -0
  32. package/src/__tests__/relations/dont-fragment/basic.test.ts +496 -0
  33. package/src/__tests__/relations/dont-fragment/query-notification.test.ts +125 -0
  34. package/src/__tests__/relations/wildcard.test.ts +179 -0
  35. package/src/__tests__/serialization/bounds.test.ts +237 -0
  36. package/src/__tests__/testing/assertions.test.ts +224 -0
  37. package/src/__tests__/testing/entity-builder.test.ts +84 -0
  38. package/src/__tests__/testing/snapshot.test.ts +150 -0
  39. package/src/__tests__/testing/world-fixture.test.ts +73 -0
  40. package/src/__tests__/world/component-hooks.test.ts +185 -0
  41. package/src/__tests__/world/component-management.test.ts +447 -0
  42. package/src/__tests__/world/entity-management.test.ts +86 -0
  43. package/src/__tests__/world/get-optional.test.ts +96 -0
  44. package/src/__tests__/world/multi-component-hooks.test.ts +502 -0
  45. package/src/__tests__/world/perf.test.ts +93 -0
  46. package/src/__tests__/world/query.test.ts +223 -0
  47. package/src/__tests__/world/serialize.test.ts +83 -0
  48. package/src/__tests__/world/wildcard-relation-hooks.test.ts +332 -0
  49. package/src/archetype/archetype.ts +472 -0
  50. package/src/archetype/helpers.ts +186 -0
  51. package/src/archetype/store.ts +33 -0
  52. package/src/commands/buffer.ts +110 -0
  53. package/src/commands/changeset.ts +104 -0
  54. package/src/component/entity-store.ts +223 -0
  55. package/src/component/registry.ts +657 -0
  56. package/src/component/type-utils.ts +9 -0
  57. package/src/entity/index.ts +63 -0
  58. package/src/entity/manager.ts +115 -0
  59. package/src/entity/relation.ts +319 -0
  60. package/src/entity/types.ts +135 -0
  61. package/src/index.ts +41 -0
  62. package/src/query/filter.ts +75 -0
  63. package/src/query/query.ts +313 -0
  64. package/src/query/registry.ts +101 -0
  65. package/src/storage/serialization.ts +130 -0
  66. package/src/testing/index.ts +634 -0
  67. package/src/types/index.ts +99 -0
  68. package/src/utils/bit-set.ts +133 -0
  69. package/src/utils/multi-map.ts +96 -0
  70. package/src/utils/utils.ts +19 -0
  71. package/src/world/builder.ts +100 -0
  72. package/src/world/commands.ts +378 -0
  73. package/src/world/hooks.ts +358 -0
  74. package/src/world/references.ts +38 -0
  75. package/src/world/serialization.ts +122 -0
  76. package/src/world/world.ts +1201 -0
  77. package/world.mjs.map +0 -1
  78. /package/{index.d.mts → dist/index.d.mts} +0 -0
  79. /package/{index.mjs → dist/index.mjs} +0 -0
  80. /package/{testing.d.mts → dist/testing.d.mts} +0 -0
  81. /package/{testing.mjs → dist/testing.mjs} +0 -0
  82. /package/{testing.mjs.map → dist/testing.mjs.map} +0 -0
@@ -0,0 +1,75 @@
1
+ import type { Archetype } from "../archetype/archetype";
2
+ import type { EntityId } from "../entity";
3
+ import {
4
+ getComponentIdFromRelationId,
5
+ getDetailedIdType,
6
+ isDontFragmentComponent,
7
+ isRelationId,
8
+ relation,
9
+ } from "../entity";
10
+
11
+ /**
12
+ * Filter options for queries
13
+ */
14
+ export interface QueryFilter {
15
+ negativeComponentTypes?: EntityId<any>[];
16
+ }
17
+
18
+ /**
19
+ * Serialize a QueryFilter into a deterministic string suitable for cache keys.
20
+ * Currently only serializes `negativeComponentTypes`.
21
+ */
22
+ export function serializeQueryFilter(filter: QueryFilter = {}): string {
23
+ const negative = (filter.negativeComponentTypes || []).slice().sort((a, b) => a - b);
24
+ if (negative.length === 0) return "";
25
+ return `neg:${negative.join(",")}`;
26
+ }
27
+
28
+ /**
29
+ * Check if an archetype matches the given component types
30
+ */
31
+ export function matchesComponentTypes(archetype: Archetype, componentTypes: EntityId<any>[]): boolean {
32
+ return componentTypes.every((type) => {
33
+ const detailedType = getDetailedIdType(type);
34
+ if (detailedType.type === "wildcard-relation") {
35
+ // For wildcard relations, check if archetype contains the component relation
36
+ return archetype.componentTypes.some((archetypeType) => {
37
+ if (!isRelationId(archetypeType)) return false;
38
+ const componentId = getComponentIdFromRelationId(archetypeType);
39
+ return componentId === detailedType.componentId;
40
+ });
41
+ } else if (
42
+ (detailedType.type === "entity-relation" || detailedType.type === "component-relation") &&
43
+ detailedType.componentId !== undefined &&
44
+ isDontFragmentComponent(detailedType.componentId)
45
+ ) {
46
+ // For specific dontFragment relations, check if archetype has the wildcard marker
47
+ const wildcardMarker = relation(detailedType.componentId, "*");
48
+ return archetype.componentTypeSet.has(wildcardMarker);
49
+ } else {
50
+ // For regular components and non-dontFragment relations, check direct inclusion
51
+ return archetype.componentTypeSet.has(type);
52
+ }
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Check if an archetype matches the filter conditions (only filtering logic)
58
+ */
59
+ export function matchesFilter(archetype: Archetype, filter: QueryFilter): boolean {
60
+ const negativeTypes = filter.negativeComponentTypes || [];
61
+ return negativeTypes.every((type) => {
62
+ const detailedType = getDetailedIdType(type);
63
+ if (detailedType.type === "wildcard-relation") {
64
+ // For wildcard relations in negative filter, exclude archetypes that contain ANY relation with the same component
65
+ return !archetype.componentTypes.some((archetypeType) => {
66
+ if (!isRelationId(archetypeType)) return false;
67
+ const componentId = getComponentIdFromRelationId(archetypeType);
68
+ return componentId === detailedType.componentId;
69
+ });
70
+ } else {
71
+ // For regular components, check direct exclusion
72
+ return !archetype.componentTypeSet.has(type);
73
+ }
74
+ });
75
+ }
@@ -0,0 +1,313 @@
1
+ import type { Archetype } from "../archetype/archetype";
2
+ import { normalizeComponentTypes } from "../component/type-utils";
3
+ import type { EntityId, WildcardRelationId } from "../entity";
4
+ import { getDetailedIdType, isDontFragmentComponent } from "../entity";
5
+ import type { ComponentTuple, ComponentType } from "../types";
6
+ import type { World } from "../world/world";
7
+ import { matchesComponentTypes, matchesFilter, type QueryFilter } from "./filter";
8
+ import type { QueryRegistry } from "./registry";
9
+
10
+ /**
11
+ * Cached query for efficiently iterating entities with specific components.
12
+ *
13
+ * Queries are created via {@link World.createQuery} and should be **reused across frames**
14
+ * for optimal performance. The world automatically keeps the query's internal archetype cache
15
+ * up to date as entities are created and destroyed.
16
+ *
17
+ * @example
18
+ * const movementQuery = world.createQuery([Position, Velocity]);
19
+ *
20
+ * // In the game loop
21
+ * movementQuery.forEach([Position, Velocity], (entity, pos, vel) => {
22
+ * pos.x += vel.x;
23
+ * pos.y += vel.y;
24
+ * });
25
+ */
26
+ export class Query {
27
+ private world: World;
28
+ private componentTypes: EntityId<any>[];
29
+ private filter: QueryFilter;
30
+ private cachedArchetypes: Archetype[] = [];
31
+ private isDisposed = false;
32
+ /** Cache key assigned by World for O(1) releaseQuery lookup */
33
+ _cacheKey: string | undefined;
34
+ /** Cached wildcard component types for faster entity filtering */
35
+ private wildcardTypes: WildcardRelationId<any>[];
36
+ /** Cached specific dontFragment relation types that need entity-level filtering */
37
+ private specificDontFragmentTypes: EntityId<any>[];
38
+
39
+ /**
40
+ * @internal Queries should be created via {@link World.createQuery}, not instantiated directly.
41
+ */
42
+ constructor(world: World, componentTypes: EntityId<any>[], filter: QueryFilter = {}, registry?: QueryRegistry) {
43
+ this.world = world;
44
+ this.componentTypes = normalizeComponentTypes(componentTypes);
45
+ this.filter = filter;
46
+ // Pre-compute wildcard types once
47
+ this.wildcardTypes = this.componentTypes.filter(
48
+ (ct) => getDetailedIdType(ct).type === "wildcard-relation",
49
+ ) as WildcardRelationId<any>[];
50
+ // Pre-compute specific dontFragment relation types that need entity-level filtering
51
+ this.specificDontFragmentTypes = this.componentTypes.filter((ct) => {
52
+ const detailedType = getDetailedIdType(ct);
53
+ return (
54
+ (detailedType.type === "entity-relation" || detailedType.type === "component-relation") &&
55
+ detailedType.componentId !== undefined &&
56
+ isDontFragmentComponent(detailedType.componentId)
57
+ );
58
+ });
59
+ this.updateCache();
60
+ // Register with registry for archetype updates
61
+ if (registry) {
62
+ registry.register(this);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Check if query is disposed and throw error if so
68
+ */
69
+ private ensureNotDisposed(): void {
70
+ if (this.isDisposed) {
71
+ throw new Error("Query has been disposed");
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Returns all entity IDs that match this query.
77
+ *
78
+ * @returns Array of matching entity IDs
79
+ *
80
+ * @example
81
+ * const entities = query.getEntities();
82
+ * for (const entity of entities) {
83
+ * const pos = world.get(entity, Position);
84
+ * }
85
+ */
86
+ getEntities(): EntityId[] {
87
+ this.ensureNotDisposed();
88
+
89
+ // Fast path: no wildcard relations and no specific dontFragment relations
90
+ if (this.wildcardTypes.length === 0 && this.specificDontFragmentTypes.length === 0) {
91
+ const result: EntityId[] = [];
92
+ for (const archetype of this.cachedArchetypes) {
93
+ for (const entity of archetype.getEntities()) {
94
+ result.push(entity);
95
+ }
96
+ }
97
+ return result;
98
+ }
99
+
100
+ // Slow path: need to filter entities that actually have the required relations
101
+ // This is necessary for:
102
+ // 1. Wildcard relations where an archetype can contain entities with/without the relation
103
+ // 2. Specific dontFragment relations where the archetype only has the wildcard marker
104
+ const result: EntityId[] = [];
105
+ for (const archetype of this.cachedArchetypes) {
106
+ for (const entity of archetype.getEntities()) {
107
+ if (this.entityMatchesQuery(archetype, entity)) {
108
+ result.push(entity);
109
+ }
110
+ }
111
+ }
112
+ return result;
113
+ }
114
+
115
+ /**
116
+ * Check if entity matches all query requirements (wildcards and specific dontFragment relations)
117
+ */
118
+ private entityMatchesQuery(archetype: Archetype, entity: EntityId): boolean {
119
+ // Check wildcard relations
120
+ for (const wildcardType of this.wildcardTypes) {
121
+ const relations = archetype.get(entity, wildcardType);
122
+ if (!relations || relations.length === 0) {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ // Check specific dontFragment relations
128
+ for (const specificType of this.specificDontFragmentTypes) {
129
+ const result = archetype.getOptional(entity, specificType);
130
+ if (result === undefined) {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ return true;
136
+ }
137
+
138
+ /**
139
+ * Returns all matching entities along with their component data.
140
+ *
141
+ * @param componentTypes - Array of component types to retrieve
142
+ * @returns Array of objects containing the entity ID and its component tuple
143
+ *
144
+ * @example
145
+ * const results = query.getEntitiesWithComponents([Position, Velocity]);
146
+ * results.forEach(({ entity, components: [pos, vel] }) => {
147
+ * pos.x += vel.x;
148
+ * });
149
+ */
150
+ getEntitiesWithComponents<const T extends readonly ComponentType<any>[]>(
151
+ componentTypes: T,
152
+ ): Array<{
153
+ entity: EntityId;
154
+ components: ComponentTuple<T>;
155
+ }> {
156
+ this.ensureNotDisposed();
157
+
158
+ const result: Array<{
159
+ entity: EntityId;
160
+ components: ComponentTuple<T>;
161
+ }> = [];
162
+
163
+ for (const archetype of this.cachedArchetypes) {
164
+ archetype.appendEntitiesWithComponents(componentTypes, result);
165
+ }
166
+
167
+ return result;
168
+ }
169
+
170
+ /**
171
+ * Iterates over all matching entities and invokes the callback with their component data.
172
+ * This is the preferred way to read and mutate components in a hot loop.
173
+ *
174
+ * @param componentTypes - Array of component types to retrieve
175
+ * @param callback - Function called for each matching entity with its components
176
+ *
177
+ * @example
178
+ * query.forEach([Position, Velocity], (entity, pos, vel) => {
179
+ * pos.x += vel.x;
180
+ * pos.y += vel.y;
181
+ * });
182
+ */
183
+ forEach<const T extends readonly ComponentType<any>[]>(
184
+ componentTypes: T,
185
+ callback: (entity: EntityId, ...components: ComponentTuple<T>) => void,
186
+ ): void {
187
+ this.ensureNotDisposed();
188
+
189
+ for (const archetype of this.cachedArchetypes) {
190
+ archetype.forEachWithComponents(componentTypes, callback);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Generator that yields each matching entity together with its component data.
196
+ *
197
+ * @param componentTypes - Array of component types to retrieve
198
+ * @yields Tuples of `[entityId, ...components]`
199
+ *
200
+ * @example
201
+ * for (const [entity, pos, vel] of query.iterate([Position, Velocity])) {
202
+ * pos.x += vel.x;
203
+ * }
204
+ */
205
+ *iterate<const T extends readonly ComponentType<any>[]>(
206
+ componentTypes: T,
207
+ ): IterableIterator<[EntityId, ...ComponentTuple<T>]> {
208
+ this.ensureNotDisposed();
209
+
210
+ for (const archetype of this.cachedArchetypes) {
211
+ yield* archetype.iterateWithComponents(componentTypes);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Returns an array containing the data of a single component for every matching entity.
217
+ *
218
+ * @param componentType - The component type to retrieve
219
+ * @returns Array of component data (one entry per matching entity)
220
+ *
221
+ * @example
222
+ * const positions = query.getComponentData(Position);
223
+ */
224
+ getComponentData<T>(componentType: EntityId<T>): T[] {
225
+ this.ensureNotDisposed();
226
+
227
+ const result: T[] = [];
228
+ for (const archetype of this.cachedArchetypes) {
229
+ for (const data of archetype.getComponentData(componentType)) {
230
+ result.push(data);
231
+ }
232
+ }
233
+ return result;
234
+ }
235
+
236
+ /**
237
+ * @internal Rebuilds the cached archetype list. Called automatically by the world.
238
+ */
239
+ updateCache(): void {
240
+ if (this.isDisposed) return;
241
+
242
+ this.cachedArchetypes = this.world
243
+ .getMatchingArchetypes(this.componentTypes)
244
+ .filter((archetype: Archetype) => matchesFilter(archetype, this.filter));
245
+ }
246
+
247
+ /**
248
+ * @internal Called by the world when a new archetype is created.
249
+ */
250
+ checkNewArchetype(archetype: Archetype): void {
251
+ if (this.isDisposed) return;
252
+ if (
253
+ matchesComponentTypes(archetype, this.componentTypes) &&
254
+ matchesFilter(archetype, this.filter) &&
255
+ !this.cachedArchetypes.includes(archetype)
256
+ ) {
257
+ this.cachedArchetypes.push(archetype);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * @internal Called by the world when an archetype is destroyed.
263
+ */
264
+ removeArchetype(archetype: Archetype): void {
265
+ if (this.isDisposed) return;
266
+ const index = this.cachedArchetypes.indexOf(archetype);
267
+ if (index !== -1) {
268
+ this.cachedArchetypes.splice(index, 1);
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Request disposal of this query.
274
+ * This will decrement the world's reference count for the query.
275
+ * The query will only be fully disposed when the ref count reaches zero.
276
+ */
277
+ dispose(): void {
278
+ // Ask the world to release this query (decrement refcount and fully dispose when zero)
279
+ this.world.releaseQuery(this);
280
+ }
281
+
282
+ /**
283
+ * @internal Fully disposes the query when the world's refCount reaches zero.
284
+ */
285
+ _disposeInternal(registry?: QueryRegistry): void {
286
+ if (!this.isDisposed) {
287
+ // Unregister from registry (remove from notification list)
288
+ if (registry) {
289
+ registry.unregister(this);
290
+ }
291
+ this.cachedArchetypes = [];
292
+ this.isDisposed = true;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Using-with-disposals support. Calls {@link dispose} automatically.
298
+ *
299
+ * @example
300
+ * using query = world.createQuery([Position]);
301
+ * // query is released automatically when the block exits
302
+ */
303
+ [Symbol.dispose](): void {
304
+ this.dispose();
305
+ }
306
+
307
+ /**
308
+ * Whether the query has been disposed and can no longer be used.
309
+ */
310
+ get disposed(): boolean {
311
+ return this.isDisposed;
312
+ }
313
+ }
@@ -0,0 +1,101 @@
1
+ import type { Archetype } from "../archetype/archetype";
2
+ import type { EntityId } from "../entity";
3
+ import type { QueryFilter } from "../query/filter";
4
+ import { Query } from "../query/query";
5
+ import type { World } from "../world/world";
6
+
7
+ /**
8
+ * Manages the lifecycle and caching of `Query` instances.
9
+ *
10
+ * Responsibilities:
11
+ * - Create / reuse cached queries keyed by component-type + filter signature.
12
+ * - Track reference counts so queries are only disposed when truly unused.
13
+ * - Notify registered queries when new archetypes are created or destroyed.
14
+ *
15
+ * The `_cacheKey` string that was previously attached directly to `Query` is now
16
+ * kept in a private `WeakMap` so the `Query` class doesn't need to expose it.
17
+ */
18
+ export class QueryRegistry {
19
+ /** All live queries that should receive archetype notifications. */
20
+ private readonly queries = new Set<Query>();
21
+ /** Cache of reusable queries keyed by a deterministic signature string. */
22
+ private readonly cache = new Map<string, { query: Query; refCount: number }>();
23
+ /** Maps each query to its cache key without polluting the Query public API. */
24
+ private readonly cacheKeys = new WeakMap<Query, string>();
25
+
26
+ /**
27
+ * Returns (or creates) a cached query for the given component types and filter.
28
+ * Increments the reference count on cache hits.
29
+ *
30
+ * @param world The world that owns this registry.
31
+ * @param sortedTypes Normalized (sorted) component types.
32
+ * @param key Combined cache key (`types|filter`).
33
+ * @param filter The raw query filter (used when creating a new Query).
34
+ */
35
+ getOrCreate(world: World, sortedTypes: EntityId<any>[], key: string, filter: QueryFilter): Query {
36
+ const cached = this.cache.get(key);
37
+ if (cached) {
38
+ cached.refCount++;
39
+ return cached.query;
40
+ }
41
+
42
+ const query = new Query(world, sortedTypes, filter, this);
43
+ this.cacheKeys.set(query, key);
44
+ this.cache.set(key, { query, refCount: 1 });
45
+ return query;
46
+ }
47
+
48
+ /**
49
+ * Decrements the reference count for the given query.
50
+ * When the count reaches zero the query is fully disposed.
51
+ */
52
+ release(query: Query): void {
53
+ const key = this.cacheKeys.get(query);
54
+ if (!key) return;
55
+
56
+ const cached = this.cache.get(key);
57
+ if (!cached || cached.query !== query) return;
58
+
59
+ cached.refCount--;
60
+ if (cached.refCount <= 0) {
61
+ this.cache.delete(key);
62
+ cached.query._disposeInternal(this);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Registers a query so it receives future archetype notifications.
68
+ * Called automatically by the `Query` constructor via `world._registerQuery`.
69
+ */
70
+ register(query: Query): void {
71
+ this.queries.add(query);
72
+ }
73
+
74
+ /**
75
+ * Removes a query from the notification list.
76
+ * Called by `Query._disposeInternal` via `world._unregisterQuery`.
77
+ */
78
+ unregister(query: Query): void {
79
+ this.queries.delete(query);
80
+ }
81
+
82
+ /**
83
+ * Notifies all live queries that a new archetype has been created.
84
+ * Queries will add the archetype to their cache if it matches.
85
+ */
86
+ onNewArchetype(archetype: Archetype): void {
87
+ for (const query of this.queries) {
88
+ query.checkNewArchetype(archetype);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Notifies all live queries that an archetype has been destroyed.
94
+ * Queries will remove the archetype from their internal cache.
95
+ */
96
+ onArchetypeRemoved(archetype: Archetype): void {
97
+ for (const query of this.queries) {
98
+ query.removeArchetype(archetype);
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,130 @@
1
+ import type { ComponentId, EntityId } from "../entity";
2
+ import { getComponentIdByName, getComponentNameById, getDetailedIdType, relation } from "../entity";
3
+
4
+ // -----------------------------------------------------------------------------
5
+ // Serialization helpers for IDs
6
+ // -----------------------------------------------------------------------------
7
+
8
+ export type SerializedEntityId = number | string | { component: string; target: number | string | "*" };
9
+
10
+ /**
11
+ * Serialized state of EntityIdManager
12
+ */
13
+ export interface SerializedEntityIdManager {
14
+ nextId: number;
15
+ freelist?: number[];
16
+ }
17
+
18
+ export type SerializedWorld = {
19
+ version: number;
20
+ entityManager: SerializedEntityIdManager;
21
+ entities: SerializedEntity[];
22
+ componentEntities?: SerializedEntity[];
23
+ };
24
+
25
+ export type SerializedEntity = {
26
+ id: SerializedEntityId;
27
+ components: SerializedComponent[];
28
+ };
29
+
30
+ export type SerializedComponent = {
31
+ type: SerializedEntityId;
32
+ value: any;
33
+ };
34
+
35
+ /**
36
+ * Encode an internal EntityId into a SerializedEntityId for snapshots
37
+ */
38
+ export function encodeEntityId(id: EntityId<any>): SerializedEntityId {
39
+ const detailed = getDetailedIdType(id);
40
+ switch (detailed.type) {
41
+ case "component": {
42
+ const name = getComponentNameById(id as ComponentId);
43
+ if (!name) {
44
+ // Warn if component doesn't have a name; keep numeric fallback
45
+ console.warn(`Component ID ${id} has no registered name, serializing as number`);
46
+ }
47
+ return name || (id as number);
48
+ }
49
+ case "entity-relation": {
50
+ const componentName = getComponentNameById(detailed.componentId);
51
+ if (!componentName) {
52
+ console.warn(`Component ID ${detailed.componentId} in relation has no registered name`);
53
+ }
54
+ // Safe: targetId is guaranteed to exist for entity-relation type
55
+ return { component: componentName || (detailed.componentId as number).toString(), target: detailed.targetId };
56
+ }
57
+ case "component-relation": {
58
+ const componentName = getComponentNameById(detailed.componentId);
59
+ // Safe: targetId is guaranteed to exist for component-relation type
60
+ const targetName = getComponentNameById(detailed.targetId as ComponentId);
61
+ if (!componentName) {
62
+ console.warn(`Component ID ${detailed.componentId} in relation has no registered name`);
63
+ }
64
+ if (!targetName) {
65
+ console.warn(`Target component ID ${detailed.targetId} in relation has no registered name`);
66
+ }
67
+ return {
68
+ component: componentName || (detailed.componentId as number).toString(),
69
+ target: targetName || (detailed.targetId as number),
70
+ };
71
+ }
72
+ case "wildcard-relation": {
73
+ const componentName = getComponentNameById(detailed.componentId);
74
+ if (!componentName) {
75
+ console.warn(`Component ID ${detailed.componentId} in relation has no registered name`);
76
+ }
77
+ return { component: componentName || (detailed.componentId as number).toString(), target: "*" };
78
+ }
79
+ default:
80
+ return id as number;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Decode a SerializedEntityId back into an internal EntityId
86
+ */
87
+ export function decodeSerializedId(sid: SerializedEntityId): EntityId<any> {
88
+ if (typeof sid === "number") {
89
+ return sid as EntityId<any>;
90
+ }
91
+ if (typeof sid === "string") {
92
+ const id = getComponentIdByName(sid);
93
+ if (id === undefined) {
94
+ const num = parseInt(sid, 10);
95
+ if (!isNaN(num)) return num as EntityId<any>;
96
+ throw new Error(`Unknown component name in snapshot: ${sid}`);
97
+ }
98
+ return id;
99
+ }
100
+ if (typeof sid === "object" && sid !== null && typeof sid.component === "string") {
101
+ let compId = getComponentIdByName(sid.component);
102
+ if (compId === undefined) {
103
+ const num = parseInt(sid.component, 10);
104
+ if (!isNaN(num)) compId = num as ComponentId;
105
+ }
106
+ if (compId === undefined) {
107
+ throw new Error(`Unknown component name in snapshot: ${sid.component}`);
108
+ }
109
+
110
+ if (sid.target === "*") {
111
+ return relation(compId, "*");
112
+ }
113
+
114
+ let targetId: EntityId<any>;
115
+ if (typeof sid.target === "string") {
116
+ const tid = getComponentIdByName(sid.target);
117
+ if (tid === undefined) {
118
+ const num = parseInt(sid.target, 10);
119
+ if (!isNaN(num)) targetId = num as EntityId<any>;
120
+ else throw new Error(`Unknown target component name in snapshot: ${sid.target}`);
121
+ } else {
122
+ targetId = tid;
123
+ }
124
+ } else {
125
+ targetId = sid.target as EntityId<any>;
126
+ }
127
+ return relation(compId, targetId as any);
128
+ }
129
+ throw new Error(`Invalid ID in snapshot: ${JSON.stringify(sid)}`);
130
+ }