@codehz/ecs 0.7.2 → 0.7.4

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 (81) hide show
  1. package/examples/advanced-scheduling.ts +96 -0
  2. package/examples/collision-detection.ts +229 -0
  3. package/examples/inventory-system-relations.ts +108 -0
  4. package/examples/parent-child-hierarchy.ts +206 -0
  5. package/examples/serialization.ts +337 -0
  6. package/examples/simple.ts +96 -0
  7. package/examples/spatial-grid.ts +276 -0
  8. package/examples/state-machine.ts +273 -0
  9. package/examples/tag-filtering.ts +266 -0
  10. package/package.json +60 -12
  11. package/src/__tests__/commands/buffer-limits.test.ts +72 -0
  12. package/src/__tests__/commands/buffer.test.ts +195 -0
  13. package/src/__tests__/component/singleton.test.ts +148 -0
  14. package/src/__tests__/core/archetype.test.ts +247 -0
  15. package/src/__tests__/core/bitset.test.ts +171 -0
  16. package/src/__tests__/core/changeset.test.ts +254 -0
  17. package/src/__tests__/core/multi-map.test.ts +74 -0
  18. package/src/__tests__/entity/component-registry.test.ts +66 -0
  19. package/src/__tests__/entity/entity.test.ts +520 -0
  20. package/src/__tests__/entity/id-manager.test.ts +157 -0
  21. package/src/__tests__/entity/id-system.test.ts +260 -0
  22. package/src/__tests__/perf/comprehensive.perf.test.ts +300 -0
  23. package/src/__tests__/perf/sync-hotpath.perf.test.ts +79 -0
  24. package/src/__tests__/query/basic.test.ts +341 -0
  25. package/src/__tests__/query/caching.test.ts +112 -0
  26. package/src/__tests__/query/filter.test.ts +111 -0
  27. package/src/__tests__/query/optional.test.ts +231 -0
  28. package/src/__tests__/query/perf.test.ts +99 -0
  29. package/src/__tests__/relations/dont-fragment/basic.test.ts +496 -0
  30. package/src/__tests__/relations/dont-fragment/query-notification.test.ts +125 -0
  31. package/src/__tests__/relations/wildcard.test.ts +179 -0
  32. package/src/__tests__/serialization/bounds.test.ts +237 -0
  33. package/src/__tests__/testing/assertions.test.ts +224 -0
  34. package/src/__tests__/testing/entity-builder.test.ts +84 -0
  35. package/src/__tests__/testing/snapshot.test.ts +150 -0
  36. package/src/__tests__/testing/world-fixture.test.ts +73 -0
  37. package/src/__tests__/world/component-hooks.test.ts +185 -0
  38. package/src/__tests__/world/component-management.test.ts +447 -0
  39. package/src/__tests__/world/entity-management.test.ts +86 -0
  40. package/src/__tests__/world/get-optional.test.ts +96 -0
  41. package/src/__tests__/world/multi-component-hooks.test.ts +502 -0
  42. package/src/__tests__/world/perf.test.ts +93 -0
  43. package/src/__tests__/world/query.test.ts +223 -0
  44. package/src/__tests__/world/serialize.test.ts +83 -0
  45. package/src/__tests__/world/wildcard-relation-hooks.test.ts +332 -0
  46. package/src/archetype/archetype.ts +472 -0
  47. package/src/archetype/helpers.ts +186 -0
  48. package/src/archetype/store.ts +33 -0
  49. package/src/commands/buffer.ts +110 -0
  50. package/src/commands/changeset.ts +104 -0
  51. package/src/component/entity-store.ts +223 -0
  52. package/src/component/registry.ts +657 -0
  53. package/src/component/type-utils.ts +9 -0
  54. package/src/entity/index.ts +63 -0
  55. package/src/entity/manager.ts +115 -0
  56. package/src/entity/relation.ts +319 -0
  57. package/src/entity/types.ts +135 -0
  58. package/src/index.ts +41 -0
  59. package/src/query/filter.ts +75 -0
  60. package/src/query/query.ts +313 -0
  61. package/src/query/registry.ts +101 -0
  62. package/src/storage/serialization.ts +130 -0
  63. package/src/testing/index.ts +634 -0
  64. package/src/types/index.ts +99 -0
  65. package/src/utils/bit-set.ts +133 -0
  66. package/src/utils/multi-map.ts +96 -0
  67. package/src/utils/utils.ts +19 -0
  68. package/src/world/builder.ts +100 -0
  69. package/src/world/commands.ts +378 -0
  70. package/src/world/hooks.ts +358 -0
  71. package/src/world/references.ts +38 -0
  72. package/src/world/serialization.ts +122 -0
  73. package/src/world/world.ts +1201 -0
  74. /package/{builder.d.mts → dist/builder.d.mts} +0 -0
  75. /package/{index.d.mts → dist/index.d.mts} +0 -0
  76. /package/{index.mjs → dist/index.mjs} +0 -0
  77. /package/{testing.d.mts → dist/testing.d.mts} +0 -0
  78. /package/{testing.mjs → dist/testing.mjs} +0 -0
  79. /package/{testing.mjs.map → dist/testing.mjs.map} +0 -0
  80. /package/{world.mjs → dist/world.mjs} +0 -0
  81. /package/{world.mjs.map → dist/world.mjs.map} +0 -0
