@codehz/ecs 0.8.2 → 0.10.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.
Files changed (57) hide show
  1. package/README.en.md +26 -3
  2. package/README.md +41 -4
  3. package/dist/builder.d.mts +348 -83
  4. package/dist/index.d.mts +2 -2
  5. package/dist/index.mjs +2 -2
  6. package/dist/testing.d.mts +1 -1
  7. package/dist/testing.mjs +1 -1
  8. package/dist/world.mjs +1922 -1400
  9. package/dist/world.mjs.map +1 -1
  10. package/examples/debug-observability.ts +92 -0
  11. package/examples/inventory-system-relations.ts +1 -1
  12. package/examples/parent-child-hierarchy.ts +18 -38
  13. package/examples/spatial-grid.ts +1 -1
  14. package/package.json +1 -1
  15. package/skills/ecs/SKILL.md +4 -4
  16. package/src/__tests__/component/singleton.test.ts +116 -35
  17. package/src/__tests__/core/archetype.test.ts +155 -13
  18. package/src/__tests__/core/bitset.test.ts +12 -0
  19. package/src/__tests__/entity/entity.test.ts +33 -0
  20. package/src/__tests__/entity/id-system.test.ts +40 -0
  21. package/src/__tests__/perf/comprehensive.perf.test.ts +6 -9
  22. package/src/__tests__/perf/serialization.perf.test.ts +242 -0
  23. package/src/__tests__/perf/{dontfragment-wildcard.perf.test.ts → sparse-wildcard.perf.test.ts} +13 -16
  24. package/src/__tests__/query/caching.test.ts +62 -0
  25. package/src/__tests__/query/filter.test.ts +16 -22
  26. package/src/__tests__/query/perf.test.ts +3 -5
  27. package/src/__tests__/relations/hierarchy.test.ts +208 -0
  28. package/src/__tests__/relations/{dont-fragment → sparse}/basic.test.ts +64 -69
  29. package/src/__tests__/relations/{dont-fragment → sparse}/query-notification.test.ts +17 -9
  30. package/src/__tests__/serialization/bounds.test.ts +133 -1
  31. package/src/__tests__/world/commands.test.ts +337 -0
  32. package/src/__tests__/world/component-management.test.ts +6 -5
  33. package/src/__tests__/world/debug-stats.test.ts +206 -0
  34. package/src/__tests__/world/multi-component-hooks.test.ts +44 -0
  35. package/src/__tests__/world/serialize.test.ts +17 -0
  36. package/src/__tests__/world/wildcard-relation-hooks.test.ts +127 -0
  37. package/src/archetype/archetype.ts +96 -46
  38. package/src/archetype/helpers.ts +7 -29
  39. package/src/archetype/store.ts +35 -20
  40. package/src/commands/buffer.ts +5 -2
  41. package/src/commands/changeset.ts +0 -31
  42. package/src/component/registry.ts +64 -63
  43. package/src/entity/index.ts +6 -3
  44. package/src/index.ts +15 -0
  45. package/src/query/filter.ts +4 -10
  46. package/src/query/query.ts +12 -12
  47. package/src/storage/serialization.ts +29 -2
  48. package/src/types/index.ts +71 -0
  49. package/src/world/archetype-manager.ts +283 -0
  50. package/src/world/command-executor.ts +258 -0
  51. package/src/world/commands.ts +44 -56
  52. package/src/world/debug-stats.ts +147 -0
  53. package/src/world/hooks.ts +8 -0
  54. package/src/world/operations.ts +88 -0
  55. package/src/world/serialization.ts +32 -18
  56. package/src/world/singleton.ts +51 -0
  57. package/src/world/world.ts +429 -457
@@ -1,14 +1,14 @@
1
1
  import type { Archetype } from "../archetype/archetype";
2
- import type { DontFragmentStore } from "../archetype/store";
2
+ import type { SparseStore } from "../archetype/store";
3
3
  import type { Command } from "../commands/buffer";
