@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,273 @@
1
+ import { pipeline } from "@codehz/pipeline";
2
+ import { World, component, type Query } from "../src";
3
+
4
+ // ── Component Type Definitions ───────────────────────────────────────────────
5
+
6
+ type State = { current: "idle" | "patrol" | "flee"; timer: number };
7
+ type Health = { value: number; maxValue: number };
8
+ type Speed = { value: number };
9
+ type Target = { x: number; y: number };
10
+ type Position = { x: number; y: number };
11
+
12
+ // Void component: presence of this marks an entity as AI-controlled
13
+ type AIEnabled = void;
14
+
15
+ // ── Component ID Registration ────────────────────────────────────────────────
16
+
17
+ const State = component<State>({ name: "State" });
18
+ const Health = component<Health>({ name: "Health" });
19
+ const Speed = component<Speed>({ name: "Speed" });
20
+ const Target = component<Target>({ name: "Target" });
21
+ const Position = component<Position>({ name: "Position" });
22
+ const AIEnabled = component<AIEnabled>({ name: "AIEnabled" });
23
+
24
+ // ── Helpers ──────────────────────────────────────────────────────────────────
25
+
26
+ function distance(a: Position, b: Target): number {
27
+ const dx = b.x - a.x;
28
+ const dy = b.y - a.y;
29
+ return Math.sqrt(dx * dx + dy * dy);
30
+ }
31
+
32
+ function randomTarget(): Target {
33
+ return {
34
+ x: Math.round((Math.random() * 20 - 10) * 100) / 100,
35
+ y: Math.round((Math.random() * 20 - 10) * 100) / 100,
36
+ };
37
+ }
38
+
39
+ // ── Main ─────────────────────────────────────────────────────────────────────
40
+
41
+ function main() {
42
+ console.log("ECS State Machine & AI Demo - Lifecycle Hooks");
43
+ console.log("=============================================\n");
44
+
45
+ const world = new World();
46
+
47
+ // ── Queries (pre-cached for performance) ─────────────────────────────────
48
+
49
+ const aiQuery: Query = world.createQuery([State, Position, Speed, Target, AIEnabled]);
50
+ const healthQuery: Query = world.createQuery([Health]);
51
+
52
+ // ── Lifecycle Hooks ──────────────────────────────────────────────────────
53
+
54
+ // Log health changes
55
+ const unsubHealth = world.hook([Health], (type, entityId, health) => {
56
+ const marker = type === "init" ? "[INIT]" : type === "set" ? "[SET]" : "[REMOVE]";
57
+ console.log(` ${marker} Health | entity=${entityId} | value=${health.value.toFixed(1)}/${health.maxValue}`);
58
+ });
59
+
60
+ // Log state transitions
61
+ const unsubState = world.hook([State], (type, entityId, state) => {
62
+ const marker = type === "init" ? "[INIT]" : type === "set" ? "[SET]" : "[REMOVE]";
63
+ console.log(` ${marker} State | entity=${entityId} | current=${state.current} | timer=${state.timer.toFixed(2)}`);
64
+ });
65
+
66
+ // ── Pass 1: AI State Machine ────────────────────────────────────────────
67
+
68
+ const aiStatePass = (env: { deltaTime: number }) => {
69
+ const dt = env.deltaTime;
70
+
71
+ aiQuery.forEach([State, Position, Speed, Target], (entity, state, position, speed, target) => {
72
+ switch (state.current) {
73
+ case "idle": {
74
+ state.timer -= dt;
75
+ if (state.timer <= 0) {
76
+ // Switch to patrol with a fresh random target
77
+ state.current = "patrol";
78
+ state.timer = 0;
79
+ const newTarget = randomTarget();
80
+ world.set(entity, Target, newTarget);
81
+ console.log(` [AI] Entity ${entity}: idle → patrol | new target (${newTarget.x}, ${newTarget.y})`);
82
+ }
83
+ break;
84
+ }
85
+
86
+ case "patrol": {
87
+ const dist = distance(position, target);
88
+ const moveSpeed = speed.value * dt;
89
+
90
+ if (dist <= moveSpeed) {
91
+ // Arrived at target → switch back to idle
92
+ position.x = target.x;
93
+ position.y = target.y;
94
+ world.set(entity, Position, { x: position.x, y: position.y });
95
+ state.current = "idle";
96
+ state.timer = 1.0 + Math.random() * 2.0; // idle for 1–3 seconds
97
+ console.log(
98
+ ` [AI] Entity ${entity}: patrol → idle | arrived at (${target.x}, ${target.y}) | idle ${state.timer.toFixed(2)}s`,
99
+ );
100
+ } else {
101
+ // Move toward target
102
+ const dx = (target.x - position.x) / dist;
103
+ const dy = (target.y - position.y) / dist;
104
+ position.x += dx * moveSpeed;
105
+ position.y += dy * moveSpeed;
106
+ world.set(entity, Position, { x: position.x, y: position.y });
107
+ }
108
+ break;
109
+ }
110
+
111
+ case "flee": {
112
+ // Move away from target at 2x speed (panicked retreat)
113
+ const dist = distance(position, target);
114
+ const fleeSpeed = speed.value * 2.0 * dt;
115
+
116
+ if (dist > 0.001) {
117
+ const dx = (position.x - target.x) / dist;
118
+ const dy = (position.y - target.y) / dist;
119
+ position.x += dx * fleeSpeed;
120
+ position.y += dy * fleeSpeed;
121
+ } else {
122
+ // Exactly on target — pick arbitrary direction
123
+ position.x += fleeSpeed;
124
+ }
125
+ world.set(entity, Position, { x: position.x, y: position.y });
126
+
127
+ state.timer += dt;
128
+ // Flee for at least 3 seconds, then try going back to idle
129
+ if (state.timer >= 3.0) {
130
+ state.current = "idle";
131
+ state.timer = 1.0;
132
+ console.log(` [AI] Entity ${entity}: flee → idle | recovered after ${state.timer.toFixed(2)}s`);
133
+ }
134
+ break;
135
+ }
136
+ }
137
+
138
+ // Commit state changes (timer always updates)
139
+ world.set(entity, State, { current: state.current, timer: state.timer });
140
+ });
141
+ };
142
+
143
+ // ── Pass 2: Health Check → Trigger Flee ─────────────────────────────────
144
+
145
+ const healthCheckPass = () => {
146
+ healthQuery.forEach([Health], (entity, health) => {
147
+ if (health.value < health.maxValue * 0.3) {
148
+ // Entity is low-health; switch to flee
149
+ const state = world.get(entity, State);
150
+ if (state && state.current !== "flee") {
151
+ console.log(` [HealthCheck] Entity ${entity}: health ${health.value.toFixed(1)} < 30% → switching to flee`);
152
+ world.set(entity, State, { current: "flee", timer: 0 });
153
+ }
154
+ }
155
+ });
156
+ };
157
+
158
+ // ── Pass 3: Status Log ──────────────────────────────────────────────────
159
+
160
+ const statusLogPass = () => {
161
+ console.log(" --- Status ---");
162
+ aiQuery.forEach([State, Position, Health], (entity, state, position, health) => {
163
+ // Health is optional for AI entities (some may not have it)
164
+ const hp = health ? `${health.value.toFixed(1)}/${health.maxValue}` : "N/A";
165
+ console.log(
166
+ ` Entity ${entity}: state=${state.current} | pos=(${position.x.toFixed(2)}, ${position.y.toFixed(2)}) | hp=${hp}`,
167
+ );
168
+ });
169
+ console.log("");
170
+ };
171
+
172
+ // ── Pass 4: Sync ────────────────────────────────────────────────────────
173
+
174
+ const syncPass = () => {
175
+ world.sync();
176
+ };
177
+
178
+ // ── Build Pipeline ──────────────────────────────────────────────────────
179
+
180
+ const gameLoop = pipeline<{ deltaTime: number }>()
181
+ .addPass(aiStatePass)
182
+ .addPass(healthCheckPass)
183
+ .addPass(statusLogPass)
184
+ .addPass(syncPass)
185
+ .build();
186
+
187
+ // ── Setup Entities ──────────────────────────────────────────────────────
188
+
189
+ // Entity 1: Starts in "idle", healthy
190
+ const e1 = world
191
+ .spawn()
192
+ .with(State, { current: "idle", timer: 0.5 })
193
+ .with(Position, { x: 0, y: 0 })
194
+ .with(Speed, { value: 3.0 })
195
+ .with(Target, { x: 0, y: 0 })
196
+ .with(Health, { value: 100, maxValue: 100 })
197
+ .with(AIEnabled)
198
+ .build();
199
+
200
+ // Entity 2: Starts in "patrol", already moving toward a target
201
+ const e2 = world
202
+ .spawn()
203
+ .with(State, { current: "patrol", timer: 0 })
204
+ .with(Position, { x: 10, y: 0 })
205
+ .with(Speed, { value: 5.0 })
206
+ .with(Target, { x: 10, y: 10 })
207
+ .with(Health, { value: 40, maxValue: 100 }) // will drop below 30% soon
208
+ .with(AIEnabled)
209
+ .build();
210
+
211
+ // Entity 3: Starts in "idle", somewhat wounded
212
+ const e3 = world
213
+ .spawn()
214
+ .with(State, { current: "idle", timer: 1.0 })
215
+ .with(Position, { x: -5, y: 5 })
216
+ .with(Speed, { value: 2.0 })
217
+ .with(Target, { x: -5, y: 5 })
218
+ .with(Health, { value: 25, maxValue: 100 }) // already below 30%
219
+ .with(AIEnabled)
220
+ .build();
221
+
222
+ // Initial sync — applies all buffered commands and fires "init" hooks
223
+ console.log("--- Initial Sync (init hooks fire) ---\n");
224
+ world.sync();
225
+
226
+ // ── Run Frames ──────────────────────────────────────────────────────────
227
+
228
+ console.log("\n=== Frame 1 (dt=1.0) ===");
229
+ gameLoop({ deltaTime: 1.0 });
230
+
231
+ console.log("=== Frame 2 (dt=1.0) ===");
232
+ gameLoop({ deltaTime: 1.0 });
233
+
234
+ console.log("=== Frame 3 (dt=1.0) ===");
235
+ // Deplete entity 2's health to trigger flee
236
+ {
237
+ const h2 = world.get(e2, Health);
238
+ world.set(e2, Health, { value: h2.value - 20, maxValue: h2.maxValue });
239
+ const h3 = world.get(e3, Health);
240
+ world.set(e3, Health, { value: h3.value - 5, maxValue: h3.maxValue });
241
+ }
242
+ gameLoop({ deltaTime: 1.0 });
243
+
244
+ console.log("=== Frame 4 (dt=1.0) ===");
245
+ // Further deplete entity 1 to get it low too
246
+ {
247
+ const h1 = world.get(e1, Health);
248
+ world.set(e1, Health, { value: h1.value - 40, maxValue: h1.maxValue });
249
+ }
250
+ gameLoop({ deltaTime: 1.0 });
251
+
252
+ console.log("=== Frame 5 (dt=1.0) ===");
253
+ // Entity 1 now gets very low
254
+ {
255
+ const h1 = world.get(e1, Health);
256
+ world.set(e1, Health, { value: h1.value - 40, maxValue: h1.maxValue });
257
+ }
258
+ gameLoop({ deltaTime: 1.0 });
259
+
260
+ console.log("=== Frame 6 (dt=1.0) ===");
261
+ gameLoop({ deltaTime: 1.0 });
262
+
263
+ // ── Cleanup ─────────────────────────────────────────────────────────────
264
+
265
+ unsubHealth();
266
+ unsubState();
267
+
268
+ console.log("Demo completed!");
269
+ }
270
+
271
+ // ── Entry Point ──────────────────────────────────────────────────────────────
272
+
273
+ main();
@@ -0,0 +1,266 @@
1
+ import { pipeline } from "@codehz/pipeline";
2
+ import { World, component, type EntityId, type Query } from "../src";
3
+
4
+ // =============================================================================
5
+ // Component type definitions
6
+ // =============================================================================
7
+ type Position = { x: number; y: number };
8
+ type Health = { value: number };
9
+ type Damage = { value: number };
10
+ type Team = { id: number }; // 1 = ally, 2 = enemy
11
+
12
+ // =============================================================================
13
+ // Component IDs — void components (tags) use component() with no type arg
14
+ // =============================================================================
15
+ const Position = component<Position>({ name: "Position" });
16
+ const Health = component<Health>({ name: "Health" });
17
+ const Damage = component<Damage>({ name: "Damage" });
18
+ const Team = component<Team>({ name: "Team" });
19
+ const Alive = component({ name: "Alive" }); // void tag — living entities
20
+ const Stunned = component({ name: "Stunned" }); // void tag — CC'd entities
21
+ const Invisible = component({ name: "Invisible" }); // void tag — stealthed entities
22
+
23
+ // =============================================================================
24
+ // World & pre-cached queries
25
+ // =============================================================================
26
+ const world = new World();
27
+
28
+ // livingAllies: [Position, Health, Team] — allies that are alive but NOT stunned
29
+ const livingAllies: Query = world.createQuery([Position, Health, Team], {
30
+ negativeComponentTypes: [Stunned],
31
+ });
32
+
33
+ // visibleEnemies: [Position, Team] — all enemies (for detection/rendering)
34
+ const visibleEnemies: Query = world.createQuery([Position, Team]);
35
+
36
+ // damageableAllies: [Position, Health, Team] — allies that can be healed
37
+ const damageableAllies: Query = world.createQuery([Position, Health, Team]);
38
+
39
+ // threats: [Position, Damage, Team] — enemies that deal damage
40
+ const threats: Query = world.createQuery([Position, Damage, Team]);
41
+
42
+ // ccTargets: [Position, Team] — valid CC targets (exclude stunned + invisible)
43
+ const ccTargets: Query = world.createQuery([Position, Team], {
44
+ negativeComponentTypes: [Stunned, Invisible],
45
+ });
46
+
47
+ // =============================================================================
48
+ // Helper: distance between two positions
49
+ // =============================================================================
50
+ function dist(a: Position, b: Position): number {
51
+ const dx = a.x - b.x;
52
+ const dy = a.y - b.y;
53
+ return Math.sqrt(dx * dx + dy * dy);
54
+ }
55
+
56
+ // =============================================================================
57
+ // Game loop built with @codehz/pipeline
58
+ // =============================================================================
59
+ const gameLoop = pipeline()
60
+ // ---------------------------------------------------------------------------
61
+ // DamagePass: threats find nearest livingAlly and deal damage
62
+ // ---------------------------------------------------------------------------
63
+ .addPass(() => {
64
+ // Collect all living ally positions for target selection
65
+ const allyEntries: Array<{ entity: EntityId; pos: Position; hp: Health }> = [];
66
+ livingAllies.forEach([Position, Health, Team], (entity, pos, hp, team) => {
67
+ if (team.id === 1) {
68
+ allyEntries.push({ entity, pos, hp });
69
+ }
70
+ });
71
+
72
+ if (allyEntries.length === 0) return;
73
+
74
+ threats.forEach([Position, Damage, Team], (entity, pos, dmg, team) => {
75
+ if (team.id !== 2) return; // only enemies
76
+
77
+ // Find nearest ally
78
+ let nearest: (typeof allyEntries)[0] | null = null;
79
+ let nearestDist = Infinity;
80
+ for (const ally of allyEntries) {
81
+ const d = dist(pos, ally.pos);
82
+ if (d < nearestDist) {
83
+ nearestDist = d;
84
+ nearest = ally;
85
+ }
86
+ }
87
+
88
+ if (nearest) {
89
+ nearest.hp.value -= dmg.value;
90
+ console.log(
91
+ `[DamagePass] Enemy ${entity} hits Ally ${nearest.entity} ` +
92
+ `for ${dmg.value} damage (HP: ${nearest.hp.value})`,
93
+ );
94
+
95
+ // If health depleted, mark for cleanup: remove Alive, add Invisible
96
+ if (nearest.hp.value <= 0) {
97
+ nearest.hp.value = 0;
98
+ world.remove(nearest.entity, Alive);
99
+ world.set(nearest.entity, Invisible);
100
+ console.log(`[DamagePass] Ally ${nearest.entity} has fallen! (Alive removed, Invisible added)`);
101
+ }
102
+ }
103
+ });
104
+ })
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // HealPass: heal all damageable allies for a small amount
108
+ // ---------------------------------------------------------------------------
109
+ .addPass(() => {
110
+ damageableAllies.forEach([Position, Health, Team], (entity, _pos, hp, team) => {
111
+ if (team.id !== 1) return;
112
+ const healAmount = 5;
113
+ const oldHp = hp.value;
114
+ hp.value = Math.min(hp.value + healAmount, 100);
115
+ if (hp.value !== oldHp) {
116
+ console.log(`[HealPass] Ally ${entity} healed by ${healAmount} ` + `(HP: ${oldHp} -> ${hp.value})`);
117
+ }
118
+ });
119
+ })
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // CCApplicationPass: apply Stunned to a random valid ccTarget (if any exist)
123
+ // ---------------------------------------------------------------------------
124
+ .addPass(() => {
125
+ const targets: Array<{ entity: EntityId; team: Team }> = [];
126
+ ccTargets.forEach([Position, Team], (entity, _pos, team) => {
127
+ targets.push({ entity, team });
128
+ });
129
+
130
+ const target = targets[0];
131
+ if (target) {
132
+ // Pick the first valid target (deterministic for demo)
133
+ world.set(target.entity, Stunned);
134
+ console.log(`[CCApplicationPass] Stunned applied to Entity ${target.entity} ` + `(Team ${target.team.id})`);
135
+ } else {
136
+ console.log(`[CCApplicationPass] No valid CC targets available`);
137
+ }
138
+ })
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // CCExpiryPass: remove Stunned from all currently-stunned entities
142
+ // ---------------------------------------------------------------------------
143
+ .addPass(() => {
144
+ // Use getEntities on a query that just checks for Stunned presence.
145
+ // We don't have a dedicated "stunnedOnly" query, so iterate via allTeams.
146
+ // Better: iterate all livingAllies + visibleEnemies and check has(Stunned).
147
+ const allEntities = world.createQuery([Position]);
148
+ const stunnedEntities: EntityId[] = [];
149
+ allEntities.forEach([Position], (entity) => {
150
+ if (world.has(entity, Stunned)) {
151
+ stunnedEntities.push(entity);
152
+ }
153
+ });
154
+ allEntities.dispose(); // one-shot query — release immediately
155
+
156
+ for (const entity of stunnedEntities) {
157
+ world.remove(entity, Stunned);
158
+ console.log(`[CCExpiryPass] Stunned expired on Entity ${entity}`);
159
+ }
160
+ })
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // CleanupPass: delete entities that are dead (no Alive tag)
164
+ // ---------------------------------------------------------------------------
165
+ .addPass(() => {
166
+ const deadEntities: EntityId[] = [];
167
+ // Use a temporary query to find entities with Position (our marker for
168
+ // "active entity") that lack the Alive tag.
169
+ const allPosQuery = world.createQuery([Position]);
170
+ allPosQuery.forEach([Position], (entity) => {
171
+ if (!world.has(entity, Alive)) {
172
+ deadEntities.push(entity);
173
+ }
174
+ });
175
+ allPosQuery.dispose();
176
+
177
+ for (const entity of deadEntities) {
178
+ world.delete(entity);
179
+ console.log(`[CleanupPass] Deleted dead Entity ${entity}`);
180
+ }
181
+ })
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // StatusPass: log counts of each query result set
185
+ // ---------------------------------------------------------------------------
186
+ .addPass(() => {
187
+ console.log(`[StatusPass] === Frame Summary ===`);
188
+ console.log(` livingAllies: ${livingAllies.getEntities().length}`);
189
+ console.log(` visibleEnemies: ${visibleEnemies.getEntities().length}`);
190
+ console.log(` damageableAllies: ${damageableAllies.getEntities().length}`);
191
+ console.log(` threats: ${threats.getEntities().length}`);
192
+ console.log(` ccTargets: ${ccTargets.getEntities().length}`);
193
+ console.log(`[StatusPass] ========================`);
194
+ })
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // SyncPass: materialise all deferred structural changes
198
+ // ---------------------------------------------------------------------------
199
+ .addPass(() => {
200
+ world.sync();
201
+ })
202
+ .build();
203
+
204
+ // =============================================================================
205
+ // Setup: create entities with different tag/team combinations
206
+ // =============================================================================
207
+ function setup() {
208
+ console.log("=== Tag Filtering Demo Setup ===\n");
209
+
210
+ // --- 2 ally soldiers (Team 1, Alive, Position, Health=100) ---
211
+ world.spawn().with(Position, { x: 0, y: 0 }).with(Health, { value: 100 }).with(Team, { id: 1 }).with(Alive).build();
212
+
213
+ world.spawn().with(Position, { x: 5, y: 5 }).with(Health, { value: 100 }).with(Team, { id: 1 }).with(Alive).build();
214
+
215
+ // --- 3 enemy soldiers (Team 2, Alive, Position, Health=80, Damage=10) ---
216
+ for (let i = 0; i < 3; i++) {
217
+ world
218
+ .spawn()
219
+ .with(Position, { x: 20 + i * 10, y: 20 + i * 5 })
220
+ .with(Health, { value: 80 })
221
+ .with(Damage, { value: 10 })
222
+ .with(Team, { id: 2 })
223
+ .with(Alive)
224
+ .build();
225
+ }
226
+
227
+ // --- 1 enemy mage (Team 2, Alive, Position, Health=50, Damage=25, Invisible) ---
228
+ world
229
+ .spawn()
230
+ .with(Position, { x: 50, y: 50 })
231
+ .with(Health, { value: 50 })
232
+ .with(Damage, { value: 25 })
233
+ .with(Team, { id: 2 })
234
+ .with(Alive)
235
+ .with(Invisible) // starts stealthed — excluded from ccTargets
236
+ .build();
237
+
238
+ // --- 1 pre-stunned ally (Team 1, Alive, Position, Health=60, Stunned) ---
239
+ world
240
+ .spawn()
241
+ .with(Position, { x: -10, y: -10 })
242
+ .with(Health, { value: 60 })
243
+ .with(Team, { id: 1 })
244
+ .with(Alive)
245
+ .with(Stunned) // starts CC'd — excluded from livingAllies until expiry
246
+ .build();
247
+
248
+ world.sync();
249
+ console.log("Setup complete. Starting simulation...\n");
250
+ }
251
+
252
+ // =============================================================================
253
+ // Main entry point
254
+ // =============================================================================
255
+ function main() {
256
+ setup();
257
+
258
+ for (let frame = 1; frame <= 4; frame++) {
259
+ console.log(`\n--- Frame ${frame} ---`);
260
+ gameLoop({});
261
+ }
262
+
263
+ console.log("\n=== Demo completed! ===");
264
+ }
265
+
266
+ main();
package/package.json CHANGED
@@ -1,23 +1,69 @@
1
1
  {
2
2
  "name": "@codehz/ecs",
3
- "version": "0.7.1",
4
- "repository": {
5
- "url": "https://github.com/codehz/ecs"
6
- },
3
+ "version": "0.7.3",
4
+ "license": "MIT",
5
+ "module": "src/index.ts",
7
6
  "type": "module",
8
- "main": "./index.mjs",
9
- "types": "./index.d.mts",
7
+ "main": "./dist/index.mjs",
8
+ "types": "./dist/index.d.mts",
10
9
  "exports": {
11
10
  ".": {
12
- "types": "./index.d.mts",
13
- "import": "./index.mjs"
11
+ "types": "./dist/index.d.mts",
12
+ "import": "./dist/index.mjs"
14
13
  },
15
14
  "./testing": {
16
- "types": "./testing.d.mts",
17
- "import": "./testing.mjs"
15
+ "types": "./dist/testing.d.mts",
16
+ "import": "./dist/testing.mjs"
18
17
  }
19
18
  },
19
+ "files": [
20
+ "dist",
21
+ "examples",
22
+ "src",
23
+ "LICENSE",
24
+ "README.md",
25
+ "README.en.md"
26
+ ],
27
+ "devDependencies": {
28
+ "@codehz/pipeline": "^0.3.0",
29
+ "@types/bun": "1.3.2",
30
+ "@typescript-eslint/eslint-plugin": "^8.48.1",
31
+ "@typescript-eslint/parser": "^8.48.1",
32
+ "eslint": "^9.39.1",
33
+ "eslint-plugin-prettier": "^5.5.4",
34
+ "husky": "^9.1.7",
35
+ "lint-staged": "^16.2.6",
36
+ "prettier": "^3.6.2",
37
+ "prettier-plugin-organize-imports": "^4.3.0",
38
+ "tsdown": "^0.16.6"
39
+ },
40
+ "repository": {
41
+ "url": "https://github.com/codehz/ecs"
42
+ },
43
+ "scripts": {
44
+ "test": "bun test",
45
+ "demo": "bun run examples/simple.ts",
46
+ "release": "bun run scripts/build.ts",
47
+ "prepare": "husky",
48
+ "lint": "eslint --ext .ts,.tsx,.js --quiet",
49
+ "lint:fix": "eslint --ext .ts,.tsx,.js --fix",
50
+ "format": "prettier --write ."
51
+ },
20
52
  "peerDependencies": {
21
- "typescript": "^5.9.3"
53
+ "typescript": "^6.0.3"
54
+ },
55
+ "lint-staged": {
56
+ "*.{ts,tsx}": [
57
+ "prettier --write",
58
+ "eslint --fix"
59
+ ],
60
+ "*.{json,md}": [
61
+ "prettier --write"
62
+ ]
63
+ },
64
+ "perry": {
65
+ "compilePackages": [
66
+ "@codehz/pipeline"
67
+ ]
22
68
  }
23
- }
69
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { component } from "../../entity";
3
+ import { World } from "../../world/world";
4
+
5
+ describe("CommandBuffer iteration limit", () => {
6
+ it("should throw when exceeding MAX_ITERATIONS", () => {
7
+ const world = new World();
8
+ const Counter = component<{ value: number }>();
9
+
10
+ // Create a hook that recursively increments a counter
11
+ world.hook([Counter], {
12
+ on_set: (entityId, data) => {
13
+ // Keep triggering new set commands beyond the iteration limit
14
+ if (data.value < 200) {
15
+ world.set(entityId, Counter, { value: data.value + 1 });
16
+ }
17
+ },
18
+ });
19
+
20
+ const entity = world.new();
21
+ world.set(entity, Counter, { value: 0 });
22
+
23
+ // Executing should exceed iteration limit and throw
24
+ expect(() => world.sync()).toThrow(/maximum.*iterations|exceeded/i);
25
+ });
26
+
27
+ it("should handle multiple entities with cascading commands", () => {
28
+ const world = new World();
29
+ const Increment = component<{ count: number }>();
30
+
31
+ let hookCalls = 0;
32
+ world.hook([Increment], {
33
+ on_set: (entityId, data) => {
34
+ hookCalls++;
35
+ // Only trigger new commands on first few calls to avoid exceeding limit
36
+ // We have 2 entities, each initial set + cascading calls
37
+ if (hookCalls < 25) {
38
+ world.set(entityId, Increment, { count: data.count + 1 });
39
+ }
40
+ },
41
+ });
42
+
43
+ const entity1 = world.new();
44
+ const entity2 = world.new();
45
+
46
+ world.set(entity1, Increment, { count: 0 });
47
+ world.set(entity2, Increment, { count: 0 });
48
+
49
+ // This should complete without reaching the iteration limit (100)
50
+ world.sync();
51
+ expect(hookCalls).toBeGreaterThan(0);
52
+ expect(hookCalls).toBeLessThan(100);
53
+ });
54
+
55
+ it("should execute all commands within iteration limit", () => {
56
+ const world = new World();
57
+ const Value = component<{ num: number }>();
58
+
59
+ const entity = world.new();
60
+
61
+ // Queue multiple commands before sync
62
+ for (let i = 0; i < 50; i++) {
63
+ world.set(entity, Value, { num: i });
64
+ }
65
+
66
+ // Should complete without throwing
67
+ world.sync();
68
+
69
+ // Final value should be the last one set
70
+ expect(world.get(entity, Value)).toEqual({ num: 49 });
71
+ });
72
+ });