@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,260 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { ComponentId, EntityId } from "../../entity";
3
+ import {
4
+ COMPONENT_ID_MAX,
5
+ createComponentId,
6
+ createEntityId,
7
+ decodeRelationId,
8
+ ENTITY_ID_START,
9
+ getDetailedIdType,
10
+ getIdType,
11
+ inspectEntityId,
12
+ INVALID_COMPONENT_ID,
13
+ isComponentId,
14
+ isEntityId,
15
+ isRelationId,
16
+ isWildcardRelationId,
17
+ relation,
18
+ } from "../../entity";
19
+
20
+ describe("Entity ID System", () => {
21
+ describe("Component IDs", () => {
22
+ it("should create valid component IDs", () => {
23
+ expect(createComponentId(1)).toBe(createComponentId(1));
24
+ expect(createComponentId(2)).toBe(createComponentId(2));
25
+ expect(createComponentId(COMPONENT_ID_MAX)).toBe(createComponentId(COMPONENT_ID_MAX));
26
+ });
27
+
28
+ it("should reject invalid component IDs", () => {
29
+ expect(() => createComponentId(0)).toThrow();
30
+ expect(() => createComponentId(-1)).toThrow();
31
+ expect(() => createComponentId(COMPONENT_ID_MAX + 1)).toThrow();
32
+ });
33
+
34
+ it("should identify component IDs correctly", () => {
35
+ expect(isComponentId(createComponentId(1))).toBe(true);
36
+ expect(isComponentId(createComponentId(2))).toBe(true);
37
+ expect(isComponentId(createComponentId(COMPONENT_ID_MAX))).toBe(true);
38
+ expect(isComponentId(createEntityId(ENTITY_ID_START))).toBe(false);
39
+ expect(isComponentId(relation(createComponentId(1), createEntityId(ENTITY_ID_START)))).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe("Entity IDs", () => {
44
+ it("should create valid entity IDs", () => {
45
+ expect(createEntityId(ENTITY_ID_START)).toBe(createEntityId(ENTITY_ID_START));
46
+ expect(createEntityId(ENTITY_ID_START + 1)).toBe(createEntityId(ENTITY_ID_START + 1));
47
+ expect(createEntityId(10000)).toBe(createEntityId(10000));
48
+ });
49
+
50
+ it("should reject invalid entity IDs", () => {
51
+ expect(() => createEntityId(ENTITY_ID_START - 1)).toThrow();
52
+ expect(() => createEntityId(0)).toThrow();
53
+ });
54
+
55
+ it("should identify entity IDs correctly", () => {
56
+ expect(isEntityId(createEntityId(ENTITY_ID_START))).toBe(true);
57
+ expect(isEntityId(createEntityId(10000))).toBe(true);
58
+ expect(isEntityId(createComponentId(1))).toBe(false);
59
+ expect(isEntityId(relation(createComponentId(1), createEntityId(ENTITY_ID_START)))).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe("Relation IDs", () => {
64
+ it("should create valid relation IDs with entities", () => {
65
+ const compId = createComponentId(5);
66
+ const entId = createEntityId(ENTITY_ID_START + 10);
67
+ const relationId = relation(compId, entId);
68
+
69
+ expect(relationId).toBeLessThan(0);
70
+ expect(isRelationId(relationId)).toBe(true);
71
+ });
72
+
73
+ it("should create valid relation IDs with components", () => {
74
+ const compId1 = createComponentId(5);
75
+ const compId2 = createComponentId(10);
76
+ const relationId = relation(compId1, compId2);
77
+
78
+ expect(relationId).toBeLessThan(0);
79
+ expect(isRelationId(relationId)).toBe(true);
80
+ });
81
+
82
+ it("should reject invalid relation creation", () => {
83
+ const entId = createEntityId(ENTITY_ID_START);
84
+ expect(() => relation(1024 as ComponentId, entId)).toThrow();
85
+ expect(() => relation(createComponentId(5), -1 as EntityId)).toThrow();
86
+ expect(() => relation(createComponentId(5), relation(createComponentId(1), createEntityId(1025)))).toThrow();
87
+ });
88
+
89
+ it("should decode relation IDs with entities correctly", () => {
90
+ const compId = createComponentId(42);
91
+ const entId = createEntityId(ENTITY_ID_START + 123);
92
+ const relationId = relation(compId, entId);
93
+
94
+ const decoded = decodeRelationId(relationId);
95
+ expect(decoded.componentId).toBe(compId);
96
+ expect(decoded.targetId).toBe(entId);
97
+ expect(decoded.type).toBe("entity");
98
+ });
99
+
100
+ it("should decode relation IDs with components correctly", () => {
101
+ const compId1 = createComponentId(42);
102
+ const compId2 = createComponentId(100);
103
+ const relationId = relation(compId1, compId2);
104
+
105
+ const decoded = decodeRelationId(relationId);
106
+ expect(decoded.componentId).toBe(compId1);
107
+ expect(decoded.targetId).toBe(compId2);
108
+ expect(decoded.type).toBe("component");
109
+ });
110
+
111
+ it("should create valid wildcard relation IDs", () => {
112
+ const compId = createComponentId(5);
113
+ const relationId = relation(compId, "*");
114
+
115
+ expect(relationId).toBeLessThan(0);
116
+ expect(isRelationId(relationId)).toBe(true);
117
+ });
118
+
119
+ it("should identify wildcard relation IDs correctly", () => {
120
+ const compId = createComponentId(5);
121
+ const wildcardRelationId = relation(compId, "*");
122
+ const entityRelationId = relation(compId, createEntityId(ENTITY_ID_START));
123
+ const componentRelationId = relation(compId, createComponentId(10));
124
+ const entityId = createEntityId(ENTITY_ID_START);
125
+ const componentId = createComponentId(1);
126
+
127
+ expect(isWildcardRelationId(wildcardRelationId)).toBe(true);
128
+ expect(isWildcardRelationId(entityRelationId)).toBe(false);
129
+ expect(isWildcardRelationId(componentRelationId)).toBe(false);
130
+ expect(isWildcardRelationId(entityId)).toBe(false);
131
+ expect(isWildcardRelationId(componentId)).toBe(false);
132
+ });
133
+
134
+ it("should decode wildcard relation IDs correctly", () => {
135
+ const compId = createComponentId(42);
136
+ const relationId = relation(compId, "*");
137
+
138
+ const decoded = decodeRelationId(relationId);
139
+ expect(decoded.componentId).toBe(compId);
140
+ expect(decoded.targetId).toBe(0 as EntityId);
141
+ expect(decoded.type).toBe("wildcard");
142
+ });
143
+ });
144
+
145
+ describe("ID Type Detection", () => {
146
+ it("should correctly identify ID types", () => {
147
+ expect(getIdType(createComponentId(1))).toBe("component");
148
+ expect(getIdType(createComponentId(500))).toBe("component");
149
+ expect(getIdType(createEntityId(ENTITY_ID_START))).toBe("entity");
150
+ expect(getIdType(createEntityId(10000))).toBe("entity");
151
+ expect(getIdType(relation(createComponentId(1), createEntityId(ENTITY_ID_START)))).toBe("entity-relation");
152
+ expect(getIdType(relation(createComponentId(1), createComponentId(2)))).toBe("component-relation");
153
+ expect(getIdType(relation(createComponentId(1), "*"))).toBe("wildcard-relation");
154
+
155
+ expect(getIdType(INVALID_COMPONENT_ID as EntityId)).toBe("invalid");
156
+ expect(getIdType(-999999 as EntityId)).toBe("invalid");
157
+ });
158
+
159
+ it("should provide detailed ID type information", () => {
160
+ const compResult = getDetailedIdType(createComponentId(42));
161
+ expect(compResult.type).toBe("component");
162
+ expect(compResult.componentId).toBeUndefined();
163
+ expect(compResult.targetId).toBeUndefined();
164
+
165
+ const entityResult = getDetailedIdType(createEntityId(ENTITY_ID_START + 100));
166
+ expect(entityResult.type).toBe("entity");
167
+ expect(entityResult.componentId).toBeUndefined();
168
+ expect(entityResult.targetId).toBeUndefined();
169
+
170
+ const entityRelationId = relation(createComponentId(5), createEntityId(ENTITY_ID_START + 200));
171
+ const entityRelationResult = getDetailedIdType(entityRelationId);
172
+ expect(entityRelationResult.type).toBe("entity-relation");
173
+ expect(entityRelationResult.componentId).toBe(createComponentId(5));
174
+ expect(entityRelationResult.targetId).toBe(createEntityId(ENTITY_ID_START + 200));
175
+
176
+ const compRelationId = relation(createComponentId(10), createComponentId(20));
177
+ const compRelationResult = getDetailedIdType(compRelationId);
178
+ expect(compRelationResult.type).toBe("component-relation");
179
+ expect(compRelationResult.componentId).toBe(createComponentId(10));
180
+ expect(compRelationResult.targetId).toBe(createComponentId(20));
181
+
182
+ const wildcardRelationId = relation(createComponentId(15), "*");
183
+ const wildcardRelationResult = getDetailedIdType(wildcardRelationId);
184
+ expect(wildcardRelationResult.type).toBe("wildcard-relation");
185
+ expect(wildcardRelationResult.componentId).toBe(createComponentId(15));
186
+ expect(wildcardRelationResult.targetId).toBe(0 as EntityId);
187
+
188
+ const invalidResult = getDetailedIdType(INVALID_COMPONENT_ID as EntityId);
189
+ expect(invalidResult.type).toBe("invalid");
190
+ expect(invalidResult.componentId).toBeUndefined();
191
+ expect(invalidResult.targetId).toBeUndefined();
192
+
193
+ const invalidRelationResult = getDetailedIdType(-999999 as EntityId);
194
+ expect(invalidRelationResult.type).toBe("invalid");
195
+ expect(invalidRelationResult.componentId).toBeUndefined();
196
+ expect(invalidRelationResult.targetId).toBeUndefined();
197
+ });
198
+ });
199
+
200
+ describe("ID Inspection", () => {
201
+ it("should inspect invalid component ID", () => {
202
+ expect(inspectEntityId(INVALID_COMPONENT_ID as EntityId)).toBe("Invalid Component ID (0)");
203
+ });
204
+
205
+ it("should inspect component IDs", () => {
206
+ expect(inspectEntityId(createComponentId(1))).toBe("Component ID (1)");
207
+ expect(inspectEntityId(createComponentId(42))).toBe("Component ID (42)");
208
+ expect(inspectEntityId(createComponentId(COMPONENT_ID_MAX))).toBe(`Component ID (${COMPONENT_ID_MAX})`);
209
+ });
210
+
211
+ it("should inspect entity IDs", () => {
212
+ expect(inspectEntityId(createEntityId(ENTITY_ID_START))).toBe(`Entity ID (${ENTITY_ID_START})`);
213
+ expect(inspectEntityId(createEntityId(10000))).toBe("Entity ID (10000)");
214
+ });
215
+
216
+ it("should inspect relation IDs with entities", () => {
217
+ const compId = createComponentId(5);
218
+ const entId = createEntityId(ENTITY_ID_START + 10);
219
+ const relationId = relation(compId, entId);
220
+
221
+ expect(inspectEntityId(relationId)).toBe("Relation ID: Component ID (5) -> Entity ID (1034)");
222
+ });
223
+
224
+ it("should inspect relation IDs with components", () => {
225
+ const compId1 = createComponentId(10);
226
+ const compId2 = createComponentId(20);
227
+ const relationId = relation(compId1, compId2);
228
+
229
+ expect(inspectEntityId(relationId)).toBe("Relation ID: Component ID (10) -> Component ID (20)");
230
+ });
231
+
232
+ it("should handle invalid relation IDs gracefully", () => {
233
+ const invalidRelationId = -999999 as EntityId;
234
+ expect(inspectEntityId(invalidRelationId)).toBe("Invalid Relation ID (-999999)");
235
+ });
236
+
237
+ it("should inspect wildcard relation IDs", () => {
238
+ const compId = createComponentId(15);
239
+ const relationId = relation(compId, "*");
240
+
241
+ expect(inspectEntityId(relationId)).toBe("Relation ID: Component ID (15) -> Wildcard (*)");
242
+ });
243
+ });
244
+
245
+ describe("Bit Operations Safety", () => {
246
+ it("should handle large entity IDs within safe integer range", () => {
247
+ const largeEntityId = (1 << 42) - 1 + ENTITY_ID_START;
248
+ expect(Number.isSafeInteger(largeEntityId)).toBe(true);
249
+
250
+ const compId = createComponentId(1023);
251
+ const relationId = relation(compId, largeEntityId as EntityId);
252
+ expect(Number.isSafeInteger(relationId)).toBe(true);
253
+
254
+ const decoded = decodeRelationId(relationId);
255
+ expect(decoded.componentId).toBe(compId);
256
+ expect(decoded.targetId).toBe(largeEntityId as EntityId);
257
+ expect(decoded.type).toBe("entity");
258
+ });
259
+ });
260
+ });
@@ -0,0 +1,300 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { component, relation, type EntityId } from "../../entity";
3
+ import { World } from "../../world/world";
4
+
5
+ function benchmark(label: string, warmupRounds: number, measuredRounds: number, fn: (round: number) => void): number {
6
+ const durations: number[] = [];
7
+
8
+ const totalRounds = warmupRounds + measuredRounds;
9
+ for (let round = 0; round < totalRounds; round++) {
10
+ const start = performance.now();
11
+ fn(round);
12
+ const duration = performance.now() - start;
13
+ if (round >= warmupRounds) {
14
+ durations.push(duration);
15
+ }
16
+ }
17
+
18
+ const average = durations.reduce((sum, duration) => sum + duration, 0) / durations.length;
19
+ console.log(
20
+ `${label}: avg ${average.toFixed(2)}ms after ${warmupRounds} warmup rounds (${durations
21
+ .map((d) => d.toFixed(2))
22
+ .join("ms, ")}ms per measured round)`,
23
+ );
24
+ return average;
25
+ }
26
+
27
+ describe("Comprehensive ECS performance benchmarks", () => {
28
+ /**
29
+ * Benchmark 1: Component set (no structural change) - hot path for data updates
30
+ * This is the most common operation: updating a component value without archetype migration
31
+ */
32
+ it("should handle many component value updates efficiently", () => {
33
+ const world = new World();
34
+ const Position = component<{ x: number; y: number }>();
35
+ const Velocity = component<{ vx: number; vy: number }>();
36
+
37
+ const entityCount = 10_000;
38
+ const entities: EntityId[] = [];
39
+ for (let i = 0; i < entityCount; i++) {
40
+ const entity = world.new();
41
+ entities.push(entity);
42
+ world.set(entity, Position, { x: i, y: i });
43
+ world.set(entity, Velocity, { vx: 1, vy: 1 });
44
+ }
45
+ world.sync();
46
+
47
+ // Single component update
48
+ const singleCompAvg = benchmark("10k entities: single component update + sync", 2, 6, (round) => {
49
+ for (let i = 0; i < entities.length; i++) {
50
+ world.set(entities[i]!, Position, { x: round, y: i });
51
+ }
52
+ world.sync();
53
+ });
54
+
55
+ // Two component update
56
+ const twoCompAvg = benchmark("10k entities: two component updates + sync", 2, 6, (round) => {
57
+ for (let i = 0; i < entities.length; i++) {
58
+ world.set(entities[i]!, Position, { x: round, y: i });
59
+ world.set(entities[i]!, Velocity, { vx: round, vy: i });
60
+ }
61
+ world.sync();
62
+ });
63
+
64
+ expect(singleCompAvg).toBeLessThan(300);
65
+ expect(twoCompAvg).toBeLessThan(500);
66
+ });
67
+
68
+ /**
69
+ * Benchmark 2: Structural archetype migrations - entities moving between archetypes
70
+ * These are more expensive than value updates because of array manipulation
71
+ */
72
+ it("should handle archetype migrations efficiently", () => {
73
+ const world = new World();
74
+ const Alive = component<void>();
75
+ const Dead = component<void>();
76
+
77
+ const entityCount = 4000;
78
+ const entities: EntityId[] = [];
79
+ for (let i = 0; i < entityCount; i++) {
80
+ const entity = world.new();
81
+ entities.push(entity);
82
+ world.set(entity, Alive);
83
+ }
84
+ world.sync();
85
+
86
+ // Add/remove components causing archetype migration
87
+ const migrationAvg = benchmark("4k entities: archetype migration (add/remove) + sync", 2, 6, (round) => {
88
+ if (round % 2 === 0) {
89
+ for (let i = 0; i < entities.length; i++) {
90
+ world.set(entities[i]!, Dead);
91
+ }
92
+ } else {
93
+ for (let i = 0; i < entities.length; i++) {
94
+ world.remove(entities[i]!, Dead);
95
+ }
96
+ }
97
+ world.sync();
98
+ });
99
+
100
+ expect(migrationAvg).toBeLessThan(300);
101
+ });
102
+
103
+ /**
104
+ * Benchmark 3: Query iteration - the inner loop of ECS systems
105
+ * This is the absolute hot path - should be very fast
106
+ */
107
+ it("should iterate over queries efficiently", () => {
108
+ const world = new World();
109
+ const Position = component<{ x: number; y: number }>();
110
+ const Velocity = component<{ vx: number; vy: number }>();
111
+
112
+ const entityCount = 10_000;
113
+ for (let i = 0; i < entityCount; i++) {
114
+ const entity = world.new();
115
+ world.set(entity, Position, { x: i, y: i });
116
+ world.set(entity, Velocity, { vx: 1, vy: 1 });
117
+ }
118
+ world.sync();
119
+
120
+ const movementQuery = world.createQuery([Position, Velocity]);
121
+
122
+ // Pure iteration (no writes)
123
+ const readAvg = benchmark("10k entities: forEach read-only query", 2, 6, () => {
124
+ let count = 0;
125
+ movementQuery.forEach([Position, Velocity], (_entity, _pos, _vel) => {
126
+ count++;
127
+ });
128
+ expect(count).toBe(entityCount);
129
+ });
130
+
131
+ // Read and modify in place (no sync needed for non-structural)
132
+ let sumX = 0;
133
+ const updateAvg = benchmark("10k entities: forEach query with accumulation", 2, 6, () => {
134
+ sumX = 0;
135
+ movementQuery.forEach([Position, Velocity], (_entity, pos, vel) => {
136
+ sumX += pos.x + vel.vx;
137
+ });
138
+ });
139
+
140
+ movementQuery.dispose();
141
+
142
+ console.log(`Sum X (to prevent optimization): ${sumX}`);
143
+ expect(readAvg).toBeLessThan(20);
144
+ expect(updateAvg).toBeLessThan(20);
145
+ });
146
+
147
+ /**
148
+ * Benchmark 4: Entity spawn and sync - creating entities
149
+ */
150
+ it("should spawn and sync entities efficiently", () => {
151
+ const world = new World();
152
+ const Position = component<{ x: number; y: number }>();
153
+ const Velocity = component<{ vx: number; vy: number }>();
154
+
155
+ const entityCount = 1000;
156
+
157
+ const spawnAvg = benchmark("1k entity spawn + 2 components + sync", 2, 6, () => {
158
+ const entities: EntityId[] = [];
159
+ for (let i = 0; i < entityCount; i++) {
160
+ const entity = world.new();
161
+ entities.push(entity);
162
+ world.set(entity, Position, { x: i, y: i });
163
+ world.set(entity, Velocity, { vx: 1, vy: 1 });
164
+ }
165
+ world.sync();
166
+ // Cleanup
167
+ for (const entity of entities) {
168
+ world.delete(entity);
169
+ }
170
+ world.sync();
171
+ });
172
+
173
+ expect(spawnAvg).toBeLessThan(150);
174
+ });
175
+
176
+ /**
177
+ * Benchmark 5: Mixed operations - realistic game loop simulation
178
+ * Some entities update, some spawn, some die - typical game scenario
179
+ */
180
+ it("should handle mixed operations in a realistic game loop", () => {
181
+ const world = new World();
182
+ const Position = component<{ x: number; y: number }>();
183
+ const Health = component<number>();
184
+ const Alive = component<void>();
185
+
186
+ const initialCount = 2000;
187
+ const entities: EntityId[] = [];
188
+
189
+ for (let i = 0; i < initialCount; i++) {
190
+ const entity = world.new();
191
+ entities.push(entity);
192
+ world.set(entity, Position, { x: i, y: i });
193
+ world.set(entity, Health, 100);
194
+ world.set(entity, Alive);
195
+ }
196
+ world.sync();
197
+
198
+ const movementQuery = world.createQuery([Position, Health]);
199
+
200
+ const mixedAvg = benchmark("2k entities: mixed ops (update 90%, spawn 5%, delete 5%) + sync", 2, 6, (round) => {
201
+ const deleteCount = Math.floor(entities.length * 0.05);
202
+ const spawnCount = deleteCount;
203
+
204
+ // Update most entities
205
+ movementQuery.forEach([Position, Health], (entity, pos, health) => {
206
+ world.set(entity, Position, { x: pos.x + 1, y: pos.y + 1 });
207
+ world.set(entity, Health, health - 1);
208
+ });
209
+
210
+ // Delete some
211
+ for (let i = 0; i < deleteCount && entities.length > 0; i++) {
212
+ const idx = (round * deleteCount + i) % entities.length;
213
+ world.delete(entities[idx]!);
214
+ entities.splice(idx, 1);
215
+ }
216
+
217
+ // Spawn some
218
+ for (let i = 0; i < spawnCount; i++) {
219
+ const entity = world.new();
220
+ entities.push(entity);
221
+ world.set(entity, Position, { x: i, y: i });
222
+ world.set(entity, Health, 100);
223
+ world.set(entity, Alive);
224
+ }
225
+
226
+ world.sync();
227
+ });
228
+
229
+ movementQuery.dispose();
230
+ expect(mixedAvg).toBeLessThan(300);
231
+ });
232
+
233
+ /**
234
+ * Benchmark 6: CommandBuffer grouping overhead
235
+ * Tests the overhead of the Map grouping in execute()
236
+ * This specifically targets the new Map() allocation per sync call
237
+ */
238
+ it("should execute command buffer efficiently with many commands", () => {
239
+ const world = new World();
240
+ const A = component<number>();
241
+ const B = component<number>();
242
+ const C = component<number>();
243
+
244
+ const entityCount = 5000;
245
+ const entities: EntityId[] = [];
246
+ for (let i = 0; i < entityCount; i++) {
247
+ const entity = world.new();
248
+ entities.push(entity);
249
+ world.set(entity, A, i);
250
+ world.set(entity, B, i * 2);
251
+ }
252
+ world.sync();
253
+
254
+ // Many commands per sync - tests command buffer grouping
255
+ const manyCommandsAvg = benchmark("5k entities: 3 commands each + sync (15k total commands)", 2, 6, (round) => {
256
+ for (let i = 0; i < entities.length; i++) {
257
+ world.set(entities[i]!, A, round + i);
258
+ world.set(entities[i]!, B, round - i);
259
+ world.set(entities[i]!, C, round * i);
260
+ }
261
+ world.sync();
262
+ });
263
+
264
+ expect(manyCommandsAvg).toBeLessThan(600);
265
+ });
266
+
267
+ /**
268
+ * Benchmark 7: dontFragment relation updates (the existing benchmark scenario)
269
+ */
270
+ it("should handle dontFragment exclusive relation flips efficiently", () => {
271
+ const world = new World();
272
+ const Position = component<{ x: number; y: number }>();
273
+ const ChildOf = component({ dontFragment: true, exclusive: true });
274
+
275
+ const parentA = world.new();
276
+ const parentB = world.new();
277
+
278
+ const entityCount = 4000;
279
+ const entities: EntityId[] = [];
280
+ for (let i = 0; i < entityCount; i++) {
281
+ const entity = world.new();
282
+ entities.push(entity);
283
+ world.set(entity, Position, { x: i, y: i });
284
+ world.set(entity, relation(ChildOf, parentA));
285
+ }
286
+ world.sync();
287
+
288
+ const relationFlipAvg = benchmark("4k entities: exclusive dontFragment relation flip + sync", 2, 8, (round) => {
289
+ const target = round % 2 === 0 ? parentB : parentA;
290
+ for (let i = 0; i < entities.length; i++) {
291
+ world.set(entities[i]!, relation(ChildOf, target));
292
+ }
293
+ world.sync();
294
+ });
295
+
296
+ expect(world.query([Position]).length).toBe(entityCount);
297
+ expect(world.query([relation(ChildOf, "*")]).length).toBe(entityCount);
298
+ expect(relationFlipAvg).toBeLessThan(350);
299
+ });
300
+ });
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { component, relation, type EntityId } from "../../entity";
3
+ import { World } from "../../world/world";
4
+
5
+ function benchmark(label: string, warmupRounds: number, measuredRounds: number, fn: (round: number) => void): number {
6
+ const durations: number[] = [];
7
+
8
+ const totalRounds = warmupRounds + measuredRounds;
9
+ for (let round = 0; round < totalRounds; round++) {
10
+ const start = performance.now();
11
+ fn(round);
12
+ const duration = performance.now() - start;
13
+ if (round >= warmupRounds) {
14
+ durations.push(duration);
15
+ }
16
+ }
17
+
18
+ const average = durations.reduce((sum, duration) => sum + duration, 0) / durations.length;
19
+ console.log(
20
+ `${label}: avg ${average.toFixed(2)}ms after ${warmupRounds} warmup rounds (${durations
21
+ .map((d) => d.toFixed(2))
22
+ .join("ms, ")}ms per measured round)`,
23
+ );
24
+ return average;
25
+ }
26
+
27
+ describe("World sync hot-path performance", () => {
28
+ it("should keep stable sync throughput for frequent set/remove patterns", () => {
29
+ const world = new World();
30
+ const Position = component<{ x: number; y: number }>();
31
+ const ChildOf = component({ dontFragment: true, exclusive: true });
32
+
33
+ const parentA = world.new();
34
+ const parentB = world.new();
35
+
36
+ const entityCount = 4000;
37
+ const entities: EntityId[] = [];
38
+
39
+ for (let i = 0; i < entityCount; i++) {
40
+ const entity = world.new();
41
+ entities.push(entity);
42
+ world.set(entity, Position, { x: i, y: i });
43
+ world.set(entity, relation(ChildOf, parentA));
44
+ }
45
+ world.sync();
46
+
47
+ const warmupRounds = 2;
48
+ const measuredRounds = 8;
49
+
50
+ const positionAverage = benchmark("position update + sync", warmupRounds, measuredRounds, (round) => {
51
+ for (let i = 0; i < entities.length; i++) {
52
+ const entity = entities[i]!;
53
+ world.set(entity, Position, { x: round, y: i });
54
+ }
55
+ world.sync();
56
+ });
57
+
58
+ const relationAverage = benchmark(
59
+ "exclusive dontFragment relation flip + sync",
60
+ warmupRounds,
61
+ measuredRounds,
62
+ (round) => {
63
+ const target = round % 2 === 0 ? parentB : parentA;
64
+ for (let i = 0; i < entities.length; i++) {
65
+ const entity = entities[i]!;
66
+ world.set(entity, relation(ChildOf, target));
67
+ }
68
+ world.sync();
69
+ },
70
+ );
71
+
72
+ expect(world.query([Position]).length).toBe(entityCount);
73
+ expect(world.query([relation(ChildOf, "*")]).length).toBe(entityCount);
74
+
75
+ // Guard against pathological regressions while keeping CI variance tolerance.
76
+ expect(positionAverage).toBeLessThan(250);
77
+ expect(relationAverage).toBeLessThan(350);
78
+ });
79
+ });