@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.
- package/{builder.d.mts → dist/builder.d.mts} +4 -2
- package/{world.mjs → dist/world.mjs} +9 -30
- package/dist/world.mjs.map +1 -0
- package/examples/advanced-scheduling.ts +96 -0
- package/examples/collision-detection.ts +229 -0
- package/examples/inventory-system-relations.ts +108 -0
- package/examples/parent-child-hierarchy.ts +206 -0
- package/examples/serialization.ts +337 -0
- package/examples/simple.ts +96 -0
- package/examples/spatial-grid.ts +276 -0
- package/examples/state-machine.ts +273 -0
- package/examples/tag-filtering.ts +266 -0
- package/package.json +58 -12
- package/src/__tests__/commands/buffer-limits.test.ts +72 -0
- package/src/__tests__/commands/buffer.test.ts +195 -0
- package/src/__tests__/component/singleton.test.ts +148 -0
- package/src/__tests__/core/archetype.test.ts +247 -0
- package/src/__tests__/core/bitset.test.ts +171 -0
- package/src/__tests__/core/changeset.test.ts +254 -0
- package/src/__tests__/core/multi-map.test.ts +74 -0
- package/src/__tests__/entity/component-registry.test.ts +66 -0
- package/src/__tests__/entity/entity.test.ts +520 -0
- package/src/__tests__/entity/id-manager.test.ts +157 -0
- package/src/__tests__/entity/id-system.test.ts +260 -0
- package/src/__tests__/perf/comprehensive.perf.test.ts +300 -0
- package/src/__tests__/perf/sync-hotpath.perf.test.ts +79 -0
- package/src/__tests__/query/basic.test.ts +341 -0
- package/src/__tests__/query/caching.test.ts +112 -0
- package/src/__tests__/query/filter.test.ts +111 -0
- package/src/__tests__/query/optional.test.ts +231 -0
- package/src/__tests__/query/perf.test.ts +99 -0
- package/src/__tests__/relations/dont-fragment/basic.test.ts +496 -0
- package/src/__tests__/relations/dont-fragment/query-notification.test.ts +125 -0
- package/src/__tests__/relations/wildcard.test.ts +179 -0
- package/src/__tests__/serialization/bounds.test.ts +237 -0
- package/src/__tests__/testing/assertions.test.ts +224 -0
- package/src/__tests__/testing/entity-builder.test.ts +84 -0
- package/src/__tests__/testing/snapshot.test.ts +150 -0
- package/src/__tests__/testing/world-fixture.test.ts +73 -0
- package/src/__tests__/world/component-hooks.test.ts +185 -0
- package/src/__tests__/world/component-management.test.ts +447 -0
- package/src/__tests__/world/entity-management.test.ts +86 -0
- package/src/__tests__/world/get-optional.test.ts +96 -0
- package/src/__tests__/world/multi-component-hooks.test.ts +502 -0
- package/src/__tests__/world/perf.test.ts +93 -0
- package/src/__tests__/world/query.test.ts +223 -0
- package/src/__tests__/world/serialize.test.ts +83 -0
- package/src/__tests__/world/wildcard-relation-hooks.test.ts +332 -0
- package/src/archetype/archetype.ts +472 -0
- package/src/archetype/helpers.ts +186 -0
- package/src/archetype/store.ts +33 -0
- package/src/commands/buffer.ts +110 -0
- package/src/commands/changeset.ts +104 -0
- package/src/component/entity-store.ts +223 -0
- package/src/component/registry.ts +657 -0
- package/src/component/type-utils.ts +9 -0
- package/src/entity/index.ts +63 -0
- package/src/entity/manager.ts +115 -0
- package/src/entity/relation.ts +319 -0
- package/src/entity/types.ts +135 -0
- package/src/index.ts +41 -0
- package/src/query/filter.ts +75 -0
- package/src/query/query.ts +313 -0
- package/src/query/registry.ts +101 -0
- package/src/storage/serialization.ts +130 -0
- package/src/testing/index.ts +634 -0
- package/src/types/index.ts +99 -0
- package/src/utils/bit-set.ts +133 -0
- package/src/utils/multi-map.ts +96 -0
- package/src/utils/utils.ts +19 -0
- package/src/world/builder.ts +100 -0
- package/src/world/commands.ts +378 -0
- package/src/world/hooks.ts +358 -0
- package/src/world/references.ts +38 -0
- package/src/world/serialization.ts +122 -0
- package/src/world/world.ts +1201 -0
- package/world.mjs.map +0 -1
- /package/{index.d.mts → dist/index.d.mts} +0 -0
- /package/{index.mjs → dist/index.mjs} +0 -0
- /package/{testing.d.mts → dist/testing.d.mts} +0 -0
- /package/{testing.mjs → dist/testing.mjs} +0 -0
- /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
|
+
});
|