@codehz/ecs 0.8.1 → 0.9.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.
- package/README.en.md +26 -3
- package/README.md +28 -3
- package/dist/builder.d.mts +296 -46
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/dist/world.mjs +452 -179
- package/dist/world.mjs.map +1 -1
- package/examples/debug-observability.ts +92 -0
- package/examples/inventory-system-relations.ts +1 -1
- package/examples/parent-child-hierarchy.ts +18 -38
- package/package.json +1 -1
- package/skills/ecs/SKILL.md +9 -4
- package/src/__tests__/component/singleton.test.ts +40 -1
- package/src/__tests__/core/archetype.test.ts +155 -13
- package/src/__tests__/core/bitset.test.ts +12 -0
- package/src/__tests__/entity/entity.test.ts +33 -0
- package/src/__tests__/entity/id-system.test.ts +40 -0
- package/src/__tests__/perf/comprehensive.perf.test.ts +6 -9
- package/src/__tests__/perf/serialization.perf.test.ts +242 -0
- package/src/__tests__/perf/{dontfragment-wildcard.perf.test.ts → sparse-wildcard.perf.test.ts} +13 -16
- package/src/__tests__/query/caching.test.ts +62 -0
- package/src/__tests__/query/filter.test.ts +16 -22
- package/src/__tests__/query/perf.test.ts +3 -5
- package/src/__tests__/relations/hierarchy.test.ts +208 -0
- package/src/__tests__/relations/{dont-fragment → sparse}/basic.test.ts +64 -69
- package/src/__tests__/relations/{dont-fragment → sparse}/query-notification.test.ts +17 -9
- package/src/__tests__/serialization/bounds.test.ts +134 -1
- package/src/__tests__/world/commands.test.ts +337 -0
- package/src/__tests__/world/debug-stats.test.ts +206 -0
- package/src/__tests__/world/multi-component-hooks.test.ts +44 -0
- package/src/__tests__/world/serialize.test.ts +17 -0
- package/src/__tests__/world/wildcard-relation-hooks.test.ts +127 -0
- package/src/archetype/archetype.ts +96 -46
- package/src/archetype/helpers.ts +7 -29
- package/src/archetype/store.ts +35 -20
- package/src/commands/buffer.ts +5 -2
- package/src/commands/changeset.ts +0 -31
- package/src/component/registry.ts +64 -63
- package/src/entity/index.ts +6 -3
- package/src/index.ts +13 -0
- package/src/query/filter.ts +4 -10
- package/src/query/query.ts +12 -12
- package/src/storage/serialization.ts +29 -2
- package/src/types/index.ts +71 -0
- package/src/world/commands.ts +44 -56
- package/src/world/hooks.ts +8 -0
- package/src/world/serialization.ts +32 -18
- package/src/world/world.ts +387 -20
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example: Using the Debug Stats Collector for development & leak detection.
|
|
3
|
+
*
|
|
4
|
+
* Run with:
|
|
5
|
+
* bun run examples/debug-observability.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { component, relation, World } from "../src";
|
|
9
|
+
|
|
10
|
+
type Position = { x: number; y: number };
|
|
11
|
+
type Velocity = { x: number; y: number };
|
|
12
|
+
|
|
13
|
+
// Components
|
|
14
|
+
const Position = component<Position>();
|
|
15
|
+
const Velocity = component<Velocity>();
|
|
16
|
+
const ChildOf = component<void>({ exclusive: true, sparse: true });
|
|
17
|
+
|
|
18
|
+
const world = new World();
|
|
19
|
+
|
|
20
|
+
// Create a debug stats collector
|
|
21
|
+
const collector = world.createDebugStatsCollector((stats) => {
|
|
22
|
+
console.log("=== Debug Stats ===");
|
|
23
|
+
console.log(`Sync time: ${(stats.timestamps.syncEnd - stats.timestamps.syncStart).toFixed(3)}ms`);
|
|
24
|
+
console.log(`Command buffer iterations: ${stats.commandIterations}`);
|
|
25
|
+
console.log(`Entities: ${stats.entities.total} (freelist: ${stats.entities.freelistSize})`);
|
|
26
|
+
console.log(`Archetypes: ${stats.archetypes.total} (empty: ${stats.archetypes.empty})`);
|
|
27
|
+
console.log(`Queries (cached/registered): ${stats.queries.cached}/${stats.queries.registered}`);
|
|
28
|
+
console.log(`Hooks: ${stats.hooks.total}`);
|
|
29
|
+
console.log(`Indices:`, stats.indices);
|
|
30
|
+
|
|
31
|
+
const act = stats.activity;
|
|
32
|
+
console.log("Activity this sync:");
|
|
33
|
+
console.log(` Migrations: ${act.migrations}`);
|
|
34
|
+
console.log(` Hooks executed: ${act.hooksExecuted}`);
|
|
35
|
+
console.log(` Archetypes created: ${act.archetypesCreated}`);
|
|
36
|
+
console.log(` Archetypes removed: ${act.archetypesRemoved}`);
|
|
37
|
+
console.log("===================\n");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Setup some entities
|
|
41
|
+
const parent = world.new();
|
|
42
|
+
world.set(parent, Position, { x: 0, y: 0 });
|
|
43
|
+
|
|
44
|
+
const children: any[] = [];
|
|
45
|
+
for (let i = 0; i < 5; i++) {
|
|
46
|
+
const child = world.new();
|
|
47
|
+
world.set(child, Position, { x: i * 10, y: 0 });
|
|
48
|
+
world.set(child, Velocity, { x: 1, y: 0.5 });
|
|
49
|
+
world.set(child, relation(ChildOf, parent));
|
|
50
|
+
children.push(child);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log("Initial sync (should create archetypes + relations)");
|
|
54
|
+
world.sync();
|
|
55
|
+
|
|
56
|
+
// Cause some migrations by adding/removing components
|
|
57
|
+
console.log("Adding/removing components to trigger migrations...");
|
|
58
|
+
for (const c of children) {
|
|
59
|
+
world.remove(c, Velocity);
|
|
60
|
+
}
|
|
61
|
+
world.sync();
|
|
62
|
+
|
|
63
|
+
world.set(children[0], Velocity, { x: 2, y: 0 });
|
|
64
|
+
world.sync();
|
|
65
|
+
|
|
66
|
+
// Add a hook to observe hook execution counting
|
|
67
|
+
world.hook([Position, Velocity], {
|
|
68
|
+
on_set: () => {},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
console.log("Triggering hook + more structural changes");
|
|
72
|
+
world.set(children[1], Velocity, { x: 3, y: 1 });
|
|
73
|
+
world.sync();
|
|
74
|
+
|
|
75
|
+
// Clean up some entities to potentially remove archetypes
|
|
76
|
+
console.log("Deleting some children...");
|
|
77
|
+
world.delete(children[2]);
|
|
78
|
+
world.delete(children[3]);
|
|
79
|
+
world.sync();
|
|
80
|
+
|
|
81
|
+
// Dispose the collector when done
|
|
82
|
+
collector[Symbol.dispose]();
|
|
83
|
+
|
|
84
|
+
console.log("Collector disposed. Further syncs will not trigger callbacks.");
|
|
85
|
+
world.set(parent, Position, { x: 100, y: 100 });
|
|
86
|
+
world.sync();
|
|
87
|
+
|
|
88
|
+
console.log("Done. This demonstrates typical usage for spotting:");
|
|
89
|
+
console.log("- Unexpected archetype growth (fragmentation)");
|
|
90
|
+
console.log("- High migration or hook execution counts");
|
|
91
|
+
console.log("- Command buffer iteration spikes");
|
|
92
|
+
console.log("- Leaking entities or relations (watch entity/freelist numbers over time)");
|
|
@@ -9,7 +9,7 @@ const ItemName = component<ItemName>({ name: "ItemName" });
|
|
|
9
9
|
const Stackable = component<Stackable>({ name: "Stackable" });
|
|
10
10
|
const Gold = component<Gold>({ name: "Gold" });
|
|
11
11
|
const EquipmentSlot = component<EquipmentSlot>({ name: "EquipmentSlot" });
|
|
12
|
-
const InInventory = component<void>({ name: "InInventory",
|
|
12
|
+
const InInventory = component<void>({ name: "InInventory", sparse: true });
|
|
13
13
|
|
|
14
14
|
const world = new World();
|
|
15
15
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { pipeline } from "@codehz/pipeline";
|
|
2
|
-
import { World, component, relation, type
|
|
2
|
+
import { World, component, relation, type Query } from "../src";
|
|
3
3
|
|
|
4
4
|
// Define component types
|
|
5
5
|
type Transform = { x: number; y: number; rotation: number; scale: number };
|
|
@@ -13,7 +13,7 @@ const LocalTransform = component<Transform>({ name: "LocalTransform" });
|
|
|
13
13
|
const WorldTransform = component<Transform>({ name: "WorldTransform" });
|
|
14
14
|
const LinearVelocity = component<LinearVelocity>({ name: "LinearVelocity" });
|
|
15
15
|
const AngularVelocity = component<AngularVelocity>({ name: "AngularVelocity" });
|
|
16
|
-
const ChildOf = component<void>({ exclusive: true,
|
|
16
|
+
const ChildOf = component<void>({ exclusive: true, sparse: true, name: "ChildOf" });
|
|
17
17
|
|
|
18
18
|
// Create the world
|
|
19
19
|
const world = new World();
|
|
@@ -22,7 +22,6 @@ const world = new World();
|
|
|
22
22
|
const movementQuery: Query = world.createQuery([LocalTransform, LinearVelocity]);
|
|
23
23
|
const rotationQuery: Query = world.createQuery([LocalTransform, AngularVelocity]);
|
|
24
24
|
const transformQuery: Query = world.createQuery([Name, LocalTransform, WorldTransform]);
|
|
25
|
-
const childQuery: Query = world.createQuery([relation(ChildOf, "*")]);
|
|
26
25
|
const renderQuery: Query = world.createQuery([Name, WorldTransform]);
|
|
27
26
|
|
|
28
27
|
function toRadians(degrees: number): number {
|
|
@@ -59,38 +58,9 @@ function formatTransform(transform: Transform): string {
|
|
|
59
58
|
return `pos=(${transform.x.toFixed(2)}, ${transform.y.toFixed(2)}) rot=${transform.rotation.toFixed(1)}deg scale=${transform.scale.toFixed(2)}`;
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
childQuery.forEach([relation(ChildOf, "*")], (child, parents) => {
|
|
66
|
-
const parent = parents[0]?.[0];
|
|
67
|
-
if (parent === undefined) return;
|
|
68
|
-
|
|
69
|
-
const children = childrenByParent.get(parent) ?? [];
|
|
70
|
-
children.push(child);
|
|
71
|
-
childrenByParent.set(parent, children);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
return childrenByParent;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function propagateChildren(
|
|
78
|
-
parent: EntityId,
|
|
79
|
-
parentWorld: Transform,
|
|
80
|
-
childrenByParent: Map<EntityId, EntityId[]>,
|
|
81
|
-
): void {
|
|
82
|
-
const children = childrenByParent.get(parent);
|
|
83
|
-
if (!children) return;
|
|
84
|
-
|
|
85
|
-
for (const child of children) {
|
|
86
|
-
const name = world.get(child, Name);
|
|
87
|
-
const local = world.get(child, LocalTransform);
|
|
88
|
-
const worldTransform = world.get(child, WorldTransform);
|
|
89
|
-
copyTransform(worldTransform, composeTransform(local, parentWorld));
|
|
90
|
-
console.log(` Child ${name.value}: ${formatTransform(worldTransform)}`);
|
|
91
|
-
propagateChildren(child, worldTransform, childrenByParent);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
61
|
+
// NOTE: Before the relation/hierarchy companion tools, this required manually
|
|
62
|
+
// building a children map every frame + writing recursive propagation.
|
|
63
|
+
// Now we can use the built-in efficient helpers (getChildren + traverseDescendants).
|
|
94
64
|
|
|
95
65
|
// Build game loop using pipeline
|
|
96
66
|
// Pass execution order is determined by addition order; no need to manually manage dependencies
|
|
@@ -115,16 +85,26 @@ const gameLoop = pipeline<{ deltaTime: number }>()
|
|
|
115
85
|
});
|
|
116
86
|
})
|
|
117
87
|
// Hierarchy pass - propagate parent transforms into world transforms
|
|
88
|
+
// (modernized with the new relation/hierarchy companion tools)
|
|
118
89
|
.addPass(() => {
|
|
119
90
|
console.log(`[HierarchyPass] Propagating world transforms`);
|
|
120
|
-
const childrenByParent = buildChildrenByParent();
|
|
121
91
|
|
|
122
92
|
transformQuery.forEach([Name, LocalTransform, WorldTransform], (entity, name, localTransform, worldTransform) => {
|
|
123
|
-
if (world.has(entity, relation(ChildOf, "*"))) return;
|
|
93
|
+
if (world.has(entity, relation(ChildOf, "*"))) return; // skip non-roots
|
|
124
94
|
|
|
125
95
|
copyTransform(worldTransform, composeTransform(localTransform));
|
|
126
96
|
console.log(` Root ${name.value}: ${formatTransform(worldTransform)}`);
|
|
127
|
-
|
|
97
|
+
|
|
98
|
+
// Use the efficient built-in traverser (replaces manual Map + recursion)
|
|
99
|
+
world.traverseDescendants(entity, ChildOf, (child, _depth, parent) => {
|
|
100
|
+
if (!parent) return;
|
|
101
|
+
const childName = world.get(child, Name);
|
|
102
|
+
const local = world.get(child, LocalTransform);
|
|
103
|
+
const childWorld = world.get(child, WorldTransform);
|
|
104
|
+
const parentWorldT = world.get(parent, WorldTransform);
|
|
105
|
+
copyTransform(childWorld, composeTransform(local, parentWorldT));
|
|
106
|
+
console.log(` Child ${childName.value}: ${formatTransform(childWorld)}`);
|
|
107
|
+
});
|
|
128
108
|
});
|
|
129
109
|
})
|
|
130
110
|
// Render pass - render propagated world transforms
|
package/package.json
CHANGED
package/skills/ecs/SKILL.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ecs
|
|
3
|
+
description: "Guide for using @codehz/ecs — a high-performance ECS (Entity Component System) library in TypeScript. Use when: writing application code with @codehz/ecs; understanding deferred command buffering, archetype storage, queries, relations, lifecycle hooks, and sync() semantics; avoiding common ECS pitfalls like stale reads, dangling EntityId references, or sync() inside iteration."
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# @codehz/ecs — User Guide for AI Coding Assistants
|
|
2
7
|
|
|
3
8
|
**Purpose**: This document defines the strict usage contract for `@codehz/ecs`. Follow these rules when writing application code. Violating them is the most common source of subtle, hard-to-debug errors.
|
|
@@ -29,7 +34,7 @@ The library uses **archetype storage + deferred command buffering**. All structu
|
|
|
29
34
|
7. **NEVER** call `sync()` inside `forEach`, hooks, or while iterating query results.
|
|
30
35
|
8. **NEVER** confuse `remove(entity, Component)` with `delete(entity)`.
|
|
31
36
|
9. **MUST** use relation components (`relation(Comp, target)`) instead of storing `EntityId` in data when you need to reference other entities.
|
|
32
|
-
10. **MUST** understand the three relation flags (`exclusive`, `cascadeDelete`, `dontFragment`) before using relations.
|
|
37
|
+
10. **MUST** understand the three relation flags (`exclusive`, `cascadeDelete`, `sparse` / legacy `dontFragment`) before using relations.
|
|
33
38
|
|
|
34
39
|
---
|
|
35
40
|
|
|
@@ -200,7 +205,7 @@ Use `relation(Component, target)` to create entity-to-entity references.
|
|
|
200
205
|
- When the target entity is deleted, **the entire referencing entity is deleted**.
|
|
201
206
|
- This is transitive and powerful. Use deliberately.
|
|
202
207
|
|
|
203
|
-
**`
|
|
208
|
+
**`sparse: true`** (preferred; legacy key `dontFragment` is fully equivalent and supported)
|
|
204
209
|
|
|
205
210
|
- Prevents archetype fragmentation when many different targets exist.
|
|
206
211
|
- **Required** for relations with high cardinality or frequent target changes (e.g. `ChildOf` with thousands of children, AI targeting, inventory).
|
|
@@ -209,8 +214,8 @@ Use `relation(Component, target)` to create entity-to-entity references.
|
|
|
209
214
|
|
|
210
215
|
```ts
|
|
211
216
|
const ChildOf = component({ exclusive: true, cascadeDelete: true });
|
|
212
|
-
const Targeting = component({ exclusive: true,
|
|
213
|
-
const InventorySlot = component({
|
|
217
|
+
const Targeting = component({ exclusive: true, sparse: true });
|
|
218
|
+
const InventorySlot = component({ sparse: true });
|
|
214
219
|
```
|
|
215
220
|
|
|
216
221
|
### 8. Referencing Other Entities — Never Store Raw EntityId
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { ComponentEntityStore } from "../../component/entity-store";
|
|
3
|
+
import { component, createEntityId, relation, type EntityId } from "../../entity";
|
|
3
4
|
import { World } from "../../world/world";
|
|
4
5
|
|
|
5
6
|
describe("World - Singleton Component", () => {
|
|
@@ -145,4 +146,42 @@ describe("World - Singleton Component", () => {
|
|
|
145
146
|
expect(world1.has(GlobalConfigId)).toBe(world2.has(GlobalConfigId, GlobalConfigId));
|
|
146
147
|
expect(world1.has(GlobalConfigId)).toBe(false);
|
|
147
148
|
});
|
|
149
|
+
|
|
150
|
+
it("should cover ComponentEntityStore hasWildcard, getWildcard, wildcard delete paths", () => {
|
|
151
|
+
const store = new ComponentEntityStore();
|
|
152
|
+
const compE = GlobalConfigId as EntityId; // reuse as component entity id (valid in range)
|
|
153
|
+
const target1 = createEntityId(1024);
|
|
154
|
+
const target2 = createEntityId(1025);
|
|
155
|
+
const relComp = relation(GlobalConfigId, target1); // entity-relation on the comp entity
|
|
156
|
+
const relComp2 = relation(GlobalConfigId, target2);
|
|
157
|
+
const wildcard = relation(GlobalConfigId, "*");
|
|
158
|
+
|
|
159
|
+
// Setup via internal? Use executeCommands to populate (simulates)
|
|
160
|
+
store.executeCommands(compE, [
|
|
161
|
+
{ type: "set", componentType: relComp, component: { dist: 1 } } as any,
|
|
162
|
+
{ type: "set", componentType: relComp2, component: { dist: 2 } } as any,
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
// hasWildcard
|
|
166
|
+
expect(store.hasWildcard(compE, GlobalConfigId as any)).toBe(true);
|
|
167
|
+
expect(store.hasWildcard(compE, GameStateId as any)).toBe(false);
|
|
168
|
+
expect(store.hasWildcard(createEntityId(9999), GlobalConfigId as any)).toBe(false); // no data
|
|
169
|
+
|
|
170
|
+
// getWildcard
|
|
171
|
+
const w1 = store.getWildcard(compE, wildcard as any);
|
|
172
|
+
expect(w1.length).toBe(2);
|
|
173
|
+
|
|
174
|
+
// wildcard delete via executeCommands
|
|
175
|
+
store.executeCommands(compE, [{ type: "delete", componentType: wildcard } as any]);
|
|
176
|
+
const afterDel = store.getWildcard(compE, wildcard as any);
|
|
177
|
+
expect(afterDel.length).toBe(0);
|
|
178
|
+
|
|
179
|
+
// also test get on non exist throws
|
|
180
|
+
expect(() => store.get(compE, createEntityId(5000) as any)).toThrow();
|
|
181
|
+
expect(store.getOptional(compE, createEntityId(5000) as any)).toBeUndefined();
|
|
182
|
+
|
|
183
|
+
// clear
|
|
184
|
+
store.clear(compE);
|
|
185
|
+
expect(store.has(compE, relComp)).toBe(false);
|
|
186
|
+
});
|
|
148
187
|
});
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { Archetype } from "../../archetype/archetype";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
buildCacheKey,
|
|
5
|
+
buildRegularComponentValue,
|
|
6
|
+
buildSingleComponent,
|
|
7
|
+
buildWildcardRelationValue,
|
|
8
|
+
findWildcardRelations,
|
|
9
|
+
getWildcardRelationDataSource,
|
|
10
|
+
hasWildcardRelation,
|
|
11
|
+
isRelationType,
|
|
12
|
+
matchesRelationComponentId,
|
|
13
|
+
} from "../../archetype/helpers";
|
|
14
|
+
import { SparseStoreImpl } from "../../archetype/store";
|
|
4
15
|
import { component, createEntityId, relation, type EntityId } from "../../entity";
|
|
5
16
|
|
|
6
17
|
describe("Archetype", () => {
|
|
@@ -10,25 +21,25 @@ describe("Archetype", () => {
|
|
|
10
21
|
const positionComponent = component<Position>();
|
|
11
22
|
const velocityComponent = component<Velocity>();
|
|
12
23
|
|
|
13
|
-
// Helper function to create a real
|
|
24
|
+
// Helper function to create a real SparseStore for testing.
|
|
14
25
|
// We use the production implementation because the interface is now fully semantic.
|
|
15
|
-
const
|
|
26
|
+
const createSparseStore = () => new SparseStoreImpl();
|
|
16
27
|
|
|
17
28
|
it("should create an archetype with component types", () => {
|
|
18
|
-
const archetype = new Archetype([positionComponent, velocityComponent],
|
|
29
|
+
const archetype = new Archetype([positionComponent, velocityComponent], createSparseStore());
|
|
19
30
|
expect(archetype.componentTypes).toEqual([positionComponent, velocityComponent]);
|
|
20
31
|
expect(archetype.size).toBe(0);
|
|
21
32
|
});
|
|
22
33
|
|
|
23
34
|
it("should match component types", () => {
|
|
24
|
-
const archetype = new Archetype([positionComponent, velocityComponent],
|
|
35
|
+
const archetype = new Archetype([positionComponent, velocityComponent], createSparseStore());
|
|
25
36
|
expect(archetype.matches([positionComponent, velocityComponent])).toBe(true);
|
|
26
37
|
expect(archetype.matches([velocityComponent, positionComponent])).toBe(true); // Order doesn't matter
|
|
27
38
|
expect(archetype.matches([positionComponent])).toBe(false);
|
|
28
39
|
});
|
|
29
40
|
|
|
30
41
|
it("should add and remove entities", () => {
|
|
31
|
-
const archetype = new Archetype([positionComponent, velocityComponent],
|
|
42
|
+
const archetype = new Archetype([positionComponent, velocityComponent], createSparseStore());
|
|
32
43
|
const entity1 = createEntityId(1024);
|
|
33
44
|
const entity2 = createEntityId(1025);
|
|
34
45
|
|
|
@@ -57,7 +68,7 @@ describe("Archetype", () => {
|
|
|
57
68
|
});
|
|
58
69
|
|
|
59
70
|
it("should get and set component data", () => {
|
|
60
|
-
const archetype = new Archetype([positionComponent],
|
|
71
|
+
const archetype = new Archetype([positionComponent], createSparseStore());
|
|
61
72
|
const entity = createEntityId(1024);
|
|
62
73
|
const initialPosition: Position = { x: 5, y: 5 };
|
|
63
74
|
|
|
@@ -83,7 +94,7 @@ describe("Archetype", () => {
|
|
|
83
94
|
const entity = createEntityId(1024);
|
|
84
95
|
|
|
85
96
|
// Archetype with multiple relations
|
|
86
|
-
const archetype = new Archetype([relation1, relation2],
|
|
97
|
+
const archetype = new Archetype([relation1, relation2], createSparseStore());
|
|
87
98
|
|
|
88
99
|
// Add entity with relations to target1 and target2
|
|
89
100
|
archetype.addEntity(
|
|
@@ -109,7 +120,7 @@ describe("Archetype", () => {
|
|
|
109
120
|
});
|
|
110
121
|
|
|
111
122
|
it("should iterate over entities", () => {
|
|
112
|
-
const archetype = new Archetype([positionComponent],
|
|
123
|
+
const archetype = new Archetype([positionComponent], createSparseStore());
|
|
113
124
|
const entity1 = createEntityId(1024);
|
|
114
125
|
const entity2 = createEntityId(1025);
|
|
115
126
|
|
|
@@ -125,7 +136,7 @@ describe("Archetype", () => {
|
|
|
125
136
|
});
|
|
126
137
|
|
|
127
138
|
it("should get component data arrays", () => {
|
|
128
|
-
const archetype = new Archetype([positionComponent],
|
|
139
|
+
const archetype = new Archetype([positionComponent], createSparseStore());
|
|
129
140
|
const entity1 = createEntityId(1024);
|
|
130
141
|
const entity2 = createEntityId(1025);
|
|
131
142
|
const pos1: Position = { x: 1, y: 1 };
|
|
@@ -153,8 +164,8 @@ describe("Archetype", () => {
|
|
|
153
164
|
const relation3 = relation(positionComponent, createEntityId(1029)); // For entity2
|
|
154
165
|
|
|
155
166
|
// Archetype with multiple relations
|
|
156
|
-
const archetype1 = new Archetype([relation1, relation2],
|
|
157
|
-
const archetype2 = new Archetype([relation3],
|
|
167
|
+
const archetype1 = new Archetype([relation1, relation2], createSparseStore());
|
|
168
|
+
const archetype2 = new Archetype([relation3], createSparseStore());
|
|
158
169
|
|
|
159
170
|
// Add entity1 with relations to target1 and target2
|
|
160
171
|
archetype1.addEntity(
|
|
@@ -200,7 +211,7 @@ describe("Archetype", () => {
|
|
|
200
211
|
const relation2 = relation(positionComponent, target2);
|
|
201
212
|
const wildcardPositionRelation = relation(positionComponent, "*");
|
|
202
213
|
|
|
203
|
-
const archetype = new Archetype([relation1, relation2],
|
|
214
|
+
const archetype = new Archetype([relation1, relation2], createSparseStore());
|
|
204
215
|
|
|
205
216
|
const entity1 = createEntityId(1024);
|
|
206
217
|
|
|
@@ -246,4 +257,135 @@ describe("Archetype", () => {
|
|
|
246
257
|
[target1, { distance: 100 }], // Updated
|
|
247
258
|
]);
|
|
248
259
|
});
|
|
260
|
+
|
|
261
|
+
it("should cover getEntity, dump, getEntitiesWithComponents, getEntityToIndexMap", () => {
|
|
262
|
+
const archetype = new Archetype([positionComponent], createSparseStore());
|
|
263
|
+
const entity = createEntityId(1024);
|
|
264
|
+
archetype.addEntity(entity, new Map([[positionComponent, { x: 1, y: 2 }]]));
|
|
265
|
+
|
|
266
|
+
// Cover getEntity
|
|
267
|
+
const data = archetype.getEntity(entity);
|
|
268
|
+
expect(data).toBeDefined();
|
|
269
|
+
expect(data!.get(positionComponent)).toEqual({ x: 1, y: 2 });
|
|
270
|
+
|
|
271
|
+
// Cover dump
|
|
272
|
+
const dumped = archetype.dump();
|
|
273
|
+
expect(dumped).toHaveLength(1);
|
|
274
|
+
expect(dumped[0]!.entity).toBe(entity);
|
|
275
|
+
|
|
276
|
+
// Cover getEntitiesWithComponents
|
|
277
|
+
const withComps = archetype.getEntitiesWithComponents([positionComponent]);
|
|
278
|
+
expect(withComps).toHaveLength(1);
|
|
279
|
+
expect(withComps[0]!.entity).toBe(entity);
|
|
280
|
+
|
|
281
|
+
// Cover getEntityToIndexMap
|
|
282
|
+
const map = archetype.getEntityToIndexMap();
|
|
283
|
+
expect(map.get(entity)).toBe(0);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("should cover SparseStoreImpl extra methods (hasAnyForComponent, getAllForEntities, multi)", () => {
|
|
287
|
+
const store = new SparseStoreImpl();
|
|
288
|
+
const e1 = createEntityId(2000);
|
|
289
|
+
const e2 = createEntityId(2001);
|
|
290
|
+
|
|
291
|
+
// Actually create proper relations for a component
|
|
292
|
+
const targetC1 = createEntityId(4001);
|
|
293
|
+
const targetC2 = createEntityId(4002);
|
|
294
|
+
const r1 = relation(velocityComponent, targetC1);
|
|
295
|
+
const r2 = relation(velocityComponent, targetC2);
|
|
296
|
+
const baseComp = velocityComponent;
|
|
297
|
+
|
|
298
|
+
store.setValue(e1, r1, { v: 1 });
|
|
299
|
+
store.setValue(e1, r2, { v: 2 }); // promote to multi
|
|
300
|
+
|
|
301
|
+
// hasAnyForComponent
|
|
302
|
+
expect(store.hasAnyForComponent(baseComp)).toBe(true);
|
|
303
|
+
expect(store.hasAnyForComponent(positionComponent)).toBe(false);
|
|
304
|
+
|
|
305
|
+
// getAllForEntities bulk
|
|
306
|
+
const bulk = store.getAllForEntities([e1, e2, createEntityId(5000)]);
|
|
307
|
+
expect(bulk.has(e1)).toBe(true);
|
|
308
|
+
expect(bulk.get(e1)).toHaveLength(2);
|
|
309
|
+
|
|
310
|
+
// getAllForEntity on one without
|
|
311
|
+
expect(store.getAllForEntity(e2)).toEqual([]);
|
|
312
|
+
|
|
313
|
+
// deleteValue on multi
|
|
314
|
+
store.deleteValue(e1, r1);
|
|
315
|
+
expect(store.getValue(e1, r1)).toBeUndefined();
|
|
316
|
+
// still has the other
|
|
317
|
+
expect(store.getValue(e1, r2)).toEqual({ v: 2 });
|
|
318
|
+
|
|
319
|
+
// delete last demotes? after delete one left in multi, delete last
|
|
320
|
+
store.deleteValue(e1, r2);
|
|
321
|
+
expect(store.hasAnyForComponent(baseComp)).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("should cover archetype helpers (find*, has*, build*, matchers, error paths)", () => {
|
|
325
|
+
const comps = new Map<EntityId, any>();
|
|
326
|
+
const relC = relation(positionComponent, createEntityId(5001));
|
|
327
|
+
const relD = relation(positionComponent, createEntityId(5002));
|
|
328
|
+
comps.set(relC, { d: 10 });
|
|
329
|
+
comps.set(relD, { d: 20 });
|
|
330
|
+
comps.set(positionComponent, { x: 1 }); // non-relation
|
|
331
|
+
|
|
332
|
+
// findWildcardRelations
|
|
333
|
+
const found = findWildcardRelations(comps, positionComponent);
|
|
334
|
+
expect(found).toHaveLength(2);
|
|
335
|
+
|
|
336
|
+
// hasWildcardRelation
|
|
337
|
+
expect(hasWildcardRelation(comps, positionComponent)).toBe(true);
|
|
338
|
+
expect(hasWildcardRelation(comps, velocityComponent)).toBe(false);
|
|
339
|
+
|
|
340
|
+
// matchesRelationComponentId
|
|
341
|
+
expect(matchesRelationComponentId(relC, positionComponent)).toBe(true);
|
|
342
|
+
expect(matchesRelationComponentId(positionComponent, positionComponent)).toBe(false);
|
|
343
|
+
|
|
344
|
+
// isRelationType
|
|
345
|
+
expect(
|
|
346
|
+
isRelationType({
|
|
347
|
+
type: "entity-relation",
|
|
348
|
+
componentId: positionComponent,
|
|
349
|
+
targetId: createEntityId(1024),
|
|
350
|
+
} as any),
|
|
351
|
+
).toBe(true);
|
|
352
|
+
expect(isRelationType({ type: "component" } as any)).toBe(false);
|
|
353
|
+
|
|
354
|
+
// buildCacheKey
|
|
355
|
+
const key = buildCacheKey([positionComponent, velocityComponent]);
|
|
356
|
+
expect(typeof key).toBe("string");
|
|
357
|
+
|
|
358
|
+
// getWildcardRelationDataSource
|
|
359
|
+
const ds = getWildcardRelationDataSource([relC, relD], positionComponent, false);
|
|
360
|
+
expect(ds).toHaveLength(2);
|
|
361
|
+
const dsOpt = getWildcardRelationDataSource([], positionComponent, true);
|
|
362
|
+
expect(dsOpt).toBeUndefined();
|
|
363
|
+
|
|
364
|
+
// buildRegularComponentValue
|
|
365
|
+
expect(buildRegularComponentValue([{ x: 9 }], 0, false)).toEqual({ x: 9 });
|
|
366
|
+
expect(buildRegularComponentValue(undefined, 0, true)).toBeUndefined();
|
|
367
|
+
expect(() => buildRegularComponentValue(undefined, 0, false)).toThrow();
|
|
368
|
+
|
|
369
|
+
// buildWildcardRelationValue - optional empty -> undefined
|
|
370
|
+
const dfStore = createSparseStore();
|
|
371
|
+
const wildcard = relation(positionComponent, "*");
|
|
372
|
+
const valOpt = buildWildcardRelationValue(wildcard, [], () => null, dfStore, createEntityId(6000), true);
|
|
373
|
+
expect(valOpt).toBeUndefined();
|
|
374
|
+
|
|
375
|
+
// buildWildcardRelationValue - mandatory empty -> throws
|
|
376
|
+
expect(() => buildWildcardRelationValue(wildcard, [], () => null, dfStore, createEntityId(6001), false)).toThrow(
|
|
377
|
+
/No matching relations found/,
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// buildSingleComponent regular path
|
|
381
|
+
const val = buildSingleComponent(
|
|
382
|
+
positionComponent,
|
|
383
|
+
[{ x: 42 }],
|
|
384
|
+
0,
|
|
385
|
+
createEntityId(7000),
|
|
386
|
+
(t) => (t === positionComponent ? [{ x: 42 }] : []),
|
|
387
|
+
dfStore,
|
|
388
|
+
);
|
|
389
|
+
expect(val).toEqual({ x: 42 });
|
|
390
|
+
});
|
|
249
391
|
});
|
|
@@ -168,4 +168,16 @@ describe("BitSet word boundary tests", () => {
|
|
|
168
168
|
expect(bitset.has(24)).toBe(false);
|
|
169
169
|
expect(bitset.has(26)).toBe(false);
|
|
170
170
|
});
|
|
171
|
+
|
|
172
|
+
it("should expose length and handle edge has/set/clear on bounds", () => {
|
|
173
|
+
const bitset = new BitSet(10);
|
|
174
|
+
expect(bitset.length).toBe(10);
|
|
175
|
+
expect(bitset.has(-1)).toBe(false);
|
|
176
|
+
expect(bitset.has(10)).toBe(false);
|
|
177
|
+
bitset.set(-1); // no-op
|
|
178
|
+
bitset.set(10); // no-op
|
|
179
|
+
bitset.clear(-1);
|
|
180
|
+
bitset.clear(10);
|
|
181
|
+
expect(bitset.has(0)).toBe(false);
|
|
182
|
+
});
|
|
171
183
|
});
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
isEntityId,
|
|
23
23
|
isExclusiveComponent,
|
|
24
24
|
isRelationId,
|
|
25
|
+
isSparseComponent,
|
|
25
26
|
isWildcardRelationId,
|
|
26
27
|
relation,
|
|
27
28
|
} from "../../entity";
|
|
@@ -503,6 +504,38 @@ describe("Component Options", () => {
|
|
|
503
504
|
expect(isDontFragmentComponent(combinedComp)).toBe(true);
|
|
504
505
|
});
|
|
505
506
|
|
|
507
|
+
it("should support the new `sparse` option (preferred name) and treat it identically to the legacy `dontFragment`", () => {
|
|
508
|
+
const sparseComp = component({ sparse: true });
|
|
509
|
+
const normalComp = component();
|
|
510
|
+
|
|
511
|
+
// New primary predicates
|
|
512
|
+
expect(isSparseComponent(sparseComp)).toBe(true);
|
|
513
|
+
expect(isSparseComponent(normalComp)).toBe(false);
|
|
514
|
+
|
|
515
|
+
// Old aliases must still work (BC)
|
|
516
|
+
expect(isDontFragmentComponent(sparseComp)).toBe(true);
|
|
517
|
+
expect(isDontFragmentComponent(normalComp)).toBe(false);
|
|
518
|
+
|
|
519
|
+
const options = getComponentOptions(sparseComp);
|
|
520
|
+
expect(options.sparse).toBe(true);
|
|
521
|
+
// Full BC: old key is also populated
|
|
522
|
+
expect(options.dontFragment).toBe(true);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("should treat `sparse` and `dontFragment` as equivalent (both spellings set the same flag)", () => {
|
|
526
|
+
const viaSparse = component({ sparse: true });
|
|
527
|
+
const viaLegacy = component({ dontFragment: true });
|
|
528
|
+
const viaBoth = component({ sparse: true, dontFragment: true });
|
|
529
|
+
|
|
530
|
+
for (const c of [viaSparse, viaLegacy, viaBoth]) {
|
|
531
|
+
expect(isSparseComponent(c)).toBe(true);
|
|
532
|
+
expect(isDontFragmentComponent(c)).toBe(true);
|
|
533
|
+
const opts = getComponentOptions(c);
|
|
534
|
+
expect(opts.sparse).toBe(true);
|
|
535
|
+
expect(opts.dontFragment).toBe(true);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
506
539
|
it("should store and retrieve component merge callback", () => {
|
|
507
540
|
const merge = (prev: number[], next: number[]) => [...prev, ...next];
|
|
508
541
|
const mailboxComp = component<number[]>({ merge });
|
|
@@ -6,12 +6,16 @@ import {
|
|
|
6
6
|
createEntityId,
|
|
7
7
|
decodeRelationId,
|
|
8
8
|
ENTITY_ID_START,
|
|
9
|
+
getComponentIdFromRelationId,
|
|
9
10
|
getDetailedIdType,
|
|
10
11
|
getIdType,
|
|
12
|
+
getTargetIdFromRelationId,
|
|
11
13
|
inspectEntityId,
|
|
12
14
|
INVALID_COMPONENT_ID,
|
|
13
15
|
isComponentId,
|
|
16
|
+
isComponentRelation,
|
|
14
17
|
isEntityId,
|
|
18
|
+
isEntityRelation,
|
|
15
19
|
isRelationId,
|
|
16
20
|
isWildcardRelationId,
|
|
17
21
|
relation,
|
|
@@ -257,4 +261,40 @@ describe("Entity ID System", () => {
|
|
|
257
261
|
expect(decoded.type).toBe("entity");
|
|
258
262
|
});
|
|
259
263
|
});
|
|
264
|
+
|
|
265
|
+
describe("Error paths and additional relation utils", () => {
|
|
266
|
+
it("should throw on decodeRelationId for non-relation", () => {
|
|
267
|
+
expect(() => decodeRelationId(123 as any)).toThrow("ID is not a relation ID");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("should throw on decodeRelationId for invalid component or target in relation", () => {
|
|
271
|
+
expect(() => decodeRelationId(-123456 as any)).toThrow();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("should cover isEntityRelation and isComponentRelation", () => {
|
|
275
|
+
const entRel = relation(createComponentId(1), createEntityId(ENTITY_ID_START));
|
|
276
|
+
const compRel = relation(createComponentId(2), createComponentId(3));
|
|
277
|
+
const wild = relation(createComponentId(4), "*");
|
|
278
|
+
const plain = createEntityId(ENTITY_ID_START);
|
|
279
|
+
|
|
280
|
+
expect(isEntityRelation(entRel)).toBe(true);
|
|
281
|
+
expect(isEntityRelation(compRel)).toBe(false);
|
|
282
|
+
expect(isEntityRelation(wild)).toBe(false);
|
|
283
|
+
expect(isEntityRelation(plain)).toBe(false);
|
|
284
|
+
|
|
285
|
+
expect(isComponentRelation(compRel)).toBe(true);
|
|
286
|
+
expect(isComponentRelation(entRel)).toBe(false);
|
|
287
|
+
expect(isComponentRelation(wild)).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("should cover getComponentIdFromRelationId and getTargetIdFromRelationId on valids and invalids", () => {
|
|
291
|
+
const r = relation(createComponentId(99), createEntityId(1024));
|
|
292
|
+
expect(getComponentIdFromRelationId(r)).toBe(createComponentId(99));
|
|
293
|
+
expect(getTargetIdFromRelationId(r)).toBe(createEntityId(1024));
|
|
294
|
+
|
|
295
|
+
expect(getComponentIdFromRelationId(123 as any)).toBeUndefined();
|
|
296
|
+
expect(getTargetIdFromRelationId(123 as any)).toBeUndefined();
|
|
297
|
+
expect(getComponentIdFromRelationId(-999999 as any)).toBeUndefined();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
260
300
|
});
|