@@ -0,0 +1,634 @@
1
+ /**
2
+ * @module testing
3
+ * Testing utilities for ECS-based game logic
4
+ *
5
+ * This module provides framework-agnostic testing helpers that work with
6
+ * bun:test, vitest, jest, or any other testing framework.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { describe, expect, it } from "bun:test";
11
+ * import { component } from "@codehz/ecs";
12
+ * import { WorldFixture, EntityBuilder, Assertions } from "@codehz/ecs/testing";
13
+ *
14
+ * const PositionId = component<{ x: number; y: number }>();
15
+ * const VelocityId = component<{ x: number; y: number }>();
16
+ *
17
+ * describe("Movement System", () => {
18
+ * it("should update position based on velocity", () => {
19
+ * const fixture = new WorldFixture();
20
+ * const entity = fixture
21
+ * .spawn()
22
+ * .with(PositionId, { x: 0, y: 0 })
23
+ * .with(VelocityId, { x: 1, y: 2 })
24
+ * .build();
25
+ *
26
+ * // Run your game logic here
27
+ * movementSystem(fixture.world, 1.0);
28
+ *
29
+ * expect(Assertions.hasComponent(fixture.world, entity, PositionId)).toBe(true);
30
+ * expect(Assertions.getComponent(fixture.world, entity, PositionId)).toEqual({ x: 1, y: 2 });
31
+ * });
32
+ * });
33
+ * ```
34
+ */
35
+
36
+ import type { ComponentId, EntityId, WildcardRelationId } from "../entity";
37
+ import { isWildcardRelationId, relation } from "../entity";
38
+ import type { Query } from "../query/query";
39
+ import { World } from "../world/world";
40
+ export { EntityBuilder } from "../world/builder";
41
+ export type { ComponentDef } from "../world/builder";
42
+
43
+ // =============================================================================
44
+ // Types
45
+ // =============================================================================
46
+
47
+ /**
48
+ * A component definition for entity building, supporting both regular components and relations
49
+ */
50
+ import type { EntityBuilder } from "../world/builder";
51
+
52
+ /**
53
+ * Snapshot of a single entity's component state
54
+ */
55
+ export interface EntitySnapshot {
56
+ entity: EntityId;
57
+ components: Map<EntityId<any>, unknown>;
58
+ }
59
+
60
+ /**
61
+ * Snapshot of multiple entities' component state
62
+ */
63
+ export interface WorldSnapshot {
64
+ entities: EntitySnapshot[];
65
+ }
66
+
67
+ /**
68
+ * Result of comparing two snapshots
69
+ */
70
+ export interface SnapshotDiff {
71
+ /** Entities that exist in 'after' but not in 'before' */
72
+ addedEntities: EntityId[];
73
+ /** Entities that exist in 'before' but not in 'after' */
74
+ removedEntities: EntityId[];
75
+ /** Changes to components on existing entities */
76
+ componentChanges: Array<{
77
+ entity: EntityId;
78
+ componentId: EntityId<any>;
79
+ before: unknown | undefined;
80
+ after: unknown | undefined;
81
+ changeType: "added" | "removed" | "modified";
82
+ }>;
83
+ }
84
+
85
+ // =============================================================================
86
+ // WorldFixture - Test World Factory
87
+ // =============================================================================
88
+
89
+ /**
90
+ * A test fixture that manages a World instance and provides convenient
91
+ * methods for setting up test scenarios.
92
+ *
93
+ * @example
94
+ * ```typescript
95
+ * const fixture = new WorldFixture();
96
+ *
97
+ * // Spawn entities with fluent API
98
+ * const player = fixture
99
+ * .spawn()
100
+ * .with(PositionId, { x: 0, y: 0 })
101
+ * .with(HealthId, { current: 100, max: 100 })
102
+ * .build();
103
+ *
104
+ * // Access the world for running systems
105
+ * movementSystem(fixture.world);
106
+ *
107
+ * // Clean up (optional - creates a fresh world)
108
+ * fixture.reset();
109
+ * ```
110
+ */
111
+ export class WorldFixture {
112
+ private _world: World;
113
+ private _queries: Query[] = [];
114
+
115
+ constructor() {
116
+ this._world = new World();
117
+ }
118
+
119
+ /**
120
+ * Get the underlying World instance
121
+ */
122
+ get world(): World {
123
+ return this._world;
124
+ }
125
+
126
+ /**
127
+ * Create a new EntityBuilder for spawning an entity with components
128
+ */
129
+ spawn(): EntityBuilder {
130
+ return this._world.spawn();
131
+ }
132
+
133
+ /**
134
+ * Spawn multiple entities with the same component configuration
135
+ * @param count Number of entities to spawn
136
+ * @param configure Function to configure each entity builder
137
+ * @returns Array of created entity IDs
138
+ */
139
+ spawnMany(count: number, configure: (builder: EntityBuilder, index: number) => EntityBuilder): EntityId[] {
140
+ return this._world.spawnMany(count, configure);
141
+ }
142
+
143
+ /**
144
+ * Create a query and track it for automatic cleanup
145
+ * @param componentTypes Component types to query for
146
+ * @returns Query instance
147
+ */
148
+ createQuery(componentTypes: EntityId<any>[]): Query {
149
+ const query = this._world.createQuery(componentTypes);
150
+ this._queries.push(query);
151
+ return query;
152
+ }
153
+
154
+ /**
155
+ * Execute pending commands (alias for world.sync())
156
+ */
157
+ sync(): void {
158
+ this._world.sync();
159
+ }
160
+
161
+ /**
162
+ * Reset the fixture with a fresh World instance
163
+ * Disposes all tracked queries
164
+ */
165
+ reset(): void {
166
+ for (const query of this._queries) {
167
+ query.dispose();
168
+ }
169
+ this._queries = [];
170
+ this._world = new World();
171
+ }
172
+
173
+ /**
174
+ * Capture a snapshot of specified entities and their components
175
+ * @param entities Entities to capture
176
+ * @param componentIds Components to include in the snapshot
177
+ */
178
+ captureSnapshot(entities: EntityId[], componentIds: EntityId<any>[]): WorldSnapshot {
179
+ return Snapshot.capture(this._world, entities, componentIds);
180
+ }
181
+
182
+ /**
183
+ * Symbol.dispose implementation for automatic resource management
184
+ */
185
+ [Symbol.dispose](): void {
186
+ this.reset();
187
+ }
188
+ }
189
+
190
+ // =============================================================================
191
+ // EntityBuilder - Fluent Entity Creation
192
+ // =============================================================================
193
+
194
+ /**
195
+ * Fluent builder for creating entities with components.
196
+ * Supports both regular components and entity relations.
197
+ *
198
+ * @example
199
+ * ```typescript
200
+ * // Basic usage
201
+ * // Note: build() will enqueue component commands but will NOT call world.sync().
202
+ * // You must call world.sync() or fixture.sync() manually to apply commands.
203
+ * const entity = new EntityBuilder(world)
204
+ * .with(PositionId, { x: 10, y: 20 })
205
+ * .with(VelocityId, { x: 1, y: 0 })
206
+ * .build();
207
+ * // Apply pending changes
208
+ * world.sync();
209
+ *
210
+ * // With relations
211
+ * const child = new EntityBuilder(world)
212
+ * .with(PositionId, { x: 0, y: 0 })
213
+ * .withRelation(ParentId, parentEntity, { offset: { x: 5, y: 5 } })
214
+ * .build();
215
+ * world.sync();
216
+ *
217
+ * // Tag component (void type)
218
+ * const tagged = new EntityBuilder(world)
219
+ * .with(PlayerTagId)
220
+ * .build();
221
+ * ```
222
+ */
223
+ // EntityBuilder is exported from world.ts; testing utilities will use world.spawn()
224
+
225
+ // =============================================================================
226
+ // Assertions - Test Assertion Helpers
227
+ // =============================================================================
228
+
229
+ /**
230
+ * Test assertion utilities that return boolean values or throw descriptive errors.
231
+ * These work with any testing framework's expect() function.
232
+ *
233
+ * @example
234
+ * ```typescript
235
+ * // With bun:test or vitest
236
+ * expect(Assertions.hasComponent(world, entity, PositionId)).toBe(true);
237
+ * expect(Assertions.getComponent(world, entity, PositionId)).toEqual({ x: 10, y: 20 });
238
+ *
239
+ * // Direct assertion (throws on failure)
240
+ * Assertions.assertHasComponent(world, entity, PositionId);
241
+ * Assertions.assertComponentEquals(world, entity, PositionId, { x: 10, y: 20 });
242
+ * ```
243
+ */
244
+ export const Assertions = {
245
+ /**
246
+ * Check if an entity has a specific component
247
+ */
248
+ hasComponent<T>(world: World, entity: EntityId, componentId: EntityId<T>): boolean {
249
+ return world.exists(entity) && world.has(entity, componentId);
250
+ },
251
+
252
+ /**
253
+ * Check if an entity does not have a specific component
254
+ */
255
+ lacksComponent<T>(world: World, entity: EntityId, componentId: EntityId<T>): boolean {
256
+ return !world.exists(entity) || !world.has(entity, componentId);
257
+ },
258
+
259
+ /**
260
+ * Get a component value (returns undefined if entity doesn't exist or doesn't have the component)
261
+ */
262
+ getComponent<T>(world: World, entity: EntityId, componentId: EntityId<T>): T | undefined {
263
+ if (!world.exists(entity) || !world.has(entity, componentId)) {
264
+ return undefined;
265
+ }
266
+ return world.get(entity, componentId);
267
+ },
268
+
269
+ /**
270
+ * Get all relation instances for a wildcard relation
271
+ */
272
+ getRelations<T>(world: World, entity: EntityId, componentId: ComponentId<T>): [EntityId<unknown>, T][] | undefined {
273
+ if (!world.exists(entity)) {
274
+ return undefined;
275
+ }
276
+ const wildcardId = relation(componentId, "*");
277
+ try {
278
+ return world.get(entity, wildcardId);
279
+ } catch {
280
+ return [];
281
+ }
282
+ },
283
+
284
+ /**
285
+ * Check if an entity has a relation to a specific target
286
+ */
287
+ hasRelation<T>(world: World, entity: EntityId, componentId: ComponentId<T>, targetEntity: EntityId<any>): boolean {
288
+ if (!world.exists(entity)) {
289
+ return false;
290
+ }
291
+ const relationId = relation(componentId, targetEntity);
292
+ return world.has(entity, relationId);
293
+ },
294
+
295
+ /**
296
+ * Check if an entity exists in the world
297
+ */
298
+ entityExists(world: World, entity: EntityId): boolean {
299
+ return world.exists(entity);
300
+ },
301
+
302
+ /**
303
+ * Check if a query contains specific entities
304
+ */
305
+ queryContains(query: Query, ...entities: EntityId[]): boolean {
306
+ const queryEntities = query.getEntities();
307
+ return entities.every((e) => queryEntities.includes(e));
308
+ },
309
+
310
+ /**
311
+ * Check if a query contains exactly the specified entities (no more, no less)
312
+ */
313
+ queryContainsExactly(query: Query, ...entities: EntityId[]): boolean {
314
+ const queryEntities = query.getEntities();
315
+ if (queryEntities.length !== entities.length) {
316
+ return false;
317
+ }
318
+ return entities.every((e) => queryEntities.includes(e));
319
+ },
320
+
321
+ /**
322
+ * Get the count of entities in a query
323
+ */
324
+ queryCount(query: Query): number {
325
+ return query.getEntities().length;
326
+ },
327
+
328
+ // === Throwing assertions ===
329
+
330
+ /**
331
+ * Assert that an entity has a component (throws if not)
332
+ */
333
+ assertHasComponent<T>(world: World, entity: EntityId, componentId: EntityId<T>): void {
334
+ if (!world.exists(entity)) {
335
+ throw new AssertionError(`Entity ${entity} does not exist`);
336
+ }
337
+ if (!world.has(entity, componentId)) {
338
+ throw new AssertionError(`Entity ${entity} does not have component ${componentId}`);
339
+ }
340
+ },
341
+
342
+ /**
343
+ * Assert that an entity lacks a component (throws if it has the component)
344
+ */
345
+ assertLacksComponent<T>(world: World, entity: EntityId, componentId: EntityId<T>): void {
346
+ if (world.exists(entity) && world.has(entity, componentId)) {
347
+ throw new AssertionError(`Entity ${entity} unexpectedly has component ${componentId}`);
348
+ }
349
+ },
350
+
351
+ /**
352
+ * Assert that a component equals an expected value (throws if not)
353
+ */
354
+ assertComponentEquals<T>(world: World, entity: EntityId, componentId: EntityId<T>, expected: T): void {
355
+ this.assertHasComponent(world, entity, componentId);
356
+ const actual = world.get(entity, componentId);
357
+ if (!deepEquals(actual, expected)) {
358
+ throw new AssertionError(
359
+ `Component ${componentId} on entity ${entity} does not match expected value.\n` +
360
+ `Expected: ${JSON.stringify(expected)}\n` +
361
+ `Actual: ${JSON.stringify(actual)}`,
362
+ );
363
+ }
364
+ },
365
+
366
+ /**
367
+ * Assert that an entity exists (throws if not)
368
+ */
369
+ assertEntityExists(world: World, entity: EntityId): void {
370
+ if (!world.exists(entity)) {
371
+ throw new AssertionError(`Entity ${entity} does not exist`);
372
+ }
373
+ },
374
+
375
+ /**
376
+ * Assert that an entity does not exist (throws if it exists)
377
+ */
378
+ assertEntityNotExists(world: World, entity: EntityId): void {
379
+ if (world.exists(entity)) {
380
+ throw new AssertionError(`Entity ${entity} unexpectedly exists`);
381
+ }
382
+ },
383
+
384
+ /**
385
+ * Assert that a query contains specific entities (throws if not)
386
+ */
387
+ assertQueryContains(query: Query, ...entities: EntityId[]): void {
388
+ const queryEntities = query.getEntities();
389
+ for (const entity of entities) {
390
+ if (!queryEntities.includes(entity)) {
391
+ throw new AssertionError(
392
+ `Query does not contain entity ${entity}.\n` + `Query entities: [${queryEntities.join(", ")}]`,
393
+ );
394
+ }
395
+ }
396
+ },
397
+
398
+ /**
399
+ * Assert that a query does not contain specific entities (throws if it does)
400
+ */
401
+ assertQueryNotContains(query: Query, ...entities: EntityId[]): void {
402
+ const queryEntities = query.getEntities();
403
+ for (const entity of entities) {
404
+ if (queryEntities.includes(entity)) {
405
+ throw new AssertionError(
406
+ `Query unexpectedly contains entity ${entity}.\n` + `Query entities: [${queryEntities.join(", ")}]`,
407
+ );
408
+ }
409
+ }
410
+ },
411
+ };
412
+
413
+ // =============================================================================
414
+ // Snapshot - State Capture and Comparison
415
+ // =============================================================================
416
+
417
+ /**
418
+ * Utilities for capturing and comparing world state snapshots.
419
+ * Useful for testing that systems produce expected state changes.
420
+ *
421
+ * @example
422
+ * ```typescript
423
+ * const before = Snapshot.capture(world, [entity], [PositionId, VelocityId]);
424
+ *
425
+ * // Run game logic
426
+ * movementSystem(world, deltaTime);
427
+ * world.sync();
428
+ *
429
+ * const after = Snapshot.capture(world, [entity], [PositionId, VelocityId]);
430
+ * const diff = Snapshot.compare(before, after);
431
+ *
432
+ * expect(diff.componentChanges).toHaveLength(1);
433
+ * expect(diff.componentChanges[0].changeType).toBe("modified");
434
+ * ```
435
+ */
436
+ export const Snapshot = {
437
+ /**
438
+ * Capture a snapshot of specified entities and their components
439
+ * @param world The world to capture from
440
+ * @param entities Entities to include in the snapshot
441
+ * @param componentIds Components to capture for each entity
442
+ */
443
+ capture(world: World, entities: EntityId[], componentIds: EntityId<any>[]): WorldSnapshot {
444
+ const entitySnapshots: EntitySnapshot[] = [];
445
+
446
+ for (const entity of entities) {
447
+ if (!world.exists(entity)) {
448
+ continue;
449
+ }
450
+
451
+ const components = new Map<EntityId<any>, unknown>();
452
+
453
+ for (const componentId of componentIds) {
454
+ if (isWildcardRelationId(componentId)) {
455
+ // For wildcard relations, capture all relation instances
456
+ try {
457
+ const relations = world.get(entity, componentId as WildcardRelationId<any>);
458
+ if (relations && relations.length > 0) {
459
+ components.set(componentId, deepClone(relations));
460
+ }
461
+ } catch {
462
+ // Entity doesn't have this relation type
463
+ }
464
+ } else if (world.has(entity, componentId)) {
465
+ components.set(componentId, deepClone(world.get(entity, componentId)));
466
+ }
467
+ }
468
+
469
+ entitySnapshots.push({ entity, components });
470
+ }
471
+
472
+ return { entities: entitySnapshots };
473
+ },
474
+
475
+ /**
476
+ * Compare two snapshots and return the differences
477
+ * @param before The 'before' snapshot
478
+ * @param after The 'after' snapshot
479
+ */
480
+ compare(before: WorldSnapshot, after: WorldSnapshot): SnapshotDiff {
481
+ const beforeEntities = new Set(before.entities.map((e) => e.entity));
482
+ const afterEntities = new Set(after.entities.map((e) => e.entity));
483
+
484
+ const addedEntities: EntityId[] = [];
485
+ const removedEntities: EntityId[] = [];
486
+ const componentChanges: SnapshotDiff["componentChanges"] = [];
487
+
488
+ // Find added entities
489
+ for (const entity of afterEntities) {
490
+ if (!beforeEntities.has(entity)) {
491
+ addedEntities.push(entity);
492
+ }
493
+ }
494
+
495
+ // Find removed entities
496
+ for (const entity of beforeEntities) {
497
+ if (!afterEntities.has(entity)) {
498
+ removedEntities.push(entity);
499
+ }
500
+ }
501
+
502
+ // Find component changes on existing entities
503
+ const beforeMap = new Map(before.entities.map((e) => [e.entity, e]));
504
+ const afterMap = new Map(after.entities.map((e) => [e.entity, e]));
505
+
506
+ for (const entity of beforeEntities) {
507
+ if (!afterEntities.has(entity)) continue; // Skip removed entities
508
+
509
+ const beforeEntity = beforeMap.get(entity)!;
510
+ const afterEntity = afterMap.get(entity)!;
511
+
512
+ // Check for component changes
513
+ const allComponentIds = new Set([...beforeEntity.components.keys(), ...afterEntity.components.keys()]);
514
+
515
+ for (const componentId of allComponentIds) {
516
+ const beforeValue = beforeEntity.components.get(componentId);
517
+ const afterValue = afterEntity.components.get(componentId);
518
+
519
+ if (beforeValue === undefined && afterValue !== undefined) {
520
+ componentChanges.push({
521
+ entity,
522
+ componentId,
523
+ before: undefined,
524
+ after: afterValue,
525
+ changeType: "added",
526
+ });
527
+ } else if (beforeValue !== undefined && afterValue === undefined) {
528
+ componentChanges.push({
529
+ entity,
530
+ componentId,
531
+ before: beforeValue,
532
+ after: undefined,
533
+ changeType: "removed",
534
+ });
535
+ } else if (!deepEquals(beforeValue, afterValue)) {
536
+ componentChanges.push({
537
+ entity,
538
+ componentId,
539
+ before: beforeValue,
540
+ after: afterValue,
541
+ changeType: "modified",
542
+ });
543
+ }
544
+ }
545
+ }
546
+
547
+ return { addedEntities, removedEntities, componentChanges };
548
+ },
549
+
550
+ /**
551
+ * Check if two snapshots are equal
552
+ */
553
+ equals(a: WorldSnapshot, b: WorldSnapshot): boolean {
554
+ const diff = this.compare(a, b);
555
+ return diff.addedEntities.length === 0 && diff.removedEntities.length === 0 && diff.componentChanges.length === 0;
556
+ },
557
+ };
558
+
559
+ // =============================================================================
560
+ // Utilities
561
+ // =============================================================================
562
+
563
+ /**
564
+ * Custom assertion error for testing utilities
565
+ */
566
+ export class AssertionError extends Error {
567
+ constructor(message: string) {
568
+ super(message);
569
+ this.name = "AssertionError";
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Deep equality check for comparing component values
575
+ */
576
+ function deepEquals(a: unknown, b: unknown): boolean {
577
+ if (a === b) return true;
578
+ if (a === null || b === null) return false;
579
+ if (typeof a !== typeof b) return false;
580
+
581
+ if (Array.isArray(a) && Array.isArray(b)) {
582
+ if (a.length !== b.length) return false;
583
+ for (let i = 0; i < a.length; i++) {
584
+ if (!deepEquals(a[i], b[i])) return false;
585
+ }
586
+ return true;
587
+ }
588
+
589
+ if (typeof a === "object" && typeof b === "object") {
590
+ const aObj = a as Record<string, unknown>;
591
+ const bObj = b as Record<string, unknown>;
592
+ const aKeys = Object.keys(aObj);
593
+ const bKeys = Object.keys(bObj);
594
+
595
+ if (aKeys.length !== bKeys.length) return false;
596
+
597
+ for (const key of aKeys) {
598
+ if (!Object.prototype.hasOwnProperty.call(bObj, key)) return false;
599
+ if (!deepEquals(aObj[key], bObj[key])) return false;
600
+ }
601
+ return true;
602
+ }
603
+
604
+ return false;
605
+ }
606
+
607
+ /**
608
+ * Deep clone a value for snapshot isolation
609
+ */
610
+ function deepClone<T>(value: T): T {
611
+ if (value === null || typeof value !== "object") {
612
+ return value;
613
+ }
614
+
615
+ if (Array.isArray(value)) {
616
+ return value.map(deepClone) as T;
617
+ }
618
+
619
+ const result: Record<string, unknown> = {};
620
+ for (const key of Object.keys(value)) {
621
+ result[key] = deepClone((value as Record<string, unknown>)[key]);
622
+ }
623
+ return result as T;
624
+ }
625
+
626
+ // =============================================================================
627
+ // Re-exports for convenience
628
+ // =============================================================================
629
+
630
+ export { component, relation } from "../entity";
631
+ export type { ComponentId, EntityId, RelationId, WildcardRelationId } from "../entity";
632
+ export type { Query } from "../query/query";
633
+ export type { LifecycleCallback, LifecycleHook } from "../types";
634
+ export { World } from "../world/world";