@codehz/ecs 0.9.0 → 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.
@@ -216,7 +216,7 @@ function main() {
216
216
  console.log("=========================================================\n");
217
217
 
218
218
  // Create singleton SpatialGrid
219
- world.set(SpatialGrid, { cells: new Map(), cellSize: 64 });
219
+ world.singleton(SpatialGrid).set({ cells: new Map(), cellSize: 64 });
220
220
  console.log("SpatialGrid singleton created (cellSize=64)");
221
221
 
222
222
  // Create 1 player near the center
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codehz/ecs",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -10,29 +10,82 @@ describe("World - Singleton Component", () => {
10
10
  const GlobalConfigId = component<GlobalConfig>();
11
11
  const GameStateId = component<GameState>();
12
12
 
13
- it("should set singleton component using shorthand syntax", () => {
13
+ it("should set singleton component through the explicit handle", () => {
14
14
  const world = new World();
15
15
  const config: GlobalConfig = { debug: true, version: "1.0.0" };
16
+ const singleton = world.singleton(GlobalConfigId);
16
17
 
17
- // Use singleton syntax: set(componentId, data)
18
- world.set(GlobalConfigId, config);
18
+ singleton.set(config);
19
19
  world.sync();
20
20
 
21
- // Verify it was set on the component entity itself
22
21
  expect(world.has(GlobalConfigId)).toBe(true);
23
22
  expect(world.get(GlobalConfigId)).toEqual(config);
24
23
  });
25
24
 
26
- it("should update singleton component using shorthand syntax", () => {
25
+ it("should interpret 2-argument set on a component entity as a void component set", () => {
26
+ const world = new World();
27
+ const singleton = world.singleton(GlobalConfigId);
28
+ const Marker = component<void>();
29
+
30
+ world.set(GlobalConfigId, Marker);
31
+ world.sync();
32
+
33
+ expect(world.has(GlobalConfigId, Marker)).toBe(true);
34
+ expect(singleton.has()).toBe(false);
35
+ });
36
+
37
+ it("should reject the removed singleton data shorthand at runtime", () => {
38
+ const world = new World();
39
+ const config: GlobalConfig = { debug: true, version: "1.0.0" };
40
+
41
+ expect(() => {
42
+ world.set(GlobalConfigId as any, config as any);
43
+ }).toThrow("Invalid component type");
44
+ });
45
+
46
+ it("should manage singleton data through an explicit handle", () => {
47
+ const world = new World();
48
+ const config = world.singleton(GlobalConfigId);
49
+
50
+ expect(config.getOptional()).toBeUndefined();
51
+ expect(config.has()).toBe(false);
52
+
53
+ config.set({ debug: true, version: "1.0.0" });
54
+ world.sync();
55
+
56
+ expect(config.has()).toBe(true);
57
+ expect(config.get()).toEqual({ debug: true, version: "1.0.0" });
58
+
59
+ config.remove();
60
+ world.sync();
61
+
62
+ expect(config.has()).toBe(false);
63
+ expect(config.getOptional()).toBeUndefined();
64
+ });
65
+
66
+ it("should support void singleton components through an explicit handle", () => {
67
+ const world = new World();
68
+ const Tag = component<void>();
69
+ const tag = world.singleton(Tag);
70
+
71
+ tag.set();
72
+ world.sync();
73
+
74
+ expect(tag.has()).toBe(true);
75
+ expect(world.has(Tag)).toBe(true);
76
+ });
77
+
78
+ it("should update singleton component through the explicit handle", () => {
27
79
  const world = new World();
28
80
  const config1: GlobalConfig = { debug: true, version: "1.0.0" };
29
81
  const config2: GlobalConfig = { debug: false, version: "2.0.0" };
82
+ const singleton = world.singleton(GlobalConfigId);
30
83
 
31
- world.set(GlobalConfigId, config1);
84
+ singleton.set(config1);
32
85
  world.sync();
33
86
  expect(world.get(GlobalConfigId)).toEqual(config1);
34
87
 
35
- world.set(GlobalConfigId, config2);
88
+ singleton.set(config2);
36
89
  world.sync();
37
90
  expect(world.get(GlobalConfigId)).toEqual(config2);
38
91
  });
@@ -42,15 +95,12 @@ describe("World - Singleton Component", () => {
42
95
  const world2 = new World();
43
96
  const config: GlobalConfig = { debug: true, version: "1.0.0" };
44
97
 
45
- // Singleton syntax
46
- world1.set(GlobalConfigId, config);
98
+ world1.singleton(GlobalConfigId).set(config);
47
99
  world1.sync();
48
100
 
49
- // Traditional syntax
50
101
  world2.set(GlobalConfigId, GlobalConfigId, config);
51
102
  world2.sync();
52
103
 
53
- // Both should have the same result
54
104
  expect(world1.get(GlobalConfigId)).toEqual(world2.get(GlobalConfigId));
55
105
  });
56
106
 
@@ -59,8 +109,8 @@ describe("World - Singleton Component", () => {
59
109
  const config: GlobalConfig = { debug: true, version: "1.0.0" };
60
110
  const state: GameState = { score: 100, level: 5 };
61
111
 
62
- world.set(GlobalConfigId, config);
63
- world.set(GameStateId, state);
112
+ world.singleton(GlobalConfigId).set(config);
113
+ world.singleton(GameStateId).set(state);
64
114
  world.sync();
65
115
 
66
116
  expect(world.get(GlobalConfigId)).toEqual(config);
@@ -78,19 +128,17 @@ describe("World - Singleton Component", () => {
78
128
  }).toThrow("does not exist");
79
129
  });
80
130
 
81
- it("should check singleton component existence using shorthand syntax", () => {
131
+ it("should check singleton component existence through the explicit handle", () => {
82
132
  const world = new World();
83
133
  const config: GlobalConfig = { debug: true, version: "1.0.0" };
134
+ const singleton = world.singleton(GlobalConfigId);
84
135
 
85
- // Before setting, should return false
86
- expect(world.has(GlobalConfigId)).toBe(false);
136
+ expect(singleton.has()).toBe(false);
87
137
 
88
- // Set singleton component
89
- world.set(GlobalConfigId, config);
138
+ singleton.set(config);
90
139
  world.sync();
91
140
 
92
- // After setting, should return true
93
- expect(world.has(GlobalConfigId)).toBe(true);
141
+ expect(singleton.has()).toBe(true);
94
142
  });
95
143
 
96
144
  it("should be equivalent to has(comp, comp)", () => {
@@ -98,29 +146,26 @@ describe("World - Singleton Component", () => {
98
146
  const world2 = new World();
99
147
  const config: GlobalConfig = { debug: true, version: "1.0.0" };
100
148
 
101
- // Singleton syntax
102
- world1.set(GlobalConfigId, config);
149
+ world1.singleton(GlobalConfigId).set(config);
103
150
  world1.sync();
104
151
 
105
- // Traditional syntax
106
152
  world2.set(GlobalConfigId, GlobalConfigId, config);
107
153
  world2.sync();
108
154
 
109
- // Both should have the same result
110
155
  expect(world1.has(GlobalConfigId)).toBe(world2.has(GlobalConfigId, GlobalConfigId));
111
156
  expect(world1.has(GlobalConfigId)).toBe(true);
112
157
  });
113
158
 
114
- it("should remove singleton component using shorthand syntax", () => {
159
+ it("should remove singleton component through the explicit handle", () => {
115
160
  const world = new World();
116
161
  const config: GlobalConfig = { debug: true, version: "1.0.0" };
162
+ const singleton = world.singleton(GlobalConfigId);
117
163
 
118
- world.set(GlobalConfigId, config);
164
+ singleton.set(config);
119
165
  world.sync();
120
166
  expect(world.has(GlobalConfigId)).toBe(true);
121
167
 
122
- // Remove using singleton syntax
123
- world.remove(GlobalConfigId);
168
+ singleton.remove();
124
169
  world.sync();
125
170
  expect(world.has(GlobalConfigId)).toBe(false);
126
171
  });
@@ -130,19 +175,16 @@ describe("World - Singleton Component", () => {
130
175
  const world2 = new World();
131
176
  const config: GlobalConfig = { debug: true, version: "1.0.0" };
132
177
 
133
- // Set on both worlds
134
- world1.set(GlobalConfigId, config);
178
+ world1.singleton(GlobalConfigId).set(config);
135
179
  world1.sync();
136
180
  world2.set(GlobalConfigId, GlobalConfigId, config);
137
181
  world2.sync();
138
182
 
139
- // Remove using different syntax
140
- world1.remove(GlobalConfigId); // Singleton syntax
141
- world2.remove(GlobalConfigId, GlobalConfigId); // Traditional syntax
183
+ world1.singleton(GlobalConfigId).remove();
184
+ world2.remove(GlobalConfigId, GlobalConfigId);
142
185
  world1.sync();
143
186
  world2.sync();
144
187
 
145
- // Both should have the same result
146
188
  expect(world1.has(GlobalConfigId)).toBe(world2.has(GlobalConfigId, GlobalConfigId));
147
189
  expect(world1.has(GlobalConfigId)).toBe(false);
148
190
  });
@@ -239,8 +239,7 @@ describe("Serialization edge cases", () => {
239
239
  it("should serialize and deserialize singleton components (covers componentEntities paths)", () => {
240
240
  const world = new World();
241
241
  const Config = component<{ debug: boolean }>();
242
- // Singleton shorthand populates internal component entity
243
- world.set(Config, { debug: true });
242
+ world.singleton(Config).set({ debug: true });
244
243
  world.sync();
245
244
 
246
245
  const snapshot = world.serialize();
@@ -347,7 +346,7 @@ describe("Serialization edge cases", () => {
347
346
  it("should ignore componentEntities snapshot entries whose id is not a real component entity (covers continue guard)", () => {
348
347
  const world = new World();
349
348
  const Config = component<{ debug: boolean }>();
350
- world.set(Config, { debug: true });
349
+ world.singleton(Config).set({ debug: true });
351
350
  world.sync();
352
351
 
353
352
  const snap: any = world.serialize();
@@ -111,15 +111,16 @@ describe("World - Component Management", () => {
111
111
  const Inbox = component<string[]>({
112
112
  merge: (prev, next) => [...prev, ...next],
113
113
  });
114
+ const inbox = world.singleton(Inbox);
114
115
 
115
- world.set(Inbox, ["A"]);
116
- world.set(Inbox, ["B"]);
116
+ inbox.set(["A"]);
117
+ inbox.set(["B"]);
117
118
  world.sync();
118
119
  expect(world.get(Inbox)).toEqual(["A", "B"]);
119
120
 
120
- world.remove(Inbox);
121
- world.set(Inbox, ["C"]);
122
- world.set(Inbox, ["D"]);
121
+ inbox.remove();
122
+ inbox.set(["C"]);
123
+ inbox.set(["D"]);
123
124
  world.sync();
124
125
  expect(world.get(Inbox)).toEqual(["C", "D"]);
125
126
  });
package/src/index.ts CHANGED
@@ -32,6 +32,8 @@ export type {
32
32
  } from "./storage/serialization";
33
33
  export { EntityBuilder } from "./world/builder";
34
34
  export type { ComponentDef } from "./world/builder";
35
+ export { SingletonHandle } from "./world/singleton";
36
+ export type { SingletonHandleOps } from "./world/singleton";
35
37
  export { World } from "./world/world";
36
38
 
37
39
  // Query class
@@ -0,0 +1,283 @@
1
+ import { Archetype } from "../archetype/archetype";
2
+ import type { SparseStore } from "../archetype/store";
3
+ import { normalizeComponentTypes } from "../component/type-utils";
4
+ import type { EntityId } from "../entity";
5
+ import {
6
+ getComponentIdFromRelationId,
7
+ getDetailedIdType,
8
+ isSparseRelation,
9
+ isSparseWildcard,
10
+ isWildcardRelationId,
11
+ } from "../entity";
12
+ import { matchesFilter } from "../query/filter";
13
+ import type { QueryRegistry } from "../query/registry";
14
+ import type { LifecycleHookEntry } from "../types";
15
+ import { getOrCompute } from "../utils/utils";
16
+ import { filterRegularComponentTypes } from "./commands";
17
+
18
+ /**
19
+ * Context provided to ArchetypeManager for notifying dependent systems
20
+ * (query caching and lifecycle hooks) without creating tight coupling or cycles.
21
+ * Follows the same callback/context injection pattern used by CommandProcessorContext,
22
+ * HooksContext, and WorldDeserializationContext.
23
+ */
24
+ export interface ArchetypeManagerContext {
25
+ queryRegistry: QueryRegistry;
26
+ hooks: Set<LifecycleHookEntry>;
27
+ /** Called only when debug collectors are active (mirrors original guard in World) */
28
+ recordArchetypeCreated?: () => void;
29
+ recordArchetypeRemoved?: () => void;
30
+ }
31
+
32
+ /**
33
+ * Encapsulates all archetype storage, indexing, creation, removal, and reverse
34
+ * referencing logic that was previously scattered as private methods + maps
35
+ * directly on the World class.
36
+ *
37
+ * Responsibilities:
38
+ * - Archetype memoization by signature
39
+ * - Component-type reverse index (archetypesByComponent)
40
+ * - Entity → current Archetype map
41
+ * - Reverse "who references this entity via component/relation" index
42
+ * - Creation + removal with proper notifications to QueryRegistry + hook matching
43
+ * - Cleanup of empty archetypes after entity cascades
44
+ *
45
+ * This extraction shrinks World while keeping the same behavior and hot-path characteristics.
46
+ */
47
+ export class ArchetypeManager {
48
+ // Public for performance (hot paths access these maps frequently).
49
+ // This intentionally breaks encapsulation a bit for speed, as requested.
50
+ readonly archetypes: Archetype[] = [];
51
+ readonly archetypeBySignature = new Map<string, Archetype>();
52
+ readonly entityToArchetype = new Map<EntityId, Archetype>();
53
+ readonly archetypesByComponent = new Map<EntityId<any>, Set<Archetype>>();
54
+ readonly entityToReferencingArchetypes = new Map<EntityId, Set<Archetype>>();
55
+
56
+ private readonly sparseStore: SparseStore;
57
+ private readonly ctx: ArchetypeManagerContext;
58
+
59
+ constructor(ctx: ArchetypeManagerContext, sparseStore: SparseStore) {
60
+ this.ctx = ctx;
61
+ this.sparseStore = sparseStore;
62
+ }
63
+
64
+ // ------------------------------------------------------------------
65
+ // Public / package-internal surface used by World and its close collaborators
66
+ // (commands.ts applyChangeset, serialization deserialization context, etc.)
67
+ // ------------------------------------------------------------------
68
+
69
+ /** Primary entry point — memoized archetype creation/lookup. */
70
+ ensureArchetype(componentTypes: Iterable<EntityId<any>>): Archetype {
71
+ const regularTypes = filterRegularComponentTypes(componentTypes);
72
+ const sortedTypes = normalizeComponentTypes(regularTypes);
73
+ const hashKey = this.createArchetypeSignature(sortedTypes);
74
+
75
+ return getOrCompute(this.archetypeBySignature, hashKey, () => this.createNewArchetype(sortedTypes));
76
+ }
77
+
78
+ getArchetypeForEntity(entityId: EntityId): Archetype | undefined {
79
+ return this.entityToArchetype.get(entityId);
80
+ }
81
+
82
+ setEntityToArchetype(entityId: EntityId, archetype: Archetype): void {
83
+ this.entityToArchetype.set(entityId, archetype);
84
+ }
85
+
86
+ // Query helpers (moved from World for cohesion)
87
+ getMatchingArchetypes(componentTypes: EntityId<any>[]): Archetype[] {
88
+ if (componentTypes.length === 0) {
89
+ return [...this.archetypes];
90
+ }
91
+
92
+ const regularComponents: EntityId<any>[] = [];
93
+ const wildcardRelations: { componentId: EntityId<any>; relationId: EntityId<any> }[] = [];
94
+
95
+ for (const componentType of componentTypes) {
96
+ if (isWildcardRelationId(componentType)) {
97
+ const componentId = getComponentIdFromRelationId(componentType);
98
+ if (componentId !== undefined) {
99
+ wildcardRelations.push({ componentId, relationId: componentType });
100
+ }
101
+ } else {
102
+ regularComponents.push(componentType);
103
+ }
104
+ }
105
+
106
+ let matchingArchetypes = this.getArchetypesWithComponents(regularComponents);
107
+
108
+ for (const { componentId, relationId } of wildcardRelations) {
109
+ const markerSet = this.archetypesByComponent.get(relationId);
110
+ const archetypesWithMarker = markerSet ? Array.from(markerSet) : [];
111
+ matchingArchetypes =
112
+ matchingArchetypes.length === 0
113
+ ? archetypesWithMarker
114
+ : matchingArchetypes.filter((a) => markerSet?.has(a) || a.hasRelationWithComponentId(componentId));
115
+ }
116
+
117
+ return matchingArchetypes;
118
+ }
119
+
120
+ getArchetypesWithComponents(componentTypes: EntityId<any>[]): Archetype[] {
121
+ if (componentTypes.length === 0) return [...this.archetypes];
122
+ if (componentTypes.length === 1) {
123
+ const set = this.archetypesByComponent.get(componentTypes[0]!);
124
+ return set ? Array.from(set) : [];
125
+ }
126
+
127
+ // Sort by Set size, intersect starting from the smallest
128
+ const sets = componentTypes
129
+ .map((type) => this.archetypesByComponent.get(type))
130
+ .filter((s): s is Set<Archetype> => s !== undefined && s.size > 0)
131
+ .sort((a, b) => a.size - b.size);
132
+
133
+ if (sets.length === 0) return [];
134
+ if (sets.length < componentTypes.length) return []; // One component has no matching archetypes
135
+
136
+ const smallest = sets[0]!;
137
+
138
+ // 2-component fast path
139
+ if (sets.length === 2) {
140
+ const other = sets[1]!;
141
+ return Array.from(smallest).filter((a) => other.has(a));
142
+ }
143
+
144
+ // Multi-component intersection
145
+ let result = new Set(smallest);
146
+ for (let i = 1; i < sets.length; i++) {
147
+ for (const item of result) {
148
+ if (!sets[i]!.has(item)) result.delete(item);
149
+ }
150
+ if (result.size === 0) return [];
151
+ }
152
+ return Array.from(result);
153
+ }
154
+
155
+ // ------------------------------------------------------------------
156
+ // Internal creation / removal (core of the original cluster)
157
+ // ------------------------------------------------------------------
158
+
159
+ private createArchetypeSignature(componentTypes: EntityId<any>[]): string {
160
+ return componentTypes.join(",");
161
+ }
162
+
163
+ /** Deduplicated version of the original pair of methods. */
164
+ private updateReferencingIndex(componentType: EntityId<any>, archetype: Archetype, isAdd: boolean): void {
165
+ const detailedType = getDetailedIdType(componentType);
166
+ let entityId: EntityId | undefined;
167
+
168
+ if (detailedType.type === "entity") {
169
+ entityId = componentType as EntityId;
170
+ } else if (detailedType.type === "entity-relation") {
171
+ entityId = detailedType.targetId;
172
+ }
173
+
174
+ if (entityId !== undefined) {
175
+ let refs = this.entityToReferencingArchetypes.get(entityId);
176
+ if (isAdd) {
177
+ if (!refs) {
178
+ refs = new Set();
179
+ this.entityToReferencingArchetypes.set(entityId, refs);
180
+ }
181
+ refs.add(archetype);
182
+ } else {
183
+ if (refs) {
184
+ refs.delete(archetype);
185
+ if (refs.size === 0) {
186
+ this.entityToReferencingArchetypes.delete(entityId);
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ private createNewArchetype(componentTypes: EntityId<any>[]): Archetype {
194
+ const newArchetype = new Archetype(componentTypes, this.sparseStore);
195
+ this.archetypes.push(newArchetype);
196
+
197
+ this.ctx.recordArchetypeCreated?.();
198
+
199
+ for (const componentType of componentTypes) {
200
+ let archetypes = this.archetypesByComponent.get(componentType);
201
+ if (!archetypes) {
202
+ archetypes = new Set();
203
+ this.archetypesByComponent.set(componentType, archetypes);
204
+ }
205
+ archetypes.add(newArchetype);
206
+
207
+ // Update reverse index (deduped)
208
+ this.updateReferencingIndex(componentType, newArchetype, true);
209
+ }
210
+
211
+ this.ctx.queryRegistry.onNewArchetype(newArchetype);
212
+ this.updateArchetypeHookMatches(newArchetype);
213
+
214
+ return newArchetype;
215
+ }
216
+
217
+ private updateArchetypeHookMatches(archetype: Archetype): void {
218
+ for (const entry of this.ctx.hooks) {
219
+ if (this.archetypeMatchesHook(archetype, entry)) {
220
+ archetype.matchingMultiHooks.add(entry);
221
+ if (entry.matchedArchetypes) {
222
+ entry.matchedArchetypes.add(archetype);
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ public archetypeMatchesHook(archetype: Archetype, entry: LifecycleHookEntry): boolean {
229
+ return (
230
+ entry.requiredComponents.every((c: EntityId<any>) => {
231
+ if (isWildcardRelationId(c)) {
232
+ if (isSparseWildcard(c)) return true;
233
+ const componentId = getComponentIdFromRelationId(c);
234
+ return componentId !== undefined && archetype.hasRelationWithComponentId(componentId);
235
+ }
236
+ return archetype.componentTypeSet.has(c) || isSparseRelation(c);
237
+ }) && matchesFilter(archetype, entry.filter)
238
+ );
239
+ }
240
+
241
+ /** Called during cascade deletion cleanup. */
242
+ cleanupArchetypesReferencingEntity(entityId: EntityId): void {
243
+ const refs = this.entityToReferencingArchetypes.get(entityId);
244
+ if (!refs) return;
245
+
246
+ for (const archetype of refs) {
247
+ if (archetype.getEntities().length === 0) {
248
+ this.removeArchetype(archetype);
249
+ }
250
+ }
251
+ // removeArchetype already cleans up the reverse index entries for the archetypes themselves
252
+ this.entityToReferencingArchetypes.delete(entityId);
253
+ }
254
+
255
+ private removeArchetype(archetype: Archetype): void {
256
+ const index = this.archetypes.indexOf(archetype);
257
+ if (index !== -1) {
258
+ // swap-and-pop: O(1) removal
259
+ const last = this.archetypes[this.archetypes.length - 1]!;
260
+ this.archetypes[index] = last;
261
+ this.archetypes.pop();
262
+ }
263
+
264
+ this.ctx.recordArchetypeRemoved?.();
265
+
266
+ this.archetypeBySignature.delete(this.createArchetypeSignature(archetype.componentTypes));
267
+
268
+ for (const componentType of archetype.componentTypes) {
269
+ const archetypes = this.archetypesByComponent.get(componentType);
270
+ if (archetypes) {
271
+ archetypes.delete(archetype);
272
+ if (archetypes.size === 0) {
273
+ this.archetypesByComponent.delete(componentType);
274
+ }
275
+ }
276
+
277
+ // Clean up reverse index (deduped)
278
+ this.updateReferencingIndex(componentType, archetype, false);
279
+ }
280
+
281
+ this.ctx.queryRegistry.onArchetypeRemoved(archetype);
282
+ }
283
+ }