4
4
  import type { ComponentChangeset } from "../commands/changeset";
5
5
  import { normalizeComponentTypes } from "../component/type-utils";
6
6
  import {
7
7
  getComponentIdFromRelationId,
8
8
  getComponentMerge,
9
- isDontFragmentComponent,
10
- isDontFragmentRelation,
11
- isDontFragmentWildcard,
9
+ isSparseComponent,
10
+ isSparseRelation,
11
+ isSparseWildcard,
12
12
  isWildcardRelationId,
13
13
  relation,
14
14
  type ComponentId,
@@ -16,7 +16,7 @@ import {
16
16
  } from "../entity";
17
17
 
18
18
  export interface CommandProcessorContext {
19
- dontFragmentStore: DontFragmentStore;
19
+ sparseStore: SparseStore;
20
20
  ensureArchetype: (componentTypes: Iterable<EntityId<any>>) => Archetype;
21
21
  }
22
22
 
@@ -59,8 +59,8 @@ function processSetCommand(
59
59
  // Handle exclusive relations by removing existing relations with the same base component
60
60
  handleExclusiveRelation(entityId, currentArchetype, componentId);
61
61
 
62
- // For dontFragment relations, ensure wildcard marker is in archetype signature
63
- if (isDontFragmentComponent(componentId)) {
62
+ // For sparse relations, ensure wildcard marker is in archetype signature
63
+ if (isSparseComponent(componentId)) {
64
64
  const wildcardMarker = relation(componentId, "*");
65
65
  // Add wildcard marker to changeset if not already in archetype
66
66
  if (!currentArchetype.componentTypeSet.has(wildcardMarker)) {
@@ -111,10 +111,10 @@ export function removeMatchingRelations(
111
111
  }
112
112
  }
113
113
 
114
- // Check dontFragment relations stored on entity
115
- const dontFragmentData = archetype.getEntityDontFragmentRelations(entityId);
116
- if (dontFragmentData) {
117
- for (const componentType of dontFragmentData.keys()) {
114
+ // Check sparse relations stored on entity
115
+ const sparseData = archetype.getEntitySparseRelations(entityId);
116
+ if (sparseData) {
117
+ for (const componentType of sparseData.keys()) {
118
118
  if (getComponentIdFromRelationId(componentType) === baseComponentId) {
119
119
  changeset.delete(componentType);
120
120
  }
@@ -130,8 +130,8 @@ function removeWildcardRelations(
130
130
  ): void {
131
131
  removeMatchingRelations(entityId, currentArchetype, baseComponentId, changeset);
132
132
 
133
- // If removing dontFragment relations, also remove the wildcard marker
134
- if (isDontFragmentComponent(baseComponentId)) {
133
+ // If removing sparse relations, also remove the wildcard marker
134
+ if (isSparseComponent(baseComponentId)) {
135
135
  changeset.delete(relation(baseComponentId, "*"));
136
136
  }
137
137
  }
@@ -143,7 +143,7 @@ export function maybeRemoveWildcardMarker(
143
143
  componentId: ComponentId<any> | undefined,
144
144
  changeset: ComponentChangeset,
145
145
  ): void {
146
- if (componentId === undefined || !isDontFragmentComponent(componentId)) {
146
+ if (componentId === undefined || !isSparseComponent(componentId)) {
147
147
  return;
148
148
  }
149
149
 
@@ -160,9 +160,9 @@ export function maybeRemoveWildcardMarker(
160
160
  }
161
161
  }
162
162
 
163
- const dontFragmentData = archetype.getEntityDontFragmentRelations(entityId);
164
- if (dontFragmentData) {
165
- for (const otherComponentType of dontFragmentData.keys()) {
163
+ const sparseData = archetype.getEntitySparseRelations(entityId);
164
+ if (sparseData) {
165
+ for (const otherComponentType of sparseData.keys()) {
166
166
  if (otherComponentType === removedComponentType) continue;
167
167
  if (changeset.removes.has(otherComponentType)) continue;
168
168
 
@@ -173,7 +173,7 @@ export function maybeRemoveWildcardMarker(
173
173
  }
174
174
 
175
175
  // Also check if this changeset itself is adding another relation of the same kind
176
- // (common in exclusive dontFragment flips: remove old target + add new target in one batch)
176
+ // (common in exclusive sparse flips: remove old target + add new target in one batch)
177
177
  for (const addedType of changeset.adds.keys()) {
178
178
  if (addedType === removedComponentType) continue;
179
179
  if (getComponentIdFromRelationId(addedType) === componentId) {
@@ -189,7 +189,7 @@ function hasEntityComponent(archetype: Archetype, entityId: EntityId, componentT
189
189
  return true;
190
190
  }
191
191
 
192
- return archetype.getEntityDontFragmentRelations(entityId)?.has(componentType) ?? false;
192
+ return archetype.getEntitySparseRelations(entityId)?.has(componentType) ?? false;
193
193
  }
194
194
 
195
195
  function pruneMissingRemovals(changeset: ComponentChangeset, archetype: Archetype, entityId: EntityId): void {
@@ -210,13 +210,13 @@ function pruneMissingRemovals(changeset: ComponentChangeset, archetype: Archetyp
210
210
 
211
211
  function hasArchetypeStructuralChange(changeset: ComponentChangeset, currentArchetype: Archetype): boolean {
212
212
  for (const componentType of changeset.removes) {
213
- if (!isDontFragmentRelation(componentType) && currentArchetype.componentTypeSet.has(componentType)) {
213
+ if (!isSparseRelation(componentType) && currentArchetype.componentTypeSet.has(componentType)) {
214
214
  return true;
215
215
  }
216
216
  }
217
217
 
218
218
  for (const componentType of changeset.adds.keys()) {
219
- if (!isDontFragmentRelation(componentType) && !currentArchetype.componentTypeSet.has(componentType)) {
219
+ if (!isSparseRelation(componentType) && !currentArchetype.componentTypeSet.has(componentType)) {
220
220
  return true;
221
221
  }
222
222
  }
@@ -228,13 +228,13 @@ function buildFinalRegularComponentTypes(currentArchetype: Archetype, changeset:
228
228
  const finalRegularTypes = new Set(currentArchetype.componentTypes);
229
229
 
230
230
  for (const componentType of changeset.removes) {
231
- if (!isDontFragmentRelation(componentType)) {
231
+ if (!isSparseRelation(componentType)) {
232
232
  finalRegularTypes.delete(componentType);
233
233
  }
234
234
  }
235
235
 
236
236
  for (const [componentType] of changeset.adds) {
237
- if (!isDontFragmentRelation(componentType)) {
237
+ if (!isSparseRelation(componentType)) {
238
238
  finalRegularTypes.add(componentType);
239
239
  }
240
240
  }
@@ -269,16 +269,16 @@ export function applyChangeset(
269
269
  return newArchetype;
270
270
  }
271
271
 
272
- // No archetype move needed: only component payload updates and/or dontFragment relation updates.
272
+ // No archetype move needed: only component payload updates and/or sparse relation updates.
273
273
  if (removedComponents !== null) {
274
- applyDontFragmentChanges(ctx.dontFragmentStore, entityId, changeset, removedComponents);
274
+ applySparseChanges(ctx.sparseStore, entityId, changeset, removedComponents);
275
275
  } else {
276
- applyDontFragmentChangesNoHooks(ctx.dontFragmentStore, entityId, changeset);
276
+ applySparseChangesNoHooks(ctx.sparseStore, entityId, changeset);
277
277
  }
278
278
 
279
279
  // Direct update for regular components in archetype
280
280
  for (const [componentType, component] of changeset.adds) {
281
- if (isDontFragmentRelation(componentType)) {
281
+ if (isSparseRelation(componentType)) {
282
282
  continue;
283
283
  }
284
284
  currentArchetype.set(entityId, componentType, component);
@@ -287,52 +287,40 @@ export function applyChangeset(
287
287
  return currentArchetype;
288
288
  }
289
289
 
290
- /**
291
- * No-hooks variant of applyDontFragmentChanges that skips tracking removed component data.
292
- *
293
- * Rewritten for the new DontFragmentStore interface (ComponentId-primary storage).
294
- */
295
- function applyDontFragmentChanges(
296
- dontFragmentRelations: DontFragmentStore,
290
+ function applySparseChanges(
291
+ sparseStore: SparseStore,
297
292
  entityId: EntityId,
298
293
  changeset: ComponentChangeset,
299
294
  removedComponents: Map<EntityId<any>, any>,
300
295
  ): void {
301
296
  for (const componentType of changeset.removes) {
302
- if (isDontFragmentRelation(componentType)) {
303
- const removedValue = dontFragmentRelations.getValue(entityId, componentType);
297
+ if (isSparseRelation(componentType)) {
298
+ const removedValue = sparseStore.getValue(entityId, componentType);
304
299
  // Record for hooks if we are actually removing something
305
- if (
306
- removedValue !== undefined ||
307
- dontFragmentRelations.getAllForEntity(entityId).some(([t]) => t === componentType)
308
- ) {
300
+ if (removedValue !== undefined || sparseStore.getAllForEntity(entityId).some(([t]) => t === componentType)) {
309
301
  removedComponents.set(componentType, removedValue);
310
302
  }
311
- dontFragmentRelations.deleteValue(entityId, componentType);
303
+ sparseStore.deleteValue(entityId, componentType);
312
304
  }
313
305
  }
314
306
 
315
307
  for (const [componentType, component] of changeset.adds) {
316
- if (isDontFragmentRelation(componentType)) {
317
- dontFragmentRelations.setValue(entityId, componentType, component);
308
+ if (isSparseRelation(componentType)) {
309
+ sparseStore.setValue(entityId, componentType, component);
318
310
  }
319
311
  }
320
312
  }
321
313
 
322
- function applyDontFragmentChangesNoHooks(
323
- dontFragmentRelations: DontFragmentStore,
324
- entityId: EntityId,
325
- changeset: ComponentChangeset,
326
- ): void {
314
+ function applySparseChangesNoHooks(sparseStore: SparseStore, entityId: EntityId, changeset: ComponentChangeset): void {
327
315
  for (const componentType of changeset.removes) {
328
- if (isDontFragmentRelation(componentType)) {
329
- dontFragmentRelations.deleteValue(entityId, componentType);
316
+ if (isSparseRelation(componentType)) {
317
+ sparseStore.deleteValue(entityId, componentType);
330
318
  }
331
319
  }
332
320
 
333
321
  for (const [componentType, component] of changeset.adds) {
334
- if (isDontFragmentRelation(componentType)) {
335
- dontFragmentRelations.setValue(entityId, componentType, component);
322
+ if (isSparseRelation(componentType)) {
323
+ sparseStore.setValue(entityId, componentType, component);
336
324
  }
337
325
  }
338
326
  }
@@ -341,14 +329,14 @@ export function filterRegularComponentTypes(componentTypes: Iterable<EntityId<an
341
329
  const regularTypes: EntityId<any>[] = [];
342
330
 
343
331
  for (const componentType of componentTypes) {
344
- // Keep wildcard markers for dontFragment components (they mark the archetype)
345
- if (isDontFragmentWildcard(componentType)) {
332
+ // Keep wildcard markers for sparse components (they mark the archetype)
333
+ if (isSparseWildcard(componentType)) {
346
334
  regularTypes.push(componentType);
347
335
  continue;
348
336
  }
349
337
 
350
- // Skip specific dontFragment relations from archetype signature
351
- if (isDontFragmentRelation(componentType)) {
338
+ // Skip specific sparse relations from archetype signature
339
+ if (isSparseRelation(componentType)) {
352
340
  continue;
353
341
  }
354
342
 
@@ -0,0 +1,147 @@
1
+ import type { DebugStatsCollector, SyncDebugStats } from "../types";
2
+ import { debugHookExecutionCounter } from "./hooks";
3
+
4
+ /**
5
+ * Manages debug stats collectors and transient activity counters for World#sync().
6
+ *
7
+ * Extracted from World to shrink the main class while keeping the entire debug/observability
8
+ * path isolated, zero-cost when no collectors are active, and easy to test/maintain.
9
+ *
10
+ * Follows the same context/callback injection style as ArchetypeManager, CommandProcessorContext,
11
+ * and HooksContext to avoid tight coupling.
12
+ *
13
+ * All collectors receive the *exact same* stats object for a given sync (as before).
14
+ * Exceptions in user callbacks are swallowed (as before).
15
+ */
16
+ export class DebugStatsManager {
17
+ private readonly collectors = new Set<(stats: SyncDebugStats) => void>();
18
+
19
+ // Transient activity counters for the current armed sync (reset each time collectors are present)
20
+ private migrations = 0;
21
+ private archetypesCreated = 0;
22
+ private archetypesRemoved = 0;
23
+
24
+ /** Fast check used to arm timing + reset + counting in hot paths. */
25
+ hasActiveCollectors(): boolean {
26
+ return this.collectors.size > 0;
27
+ }
28
+
29
+ /**
30
+ * Registers a collector. Returns a disposable handle (supports `using`).
31
+ * Collection stops when the handle is disposed.
32
+ */
33
+ createCollector(callback: (stats: SyncDebugStats) => void): DebugStatsCollector {
34
+ this.collectors.add(callback);
35
+
36
+ return {
37
+ [Symbol.dispose]: () => {
38
+ this.collectors.delete(callback);
39
+ },
40
+ };
41
+ }
42
+
43
+ // ------------------------------------------------------------------
44
+ // Recording hooks (called from ArchetypeManager ctx and command apply paths)
45
+ // These are cheap no-ops when no collectors are active.
46
+ // ------------------------------------------------------------------
47
+
48
+ recordArchetypeCreated(): void {
49
+ if (this.hasActiveCollectors()) {
50
+ this.archetypesCreated++;
51
+ }
52
+ }
53
+
54
+ recordArchetypeRemoved(): void {
55
+ if (this.hasActiveCollectors()) {
56
+ this.archetypesRemoved++;
57
+ }
58
+ }
59
+
60
+ incrementMigrations(): void {
61
+ if (this.hasActiveCollectors()) {
62
+ this.migrations++;
63
+ }
64
+ }
65
+
66
+ /** Reset all activity counters + the shared hook execution counter. Called at start of an armed sync. */
67
+ resetActivity(): void {
68
+ this.migrations = 0;
69
+ this.archetypesCreated = 0;
70
+ this.archetypesRemoved = 0;
71
+ debugHookExecutionCounter.value = 0;
72
+ }
73
+
74
+ /**
75
+ * Build and deliver a SyncDebugStats payload to every active collector.
76
+ * World supplies the pre-computed snapshot numbers (keeps debug manager decoupled from
77
+ * internal World maps/registries while preserving exact original stats shape and values).
78
+ */
79
+ deliver(
80
+ timings: {
81
+ syncStart: number;
82
+ syncEnd: number;
83
+ commandBufferStart: number;
84
+ commandBufferEnd: number;
85
+ commandIterations: number;
86
+ },
87
+ data: {
88
+ entityCount: number;
89
+ freelistSize: number;
90
+ nextId: number;
91
+ archetypeCount: number;
92
+ emptyArchetypes: number;
93
+ archetypesByComponentSize: number;
94
+ cachedQueryCount: number;
95
+ registeredQueryCount: number;
96
+ hookCount: number;
97
+ entityReferencesSize: number;
98
+ entityToReferencingArchetypesSize: number;
99
+ },
100
+ ): void {
101
+ const stats: SyncDebugStats = {
102
+ timestamps: {
103
+ syncStart: timings.syncStart,
104
+ syncEnd: timings.syncEnd,
105
+ commandBufferStart: timings.commandBufferStart,
106
+ commandBufferEnd: timings.commandBufferEnd,
107
+ },
108
+ commandIterations: timings.commandIterations,
109
+
110
+ entities: {
111
+ total: data.entityCount,
112
+ freelistSize: data.freelistSize,
113
+ nextId: data.nextId,
114
+ },
115
+ archetypes: {
116
+ total: data.archetypeCount,
117
+ empty: data.emptyArchetypes,
118
+ },
119
+ queries: {
120
+ cached: data.cachedQueryCount,
121
+ registered: data.registeredQueryCount,
122
+ },
123
+ hooks: {
124
+ total: data.hookCount,
125
+ },
126
+ indices: {
127
+ entityReferences: data.entityReferencesSize,
128
+ entityToReferencingArchetypes: data.entityToReferencingArchetypesSize,
129
+ archetypesByComponent: data.archetypesByComponentSize,
130
+ },
131
+ activity: {
132
+ migrations: this.migrations,
133
+ hooksExecuted: debugHookExecutionCounter.value,
134
+ archetypesCreated: this.archetypesCreated,
135
+ archetypesRemoved: this.archetypesRemoved,
136
+ },
137
+ };
138
+
139
+ for (const cb of this.collectors) {
140
+ try {
141
+ cb(stats);
142
+ } catch {
143
+ // Intentionally ignore user callback errors (preserves original behavior)
144
+ }
145
+ }
146
+ }
147
+ }
@@ -7,6 +7,12 @@ import {
7
7
  } from "../entity";
8
8
  import { isOptionalEntityId, type ComponentType, type LifecycleHookEntry } from "../types";
9
9
 
10
+ /**
11
+ * Debug-only counter incremented on every invokeHook call when armed.
12
+ * World reads and resets this during armed syncs.
13
+ */
14
+ export const debugHookExecutionCounter = { value: 0 };
15
+
10
16
  /**
11
17
  * Unified hook invocation: prefers entry.callback (callback style) over hook.on_* (object style).
12
18
  */
@@ -16,6 +22,8 @@ function invokeHook(
16
22
  entityId: EntityId,
17
23
  components: any[],
18
24
  ): void {
25
+ debugHookExecutionCounter.value++;
26
+
19
27
  if (entry.callback) {
20
28
  entry.callback(event as any, entityId, ...components);
21
29
  return;
@@ -0,0 +1,88 @@
1
+ import type { ComponentId, EntityId } from "../entity";
2
+ import { getDetailedIdType } from "../entity";
3
+
4
+ /**
5
+ * Validation and overload-resolution helpers extracted from World.
6
+ *
7
+ * These were previously private methods on World. Moving them reduces line count
8
+ * in the core class with almost zero coupling (the only dep is a liveness predicate
9
+ * for assertEntityExists, supplied by the caller).
10
+ *
11
+ * Pure type checks (assert*TypeValid) and the resolve* helpers for set/remove
12
+ * overloads live here.
13
+ */
14
+
15
+ /**
16
+ * Assert that an entity (or component-entity) is alive in the world.
17
+ * The caller supplies the liveness check (World.exists or equivalent) to keep
18
+ * this module free of direct references to stores.
19
+ */
20
+ export function assertEntityExists(
21
+ entityId: EntityId,
22
+ label: "Entity" | "Component entity",
23
+ exists: (id: EntityId) => boolean,
24
+ ): void {
25
+ if (!exists(entityId)) {
26
+ throw new Error(`${label} ${entityId} does not exist`);
27
+ }
28
+ }
29
+
30
+ export function assertComponentTypeValid(componentType: EntityId): void {
31
+ const detailedType = getDetailedIdType(componentType);
32
+ if (detailedType.type === "invalid") {
33
+ throw new Error(`Invalid component type: ${componentType}`);
34
+ }
35
+ }
36
+
37
+ export function assertSetComponentTypeValid(componentType: EntityId): void {
38
+ const detailedType = getDetailedIdType(componentType);
39
+ if (detailedType.type === "invalid") {
40
+ throw new Error(`Invalid component type: ${componentType}`);
41
+ }
42
+ if (detailedType.type === "wildcard-relation") {
43
+ throw new Error(`Cannot directly add wildcard relation components: ${componentType}`);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Resolve the (entity, componentType, value) for a set() call.
49
+ */
50
+ export function resolveSetOperation(
51
+ entityId: EntityId | ComponentId,
52
+ componentTypeOrComponent?: EntityId | any,
53
+ maybeComponent?: any,
54
+ exists: (id: EntityId) => boolean = () => true, // default permissive for tests / internal
55
+ ): { entityId: EntityId; componentType: EntityId; component: any } {
56
+ const targetEntityId = entityId as EntityId;
57
+ const componentType = componentTypeOrComponent as EntityId;
58
+ assertEntityExists(targetEntityId, "Entity", exists);
59
+ assertSetComponentTypeValid(componentType);
60
+
61
+ return { entityId: targetEntityId, componentType, component: maybeComponent };
62
+ }
63
+
64
+ /**
65
+ * Resolve the (entity, componentType) for a remove() call, handling the
66
+ * singleton component overload (remove(componentId)).
67
+ */
68
+ export function resolveRemoveOperation<T>(
69
+ entityId: EntityId | ComponentId,
70
+ componentType?: EntityId<T>,
71
+ exists: (id: EntityId) => boolean = () => true,
72
+ ): { entityId: EntityId; componentType: EntityId } {
73
+ // Handle singleton component overload: remove(componentId)
74
+ if (componentType === undefined) {
75
+ const componentId = entityId as ComponentId<T>;
76
+ assertEntityExists(componentId, "Component entity", exists);
77
+ return { entityId: componentId, componentType: componentId };
78
+ }
79
+
80
+ const targetEntityId = entityId as EntityId;
81
+ assertEntityExists(targetEntityId, "Entity", exists);
82
+ assertComponentTypeValid(componentType);
83
+
84
+ return { entityId: targetEntityId, componentType };
85
+ }
86
+
87
+ // Re-export the type for callers that need it in signatures (ComponentId lives in entity)
88
+ export type { ComponentId } from "../entity";
@@ -3,7 +3,7 @@ import type { ComponentEntityStore } from "../component/entity-store";
3
3
  import { getDetailedIdType, type EntityId, type EntityIdManager } from "../entity";
4
4
  import {
5
5
  decodeSerializedId,
6
- encodeEntityId,
6
+ encodeEntityIdCached,
7
7
  type SerializedComponent,
8
8
  type SerializedEntity,
9
9
  type SerializedWorld,
@@ -18,29 +18,25 @@ export function serializeWorld(
18
18
  componentEntities: ComponentEntityStore,
19
19
  entityIdManager: EntityIdManager,
20
20
  ): SerializedWorld {
21
+ // ID cache turns repeated encode work (especially component type IDs) into O(#unique IDs)
22
+ const idCache = new Map<any, any>();
23
+
21
24
  const entities: SerializedEntity[] = [];
22
25
 
23
26
  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
- }
27
+ // Pre-encode this archetype's component type IDs exactly once (big win when many entities share the archetype)
28
+ const encodedComponentTypes = archetype.componentTypes.map((t) => encodeEntityIdCached(t, idCache));
29
+
30
+ // The append method will use the bulk helper internally when a pre-fetched map is supplied.
31
+ // For now we rely on the per-entity fallback inside the archetype (already much cheaper than old dump path).
32
+ archetype.appendSerializedEntities(entities, (id) => encodeEntityIdCached(id, idCache), encodedComponentTypes);
34
33
  }
35
34
 
36
35
  const componentEntitiesArr: SerializedEntity[] = [];
37
36
  for (const [entityId, components] of componentEntities.entries()) {
38
37
  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
- })),
38
+ id: encodeEntityIdCached(entityId, idCache),
39
+ components: serializeComponentsFromMap(components, idCache),
44
40
  });
45
41
  }
46
42
 
@@ -52,6 +48,21 @@ export function serializeWorld(
52
48
  };
53
49
  }
54
50
 
51
+ /** Small helper to avoid duplicating the "Map → SerializedComponent[] with cache" pattern. */
52
+ function serializeComponentsFromMap(
53
+ components: Map<EntityId<any>, any>,
54
+ idCache: Map<any, any>,
55
+ ): SerializedComponent[] {
56
+ const result: SerializedComponent[] = [];
57
+ for (const [rawType, value] of components) {
58
+ result.push({
59
+ type: encodeEntityIdCached(rawType, idCache),
60
+ value: value === MISSING_COMPONENT ? undefined : value,
61
+ });
62
+ }
63
+ return result;
64
+ }
65
+
55
66
  /**
56
67
  * Context needed by `deserializeWorld` to populate world-internal state.
57
68
  * Defined as an interface to avoid a circular import between world.ts and this module.
@@ -96,14 +107,17 @@ export function deserializeWorld(ctx: WorldDeserializationContext, snapshot: Ser
96
107
  const componentsArray: SerializedComponent[] = entry.components || [];
97
108
 
98
109
  const componentMap = new Map<EntityId<any>, any>();
99
- const componentTypes: EntityId<any>[] = [];
100
110
 
101
111
  for (const componentEntry of componentsArray) {
102
112
  const componentType = decodeSerializedId(componentEntry.type);
103
113
  componentMap.set(componentType, componentEntry.value);
104
- componentTypes.push(componentType);
105
114
  }
106
115
 
116
+ // Build the list of component types from the map we just populated (no redundant push loop)
117
+ const componentTypes = Array.from(componentMap.keys());
118
+
119
+ // ensureArchetype is internally memoized (getOrCompute on signature), so repeated calls
120
+ // for the same component set are cheap after the first archetype is created.
107
121
  const archetype = ctx.ensureArchetype(componentTypes);
108
122
  archetype.addEntity(entityId, componentMap);
109
123
  ctx.setEntityToArchetype(entityId, archetype);
@@ -0,0 +1,51 @@
1
+ import type { ComponentId } from "../entity";
2
+
3
+ export interface SingletonHandleOps<T> {
4
+ has(): boolean;
5
+ get(): T;
6
+ getOptional(): { value: T } | undefined;
7
+ remove(): void;
8
+ set(value: T | undefined): void;
9
+ }
10
+
11
+ /**
12
+ * Explicit handle for a singleton component (component-as-entity).
13
+ *
14
+ * This provides an explicit and concise API for singleton components without
15
+ * overloading `world.set()` semantics.
16
+ *
17
+ * @example
18
+ * const config = world.singleton(Config);
19
+ * config.set({ debug: true });
20
+ * world.sync();
21
+ * console.log(config.get());
22
+ */
23
+ export class SingletonHandle<T = void> {
24
+ readonly componentId: ComponentId<T>;
25
+ private readonly ops: SingletonHandleOps<T>;
26
+
27
+ constructor(componentId: ComponentId<T>, ops: SingletonHandleOps<T>) {
28
+ this.componentId = componentId;
29
+ this.ops = ops;
30
+ }
31
+
32
+ has(): boolean {
33
+ return this.ops.has();
34
+ }
35
+
36
+ get(): T {
37
+ return this.ops.get();
38
+ }
39
+
40
+ getOptional(): { value: T } | undefined {
41
+ return this.ops.getOptional();
42
+ }
43
+
44
+ remove(): void {
45
+ this.ops.remove();
46
+ }
47
+
48
+ set(...args: T extends void ? [] : [value: NoInfer<T>]): void {
49
+ this.ops.set(args[0] as T | undefined);
50
+ }
51
+ }