@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,358 @@
1
+ import type { Archetype } from "../archetype/archetype";
2
+ import {
3
+ getComponentIdFromRelationId,
4
+ getTargetIdFromRelationId,
5
+ isWildcardRelationId,
6
+ type EntityId,
7
+ } from "../entity";
8
+ import { isOptionalEntityId, type ComponentType, type LifecycleHookEntry } from "../types";
9
+
10
+ /**
11
+ * Unified hook invocation: prefers entry.callback (callback style) over hook.on_* (object style).
12
+ */
13
+ function invokeHook(
14
+ entry: LifecycleHookEntry,
15
+ event: "init" | "set" | "remove",
16
+ entityId: EntityId,
17
+ components: any[],
18
+ ): void {
19
+ if (entry.callback) {
20
+ entry.callback(event as any, entityId, ...components);
21
+ return;
22
+ }
23
+ const hook = entry.hook;
24
+ if (event === "init") hook.on_init?.(entityId, ...components);
25
+ else if (event === "set") hook.on_set?.(entityId, ...components);
26
+ else hook.on_remove?.(entityId, ...components);
27
+ }
28
+
29
+ /**
30
+ * Check if a component change matches a hook component type.
31
+ * Handles wildcard-relation matching: if hookComponent is a wildcard relation (e.g., relation(A, "*")),
32
+ * it matches any concrete relation with the same component ID (e.g., relation(A, entity1)).
33
+ */
34
+ function componentMatchesHookType(changedComponent: EntityId<any>, hookComponent: EntityId<any>): boolean {
35
+ if (changedComponent === hookComponent) return true;
36
+
37
+ // Check if hookComponent is a wildcard relation and changedComponent is a matching relation
38
+ if (isWildcardRelationId(hookComponent)) {
39
+ const hookComponentId = getComponentIdFromRelationId(hookComponent);
40
+ const changedComponentId = getComponentIdFromRelationId(changedComponent);
41
+ if (hookComponentId !== undefined && changedComponentId !== undefined) {
42
+ return hookComponentId === changedComponentId;
43
+ }
44
+ }
45
+
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * Check if any component in the changes map matches a hook component type.
51
+ */
52
+ function anyComponentMatches(changes: Map<EntityId<any>, any>, hookComponent: EntityId<any>): boolean {
53
+ for (const changedComponent of changes.keys()) {
54
+ if (componentMatchesHookType(changedComponent, hookComponent)) {
55
+ return true;
56
+ }
57
+ }
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * Find a matching component in the changes map that matches the hook component type.
63
+ * Returns [componentId, value] if found, undefined otherwise.
64
+ */
65
+ function findMatchingComponent(
66
+ changes: Map<EntityId<any>, any>,
67
+ hookComponent: EntityId<any>,
68
+ ): [EntityId<any>, any] | undefined {
69
+ for (const [changedComponent, value] of changes.entries()) {
70
+ if (componentMatchesHookType(changedComponent, hookComponent)) {
71
+ return [changedComponent, value];
72
+ }
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ export interface HooksContext {
78
+ multiHooks: Set<LifecycleHookEntry>;
79
+ has: (entityId: EntityId, componentType: EntityId<any>) => boolean;
80
+ get: <T>(entityId: EntityId, componentType: EntityId<T>) => T;
81
+ getOptional: <T>(entityId: EntityId, componentType: EntityId<T>) => { value: T } | undefined;
82
+ }
83
+
84
+ export function triggerLifecycleHooks(
85
+ ctx: HooksContext,
86
+ entityId: EntityId,
87
+ addedComponents: Map<EntityId<any>, any>,
88
+ removedComponents: Map<EntityId<any>, any>,
89
+ oldArchetype: Archetype,
90
+ newArchetype: Archetype,
91
+ ): void {
92
+ triggerMultiComponentHooks(ctx, entityId, addedComponents, removedComponents, oldArchetype, newArchetype);
93
+ }
94
+
95
+ /**
96
+ * Fast path for triggering lifecycle hooks when an entity is being deleted.
97
+ * This avoids unnecessary archetype lookups and on_set checks since the entity
98
+ * is being completely removed.
99
+ */
100
+ export function triggerRemoveHooksForEntityDeletion(
101
+ entityId: EntityId,
102
+ removedComponents: Map<EntityId<any>, any>,
103
+ oldArchetype: Archetype,
104
+ ): void {
105
+ if (removedComponents.size === 0) return;
106
+
107
+ // Trigger multi-component hooks - only on_remove since entity is being deleted
108
+ for (const entry of oldArchetype.matchingMultiHooks) {
109
+ const { requiredComponents, componentTypes } = entry;
110
+
111
+ // Skip if neither callback-style nor hook-style on_remove is provided
112
+ if (!entry.callback && !entry.hook.on_remove) continue;
113
+
114
+ // Check if any required component was removed
115
+ const anyRequiredRemoved = requiredComponents.some((c) => anyComponentMatches(removedComponents, c));
116
+ if (!anyRequiredRemoved) continue;
117
+
118
+ // For entity deletion, we know:
119
+ // 1. All components are being removed, so entity "had" all required components
120
+ // 2. Entity will no longer match after deletion
121
+ // Just need to verify the entity actually had all required components before
122
+ const hadAllRequired = requiredComponents.every((c) => anyComponentMatches(removedComponents, c));
123
+ if (!hadAllRequired) continue;
124
+
125
+ // Collect component values from removedComponents directly (no entity lookup needed)
126
+ const components = collectComponentsFromRemoved(componentTypes, removedComponents);
127
+ invokeHook(entry, "remove", entityId, components);
128
+ }
129
+ }
130
+
131
+ function triggerMultiComponentHooks(
132
+ ctx: HooksContext,
133
+ entityId: EntityId,
134
+ addedComponents: Map<EntityId<any>, any>,
135
+ removedComponents: Map<EntityId<any>, any>,
136
+ oldArchetype: Archetype,
137
+ newArchetype: Archetype,
138
+ ): void {
139
+ // Handle on_set:
140
+ // 1. Required/optional components changed while entity still matches
141
+ // 2. Entity entered the matching set (e.g. removed a negative filter component)
142
+ for (const entry of newArchetype.matchingMultiHooks) {
143
+ const { requiredComponents, optionalComponents, componentTypes } = entry;
144
+
145
+ // Skip if neither callback-style nor hook-style on_set is provided
146
+ if (!entry.callback && !entry.hook.on_set) continue;
147
+
148
+ const anyRequiredAdded = requiredComponents.some((c) => anyComponentMatches(addedComponents, c));
149
+ const anyOptionalAdded = optionalComponents.some((c) => anyComponentMatches(addedComponents, c));
150
+ const anyOptionalRemoved = optionalComponents.some((c) => anyComponentMatches(removedComponents, c));
151
+ const enteredMatchingSet = !oldArchetype.matchingMultiHooks.has(entry);
152
+ const hasRelevantComponentChange = anyRequiredAdded || anyOptionalAdded || anyOptionalRemoved;
153
+ const shouldTriggerSet =
154
+ enteredMatchingSet || (hasRelevantComponentChange && entityHasAllComponents(ctx, entityId, requiredComponents));
155
+
156
+ if (shouldTriggerSet) {
157
+ const components = collectMultiHookComponents(ctx, entityId, componentTypes);
158
+ invokeHook(entry, "set", entityId, components);
159
+ }
160
+ }
161
+
162
+ // Handle on_remove:
163
+ // 1. Required component removal made the entity stop matching
164
+ // 2. Entity exited the matching set (e.g. added a negative filter component)
165
+ for (const entry of oldArchetype.matchingMultiHooks) {
166
+ const { requiredComponents, componentTypes } = entry;
167
+
168
+ // Skip if neither callback-style nor hook-style on_remove is provided
169
+ if (!entry.callback && !entry.hook.on_remove) continue;
170
+
171
+ const anyRequiredRemoved = requiredComponents.some((c) => anyComponentMatches(removedComponents, c));
172
+ const lostRequiredMatch =
173
+ anyRequiredRemoved &&
174
+ entityHadAllComponentsBefore(ctx, entityId, requiredComponents, removedComponents) &&
175
+ !entityHasAllComponents(ctx, entityId, requiredComponents);
176
+ const exitedMatchingSet = !newArchetype.matchingMultiHooks.has(entry);
177
+ const shouldTriggerRemove = lostRequiredMatch || exitedMatchingSet;
178
+
179
+ if (shouldTriggerRemove) {
180
+ const components = collectMultiHookComponentsWithRemoved(ctx, entityId, componentTypes, removedComponents);
181
+ invokeHook(entry, "remove", entityId, components);
182
+ }
183
+ }
184
+ }
185
+
186
+ function entityHasAllComponents(ctx: HooksContext, entityId: EntityId, requiredComponents: EntityId<any>[]): boolean {
187
+ return requiredComponents.every((c) => {
188
+ // For wildcard relations, check if the entity has the wildcard relation data
189
+ if (isWildcardRelationId(c)) {
190
+ const wildcardResult = ctx.getOptional(entityId, c);
191
+ if (!wildcardResult) return false;
192
+ const wildcardData = wildcardResult.value;
193
+ return Array.isArray(wildcardData) && wildcardData.length > 0;
194
+ }
195
+ return ctx.has(entityId, c);
196
+ });
197
+ }
198
+
199
+ function entityHadAllComponentsBefore(
200
+ ctx: HooksContext,
201
+ entityId: EntityId,
202
+ requiredComponents: EntityId<any>[],
203
+ removedComponents: Map<EntityId<any>, any>,
204
+ ): boolean {
205
+ return requiredComponents.every((c) => {
206
+ // Check if a matching component was removed
207
+ if (anyComponentMatches(removedComponents, c)) return true;
208
+
209
+ // For wildcard relations, check if the entity still has matching relations
210
+ if (isWildcardRelationId(c)) {
211
+ const wildcardResult = ctx.getOptional(entityId, c);
212
+ if (!wildcardResult) return false;
213
+ const wildcardData = wildcardResult.value;
214
+ return Array.isArray(wildcardData) && wildcardData.length > 0;
215
+ }
216
+ return ctx.has(entityId, c);
217
+ });
218
+ }
219
+
220
+ export function collectMultiHookComponents(
221
+ ctx: HooksContext,
222
+ entityId: EntityId,
223
+ componentTypes: readonly ComponentType<any>[],
224
+ ): any[] {
225
+ return componentTypes.map((ct) =>
226
+ isOptionalEntityId(ct) ? ctx.getOptional(entityId, ct.optional) : ctx.get(entityId, ct as EntityId<any>),
227
+ );
228
+ }
229
+
230
+ /**
231
+ * Reconstructs wildcard relation data by merging current data with removed components.
232
+ * Returns an array of [targetId, value] tuples for the wildcard relation.
233
+ *
234
+ * This is used during "on_remove" hook invocation: the removed components have already
235
+ * been taken out of the entity's archetype, but the hook callback expects to see the
236
+ * full data as it existed *before* removal. We reconstruct that snapshot by taking the
237
+ * current wildcard data (post-removal) and adding back the entries that were just removed.
238
+ */
239
+ function reconstructWildcardWithRemoved(
240
+ ctx: HooksContext,
241
+ entityId: EntityId,
242
+ wildcardId: EntityId<any>,
243
+ removedComponents: Map<EntityId<any>, any>,
244
+ ): [EntityId, any][] {
245
+ // ctx.get() for a wildcard relation ID always returns [EntityId, any][] at runtime
246
+ // (see Archetype.getWildcardRelations / ComponentEntityStore.getWildcard).
247
+ // The HooksContext interface erases the WildcardRelationId overload for simplicity,
248
+ // so we assert the expected shape here rather than silently falling back to [].
249
+ const currentData = ctx.get(entityId, wildcardId);
250
+ if (!Array.isArray(currentData)) {
251
+ throw new Error(
252
+ `Expected wildcard relation data to be an array, but got ${typeof currentData} ` +
253
+ `for entity ${entityId} and wildcard ${wildcardId}. ` +
254
+ `This indicates a HooksContext implementation that does not conform to the expected contract.`,
255
+ );
256
+ }
257
+
258
+ // Spread-copy the array so that pushing removed entries below does not mutate
259
+ // the archetype's internal storage. Without the copy, we would leak removed
260
+ // component data back into the live entity data.
261
+ const result = [...currentData];
262
+
263
+ // Re-inject matching relations that were just removed, so the hook callback
264
+ // sees the complete snapshot as it existed before the removal.
265
+ for (const [removedCompId, removedValue] of removedComponents.entries()) {
266
+ if (componentMatchesHookType(removedCompId, wildcardId)) {
267
+ const targetId = getTargetIdFromRelationId(removedCompId);
268
+ if (targetId !== undefined) {
269
+ result.push([targetId, removedValue]);
270
+ }
271
+ }
272
+ }
273
+
274
+ return result;
275
+ }
276
+
277
+ function collectMultiHookComponentsWithRemoved(
278
+ ctx: HooksContext,
279
+ entityId: EntityId,
280
+ componentTypes: readonly ComponentType<any>[],
281
+ removedComponents: Map<EntityId<any>, any>,
282
+ ): any[] {
283
+ return componentTypes.map((ct) => {
284
+ if (isOptionalEntityId(ct)) {
285
+ const optionalId = ct.optional;
286
+
287
+ if (isWildcardRelationId(optionalId)) {
288
+ const result = reconstructWildcardWithRemoved(ctx, entityId, optionalId, removedComponents);
289
+ return result.length > 0 ? { value: result } : undefined;
290
+ }
291
+
292
+ const match = findMatchingComponent(removedComponents, optionalId);
293
+ return match ? { value: match[1] } : ctx.getOptional(entityId, optionalId);
294
+ }
295
+
296
+ const compId = ct as EntityId<any>;
297
+
298
+ if (isWildcardRelationId(compId)) {
299
+ return reconstructWildcardWithRemoved(ctx, entityId, compId, removedComponents);
300
+ }
301
+
302
+ const match = findMatchingComponent(removedComponents, compId);
303
+ return match ? match[1] : ctx.get(entityId, compId);
304
+ });
305
+ }
306
+
307
+ /**
308
+ * Collect component values directly from removedComponents map.
309
+ * Used for entity deletion fast path where the entity no longer exists.
310
+ */
311
+ function collectComponentsFromRemoved(
312
+ componentTypes: readonly ComponentType<any>[],
313
+ removedComponents: Map<EntityId<any>, any>,
314
+ ): any[] {
315
+ return componentTypes.map((ct) => {
316
+ if (isOptionalEntityId(ct)) {
317
+ const optionalId = ct.optional;
318
+
319
+ if (isWildcardRelationId(optionalId)) {
320
+ const result = collectWildcardFromRemoved(optionalId, removedComponents);
321
+ return result.length > 0 ? { value: result } : undefined;
322
+ }
323
+
324
+ const match = findMatchingComponent(removedComponents, optionalId);
325
+ return match ? { value: match[1] } : undefined;
326
+ }
327
+
328
+ const compId = ct as EntityId<any>;
329
+
330
+ if (isWildcardRelationId(compId)) {
331
+ return collectWildcardFromRemoved(compId, removedComponents);
332
+ }
333
+
334
+ const match = findMatchingComponent(removedComponents, compId);
335
+ return match ? match[1] : undefined;
336
+ });
337
+ }
338
+
339
+ /**
340
+ * Collect all matching wildcard relation data from removed components.
341
+ */
342
+ function collectWildcardFromRemoved(
343
+ wildcardId: EntityId<any>,
344
+ removedComponents: Map<EntityId<any>, any>,
345
+ ): [EntityId, any][] {
346
+ const result: [EntityId, any][] = [];
347
+
348
+ for (const [removedCompId, removedValue] of removedComponents.entries()) {
349
+ if (componentMatchesHookType(removedCompId, wildcardId)) {
350
+ const targetId = getTargetIdFromRelationId(removedCompId);
351
+ if (targetId !== undefined) {
352
+ result.push([targetId, removedValue]);
353
+ }
354
+ }
355
+ }
356
+
357
+ return result;
358
+ }
@@ -0,0 +1,38 @@
1
+ import type { EntityId } from "../entity";
2
+ import { MultiMap } from "../utils/multi-map";
3
+
4
+ export type EntityReferencesMap = Map<EntityId, MultiMap<EntityId, EntityId>>;
5
+
6
+ export function trackEntityReference(
7
+ entityReferences: EntityReferencesMap,
8
+ sourceEntityId: EntityId,
9
+ componentType: EntityId,
10
+ targetEntityId: EntityId,
11
+ ): void {
12
+ if (!entityReferences.has(targetEntityId)) {
13
+ entityReferences.set(targetEntityId, new MultiMap());
14
+ }
15
+ entityReferences.get(targetEntityId)!.add(sourceEntityId, componentType);
16
+ }
17
+
18
+ export function untrackEntityReference(
19
+ entityReferences: EntityReferencesMap,
20
+ sourceEntityId: EntityId,
21
+ componentType: EntityId,
22
+ targetEntityId: EntityId,
23
+ ): void {
24
+ const references = entityReferences.get(targetEntityId);
25
+ if (references) {
26
+ references.remove(sourceEntityId, componentType);
27
+ if (references.keyCount === 0) {
28
+ entityReferences.delete(targetEntityId);
29
+ }
30
+ }
31
+ }
32
+
33
+ export function getEntityReferences(
34
+ entityReferences: EntityReferencesMap,
35
+ targetEntityId: EntityId,
36
+ ): Iterable<[EntityId, EntityId]> {
37
+ return entityReferences.get(targetEntityId) ?? new MultiMap();
38
+ }
@@ -0,0 +1,122 @@
1
+ import { MISSING_COMPONENT, type Archetype } from "../archetype/archetype";
2
+ import type { ComponentEntityStore } from "../component/entity-store";
3
+ import { getDetailedIdType, type EntityId, type EntityIdManager } from "../entity";
4
+ import {
5
+ decodeSerializedId,
6
+ encodeEntityId,
7
+ type SerializedComponent,
8
+ type SerializedEntity,
9
+ type SerializedWorld,
10
+ } from "../storage/serialization";
11
+ import { trackEntityReference, type EntityReferencesMap } from "./references";
12
+
13
+ /**
14
+ * Serializes the full world state to a plain JS object suitable for JSON encoding.
15
+ */
16
+ export function serializeWorld(
17
+ archetypes: Archetype[],
18
+ componentEntities: ComponentEntityStore,
19
+ entityIdManager: EntityIdManager,
20
+ ): SerializedWorld {
21
+ const entities: SerializedEntity[] = [];
22
+
23
+ for (const archetype of archetypes) {
24
+ const dumpedEntities = archetype.dump();
25
+ for (const { entity, components } of dumpedEntities) {
26
+ entities.push({
27
+ id: encodeEntityId(entity),
28
+ components: Array.from(components.entries()).map(([rawType, value]) => ({
29
+ type: encodeEntityId(rawType),
30
+ value: value === MISSING_COMPONENT ? undefined : value,
31
+ })),
32
+ });
33
+ }
34
+ }
35
+
36
+ const componentEntitiesArr: SerializedEntity[] = [];
37
+ for (const [entityId, components] of componentEntities.entries()) {
38
+ componentEntitiesArr.push({
39
+ id: encodeEntityId(entityId),
40
+ components: Array.from(components.entries()).map(([rawType, value]) => ({
41
+ type: encodeEntityId(rawType),
42
+ value: value === MISSING_COMPONENT ? undefined : value,
43
+ })),
44
+ });
45
+ }
46
+
47
+ return {
48
+ version: 1,
49
+ entityManager: entityIdManager.serializeState(),
50
+ entities,
51
+ componentEntities: componentEntitiesArr,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Context needed by `deserializeWorld` to populate world-internal state.
57
+ * Defined as an interface to avoid a circular import between world.ts and this module.
58
+ */
59
+ export interface WorldDeserializationContext {
60
+ entityIdManager: EntityIdManager;
61
+ componentEntities: ComponentEntityStore;
62
+ entityReferences: EntityReferencesMap;
63
+ ensureArchetype(componentTypes: EntityId<any>[]): Archetype;
64
+ setEntityToArchetype(entityId: EntityId, archetype: Archetype): void;
65
+ }
66
+
67
+ /**
68
+ * Restores world state from a snapshot into the provided context.
69
+ * Intended to be called from `World`'s constructor.
70
+ */
71
+ export function deserializeWorld(ctx: WorldDeserializationContext, snapshot: SerializedWorld): void {
72
+ if (snapshot.entityManager) {
73
+ ctx.entityIdManager.deserializeState(snapshot.entityManager);
74
+ }
75
+
76
+ if (Array.isArray(snapshot.componentEntities)) {
77
+ for (const entry of snapshot.componentEntities) {
78
+ const entityId = decodeSerializedId(entry.id);
79
+ if (!ctx.componentEntities.exists(entityId)) continue;
80
+
81
+ const componentsArray: SerializedComponent[] = entry.components || [];
82
+ const componentMap = new Map<EntityId<any>, any>();
83
+
84
+ for (const componentEntry of componentsArray) {
85
+ const componentType = decodeSerializedId(componentEntry.type);
86
+ componentMap.set(componentType, componentEntry.value);
87
+ }
88
+
89
+ ctx.componentEntities.initFromSnapshot(entityId, componentMap);
90
+ }
91
+ }
92
+
93
+ if (Array.isArray(snapshot.entities)) {
94
+ for (const entry of snapshot.entities) {
95
+ const entityId = decodeSerializedId(entry.id);
96
+ const componentsArray: SerializedComponent[] = entry.components || [];
97
+
98
+ const componentMap = new Map<EntityId<any>, any>();
99
+ const componentTypes: EntityId<any>[] = [];
100
+
101
+ for (const componentEntry of componentsArray) {
102
+ const componentType = decodeSerializedId(componentEntry.type);
103
+ componentMap.set(componentType, componentEntry.value);
104
+ componentTypes.push(componentType);
105
+ }
106
+
107
+ const archetype = ctx.ensureArchetype(componentTypes);
108
+ archetype.addEntity(entityId, componentMap);
109
+ ctx.setEntityToArchetype(entityId, archetype);
110
+
111
+ for (const compType of componentTypes) {
112
+ const detailedType = getDetailedIdType(compType);
113
+ if (detailedType.type === "entity-relation") {
114
+ // Safe: targetId guaranteed for entity-relation type
115
+ trackEntityReference(ctx.entityReferences, entityId, compType, detailedType.targetId);
116
+ } else if (detailedType.type === "entity") {
117
+ trackEntityReference(ctx.entityReferences, entityId, compType, compType);
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }