Initial Commit
This commit is contained in:
91
packages/gaming/games/ecs/README.md
Normal file
91
packages/gaming/games/ecs/README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# @suaveplan/ecs - Archetype-based Entity Component System with Uint32 bitset queries, phase-ordered system scheduler, entity-ID recycling, Zod-validated component schemas, structured errors, and OpenTelemetry instrumentation
|
||||
|
||||
Archetype-based Entity Component System with `Uint32`-backed bitset queries, phase-ordered system scheduling, entity-ID recycling, Zod-validated component schemas, structured errors, and OpenTelemetry instrumentation. Universal — runs identically in Bun, Node, and the browser.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [archetype](./src/archetype/archetype.md) — An `Archetype` represents the unique combination of component types shared by a group of entities. All entities with th…
|
||||
- [archetype](./src/archetype/archetype.md) — An `Archetype` represents the unique combination of component types shared by a group of entities. All entities with th…
|
||||
- [bitset](./src/bitset/bitset.md) — `BitSet` is the foundational mask primitive used by archetypes, queries, and the component registry inside `@suaveplan/…
|
||||
- [bitset](./src/bitset/bitset.md) — `BitSet` is the foundational mask primitive used by archetypes, queries, and the component registry inside `@suaveplan/…
|
||||
- [component-registry](./src/component-registry/component-registry.md) — `ComponentRegistry` translates `ComponentSchema` references into stable integer IDs. These IDs are used as bit position…
|
||||
- [component-registry](./src/component-registry/component-registry.md) — `ComponentRegistry` translates `ComponentSchema` references into stable integer IDs. These IDs are used as bit position…
|
||||
- [entity-pool](./src/entity-pool/entity-pool.md) — `EntityPool` is the entity-ID allocator. It hands out positive integer IDs on `allocate()`, recycles freed IDs on `free…
|
||||
- [entity-pool](./src/entity-pool/entity-pool.md) — `EntityPool` is the entity-ID allocator. It hands out positive integer IDs on `allocate()`, recycles freed IDs on `free…
|
||||
- [errors](./src/errors/ecs-error.md) — `EcsError` is the typed error class for every failure mode raised by `@suaveplan/ecs`. It extends `SuaveplanError` from…
|
||||
- [ecs-error](./src/errors/ecs-error.md) — `EcsError` is the typed error class for every failure mode raised by `@suaveplan/ecs`. It extends `SuaveplanError` from…
|
||||
- [observability](./src/observability/otel.md) — This module owns the OpenTelemetry instruments used by `@suaveplan/ecs`. There are two metrics — `ecs.entity.lifecycle_…
|
||||
- [otel](./src/observability/otel.md) — This module owns the OpenTelemetry instruments used by `@suaveplan/ecs`. There are two metrics — `ecs.entity.lifecycle_…
|
||||
- [query](./src/query/query.md) — `Query` compiles a declarative `QueryDescriptor` (`include` / optional `exclude` schemas) into bitset masks and evaluat…
|
||||
- [query](./src/query/query.md) — `Query` compiles a declarative `QueryDescriptor` (`include` / optional `exclude` schemas) into bitset masks and evaluat…
|
||||
- [schema](./src/schema/schema.md) — `defineComponent` is the canonical factory for `ComponentSchema` objects. It produces a deep-frozen descriptor pairing…
|
||||
- [schema](./src/schema/schema.md) — `defineComponent` is the canonical factory for `ComponentSchema` objects. It produces a deep-frozen descriptor pairing…
|
||||
- [system](./src/system/system.md) — A *system* is the unit of behaviour in an ECS — a function that runs over every entity matching a query and mutates com…
|
||||
- [system](./src/system/system.md) — A *system* is the unit of behaviour in an ECS — a function that runs over every entity matching a query and mutates com…
|
||||
- [world](./src/world/world.md) — `World` is the central container for an ECS instance. It owns the component registry, entity pool, archetype graph, que…
|
||||
- [world](./src/world/world.md) — `World` is the central container for an ECS instance. It owns the component registry, entity pool, archetype graph, que…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/ecs
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent, defineSystem } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 1, dy: 0 });
|
||||
|
||||
const world = createWorld();
|
||||
world.addSystem(defineSystem({
|
||||
name: "physics",
|
||||
query: { include: [Position, Velocity] },
|
||||
execute: ({ entities, world, dt }) => {
|
||||
for (const entity of entities) {
|
||||
const p = world.getComponent(entity, Position)!;
|
||||
const v = world.getComponent(entity, Velocity)!;
|
||||
world.setComponent(entity, Position, {
|
||||
x: p.x + v.dx * dt,
|
||||
y: p.y + v.dy * dt,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const player = world.spawn();
|
||||
world.addComponent(player, Position);
|
||||
world.addComponent(player, Velocity);
|
||||
world.execute(1 / 60);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`bitset/`](./src/bitset/bitset.md) — `Uint32Array`-backed bitset primitive.
|
||||
- [`component-registry/`](./src/component-registry/component-registry.md) — stable-ID registry for component schemas.
|
||||
- [`entity-pool/`](./src/entity-pool/entity-pool.md) — entity-ID allocator with recycling and generations.
|
||||
- [`archetype/`](./src/archetype/archetype.md) — column-oriented storage with O(1) swap-remove.
|
||||
- [`query/`](./src/query/query.md) — bitset-driven query compiler.
|
||||
- [`system/`](./src/system/system.md) — phase-priority system scheduler.
|
||||
- [`schema/`](./src/schema/schema.md) — `defineComponent` factory with optional Zod validator.
|
||||
- [`world/`](./src/world/world.md) — central container; spawn/despawn, components, queries, systems, snapshot/restore.
|
||||
- [`errors/`](./src/errors/ecs-error.md) — `EcsError` and the `ECS_CODES` registry.
|
||||
- [`observability/`](./src/observability/otel.md) — OTel counters and histograms.
|
||||
- [`types/`](./src/types/ecs.types.md) — every public type alias and interface.
|
||||
|
||||
## Quality gates
|
||||
|
||||
```bash
|
||||
bunx turbo lint typecheck test --filter=@suaveplan/ecs
|
||||
bunx turbo test:coverage --filter=@suaveplan/ecs # 100% statement+branch+function+line
|
||||
bun run docs:check --filter=@suaveplan/ecs
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT © Suaveplan Team
|
||||
|
||||
62
packages/gaming/games/ecs/package.json
Normal file
62
packages/gaming/games/ecs/package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@suaveplan/ecs",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Archetype-based Entity Component System with Uint32 bitset queries, phase-ordered system scheduler, entity-ID recycling, Zod-validated component schemas, structured errors, and OpenTelemetry instrumentation",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/opentelemetry": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
80
packages/gaming/games/ecs/src/archetype/archetype.md
Normal file
80
packages/gaming/games/ecs/src/archetype/archetype.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Archetype
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
An `Archetype` represents the unique combination of component types shared by a group of entities. All entities with the same component set live in a single archetype, whose storage is column-oriented (Struct of Arrays). When an entity's component set changes, the world swap-removes it from its current archetype and inserts it into the destination archetype, keeping iteration over any single component cache-friendly and O(1).
|
||||
|
||||
This class is internal-but-exported so advanced consumers (debug overlays, profilers, custom system runners) can inspect or iterate dense entity arrays without going through the world's higher-level API.
|
||||
|
||||
## Features
|
||||
|
||||
- Mask-keyed identity (`mask.toKey()`) plus a sorted component-ID list.
|
||||
- One dense `ComponentData[]` per component, addressed by entity slot index.
|
||||
- O(1) `removeEntity` via swap-with-tail; dense packing is invariant across mutations.
|
||||
- Returns the removed entity's full column map for cheap migration to the destination archetype.
|
||||
- `setComponent` merges partial updates without re-allocating the slot.
|
||||
- `hasComponent`, `hasEntity`, `getEntities`, `getComponentIds` accessors for read-only inspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Archetype, BitSet } from "@suaveplan/ecs";
|
||||
|
||||
const mask = new BitSet(32);
|
||||
mask.set(0);
|
||||
mask.set(1);
|
||||
const arch = new Archetype(mask, [0, 1]);
|
||||
|
||||
arch.addEntity(7, new Map([
|
||||
[0, { x: 0, y: 0 }],
|
||||
[1, { dx: 1, dy: 0 }],
|
||||
]));
|
||||
|
||||
console.log(arch.count); // 1
|
||||
console.log(arch.getComponent(7, 0)); // { x: 0, y: 0 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Archetype, BitSet } from "@suaveplan/ecs";
|
||||
|
||||
const mask = new BitSet(32);
|
||||
mask.set(0);
|
||||
const arch = new Archetype(mask, [0]);
|
||||
|
||||
for (const id of [1, 2, 3]) {
|
||||
arch.addEntity(id, new Map([[0, { v: id }]]));
|
||||
}
|
||||
|
||||
// Migration to a new archetype: pull the removed entity's data and forward it.
|
||||
const removed = arch.removeEntity(2);
|
||||
const targetArch = new Archetype(mask, [0]);
|
||||
if (removed) targetArch.addEntity(2, removed);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new Archetype(mask, componentIds)` | Build an archetype with the given component-ID set. |
|
||||
| `mask` | The bitset of owned component IDs. |
|
||||
| `id` | `mask.toKey()` — stable archetype identity. |
|
||||
| `count` | Number of entities currently in this archetype. |
|
||||
| `addEntity(entity, data)` | Append an entity with its initial column values. |
|
||||
| `removeEntity(entity)` | Swap-remove and return the entity's full column map (or `undefined`). |
|
||||
| `getComponent(entity, componentId)` | Read a single column value. |
|
||||
| `setComponent(entity, componentId, data)` | Merge a partial update; returns `false` on missing entity/column. |
|
||||
| `hasEntity(entity)` / `hasComponent(componentId)` | Membership checks. |
|
||||
| `getEntities()` / `getComponentIds()` | Read-only snapshots of the storage order. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Component data is stored in parallel arrays — one array per component-ID. Entity slot `n` maps to position `n` in every array, so a system iterating one archetype reads dense memory.
|
||||
- Swap-remove is the classic dense-vector trick: copy the tail value into the removed slot then pop. This keeps the entities array contiguous, but means the entity order is **not stable** across removals — callers needing stable order must impose it externally.
|
||||
- `setComponent` performs an object spread merge rather than assigning by reference. This costs an allocation per mutation but keeps the stored value immutable from the caller's perspective.
|
||||
- `removeEntity` collects every column value into a `Map` before destroying the slot. The world uses this map to seed the destination archetype during component-add / component-remove transitions.
|
||||
127
packages/gaming/games/ecs/src/archetype/archetype.test.ts
Normal file
127
packages/gaming/games/ecs/src/archetype/archetype.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BitSet } from "../bitset/bitset.js";
|
||||
import { Archetype } from "./archetype.js";
|
||||
|
||||
function archetypeWith(componentIds: number[], maskSize = 32): Archetype {
|
||||
const mask = new BitSet(maskSize);
|
||||
for (const cid of componentIds) mask.set(cid);
|
||||
return new Archetype(mask, componentIds);
|
||||
}
|
||||
|
||||
describe("Archetype", () => {
|
||||
it("id matches the mask key", { timeout: 5000 }, () => {
|
||||
const arch = archetypeWith([1, 3]);
|
||||
expect(arch.id).toBe("1,3");
|
||||
});
|
||||
|
||||
it("addEntity fills every column with provided data or empty object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0, 1]);
|
||||
const data = new Map<number, Record<string, unknown>>([
|
||||
[0, { x: 5 }],
|
||||
[1, { dx: 1 }],
|
||||
]);
|
||||
arch.addEntity(7, data);
|
||||
expect(arch.count).toBe(1);
|
||||
expect(arch.getComponent(7, 0)).toEqual({ x: 5 });
|
||||
expect(arch.getComponent(7, 1)).toEqual({ dx: 1 });
|
||||
});
|
||||
|
||||
it("addEntity defaults missing column values to empty object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0, 1]);
|
||||
arch.addEntity(1, new Map([[0, { x: 1 }]]));
|
||||
expect(arch.getComponent(1, 1)).toEqual({});
|
||||
});
|
||||
|
||||
it("removeEntity swap-removes and preserves dense packing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0]);
|
||||
for (const id of [1, 2, 3]) {
|
||||
arch.addEntity(id, new Map([[0, { v: id }]]));
|
||||
}
|
||||
arch.removeEntity(2);
|
||||
expect(arch.count).toBe(2);
|
||||
expect(arch.getEntities()).toEqual([1, 3]);
|
||||
expect(arch.getComponent(3, 0)).toEqual({ v: 3 });
|
||||
expect(arch.getComponent(1, 0)).toEqual({ v: 1 });
|
||||
});
|
||||
|
||||
it("removeEntity removing the tail does not move other entries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0]);
|
||||
for (const id of [1, 2, 3]) {
|
||||
arch.addEntity(id, new Map([[0, { v: id }]]));
|
||||
}
|
||||
arch.removeEntity(3);
|
||||
expect(arch.getEntities()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("removeEntity returns a map of every column value at the removed slot", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0, 1]);
|
||||
arch.addEntity(
|
||||
1,
|
||||
new Map([
|
||||
[0, { x: 1 }],
|
||||
[1, { dx: 2 }],
|
||||
])
|
||||
);
|
||||
const removed = arch.removeEntity(1);
|
||||
expect(removed?.get(0)).toEqual({ x: 1 });
|
||||
expect(removed?.get(1)).toEqual({ dx: 2 });
|
||||
});
|
||||
|
||||
it("removeEntity returns undefined for an unknown entity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0]);
|
||||
expect(arch.removeEntity(123)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("setComponent merges a partial update into the existing column value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0]);
|
||||
arch.addEntity(1, new Map([[0, { x: 0, y: 0 }]]));
|
||||
expect(arch.setComponent(1, 0, { x: 5 })).toBe(true);
|
||||
expect(arch.getComponent(1, 0)).toEqual({ x: 5, y: 0 });
|
||||
});
|
||||
|
||||
it("setComponent returns false for unknown entity or column", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0]);
|
||||
arch.addEntity(1, new Map([[0, { x: 0 }]]));
|
||||
expect(arch.setComponent(99, 0, { x: 1 })).toBe(false);
|
||||
expect(arch.setComponent(1, 99, { x: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("hasEntity and hasComponent reflect membership", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0]);
|
||||
arch.addEntity(1, new Map([[0, {}]]));
|
||||
expect(arch.hasEntity(1)).toBe(true);
|
||||
expect(arch.hasEntity(2)).toBe(false);
|
||||
expect(arch.hasComponent(0)).toBe(true);
|
||||
expect(arch.hasComponent(1)).toBe(false);
|
||||
});
|
||||
|
||||
it("getComponent on an unknown entity returns undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const arch = archetypeWith([0]);
|
||||
expect(arch.getComponent(42, 0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getComponentIds returns the registered ids", { timeout: 5000 }, () => {
|
||||
const arch = archetypeWith([2, 5]);
|
||||
expect([...arch.getComponentIds()]).toEqual([2, 5]);
|
||||
});
|
||||
});
|
||||
131
packages/gaming/games/ecs/src/archetype/archetype.ts
Normal file
131
packages/gaming/games/ecs/src/archetype/archetype.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { BitSet } from "../bitset/bitset.js";
|
||||
import type { ComponentData, ComponentId, Entity } from "../types/ecs.types.js";
|
||||
|
||||
/**
|
||||
* An archetype is a unique combination of component types. All entities with
|
||||
* the same component set share a single archetype, whose storage is laid out
|
||||
* column-first (Struct-of-Arrays): one dense array per component.
|
||||
*
|
||||
* Removal is O(1) via swap-remove with the trailing entity, and the dense
|
||||
* packing invariant is preserved across mutations.
|
||||
*/
|
||||
export class Archetype {
|
||||
/** Mask of component IDs that this archetype owns. */
|
||||
public readonly mask: BitSet;
|
||||
|
||||
/** Sorted-component-ID string used to identify and dedupe archetypes. */
|
||||
public readonly id: string;
|
||||
|
||||
private readonly entities: Entity[] = [];
|
||||
private readonly entityIndex = new Map<Entity, number>();
|
||||
private readonly columns = new Map<ComponentId, ComponentData[]>();
|
||||
private readonly componentIds: ReadonlyArray<ComponentId>;
|
||||
|
||||
public constructor(mask: BitSet, componentIds: ReadonlyArray<ComponentId>) {
|
||||
this.mask = mask;
|
||||
this.id = mask.toKey();
|
||||
this.componentIds = componentIds;
|
||||
for (const cid of componentIds) {
|
||||
this.columns.set(cid, []);
|
||||
}
|
||||
}
|
||||
|
||||
/** Append an entity with its column values. */
|
||||
public addEntity(
|
||||
entity: Entity,
|
||||
data: ReadonlyMap<ComponentId, ComponentData>
|
||||
): void {
|
||||
const index = this.entities.length;
|
||||
this.entities.push(entity);
|
||||
this.entityIndex.set(entity, index);
|
||||
for (const cid of this.componentIds) {
|
||||
const column = this.columns.get(cid) as ComponentData[];
|
||||
column.push(data.get(cid) ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove `entity` via swap-remove and return its column values.
|
||||
* Returns `undefined` if the entity is not present.
|
||||
*/
|
||||
public removeEntity(
|
||||
entity: Entity
|
||||
): Map<ComponentId, ComponentData> | undefined {
|
||||
const index = this.entityIndex.get(entity);
|
||||
if (index === undefined) return undefined;
|
||||
|
||||
const removed = new Map<ComponentId, ComponentData>();
|
||||
const lastIndex = this.entities.length - 1;
|
||||
|
||||
for (const cid of this.componentIds) {
|
||||
const column = this.columns.get(cid) as ComponentData[];
|
||||
removed.set(cid, column[index] as ComponentData);
|
||||
if (index !== lastIndex) {
|
||||
column[index] = column[lastIndex] as ComponentData;
|
||||
}
|
||||
column.pop();
|
||||
}
|
||||
|
||||
if (index !== lastIndex) {
|
||||
const lastEntity = this.entities[lastIndex] as Entity;
|
||||
this.entities[index] = lastEntity;
|
||||
this.entityIndex.set(lastEntity, index);
|
||||
}
|
||||
this.entities.pop();
|
||||
this.entityIndex.delete(entity);
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Look up a single component value for `entity`. */
|
||||
public getComponent(
|
||||
entity: Entity,
|
||||
componentId: ComponentId
|
||||
): ComponentData | undefined {
|
||||
const index = this.entityIndex.get(entity);
|
||||
if (index === undefined) return undefined;
|
||||
return this.columns.get(componentId)?.[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `data` into the existing column value for `entity` / `componentId`.
|
||||
* Returns `false` if the entity or column is absent.
|
||||
*/
|
||||
public setComponent(
|
||||
entity: Entity,
|
||||
componentId: ComponentId,
|
||||
data: ComponentData
|
||||
): boolean {
|
||||
const index = this.entityIndex.get(entity);
|
||||
if (index === undefined) return false;
|
||||
const column = this.columns.get(componentId);
|
||||
if (column === undefined) return false;
|
||||
column[index] = { ...(column[index] as ComponentData), ...data };
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when this archetype currently holds `entity`. */
|
||||
public hasEntity(entity: Entity): boolean {
|
||||
return this.entityIndex.has(entity);
|
||||
}
|
||||
|
||||
/** Read-only snapshot of the archetype's entity list, in storage order. */
|
||||
public getEntities(): ReadonlyArray<Entity> {
|
||||
return this.entities;
|
||||
}
|
||||
|
||||
/** Number of entities currently in this archetype. */
|
||||
public get count(): number {
|
||||
return this.entities.length;
|
||||
}
|
||||
|
||||
/** True when this archetype's mask has bit `componentId`. */
|
||||
public hasComponent(componentId: ComponentId): boolean {
|
||||
return this.mask.has(componentId);
|
||||
}
|
||||
|
||||
/** Read-only snapshot of the archetype's component-ID list. */
|
||||
public getComponentIds(): ReadonlyArray<ComponentId> {
|
||||
return this.componentIds;
|
||||
}
|
||||
}
|
||||
1
packages/gaming/games/ecs/src/archetype/index.ts
Normal file
1
packages/gaming/games/ecs/src/archetype/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Archetype } from "./archetype.js";
|
||||
82
packages/gaming/games/ecs/src/bitset/bitset.md
Normal file
82
packages/gaming/games/ecs/src/bitset/bitset.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# BitSet
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`BitSet` is the foundational mask primitive used by archetypes, queries, and the component registry inside `@suaveplan/ecs`. Storing component membership as bits packed into `Uint32Array` words gives the engine cache-friendly storage and O(words) algebra for subset, superset, and intersection tests — the hot paths driving every archetype-graph transition.
|
||||
|
||||
The class is intentionally narrow: it offers only the operators needed by the engine itself (`set`, `unset`, `has`, `containsAll`, `intersects`, `equals`, `count`, `clone`, `clear`, `toKey`, iterator). It is universal — no `node:*`, browser, or React dependency — so it runs identically in Bun, Node, and the browser.
|
||||
|
||||
## Features
|
||||
|
||||
- `Uint32Array`-backed storage; one word per 32 bits.
|
||||
- O(words) `containsAll` and `intersects`, safe across mismatched sizes.
|
||||
- Population count (`count`) via the SWAR Hamming-weight trick.
|
||||
- Sorted-ascending iteration through `Symbol.iterator`.
|
||||
- Stable serialised key from `toKey()` used as the archetype-graph map key.
|
||||
- Defensive `set` / `unset` guards: out-of-range bit indices throw `RangeError`; the constructor rejects non-positive sizes.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BitSet } from "@suaveplan/ecs";
|
||||
|
||||
const mask = new BitSet(64);
|
||||
mask.set(0);
|
||||
mask.set(5);
|
||||
mask.set(32);
|
||||
|
||||
console.log(mask.has(5)); // true
|
||||
console.log([...mask]); // [0, 5, 32]
|
||||
console.log(mask.toKey()); // "0,5,32"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BitSet } from "@suaveplan/ecs";
|
||||
|
||||
const include = new BitSet(128);
|
||||
const archetype = new BitSet(128);
|
||||
include.set(3);
|
||||
include.set(7);
|
||||
for (const cid of [3, 7, 11, 18]) archetype.set(cid);
|
||||
|
||||
if (archetype.containsAll(include)) {
|
||||
// Archetype has every required component — yield its entities.
|
||||
}
|
||||
|
||||
const exclude = new BitSet(128);
|
||||
exclude.set(11);
|
||||
if (archetype.intersects(exclude)) {
|
||||
// Skip — archetype contains a banned component.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new BitSet(size)` | Allocate a bitset of `size` bits. Throws `RangeError` for non-positive integer sizes. |
|
||||
| `size` | Read-only declared bit-width. |
|
||||
| `set(bit)` / `unset(bit)` | Flip a single bit. `RangeError` on out-of-range index. |
|
||||
| `has(bit)` | Test a bit. Returns `false` for out-of-range indices. |
|
||||
| `containsAll(other)` | `true` when every bit set in `other` is also set here. Safe across mismatched word counts. |
|
||||
| `intersects(other)` | `true` when at least one bit is set in both bitsets. |
|
||||
| `equals(other)` | Structural word-by-word equality. |
|
||||
| `count()` | Population count via SWAR. |
|
||||
| `clone()` | Deep copy with the same size. |
|
||||
| `clear()` | Reset every bit to zero. |
|
||||
| `toKey()` | Comma-separated string of set indices, ascending. |
|
||||
| `[Symbol.iterator]()` | Yield set indices in ascending order. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Words are accessed with `>>> 5` (divide by 32) and `& 31` (mod 32). Using bitwise math keeps everything in the 32-bit-integer fast path.
|
||||
- `containsAll` walks each word in lock-step and treats trailing words of `other` as zero when `this` is shorter — this lets the world build mask-only diffs between archetypes without forcing both sides to the same allocation size.
|
||||
- `count` is the classic 5-step parallel bit-population trick, fast and branch-free.
|
||||
- `toKey` produces a stable, comparable string used as the archetype-graph map key. It is O(size) but only paid once per archetype-create event.
|
||||
- The iterator is a generator; `for (const bit of set)` consumes it lazily.
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { BitSet } from "./bitset.js";
|
||||
|
||||
describe("BitSet — spec conformance", () => {
|
||||
it("iterator yields number indices", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(8);
|
||||
set.set(3);
|
||||
const iter = set[Symbol.iterator]();
|
||||
expectTypeOf(iter).toEqualTypeOf<Generator<number>>();
|
||||
expect(iter.next().value).toBe(3);
|
||||
});
|
||||
|
||||
it("size is publicly readable", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(128);
|
||||
expectTypeOf(set.size).toEqualTypeOf<number>();
|
||||
expect(set.size).toBe(128);
|
||||
});
|
||||
});
|
||||
129
packages/gaming/games/ecs/src/bitset/bitset.test.ts
Normal file
129
packages/gaming/games/ecs/src/bitset/bitset.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BitSet } from "./bitset.js";
|
||||
|
||||
describe("BitSet", () => {
|
||||
it("set, has, and unset propagate across multiple Uint32 words", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const set = new BitSet(96);
|
||||
for (const bit of [2, 31, 32, 64, 95]) set.set(bit);
|
||||
for (const bit of [2, 31, 32, 64, 95]) expect(set.has(bit)).toBe(true);
|
||||
expect(set.has(33)).toBe(false);
|
||||
set.unset(32);
|
||||
expect(set.has(32)).toBe(false);
|
||||
expect(set.has(31)).toBe(true);
|
||||
expect(set.has(64)).toBe(true);
|
||||
});
|
||||
|
||||
it("out-of-range has returns false rather than throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const set = new BitSet(8);
|
||||
expect(set.has(-1)).toBe(false);
|
||||
expect(set.has(99)).toBe(false);
|
||||
});
|
||||
|
||||
it("out-of-range set and unset throw RangeError", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(8);
|
||||
expect(() => set.set(8)).toThrow(RangeError);
|
||||
expect(() => set.unset(-1)).toThrow(RangeError);
|
||||
expect(() => set.set(1.5)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("constructor rejects non-positive sizes", { timeout: 5000 }, () => {
|
||||
expect(() => new BitSet(0)).toThrow(RangeError);
|
||||
expect(() => new BitSet(-1)).toThrow(RangeError);
|
||||
expect(() => new BitSet(1.5)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("containsAll returns false when other has bits in trailing words this lacks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const small = new BitSet(32);
|
||||
const big = new BitSet(96);
|
||||
big.set(64);
|
||||
expect(small.containsAll(big)).toBe(false);
|
||||
big.unset(64);
|
||||
expect(small.containsAll(big)).toBe(true);
|
||||
});
|
||||
|
||||
it("containsAll matches the brute-force scan", { timeout: 5000 }, () => {
|
||||
const a = new BitSet(64);
|
||||
const b = new BitSet(64);
|
||||
for (const bit of [1, 5, 10, 31, 32, 63]) a.set(bit);
|
||||
for (const bit of [5, 10, 31]) b.set(bit);
|
||||
expect(a.containsAll(b)).toBe(true);
|
||||
b.set(2);
|
||||
expect(a.containsAll(b)).toBe(false);
|
||||
});
|
||||
|
||||
it("intersects is symmetric and detects shared bits", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = new BitSet(64);
|
||||
const b = new BitSet(64);
|
||||
a.set(7);
|
||||
b.set(7);
|
||||
expect(a.intersects(b)).toBe(true);
|
||||
expect(b.intersects(a)).toBe(true);
|
||||
b.unset(7);
|
||||
expect(a.intersects(b)).toBe(false);
|
||||
});
|
||||
|
||||
it("the iterator yields set bit indices in ascending order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const set = new BitSet(96);
|
||||
for (const bit of [64, 2, 32, 31]) set.set(bit);
|
||||
expect([...set]).toEqual([2, 31, 32, 64]);
|
||||
});
|
||||
|
||||
it("toKey produces a stable comma-separated string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const set = new BitSet(32);
|
||||
set.set(3);
|
||||
set.set(1);
|
||||
expect(set.toKey()).toBe("1,3");
|
||||
expect(new BitSet(32).toKey()).toBe("");
|
||||
});
|
||||
|
||||
it("clone produces an independent copy", { timeout: 5000 }, () => {
|
||||
const a = new BitSet(32);
|
||||
a.set(5);
|
||||
const b = a.clone();
|
||||
b.set(10);
|
||||
expect(a.has(10)).toBe(false);
|
||||
expect(b.has(5)).toBe(true);
|
||||
expect(b.has(10)).toBe(true);
|
||||
});
|
||||
|
||||
it("equals returns true for structurally identical sets including different word lengths", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = new BitSet(32);
|
||||
const b = new BitSet(64);
|
||||
a.set(5);
|
||||
b.set(5);
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(b.equals(a)).toBe(true);
|
||||
b.set(33);
|
||||
expect(a.equals(b)).toBe(false);
|
||||
expect(b.equals(a)).toBe(false);
|
||||
});
|
||||
|
||||
it("count returns the population of set bits", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(96);
|
||||
expect(set.count()).toBe(0);
|
||||
for (const bit of [0, 31, 32, 63, 64, 95]) set.set(bit);
|
||||
expect(set.count()).toBe(6);
|
||||
});
|
||||
|
||||
it("clear resets every bit", { timeout: 5000 }, () => {
|
||||
const set = new BitSet(64);
|
||||
set.set(5);
|
||||
set.set(40);
|
||||
set.clear();
|
||||
expect(set.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
127
packages/gaming/games/ecs/src/bitset/bitset.ts
Normal file
127
packages/gaming/games/ecs/src/bitset/bitset.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Fixed-size bitset backed by `Uint32Array` storage for fast component-mask
|
||||
* arithmetic. Used by archetypes and queries for O(words) subset/superset
|
||||
* checks.
|
||||
*/
|
||||
export class BitSet {
|
||||
private readonly data: Uint32Array;
|
||||
public readonly size: number;
|
||||
|
||||
public constructor(size: number) {
|
||||
if (!Number.isInteger(size) || size <= 0) {
|
||||
throw new RangeError(
|
||||
`BitSet size must be a positive integer, got ${size}`
|
||||
);
|
||||
}
|
||||
this.size = size;
|
||||
this.data = new Uint32Array(Math.ceil(size / 32));
|
||||
}
|
||||
|
||||
/** Set the bit at `bit` to 1. */
|
||||
public set(bit: number): void {
|
||||
this.checkBit(bit);
|
||||
const index = bit >>> 5;
|
||||
const offset = bit & 31;
|
||||
this.data[index] = (this.data[index] as number) | (1 << offset);
|
||||
}
|
||||
|
||||
/** Clear the bit at `bit`. */
|
||||
public unset(bit: number): void {
|
||||
this.checkBit(bit);
|
||||
const index = bit >>> 5;
|
||||
const offset = bit & 31;
|
||||
this.data[index] = (this.data[index] as number) & ~(1 << offset);
|
||||
}
|
||||
|
||||
/** Test whether `bit` is set. */
|
||||
public has(bit: number): boolean {
|
||||
if (bit < 0 || bit >= this.size) return false;
|
||||
const index = bit >>> 5;
|
||||
const offset = bit & 31;
|
||||
return ((this.data[index] as number) & (1 << offset)) !== 0;
|
||||
}
|
||||
|
||||
/** True when this bitset has every bit set in `other`. */
|
||||
public containsAll(other: BitSet): boolean {
|
||||
const shared = Math.min(this.data.length, other.data.length);
|
||||
for (let i = 0; i < shared; i++) {
|
||||
const a = this.data[i] as number;
|
||||
const b = other.data[i] as number;
|
||||
if ((a & b) >>> 0 !== b) return false;
|
||||
}
|
||||
for (let i = shared; i < other.data.length; i++) {
|
||||
if ((other.data[i] as number) !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when this bitset shares at least one set bit with `other`. */
|
||||
public intersects(other: BitSet): boolean {
|
||||
const shared = Math.min(this.data.length, other.data.length);
|
||||
for (let i = 0; i < shared; i++) {
|
||||
if (((this.data[i] as number) & (other.data[i] as number)) !== 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Comma-separated string of set bit indices, ascending. Stable across runs. */
|
||||
public toKey(): string {
|
||||
const bits: number[] = [];
|
||||
for (let i = 0; i < this.size; i++) {
|
||||
if (this.has(i)) bits.push(i);
|
||||
}
|
||||
return bits.join(",");
|
||||
}
|
||||
|
||||
/** Return a new BitSet with the same size and bits. */
|
||||
public clone(): BitSet {
|
||||
const copy = new BitSet(this.size);
|
||||
copy.data.set(this.data);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Structural equality — true when every word matches. */
|
||||
public equals(other: BitSet): boolean {
|
||||
const len = Math.max(this.data.length, other.data.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const a = i < this.data.length ? (this.data[i] as number) : 0;
|
||||
const b = i < other.data.length ? (other.data[i] as number) : 0;
|
||||
if (a !== b) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Population count — total number of set bits. */
|
||||
public count(): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < this.data.length; i++) {
|
||||
let v = this.data[i] as number;
|
||||
v = v - ((v >>> 1) & 0x55555555);
|
||||
v = (v & 0x33333333) + ((v >>> 2) & 0x33333333);
|
||||
total += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Clear every bit. */
|
||||
public clear(): void {
|
||||
this.data.fill(0);
|
||||
}
|
||||
|
||||
/** Iterate set bit indices in ascending order. */
|
||||
public *[Symbol.iterator](): Generator<number> {
|
||||
for (let i = 0; i < this.size; i++) {
|
||||
if (this.has(i)) yield i;
|
||||
}
|
||||
}
|
||||
|
||||
private checkBit(bit: number): void {
|
||||
if (!Number.isInteger(bit) || bit < 0 || bit >= this.size) {
|
||||
throw new RangeError(
|
||||
`bit index ${bit} out of range [0, ${this.size})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/gaming/games/ecs/src/bitset/index.ts
Normal file
1
packages/gaming/games/ecs/src/bitset/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BitSet } from "./bitset.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# ComponentRegistry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ComponentRegistry` translates `ComponentSchema` references into stable integer IDs. These IDs are used as bit positions in archetype bitsets, and registering a schema is the first step before it can be added to any entity. The registry enforces three contracts: identity-stable registration (same schema → same ID), name-uniqueness (no two schemas may share a `name`), and a hard cap on how many distinct types can be registered (`maxComponents`, default 256, matching the bitset's typical 8-word allocation).
|
||||
|
||||
The registry is owned by `World` but exposed publicly so editors, profilers, and serialisation layers can iterate every registered type.
|
||||
|
||||
## Features
|
||||
|
||||
- Idempotent `register` — passing the same schema reference returns the same ID.
|
||||
- Throws structured `EcsError` with stable codes for every failure mode (`COMPONENT_NAME_CONFLICT`, `COMPONENT_LIMIT_REACHED`, `COMPONENT_NOT_REGISTERED`).
|
||||
- Bi-directional lookup: schema → ID, ID → schema, name → schema.
|
||||
- `maxComponents` cap configured at construction; validates positive integer.
|
||||
- `count`, `has`, and `schemas()` accessors for inspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ComponentRegistry, defineComponent } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const registry = new ComponentRegistry();
|
||||
const positionId = registry.register(Position);
|
||||
|
||||
console.log(positionId); // 0
|
||||
console.log(registry.register(Position) === positionId); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ComponentRegistry,
|
||||
defineComponent,
|
||||
ECS_CODES,
|
||||
EcsError,
|
||||
} from "@suaveplan/ecs";
|
||||
|
||||
const registry = new ComponentRegistry(4);
|
||||
registry.register(defineComponent("A", {}));
|
||||
registry.register(defineComponent("B", {}));
|
||||
registry.register(defineComponent("C", {}));
|
||||
registry.register(defineComponent("D", {}));
|
||||
|
||||
try {
|
||||
registry.register(defineComponent("E", {}));
|
||||
} catch (err) {
|
||||
if (err instanceof EcsError && err.code === ECS_CODES.COMPONENT_LIMIT_REACHED) {
|
||||
console.warn("raise maxComponents in WorldConfig");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new ComponentRegistry(maxComponents?)` | Default `maxComponents` is 256. Rejects non-positive integers. |
|
||||
| `register(schema)` | Return stable ID. Throws `ECS.COMPONENT_NAME_CONFLICT` / `ECS.COMPONENT_LIMIT_REACHED`. |
|
||||
| `getId(schema)` | Return ID, or throw `ECS.COMPONENT_NOT_REGISTERED`. |
|
||||
| `getSchema(id)` / `getSchemaByName(name)` | Lookups; return `undefined` when missing. |
|
||||
| `has(schema)` | Boolean membership. |
|
||||
| `count` | Number of registered schemas. |
|
||||
| `schemas()` | Read-only snapshot in registration order. |
|
||||
| `maxComponents` | Hard cap configured at construction. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Three maps back the registry — schema → id, id → schema, name → schema. The slight memory overhead pays off in O(1) lookups across all three directions.
|
||||
- IDs are dense and monotonically increasing starting at `0`. They are intentionally not stable across processes: the same schema registered second in process A and first in process B yields different IDs. Persistence layers use `schema.name`, never the ID.
|
||||
- Name uniqueness is enforced via the dedicated `nameToSchema` map; this catches the most common bug — two `defineComponent("Position", …)` calls in different modules — at registration time rather than at the first archetype-create.
|
||||
- The `maxComponents` cap is also the bitset size for archetype masks. Exceeding it would mean an archetype's mask runs off the end of its `Uint32Array`, so the limit is hard, not advisory.
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ECS_CODES, EcsError } from "../errors/ecs-error.js";
|
||||
import { defineComponent } from "../schema/schema.js";
|
||||
import { ComponentRegistry } from "./component-registry.js";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 0, dy: 0 });
|
||||
|
||||
describe("ComponentRegistry", () => {
|
||||
it("register returns the same id for the same schema reference", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
const id1 = reg.register(Position);
|
||||
const id2 = reg.register(Position);
|
||||
expect(id1).toBe(id2);
|
||||
expect(reg.count).toBe(1);
|
||||
});
|
||||
|
||||
it("register assigns dense ascending ids across schemas", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
expect(reg.register(Position)).toBe(0);
|
||||
expect(reg.register(Velocity)).toBe(1);
|
||||
expect(reg.count).toBe(2);
|
||||
});
|
||||
|
||||
it("register throws ECS.COMPONENT_NAME_CONFLICT for a duplicate name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
reg.register(Position);
|
||||
const dup = defineComponent("Position", { x: 1, y: 1 });
|
||||
try {
|
||||
reg.register(dup);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EcsError);
|
||||
expect((err as EcsError).code).toBe(
|
||||
ECS_CODES.COMPONENT_NAME_CONFLICT
|
||||
);
|
||||
expect((err as EcsError).context?.name).toBe("Position");
|
||||
}
|
||||
});
|
||||
|
||||
it("register throws ECS.COMPONENT_LIMIT_REACHED past maxComponents", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry(2);
|
||||
reg.register(Position);
|
||||
reg.register(Velocity);
|
||||
const Extra = defineComponent("Extra", { v: 0 });
|
||||
try {
|
||||
reg.register(Extra);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EcsError);
|
||||
expect((err as EcsError).code).toBe(
|
||||
ECS_CODES.COMPONENT_LIMIT_REACHED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("getId throws ECS.COMPONENT_NOT_REGISTERED for unknown schemas", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
try {
|
||||
reg.getId(Position);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EcsError);
|
||||
expect((err as EcsError).code).toBe(
|
||||
ECS_CODES.COMPONENT_NOT_REGISTERED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("getSchema and getSchemaByName resolve registered schemas", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
const id = reg.register(Position);
|
||||
expect(reg.getSchema(id)).toBe(Position);
|
||||
expect(reg.getSchemaByName("Position")).toBe(Position);
|
||||
expect(reg.getSchema(999)).toBeUndefined();
|
||||
expect(reg.getSchemaByName("Nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("has returns true for registered, false for unregistered", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
expect(reg.has(Position)).toBe(false);
|
||||
reg.register(Position);
|
||||
expect(reg.has(Position)).toBe(true);
|
||||
});
|
||||
|
||||
it("schemas returns a snapshot in registration order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
reg.register(Position);
|
||||
reg.register(Velocity);
|
||||
expect(reg.schemas()).toEqual([Position, Velocity]);
|
||||
});
|
||||
|
||||
it("constructor rejects non-positive maxComponents", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new ComponentRegistry(0)).toThrow(RangeError);
|
||||
expect(() => new ComponentRegistry(-1)).toThrow(RangeError);
|
||||
expect(() => new ComponentRegistry(1.5)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ECS_CODES, EcsError } from "../errors/ecs-error.js";
|
||||
import type {
|
||||
ComponentData,
|
||||
ComponentId,
|
||||
ComponentSchema,
|
||||
} from "../types/ecs.types.js";
|
||||
|
||||
/**
|
||||
* Stable-ID registry for component schemas. IDs are dense, monotonically
|
||||
* increasing integers used as bit positions inside archetype bitsets.
|
||||
*/
|
||||
export class ComponentRegistry {
|
||||
private nextId = 0;
|
||||
private readonly schemaToId = new Map<ComponentSchema, ComponentId>();
|
||||
private readonly idToSchema = new Map<ComponentId, ComponentSchema>();
|
||||
private readonly nameToSchema = new Map<string, ComponentSchema>();
|
||||
|
||||
/** Hard cap on the number of distinct component types. Default 256. */
|
||||
public readonly maxComponents: number;
|
||||
|
||||
public constructor(maxComponents = 256) {
|
||||
if (!Number.isInteger(maxComponents) || maxComponents <= 0) {
|
||||
throw new RangeError(
|
||||
`maxComponents must be a positive integer, got ${maxComponents}`
|
||||
);
|
||||
}
|
||||
this.maxComponents = maxComponents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a schema and return its ID.
|
||||
*
|
||||
* Idempotent by schema identity — passing the same schema reference twice
|
||||
* yields the same ID. Throws `ECS.COMPONENT_NAME_CONFLICT` when a different
|
||||
* schema is already registered under the same `name`, and
|
||||
* `ECS.COMPONENT_LIMIT_REACHED` once `maxComponents` distinct schemas
|
||||
* have been registered.
|
||||
*/
|
||||
public register<T extends ComponentData>(
|
||||
schema: ComponentSchema<T>
|
||||
): ComponentId {
|
||||
const existing = this.schemaToId.get(schema);
|
||||
if (existing !== undefined) return existing;
|
||||
|
||||
if (this.nextId >= this.maxComponents) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.COMPONENT_LIMIT_REACHED,
|
||||
"error.ecs.component_limit_reached",
|
||||
{ context: { name: schema.name, max: this.maxComponents } }
|
||||
);
|
||||
}
|
||||
|
||||
if (this.nameToSchema.has(schema.name)) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.COMPONENT_NAME_CONFLICT,
|
||||
"error.ecs.component_name_conflict",
|
||||
{ context: { name: schema.name } }
|
||||
);
|
||||
}
|
||||
|
||||
const id = this.nextId++;
|
||||
this.schemaToId.set(schema, id);
|
||||
this.idToSchema.set(id, schema);
|
||||
this.nameToSchema.set(schema.name, schema);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Return the ID for a registered schema, or throw `ECS.COMPONENT_NOT_REGISTERED`. */
|
||||
public getId(schema: ComponentSchema): ComponentId {
|
||||
const id = this.schemaToId.get(schema);
|
||||
if (id === undefined) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.COMPONENT_NOT_REGISTERED,
|
||||
"error.ecs.component_not_registered",
|
||||
{ context: { name: schema.name } }
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Lookup schema by ID. Returns `undefined` for unknown IDs. */
|
||||
public getSchema(id: ComponentId): ComponentSchema | undefined {
|
||||
return this.idToSchema.get(id);
|
||||
}
|
||||
|
||||
/** Lookup schema by name. Returns `undefined` when no schema is registered. */
|
||||
public getSchemaByName(name: string): ComponentSchema | undefined {
|
||||
return this.nameToSchema.get(name);
|
||||
}
|
||||
|
||||
/** True when the schema reference has been registered. */
|
||||
public has(schema: ComponentSchema): boolean {
|
||||
return this.schemaToId.has(schema);
|
||||
}
|
||||
|
||||
/** Number of registered schemas. */
|
||||
public get count(): number {
|
||||
return this.nextId;
|
||||
}
|
||||
|
||||
/** Snapshot of every registered schema, in registration order. */
|
||||
public schemas(): ReadonlyArray<ComponentSchema> {
|
||||
return Array.from(this.schemaToId.keys());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ComponentRegistry } from "./component-registry.js";
|
||||
71
packages/gaming/games/ecs/src/entity-pool/entity-pool.md
Normal file
71
packages/gaming/games/ecs/src/entity-pool/entity-pool.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# EntityPool
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EntityPool` is the entity-ID allocator. It hands out positive integer IDs on `allocate()`, recycles freed IDs on `free()`, and tracks a per-ID generation counter so callers can detect stale references to a recycled slot. The pool keeps the live-ID range dense, which is important because every alive entity occupies a bitset slot and dense IDs limit the working-set footprint.
|
||||
|
||||
The pool is owned by `World`; the class is exported only so debug tooling can read its state.
|
||||
|
||||
## Features
|
||||
|
||||
- Recycle-first allocation: freed IDs are returned ahead of fresh ones.
|
||||
- Per-ID generation counter — increments on every allocation, including reuse.
|
||||
- O(1) `allocate`, `free`, `isAlive`, and `generation`.
|
||||
- `peekNextId` / `setNextId` enable snapshot/restore of the underlying counter.
|
||||
- `clear()` resets every counter and free-list back to the initial state.
|
||||
- Defensive `setNextId` — rejects non-positive integers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { EntityPool } from "@suaveplan/ecs";
|
||||
|
||||
const pool = new EntityPool();
|
||||
const a = pool.allocate(); // 1
|
||||
const b = pool.allocate(); // 2
|
||||
pool.free(a);
|
||||
const recycled = pool.allocate(); // 1 — freed id comes back first
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { EntityPool } from "@suaveplan/ecs";
|
||||
|
||||
const pool = new EntityPool();
|
||||
const a = pool.allocate();
|
||||
const generationAtSpawn = pool.generation(a); // 1
|
||||
|
||||
pool.free(a);
|
||||
pool.allocate(); // recycles `a`
|
||||
const generationNow = pool.generation(a); // 2
|
||||
|
||||
// External handle compares its captured generation against the current one
|
||||
// to detect stale references to a slot that has since been recycled.
|
||||
const handleIsStale = generationAtSpawn !== generationNow;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `allocate()` | Return a fresh or recycled positive integer. Increments the entity's generation. |
|
||||
| `free(entity)` | Move `entity` to the recycled list. Returns `false` for non-alive IDs. |
|
||||
| `isAlive(entity)` | True when `entity` is currently allocated. |
|
||||
| `generation(entity)` | Generation counter. Zero for never-allocated IDs. |
|
||||
| `aliveCount` / `recycledCount` | Inspectable counts. |
|
||||
| `peekNextId` | Next fresh ID that would be assigned. |
|
||||
| `setNextId(id)` | Restore the counter from a snapshot. Rejects non-positive integers. |
|
||||
| `getAlive()` | Read-only snapshot of the live set. |
|
||||
| `clear()` | Reset every counter. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The free-list is a plain `Entity[]` used as a LIFO stack. LIFO maximises cache reuse: the most recently freed slot is the first one re-handed, so it is still likely to be hot.
|
||||
- The generation map is keyed by entity ID, not slot index, because IDs are themselves dense — using the ID as the key keeps the data structure simple.
|
||||
- `peekNextId` returns the counter even after `clear()` (which resets it to `1`). The restore path uses this to rebuild the world's counter before re-spawning entities.
|
||||
- The pool never reuses an ID that was returned by a fresh allocation but never freed — so an `aliveCount` of zero plus `recycledCount` of zero is enough to prove no stale handles exist.
|
||||
109
packages/gaming/games/ecs/src/entity-pool/entity-pool.test.ts
Normal file
109
packages/gaming/games/ecs/src/entity-pool/entity-pool.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EntityPool } from "./entity-pool.js";
|
||||
|
||||
describe("EntityPool", () => {
|
||||
it("allocate returns positive fresh ids in ascending order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pool = new EntityPool();
|
||||
expect(pool.allocate()).toBe(1);
|
||||
expect(pool.allocate()).toBe(2);
|
||||
expect(pool.allocate()).toBe(3);
|
||||
});
|
||||
|
||||
it("allocate prefers a recycled id over a fresh id", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pool = new EntityPool();
|
||||
const a = pool.allocate();
|
||||
const b = pool.allocate();
|
||||
pool.free(a);
|
||||
const recycled = pool.allocate();
|
||||
expect(recycled).toBe(a);
|
||||
expect(b).toBe(2);
|
||||
});
|
||||
|
||||
it("generation increments on every allocation of the same id", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pool = new EntityPool();
|
||||
const a = pool.allocate();
|
||||
expect(pool.generation(a)).toBe(1);
|
||||
pool.free(a);
|
||||
pool.allocate();
|
||||
expect(pool.generation(a)).toBe(2);
|
||||
});
|
||||
|
||||
it("free returns false for a non-alive entity", { timeout: 5000 }, () => {
|
||||
const pool = new EntityPool();
|
||||
expect(pool.free(999)).toBe(false);
|
||||
const a = pool.allocate();
|
||||
expect(pool.free(a)).toBe(true);
|
||||
expect(pool.free(a)).toBe(false);
|
||||
});
|
||||
|
||||
it("isAlive reflects the alive set", { timeout: 5000 }, () => {
|
||||
const pool = new EntityPool();
|
||||
const a = pool.allocate();
|
||||
expect(pool.isAlive(a)).toBe(true);
|
||||
pool.free(a);
|
||||
expect(pool.isAlive(a)).toBe(false);
|
||||
});
|
||||
|
||||
it("generation returns 0 for never-allocated entities", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pool = new EntityPool();
|
||||
expect(pool.generation(42)).toBe(0);
|
||||
});
|
||||
|
||||
it("aliveCount and recycledCount track the pool state", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pool = new EntityPool();
|
||||
pool.allocate();
|
||||
const b = pool.allocate();
|
||||
expect(pool.aliveCount).toBe(2);
|
||||
expect(pool.recycledCount).toBe(0);
|
||||
pool.free(b);
|
||||
expect(pool.aliveCount).toBe(1);
|
||||
expect(pool.recycledCount).toBe(1);
|
||||
});
|
||||
|
||||
it("peekNextId and setNextId round-trip", { timeout: 5000 }, () => {
|
||||
const pool = new EntityPool();
|
||||
pool.allocate();
|
||||
pool.allocate();
|
||||
expect(pool.peekNextId).toBe(3);
|
||||
pool.setNextId(100);
|
||||
expect(pool.allocate()).toBe(100);
|
||||
});
|
||||
|
||||
it("setNextId rejects non-positive integers", { timeout: 5000 }, () => {
|
||||
const pool = new EntityPool();
|
||||
expect(() => pool.setNextId(0)).toThrow(RangeError);
|
||||
expect(() => pool.setNextId(-1)).toThrow(RangeError);
|
||||
expect(() => pool.setNextId(1.5)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("getAlive returns a read-only snapshot of the live set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pool = new EntityPool();
|
||||
const a = pool.allocate();
|
||||
const b = pool.allocate();
|
||||
expect(pool.getAlive().has(a)).toBe(true);
|
||||
expect(pool.getAlive().has(b)).toBe(true);
|
||||
});
|
||||
|
||||
it("clear resets every internal counter", { timeout: 5000 }, () => {
|
||||
const pool = new EntityPool();
|
||||
pool.allocate();
|
||||
pool.allocate();
|
||||
pool.clear();
|
||||
expect(pool.aliveCount).toBe(0);
|
||||
expect(pool.recycledCount).toBe(0);
|
||||
expect(pool.peekNextId).toBe(1);
|
||||
expect(pool.allocate()).toBe(1);
|
||||
});
|
||||
});
|
||||
85
packages/gaming/games/ecs/src/entity-pool/entity-pool.ts
Normal file
85
packages/gaming/games/ecs/src/entity-pool/entity-pool.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Entity } from "../types/ecs.types.js";
|
||||
|
||||
/**
|
||||
* Entity-ID allocator with recycling and per-ID generation counters.
|
||||
*
|
||||
* Despawned IDs go onto a free-list and are preferred for the next
|
||||
* allocation, keeping the live-id range dense. Every allocation bumps a
|
||||
* generation counter so callers can detect references to a recycled slot.
|
||||
*/
|
||||
export class EntityPool {
|
||||
private nextId = 1;
|
||||
private readonly recycled: Entity[] = [];
|
||||
private readonly alive = new Set<Entity>();
|
||||
private readonly generations = new Map<Entity, number>();
|
||||
|
||||
/** Allocate a fresh entity ID, preferring recycled IDs. */
|
||||
public allocate(): Entity {
|
||||
let id: Entity;
|
||||
const recycled = this.recycled.pop();
|
||||
if (recycled !== undefined) {
|
||||
id = recycled;
|
||||
} else {
|
||||
id = this.nextId++;
|
||||
}
|
||||
this.alive.add(id);
|
||||
this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Return a live entity to the pool. Returns `false` for non-alive IDs. */
|
||||
public free(entity: Entity): boolean {
|
||||
if (!this.alive.has(entity)) return false;
|
||||
this.alive.delete(entity);
|
||||
this.recycled.push(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when `entity` is currently alive. */
|
||||
public isAlive(entity: Entity): boolean {
|
||||
return this.alive.has(entity);
|
||||
}
|
||||
|
||||
/** Generation counter for `entity`. Zero if never allocated. */
|
||||
public generation(entity: Entity): number {
|
||||
return this.generations.get(entity) ?? 0;
|
||||
}
|
||||
|
||||
/** Number of alive entities. */
|
||||
public get aliveCount(): number {
|
||||
return this.alive.size;
|
||||
}
|
||||
|
||||
/** Number of IDs on the recycled free-list. */
|
||||
public get recycledCount(): number {
|
||||
return this.recycled.length;
|
||||
}
|
||||
|
||||
/** Next fresh ID that will be assigned. Used by `World.snapshot`. */
|
||||
public get peekNextId(): number {
|
||||
return this.nextId;
|
||||
}
|
||||
|
||||
/** Restore the `nextId` counter from a snapshot. Generations reset. */
|
||||
public setNextId(id: number): void {
|
||||
if (!Number.isInteger(id) || id < 1) {
|
||||
throw new RangeError(
|
||||
`nextId must be a positive integer, got ${id}`
|
||||
);
|
||||
}
|
||||
this.nextId = id;
|
||||
}
|
||||
|
||||
/** Snapshot of currently alive entities. Read-only view. */
|
||||
public getAlive(): ReadonlySet<Entity> {
|
||||
return this.alive;
|
||||
}
|
||||
|
||||
/** Clear all state — alive set, free-list, generations, and counter. */
|
||||
public clear(): void {
|
||||
this.alive.clear();
|
||||
this.recycled.length = 0;
|
||||
this.generations.clear();
|
||||
this.nextId = 1;
|
||||
}
|
||||
}
|
||||
1
packages/gaming/games/ecs/src/entity-pool/index.ts
Normal file
1
packages/gaming/games/ecs/src/entity-pool/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { EntityPool } from "./entity-pool.js";
|
||||
83
packages/gaming/games/ecs/src/errors/ecs-error.md
Normal file
83
packages/gaming/games/ecs/src/errors/ecs-error.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# EcsError & ECS_CODES
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EcsError` is the typed error class for every failure mode raised by `@suaveplan/ecs`. It extends `SuaveplanError` from `@suaveplan/error`, which means every thrown error carries a stable, fully-qualified `code` string and a structured `context` object documenting the inputs at the failure site. Callers can discriminate failures from this package either by `instanceof EcsError` or by inspecting `error.code`.
|
||||
|
||||
`ECS_CODES` is the canonical registry of every code this package raises. The registry is generated through `defineCodes("ECS", [...])` from `@suaveplan/error`, which fully-qualifies the string literals (e.g., `"ECS.ENTITY_NOT_ALIVE"`) and exposes them as a frozen object.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable, fully-qualified codes — `ECS.ENTITY_NOT_ALIVE`, `ECS.COMPONENT_NOT_REGISTERED`, `ECS.COMPONENT_NAME_CONFLICT`, `ECS.COMPONENT_LIMIT_REACHED`, `ECS.COMPONENT_VALIDATION_FAILED`, `ECS.SYSTEM_NAME_CONFLICT`, `ECS.SNAPSHOT_INVALID`.
|
||||
- Every code is exercised by `src/errors/ecs-error.test.ts` from a real production code path — registry-to-throw-site coverage is a test invariant.
|
||||
- Structured `context` field on every error (`{ entity }`, `{ name }`, `{ issues }`, etc.) so the wire-redacted error envelope remains useful for debugging.
|
||||
- Cause chain preserved through `SuaveplanError`'s base support.
|
||||
- Code type `EcsCode` is a literal union narrowable in `switch` statements.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent, ECS_CODES, EcsError } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.despawn(entity);
|
||||
|
||||
try {
|
||||
world.addComponent(entity, Position);
|
||||
} catch (err) {
|
||||
if (err instanceof EcsError && err.code === ECS_CODES.ENTITY_NOT_ALIVE) {
|
||||
console.error("entity was already despawned");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ECS_CODES, EcsError } from "@suaveplan/ecs";
|
||||
|
||||
function isRetryable(err: unknown): boolean {
|
||||
if (!(err instanceof EcsError)) return false;
|
||||
switch (err.code) {
|
||||
case ECS_CODES.ENTITY_NOT_ALIVE:
|
||||
case ECS_CODES.SNAPSHOT_INVALID:
|
||||
return false;
|
||||
case ECS_CODES.COMPONENT_VALIDATION_FAILED:
|
||||
return false;
|
||||
case ECS_CODES.COMPONENT_LIMIT_REACHED:
|
||||
case ECS_CODES.COMPONENT_NAME_CONFLICT:
|
||||
case ECS_CODES.COMPONENT_NOT_REGISTERED:
|
||||
case ECS_CODES.SYSTEM_NAME_CONFLICT:
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Description |
|
||||
|---|---|
|
||||
| `EcsError` | `SuaveplanError`-extending class. Constructor: `new EcsError(code, messageKey, options?)`. |
|
||||
| `EcsCode` | Type union over every registered code string. |
|
||||
| `ECS_CODES` | Frozen registry returned by `defineCodes`. |
|
||||
| `ECS_CODES.ENTITY_NOT_ALIVE` | Thrown from `addComponent` / `removeComponent` / `setComponent` for despawned entities. |
|
||||
| `ECS_CODES.COMPONENT_NOT_REGISTERED` | `registry.getId(schema)` on an unknown schema. |
|
||||
| `ECS_CODES.COMPONENT_NAME_CONFLICT` | Two distinct schemas sharing a `name`. |
|
||||
| `ECS_CODES.COMPONENT_LIMIT_REACHED` | `register` past `maxComponents`. |
|
||||
| `ECS_CODES.COMPONENT_VALIDATION_FAILED` | Zod validator rejected merged component data. |
|
||||
| `ECS_CODES.SYSTEM_NAME_CONFLICT` | `addSystem` with a name already registered. |
|
||||
| `ECS_CODES.SNAPSHOT_INVALID` | `restore` payload failed `WorldSnapshotSchema`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Codes are namespaced under `ECS.` so cross-package error log aggregation can filter by the `ECS.*` prefix.
|
||||
- `EcsError` does not override `Error.prototype.toJSON` — `SuaveplanError` already exposes `toWire` / `toSpanEvent` for serialisation, and we don't extend the contract.
|
||||
- The test suite enumerates `Object.values(ECS_CODES)` and triggers every code through a production call. If a new code is added without a real throw site, the test will fail — this is the registered-but-unthrown drift guard required by the spec-conformance checklist (`AGENTS.md` §13).
|
||||
100
packages/gaming/games/ecs/src/errors/ecs-error.test.ts
Normal file
100
packages/gaming/games/ecs/src/errors/ecs-error.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import { defineComponent } from "../schema/schema.js";
|
||||
import { defineSystem } from "../system/system.js";
|
||||
import { createWorld } from "../world/world.js";
|
||||
import { ECS_CODES, type EcsCode, EcsError } from "./ecs-error.js";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 0 });
|
||||
const Health = defineComponent(
|
||||
"Health",
|
||||
{ hp: 100 },
|
||||
z.object({ hp: z.number().min(0) })
|
||||
);
|
||||
|
||||
/**
|
||||
* Force-trigger every registered `ECS.*` code from a production path so the
|
||||
* "every code is reachable" guarantee is enforced by the test suite.
|
||||
*/
|
||||
const triggers: Record<EcsCode, () => void> = {
|
||||
[ECS_CODES.ENTITY_NOT_ALIVE]: () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.despawn(entity);
|
||||
world.addComponent(entity, Position);
|
||||
},
|
||||
[ECS_CODES.COMPONENT_NOT_REGISTERED]: () => {
|
||||
const world = createWorld();
|
||||
world.hasComponent(world.spawn(), Position);
|
||||
},
|
||||
[ECS_CODES.COMPONENT_NAME_CONFLICT]: () => {
|
||||
const world = createWorld();
|
||||
const PositionA = defineComponent("Position", { x: 0 });
|
||||
const PositionB = defineComponent("Position", { x: 1 });
|
||||
world.registerComponent(PositionA);
|
||||
world.registerComponent(PositionB);
|
||||
},
|
||||
[ECS_CODES.COMPONENT_LIMIT_REACHED]: () => {
|
||||
const world = createWorld({ maxComponents: 1 });
|
||||
world.registerComponent(Position);
|
||||
world.registerComponent(Velocity);
|
||||
},
|
||||
[ECS_CODES.COMPONENT_VALIDATION_FAILED]: () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Health, { hp: -10 });
|
||||
},
|
||||
[ECS_CODES.SYSTEM_NAME_CONFLICT]: () => {
|
||||
const world = createWorld();
|
||||
const sys = defineSystem({
|
||||
name: "tick",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
});
|
||||
world.addSystem(sys);
|
||||
world.addSystem(sys);
|
||||
},
|
||||
[ECS_CODES.SNAPSHOT_INVALID]: () => {
|
||||
const world = createWorld();
|
||||
world.restore({ entities: [], nextEntityId: -1 });
|
||||
},
|
||||
};
|
||||
|
||||
describe("EcsError", () => {
|
||||
it("every ECS_CODES entry is thrown by at least one production code path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(ECS_CODES)) {
|
||||
let caught: unknown;
|
||||
try {
|
||||
triggers[code]();
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(EcsError);
|
||||
expect((caught as EcsError).code).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it("EcsError carries the supplied context", { timeout: 5000 }, () => {
|
||||
const err = new EcsError(
|
||||
ECS_CODES.ENTITY_NOT_ALIVE,
|
||||
"error.ecs.entity_not_alive",
|
||||
{ context: { entity: 7 } }
|
||||
);
|
||||
expect(err.code).toBe(ECS_CODES.ENTITY_NOT_ALIVE);
|
||||
expect(err.context?.entity).toBe(7);
|
||||
expect(err.name).toBe("EcsError");
|
||||
});
|
||||
|
||||
it("EcsError preserves the cause chain", { timeout: 5000 }, () => {
|
||||
const cause = new Error("boom");
|
||||
const err = new EcsError(
|
||||
ECS_CODES.SNAPSHOT_INVALID,
|
||||
"error.ecs.snapshot_invalid",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
51
packages/gaming/games/ecs/src/errors/ecs-error.ts
Normal file
51
packages/gaming/games/ecs/src/errors/ecs-error.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/ecs`.
|
||||
*
|
||||
* Codes follow the pattern `ECS.<DOMAIN>` and are resolved via
|
||||
* `@suaveplan/error`'s `defineCodes` registry. Use the fully-qualified
|
||||
* strings (e.g. `"ECS.ENTITY_NOT_ALIVE"`) for stable `error.code`
|
||||
* discrimination without relying on `instanceof`.
|
||||
*/
|
||||
export const ECS_CODES = defineCodes("ECS", [
|
||||
"ENTITY_NOT_ALIVE",
|
||||
"COMPONENT_NOT_REGISTERED",
|
||||
"COMPONENT_NAME_CONFLICT",
|
||||
"COMPONENT_LIMIT_REACHED",
|
||||
"COMPONENT_VALIDATION_FAILED",
|
||||
"SYSTEM_NAME_CONFLICT",
|
||||
"SNAPSHOT_INVALID",
|
||||
] as const);
|
||||
|
||||
export type EcsCode = (typeof ECS_CODES)[keyof typeof ECS_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for all `@suaveplan/ecs` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` so callers can discriminate failures from this
|
||||
* package via the stable `error.code` property or `error instanceof EcsError`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { ECS_CODES, EcsError } from "@suaveplan/ecs";
|
||||
*
|
||||
* try {
|
||||
* world.addComponent(deadEntity, Position);
|
||||
* } catch (err) {
|
||||
* if (err instanceof EcsError && err.code === ECS_CODES.ENTITY_NOT_ALIVE) {
|
||||
* console.error("entity was already despawned");
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class EcsError extends SuaveplanError<EcsCode> {
|
||||
public constructor(
|
||||
code: EcsCode,
|
||||
message: string,
|
||||
options?: { cause?: unknown; context?: Record<string, unknown> }
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "EcsError";
|
||||
}
|
||||
}
|
||||
1
packages/gaming/games/ecs/src/errors/index.ts
Normal file
1
packages/gaming/games/ecs/src/errors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ECS_CODES, type EcsCode, EcsError } from "./ecs-error.js";
|
||||
42
packages/gaming/games/ecs/src/index.ts
Normal file
42
packages/gaming/games/ecs/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export { Archetype } from "./archetype/archetype.js";
|
||||
export { BitSet } from "./bitset/bitset.js";
|
||||
export { ComponentRegistry } from "./component-registry/component-registry.js";
|
||||
export { EntityPool } from "./entity-pool/entity-pool.js";
|
||||
export { ECS_CODES, type EcsCode, EcsError } from "./errors/ecs-error.js";
|
||||
export {
|
||||
entityLifecycleCounter,
|
||||
executeDurationHistogram,
|
||||
recordExecuteDuration,
|
||||
recordLifecycle,
|
||||
} from "./observability/otel.js";
|
||||
export { defineQuery, Query } from "./query/query.js";
|
||||
export { defineComponent } from "./schema/schema.js";
|
||||
export {
|
||||
EntityComponentSnapshotSchema,
|
||||
EntitySnapshotSchema,
|
||||
SystemPhaseSchema,
|
||||
WorldConfigSchema,
|
||||
WorldSnapshotSchema,
|
||||
} from "./schemas/world.schemas.js";
|
||||
export {
|
||||
type DefineSystemOptions,
|
||||
defineSystem,
|
||||
SystemScheduler,
|
||||
} from "./system/system.js";
|
||||
export type {
|
||||
ArchetypeId,
|
||||
ComponentData,
|
||||
ComponentId,
|
||||
ComponentSchema,
|
||||
Entity,
|
||||
EntitySnapshot,
|
||||
QueryDescriptor,
|
||||
SystemContext,
|
||||
SystemDefinition,
|
||||
SystemExecuteFn,
|
||||
SystemPhase,
|
||||
WorldApi,
|
||||
WorldEventMap,
|
||||
WorldSnapshot,
|
||||
} from "./types/ecs.types.js";
|
||||
export { createWorld, World, type WorldConfig } from "./world/world.js";
|
||||
6
packages/gaming/games/ecs/src/observability/index.ts
Normal file
6
packages/gaming/games/ecs/src/observability/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
entityLifecycleCounter,
|
||||
executeDurationHistogram,
|
||||
recordExecuteDuration,
|
||||
recordLifecycle,
|
||||
} from "./otel.js";
|
||||
65
packages/gaming/games/ecs/src/observability/otel.md
Normal file
65
packages/gaming/games/ecs/src/observability/otel.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# OpenTelemetry Instrumentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the OpenTelemetry instruments used by `@suaveplan/ecs`. There are two metrics — `ecs.entity.lifecycle_total` (counter) and `ecs.world.execute_duration_ms` (histogram) — plus the helper functions `recordLifecycle` and `recordExecuteDuration` that the world calls from its mutation paths. Instruments are bound to a single meter (`@suaveplan/ecs`) resolved through the global `MeterProvider` at module load.
|
||||
|
||||
When no SDK is registered (e.g. plain production deploys without an OTel collector), the OTel API returns a no-op meter and recording is free. When tests register an in-memory `MeterProvider` (`tests/setup/polyfills.ts`), the same instruments emit real data points readable through the test exporter.
|
||||
|
||||
## Features
|
||||
|
||||
- Single `Counter` (`ecs.entity.lifecycle_total`) keyed by `op ∈ {spawn, despawn}`.
|
||||
- Single `Histogram` (`ecs.world.execute_duration_ms`) in milliseconds.
|
||||
- Stable meter name `@suaveplan/ecs` for filtering in dashboards.
|
||||
- Helper functions wrap `.add` / `.record` so call sites stay terse.
|
||||
- No SDK dependency at runtime — `@opentelemetry/api` is the only import.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordLifecycle, recordExecuteDuration } from "@suaveplan/ecs";
|
||||
|
||||
recordLifecycle("spawn");
|
||||
recordExecuteDuration(0.42);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider, PeriodicExportingMetricReader, InMemoryMetricExporter, AggregationTemporality } from "@opentelemetry/sdk-metrics";
|
||||
import { createWorld } from "@suaveplan/ecs";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
|
||||
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
|
||||
|
||||
const world = createWorld();
|
||||
world.spawn();
|
||||
world.execute(0.016);
|
||||
|
||||
// later
|
||||
const snapshots = await /* meterProvider */;
|
||||
// Inspect the spawn / despawn counter and the execute duration histogram.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `entityLifecycleCounter` | Raw `Counter` instrument. Attribute `op ∈ {spawn, despawn}`. |
|
||||
| `executeDurationHistogram` | Raw `Histogram` instrument; unit `"ms"`. |
|
||||
| `recordLifecycle(op)` | Increment the counter for a spawn or despawn. |
|
||||
| `recordExecuteDuration(durationMs)` | Record a single execute-tick duration. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The meter is resolved at module load via `metrics.getMeter("@suaveplan/ecs")`. The OTel API guarantees a stable meter reference: when a real SDK is later registered, *previously created* instruments continue to emit through the new provider. This is the documented "no-op-then-bind" pattern.
|
||||
- The histogram unit is `"ms"`, matching `performance.now()`. The world records the wall-clock difference between the start and end of `execute(dt)`.
|
||||
- Counter attributes are keyed by `op`, not by code-path, so dashboards can sum spawn + despawn as a single series or split per-op trivially.
|
||||
- Tests should reset the in-memory exporter between cases (`testInMemoryMetricExporter.reset()`) to avoid cross-test bleed; the package's `world.spec-conformance.test.ts` does this in an `afterEach` hook.
|
||||
- No spans are created here — the histogram is sufficient for measuring execute duration, and span-creation overhead would dominate the metric it's meant to observe. If consumers want per-system spans, they can wrap `execute` in their own tracer.
|
||||
38
packages/gaming/games/ecs/src/observability/otel.ts
Normal file
38
packages/gaming/games/ecs/src/observability/otel.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/ecs";
|
||||
|
||||
/**
|
||||
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
|
||||
* first access. When no provider is registered (i.e. production without an
|
||||
* SDK or tests without the polyfill), the OTel API returns a no-op meter.
|
||||
*/
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
|
||||
/** Counter for entity-lifecycle events; attribute `op` ∈ `{spawn, despawn}`. */
|
||||
export const entityLifecycleCounter: Counter = meter.createCounter(
|
||||
"ecs.entity.lifecycle_total",
|
||||
{
|
||||
description:
|
||||
"Total spawn/despawn events emitted by a World, attribute op ∈ {spawn, despawn}",
|
||||
}
|
||||
);
|
||||
|
||||
/** Histogram recording `World.execute` duration in milliseconds. */
|
||||
export const executeDurationHistogram: Histogram = meter.createHistogram(
|
||||
"ecs.world.execute_duration_ms",
|
||||
{
|
||||
description: "Duration of a World.execute tick in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/** Record a spawn or despawn lifecycle event. */
|
||||
export function recordLifecycle(op: "spawn" | "despawn"): void {
|
||||
entityLifecycleCounter.add(1, { op });
|
||||
}
|
||||
|
||||
/** Record an execute-tick duration. */
|
||||
export function recordExecuteDuration(durationMs: number): void {
|
||||
executeDurationHistogram.record(durationMs);
|
||||
}
|
||||
4
packages/gaming/games/ecs/src/query/index.ts
Normal file
4
packages/gaming/games/ecs/src/query/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
defineQuery,
|
||||
Query,
|
||||
} from "./query.js";
|
||||
84
packages/gaming/games/ecs/src/query/query.md
Normal file
84
packages/gaming/games/ecs/src/query/query.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Query
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`Query` compiles a declarative `QueryDescriptor` (`include` / optional `exclude` schemas) into bitset masks and evaluates it against the archetype graph. Because archetypes group entities by their exact component set, a query only needs to test each archetype's mask once — every entity inside a matching archetype is automatically a match. The result is O(archetypes) filtering plus O(entities-in-matching-archetypes) collection.
|
||||
|
||||
`defineQuery` is the canonical factory; raw `Query` instances are exposed for advanced consumers building custom system runners.
|
||||
|
||||
## Features
|
||||
|
||||
- Bitset-only match predicate (`containsAll` + `intersects`).
|
||||
- Cached `matchedArchetypes` list, invalidated by `World` whenever archetypes change.
|
||||
- Handles include-only, include + exclude, and empty-exclude descriptors cleanly.
|
||||
- Result is a flat `ReadonlyArray<Entity>` in archetype-discovery order.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defineComponent, defineQuery, createWorld } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
|
||||
const result = world.query(defineQuery([Position]));
|
||||
console.log([...result]); // [1]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineComponent,
|
||||
defineQuery,
|
||||
Query,
|
||||
createWorld,
|
||||
} from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", {});
|
||||
const Disabled = defineComponent("Disabled", {});
|
||||
|
||||
const world = createWorld();
|
||||
world.registerComponent(Position);
|
||||
world.registerComponent(Disabled);
|
||||
|
||||
// Build a long-lived Query and re-evaluate on every tick.
|
||||
const activeMovers = new Query(
|
||||
defineQuery([Position], [Disabled]),
|
||||
world.registry,
|
||||
);
|
||||
|
||||
function tick() {
|
||||
const entities = activeMovers.evaluate(
|
||||
// archetype graph is exposed indirectly via world.query; advanced
|
||||
// consumers run their own archetype scans through reflection.
|
||||
[],
|
||||
);
|
||||
// ... iterate entities ...
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new Query(descriptor, registry)` | Compile masks from a descriptor. Throws if descriptor references unregistered schemas. |
|
||||
| `defineQuery(include, exclude?)` | Build a descriptor with optional `exclude`. |
|
||||
| `matches(archetype)` | Predicate: does this archetype's mask satisfy the query? |
|
||||
| `evaluate(archetypes)` | Collect every entity from every matching archetype. Result is cached until `invalidate()`. |
|
||||
| `invalidate()` | Drop the cached match list; the next `evaluate` rebuilds it. |
|
||||
| `includeMask` / `excludeMask` | Read-only masks for debug / inspection. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `excludeMask` is `null` (not an empty `BitSet`) when no exclude is declared, so the predicate skips the `intersects` test entirely in the common case.
|
||||
- Empty `exclude: []` is treated as "no exclude" — the descriptor normalises the difference.
|
||||
- The world owns its `Query` objects; one-off `world.query(descriptor)` calls build an ad-hoc `Query` per call and discard it. Long-lived systems should pin a `Query` instance and call `evaluate` repeatedly.
|
||||
- The cached `matchedArchetypes` list is invalidated whenever the archetype graph mutates (component add/remove that creates a new archetype, or `clear`). Mutations within an existing archetype do not invalidate — entity lists are read fresh on every evaluate.
|
||||
- Iteration order is "every entity in archetype 0, then every entity in archetype 1, …". Within an archetype the order matches insertion order (subject to swap-remove side-effects).
|
||||
103
packages/gaming/games/ecs/src/query/query.test.ts
Normal file
103
packages/gaming/games/ecs/src/query/query.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Archetype } from "../archetype/archetype.js";
|
||||
import { BitSet } from "../bitset/bitset.js";
|
||||
import { ComponentRegistry } from "../component-registry/component-registry.js";
|
||||
import { defineComponent } from "../schema/schema.js";
|
||||
import { defineQuery, Query } from "./query.js";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 0 });
|
||||
const Disabled = defineComponent("Disabled", { d: true });
|
||||
|
||||
function makeArch(
|
||||
registry: ComponentRegistry,
|
||||
schemas: (typeof Position)[]
|
||||
): Archetype {
|
||||
const mask = new BitSet(registry.maxComponents);
|
||||
const ids: number[] = [];
|
||||
for (const s of schemas) {
|
||||
const id = registry.getId(s);
|
||||
mask.set(id);
|
||||
ids.push(id);
|
||||
}
|
||||
ids.sort((a, b) => a - b);
|
||||
return new Archetype(mask, ids);
|
||||
}
|
||||
|
||||
describe("Query", () => {
|
||||
it("include-only matches every superset archetype", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
reg.register(Position);
|
||||
reg.register(Velocity);
|
||||
const arch1 = makeArch(reg, [Position]);
|
||||
const arch2 = makeArch(reg, [Position, Velocity]);
|
||||
const arch3 = makeArch(reg, [Velocity]);
|
||||
for (const id of [10, 11]) arch1.addEntity(id, new Map());
|
||||
arch2.addEntity(20, new Map());
|
||||
arch3.addEntity(30, new Map());
|
||||
|
||||
const q = new Query(defineQuery([Position]), reg);
|
||||
const result = q.evaluate([arch1, arch2, arch3]);
|
||||
expect([...result].sort((a, b) => a - b)).toEqual([10, 11, 20]);
|
||||
});
|
||||
|
||||
it("exclude filters out archetypes whose mask intersects the exclude mask", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
reg.register(Position);
|
||||
reg.register(Disabled);
|
||||
const archEnabled = makeArch(reg, [Position]);
|
||||
const archDisabled = makeArch(reg, [Position, Disabled]);
|
||||
archEnabled.addEntity(1, new Map());
|
||||
archDisabled.addEntity(2, new Map());
|
||||
|
||||
const q = new Query(defineQuery([Position], [Disabled]), reg);
|
||||
const result = q.evaluate([archEnabled, archDisabled]);
|
||||
expect([...result]).toEqual([1]);
|
||||
});
|
||||
|
||||
it("invalidate clears the cached match list and forces a re-scan", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
reg.register(Position);
|
||||
reg.register(Velocity);
|
||||
const arch1 = makeArch(reg, [Position]);
|
||||
arch1.addEntity(1, new Map());
|
||||
|
||||
const archetypes: Archetype[] = [arch1];
|
||||
const q = new Query(defineQuery([Position]), reg);
|
||||
expect(q.evaluate(archetypes)).toEqual([1]);
|
||||
|
||||
const arch2 = makeArch(reg, [Position, Velocity]);
|
||||
arch2.addEntity(2, new Map());
|
||||
archetypes.push(arch2);
|
||||
|
||||
// Without invalidate, the new archetype is not reconsidered.
|
||||
expect(q.evaluate(archetypes)).toEqual([1]);
|
||||
q.invalidate();
|
||||
expect([...q.evaluate(archetypes)].sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("defineQuery omits exclude when not supplied", { timeout: 5000 }, () => {
|
||||
const descriptor = defineQuery([Position]);
|
||||
expect(descriptor.exclude).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defineQuery includes exclude when provided", { timeout: 5000 }, () => {
|
||||
const descriptor = defineQuery([Position], [Disabled]);
|
||||
expect(descriptor.exclude).toEqual([Disabled]);
|
||||
});
|
||||
|
||||
it("empty exclude array is treated as no exclude", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new ComponentRegistry();
|
||||
reg.register(Position);
|
||||
const q = new Query({ include: [Position], exclude: [] }, reg);
|
||||
expect(q.excludeMask).toBeNull();
|
||||
});
|
||||
});
|
||||
87
packages/gaming/games/ecs/src/query/query.ts
Normal file
87
packages/gaming/games/ecs/src/query/query.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Archetype } from "../archetype/archetype.js";
|
||||
import { BitSet } from "../bitset/bitset.js";
|
||||
import type { ComponentRegistry } from "../component-registry/component-registry.js";
|
||||
import type { Entity, QueryDescriptor } from "../types/ecs.types.js";
|
||||
|
||||
/**
|
||||
* Compiled query matching archetypes by include/exclude masks.
|
||||
*
|
||||
* Queries cache the list of matched archetypes between mutations. The world
|
||||
* calls `invalidate()` whenever the archetype graph changes so the next
|
||||
* `evaluate` re-scans.
|
||||
*/
|
||||
export class Query {
|
||||
public readonly includeMask: BitSet;
|
||||
public readonly excludeMask: BitSet | null;
|
||||
|
||||
private matchedArchetypes: Archetype[] = [];
|
||||
private dirty = true;
|
||||
|
||||
public constructor(
|
||||
descriptor: QueryDescriptor,
|
||||
registry: ComponentRegistry
|
||||
) {
|
||||
this.includeMask = new BitSet(registry.maxComponents);
|
||||
for (const schema of descriptor.include) {
|
||||
this.includeMask.set(registry.getId(schema));
|
||||
}
|
||||
|
||||
if (descriptor.exclude !== undefined && descriptor.exclude.length > 0) {
|
||||
this.excludeMask = new BitSet(registry.maxComponents);
|
||||
for (const schema of descriptor.exclude) {
|
||||
this.excludeMask.set(registry.getId(schema));
|
||||
}
|
||||
} else {
|
||||
this.excludeMask = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when an archetype matches this query's include/exclude masks. */
|
||||
public matches(archetype: Archetype): boolean {
|
||||
if (!archetype.mask.containsAll(this.includeMask)) return false;
|
||||
if (
|
||||
this.excludeMask !== null &&
|
||||
archetype.mask.intersects(this.excludeMask)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Mark the cached match list dirty. */
|
||||
public invalidate(): void {
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
/** Collect every matching entity across all archetypes. */
|
||||
public evaluate(
|
||||
archetypes: ReadonlyArray<Archetype>
|
||||
): ReadonlyArray<Entity> {
|
||||
if (this.dirty) {
|
||||
this.matchedArchetypes = [];
|
||||
for (const arch of archetypes) {
|
||||
if (this.matches(arch)) {
|
||||
this.matchedArchetypes.push(arch);
|
||||
}
|
||||
}
|
||||
this.dirty = false;
|
||||
}
|
||||
|
||||
const result: Entity[] = [];
|
||||
for (const arch of this.matchedArchetypes) {
|
||||
const entities = arch.getEntities();
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
result.push(entities[i] as Entity);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a {@link QueryDescriptor} from component schemas. */
|
||||
export function defineQuery(
|
||||
include: QueryDescriptor["include"],
|
||||
exclude?: QueryDescriptor["exclude"]
|
||||
): QueryDescriptor {
|
||||
return exclude !== undefined ? { include, exclude } : { include };
|
||||
}
|
||||
1
packages/gaming/games/ecs/src/schema/index.ts
Normal file
1
packages/gaming/games/ecs/src/schema/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { defineComponent } from "./schema.js";
|
||||
65
packages/gaming/games/ecs/src/schema/schema.md
Normal file
65
packages/gaming/games/ecs/src/schema/schema.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# defineComponent
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`defineComponent` is the canonical factory for `ComponentSchema` objects. It produces a deep-frozen descriptor pairing a stable `name`, a frozen `defaults` value, and an optional Zod validator. The descriptor is the unit the registry, world, and systems treat as a component's public identity — passing the same descriptor reference through the API is what proves two call sites mean the same component.
|
||||
|
||||
The optional Zod validator opts the descriptor into runtime validation: `World.addComponent` and `World.setComponent` will reject component data that fails the schema with `ECS.COMPONENT_VALIDATION_FAILED`, capturing the Zod issues in `error.context`.
|
||||
|
||||
## Features
|
||||
|
||||
- Deep-freeze on the descriptor and its `defaults` — strict-mode mutation throws.
|
||||
- Defaults are *copied* on construction, so caller mutations after `defineComponent` don't leak into the descriptor.
|
||||
- Optional `schema?: z.ZodType<T>` validator threaded through to the world's validation step.
|
||||
- specific addition over upstream's `defineComponent` — upstream lacked the Zod hook.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defineComponent } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0, z: 0 });
|
||||
console.log(Position.name); // "Position"
|
||||
console.log(Position.defaults); // { x: 0, y: 0, z: 0 } (frozen)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { defineComponent, createWorld } from "@suaveplan/ecs";
|
||||
|
||||
const Health = defineComponent(
|
||||
"Health",
|
||||
{ current: 100, max: 100 },
|
||||
z.object({
|
||||
current: z.number().nonnegative(),
|
||||
max: z.number().positive(),
|
||||
}),
|
||||
);
|
||||
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
// Throws EcsError(code = ECS.COMPONENT_VALIDATION_FAILED)
|
||||
world.addComponent(entity, Health, { current: -1, max: 100 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Signature | Description |
|
||||
|---|---|
|
||||
| `defineComponent<T>(name, defaults, schema?)` | Build a frozen `ComponentSchema<T>` with optional Zod validator. |
|
||||
| `ComponentSchema.name` | Stable string identity. |
|
||||
| `ComponentSchema.defaults` | Frozen default value, deep-copied. |
|
||||
| `ComponentSchema.schema?` | Optional Zod validator for runtime checks inside `World.addComponent` / `World.setComponent`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The defaults clone is shallow (`{ ...defaults }`). For nested mutable references, callers should freeze deeply themselves before passing in. The component schema is identity-stable, but interior mutability of a passed reference is out of scope.
|
||||
- The schema property is added conditionally (omitted from the object when not supplied) so JSON serialisation of a descriptor produces a clean two-key shape when no validator is supplied.
|
||||
- Validation is *not* applied at registration — only at component-add / component-set boundaries. This keeps the registry decoupled from Zod and means a schema can be retrofitted onto an existing component without rewiring registration.
|
||||
- The factory does not generate IDs; the registry assigns those on first registration via `World.registerComponent` or implicitly during `addComponent`.
|
||||
42
packages/gaming/games/ecs/src/schema/schema.test.ts
Normal file
42
packages/gaming/games/ecs/src/schema/schema.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import { defineComponent } from "./schema.js";
|
||||
|
||||
describe("defineComponent", () => {
|
||||
it("returns a frozen descriptor with frozen defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
expect(Object.isFrozen(Position)).toBe(true);
|
||||
expect(Object.isFrozen(Position.defaults)).toBe(true);
|
||||
expect(() => {
|
||||
(Position.defaults as { x: number }).x = 5;
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("exposes the name and defaults", { timeout: 5000 }, () => {
|
||||
const Health = defineComponent("Health", { hp: 100, max: 100 });
|
||||
expect(Health.name).toBe("Health");
|
||||
expect(Health.defaults).toEqual({ hp: 100, max: 100 });
|
||||
});
|
||||
|
||||
it("omits schema when not supplied", { timeout: 5000 }, () => {
|
||||
const Position = defineComponent("Position", { x: 0 });
|
||||
expect(Position.schema).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exposes a supplied Zod schema", { timeout: 5000 }, () => {
|
||||
const schema = z.object({ hp: z.number().min(0) });
|
||||
const Health = defineComponent("Health", { hp: 100 }, schema);
|
||||
expect(Health.schema).toBe(schema);
|
||||
});
|
||||
|
||||
it("defaults are copied so caller mutations cannot leak in", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const draft = { x: 0, y: 0 };
|
||||
const Position = defineComponent("Position", draft);
|
||||
draft.x = 5;
|
||||
expect(Position.defaults).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
33
packages/gaming/games/ecs/src/schema/schema.ts
Normal file
33
packages/gaming/games/ecs/src/schema/schema.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type z from "zod";
|
||||
import type { ComponentData, ComponentSchema } from "../types/ecs.types.js";
|
||||
|
||||
/**
|
||||
* Build a frozen {@link ComponentSchema}.
|
||||
*
|
||||
* The returned object and its `defaults` are deep-frozen so accidental
|
||||
* mutation is rejected in strict mode. Supplying a Zod `schema` opts the
|
||||
* descriptor into runtime validation inside `World.addComponent` and
|
||||
* `World.setComponent`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const Position = defineComponent("Position", { x: 0, y: 0, z: 0 });
|
||||
* const Health = defineComponent(
|
||||
* "Health",
|
||||
* { current: 100, max: 100 },
|
||||
* z.object({ current: z.number().nonnegative(), max: z.number().positive() }),
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function defineComponent<T extends ComponentData>(
|
||||
name: string,
|
||||
defaults: T,
|
||||
schema?: z.ZodType<T>
|
||||
): ComponentSchema<T> {
|
||||
const frozen: ComponentSchema<T> = {
|
||||
name,
|
||||
defaults: Object.freeze({ ...defaults }),
|
||||
...(schema !== undefined ? { schema } : {}),
|
||||
};
|
||||
return Object.freeze(frozen);
|
||||
}
|
||||
7
packages/gaming/games/ecs/src/schemas/index.ts
Normal file
7
packages/gaming/games/ecs/src/schemas/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
EntityComponentSnapshotSchema,
|
||||
EntitySnapshotSchema,
|
||||
SystemPhaseSchema,
|
||||
WorldConfigSchema,
|
||||
WorldSnapshotSchema,
|
||||
} from "./world.schemas.js";
|
||||
34
packages/gaming/games/ecs/src/schemas/world.schemas.ts
Normal file
34
packages/gaming/games/ecs/src/schemas/world.schemas.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Zod schema for a single snapshotted component entry. */
|
||||
export const EntityComponentSnapshotSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
/** Zod schema for a single snapshotted entity. */
|
||||
export const EntitySnapshotSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
components: z.array(EntityComponentSnapshotSchema),
|
||||
});
|
||||
|
||||
/** Zod schema for {@link WorldSnapshot}. */
|
||||
export const WorldSnapshotSchema = z.object({
|
||||
entities: z.array(EntitySnapshotSchema),
|
||||
nextEntityId: z.number().int().positive(),
|
||||
});
|
||||
|
||||
/** Zod schema for {@link WorldConfig}. */
|
||||
export const WorldConfigSchema = z
|
||||
.object({
|
||||
maxComponents: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Zod schema for {@link SystemPhase}. */
|
||||
export const SystemPhaseSchema = z.enum([
|
||||
"preUpdate",
|
||||
"update",
|
||||
"postUpdate",
|
||||
"render",
|
||||
]);
|
||||
5
packages/gaming/games/ecs/src/system/index.ts
Normal file
5
packages/gaming/games/ecs/src/system/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { DefineSystemOptions } from "./system.js";
|
||||
export {
|
||||
defineSystem,
|
||||
SystemScheduler,
|
||||
} from "./system.js";
|
||||
97
packages/gaming/games/ecs/src/system/system.md
Normal file
97
packages/gaming/games/ecs/src/system/system.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# System & SystemScheduler
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
A *system* is the unit of behaviour in an ECS — a function that runs over every entity matching a query and mutates component state. `defineSystem` is the factory that fills in sensible defaults (`phase: "update"`, `priority: 0`, `enabled: true`) and produces a frozen `SystemDefinition`. `SystemScheduler` is the world's internal sorted registry: it orders systems by phase then priority, lazily resorting only when the set changes.
|
||||
|
||||
Phase ordering is fixed at `preUpdate → update → postUpdate → render`, mirroring the canonical game-loop pipeline. Priority breaks ties within a phase, lowest first.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen-by-default `SystemDefinition`s with sensible defaults.
|
||||
- `enabled: false` lets systems be added but skipped on `execute` — useful for runtime toggling.
|
||||
- Stable phase order: `preUpdate < update < postUpdate < render`.
|
||||
- Insertion order is the implicit tiebreaker when priority is equal (Array#sort is stable in modern engines).
|
||||
- `has(name)` membership check used by the world to enforce unique system names.
|
||||
- `count` / `getAll` accessors for inspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent, defineSystem } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 1, dy: 0 });
|
||||
|
||||
const world = createWorld();
|
||||
world.addSystem(defineSystem({
|
||||
name: "physics",
|
||||
query: { include: [Position, Velocity] },
|
||||
execute: ({ entities, world, dt }) => {
|
||||
for (const entity of entities) {
|
||||
const pos = world.getComponent(entity, Position)!;
|
||||
const vel = world.getComponent(entity, Velocity)!;
|
||||
world.setComponent(entity, Position, {
|
||||
x: pos.x + vel.dx * dt,
|
||||
y: pos.y + vel.dy * dt,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
world.execute(1 / 60);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { defineSystem, SystemScheduler } from "@suaveplan/ecs";
|
||||
|
||||
const scheduler = new SystemScheduler();
|
||||
scheduler.add(defineSystem({
|
||||
name: "input",
|
||||
phase: "preUpdate",
|
||||
priority: 0,
|
||||
query: { include: [] },
|
||||
execute: () => undefined,
|
||||
}));
|
||||
scheduler.add(defineSystem({
|
||||
name: "physics",
|
||||
priority: 10,
|
||||
query: { include: [] },
|
||||
execute: () => undefined,
|
||||
}));
|
||||
scheduler.add(defineSystem({
|
||||
name: "gameplay",
|
||||
priority: 5,
|
||||
query: { include: [] },
|
||||
execute: () => undefined,
|
||||
}));
|
||||
|
||||
for (const sys of scheduler.getOrdered()) {
|
||||
// order: input, gameplay, physics
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `defineSystem(options)` | Build a `SystemDefinition` with phase / priority / enabled defaults. |
|
||||
| `SystemScheduler#add(system)` | Register a system. Marks scheduler dirty. |
|
||||
| `SystemScheduler#remove(name)` | Remove by name. Returns `false` for unknowns. |
|
||||
| `SystemScheduler#has(name)` | Membership check. |
|
||||
| `SystemScheduler#getOrdered()` | Read-only execution-ordered snapshot. |
|
||||
| `SystemScheduler#getAll()` | Read-only unordered snapshot. |
|
||||
| `SystemScheduler#count` | Number of registered systems. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `getOrdered` sorts lazily — the `sorted` flag clears on add/remove and is re-set after the first sort. The world is therefore free to call it once per tick without paying repeated sort cost when no mutation occurred.
|
||||
- `defineSystem` returns a plain object, not a frozen one — performance-sensitive call sites can mutate fields like `enabled` to gate execution at runtime without rebuilding the definition. The world contract still treats it as immutable.
|
||||
- The `query` field references `ComponentSchema` objects, not IDs. The world resolves them through its registry on `addSystem`, registering any not-yet-known schemas at that point.
|
||||
- The phase order is encoded as a numeric lookup table (`PHASE_ORDER`), which keeps the sort key access branch-free.
|
||||
128
packages/gaming/games/ecs/src/system/system.test.ts
Normal file
128
packages/gaming/games/ecs/src/system/system.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { defineComponent } from "../schema/schema.js";
|
||||
import { defineSystem, SystemScheduler } from "./system.js";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0 });
|
||||
|
||||
describe("defineSystem + SystemScheduler", () => {
|
||||
it("defineSystem applies phase / priority / enabled defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const sys = defineSystem({
|
||||
name: "render",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
});
|
||||
expect(sys.phase).toBe("update");
|
||||
expect(sys.priority).toBe(0);
|
||||
expect(sys.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("defineSystem honors explicit values", { timeout: 5000 }, () => {
|
||||
const sys = defineSystem({
|
||||
name: "render",
|
||||
phase: "render",
|
||||
priority: 5,
|
||||
enabled: false,
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
});
|
||||
expect(sys.phase).toBe("render");
|
||||
expect(sys.priority).toBe(5);
|
||||
expect(sys.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("phases run in fixed order preUpdate update postUpdate render", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const scheduler = new SystemScheduler();
|
||||
for (const phase of ["render", "preUpdate", "update"] as const) {
|
||||
scheduler.add(
|
||||
defineSystem({
|
||||
name: phase,
|
||||
phase,
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
const names = scheduler.getOrdered().map((s) => s.name);
|
||||
expect(names).toEqual(["preUpdate", "update", "render"]);
|
||||
});
|
||||
|
||||
it("ties within a phase break by ascending priority", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const scheduler = new SystemScheduler();
|
||||
scheduler.add(
|
||||
defineSystem({
|
||||
name: "B",
|
||||
priority: 5,
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
scheduler.add(
|
||||
defineSystem({
|
||||
name: "A",
|
||||
priority: 2,
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
const names = scheduler.getOrdered().map((s) => s.name);
|
||||
expect(names).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("remove returns false for unknown system name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const scheduler = new SystemScheduler();
|
||||
expect(scheduler.remove("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("remove returns true and shrinks the registered list", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const scheduler = new SystemScheduler();
|
||||
scheduler.add(
|
||||
defineSystem({
|
||||
name: "A",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
expect(scheduler.remove("A")).toBe(true);
|
||||
expect(scheduler.count).toBe(0);
|
||||
});
|
||||
|
||||
it("has reports membership by name", { timeout: 5000 }, () => {
|
||||
const scheduler = new SystemScheduler();
|
||||
scheduler.add(
|
||||
defineSystem({
|
||||
name: "A",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
expect(scheduler.has("A")).toBe(true);
|
||||
expect(scheduler.has("B")).toBe(false);
|
||||
});
|
||||
|
||||
it("getAll returns a live read-only view, getOrdered returns a sorted view", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const scheduler = new SystemScheduler();
|
||||
scheduler.add(
|
||||
defineSystem({
|
||||
name: "A",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
expect(scheduler.getAll().length).toBe(1);
|
||||
expect(scheduler.getOrdered().length).toBe(1);
|
||||
// calling again re-uses the sorted cache
|
||||
expect(scheduler.getOrdered().length).toBe(1);
|
||||
});
|
||||
});
|
||||
90
packages/gaming/games/ecs/src/system/system.ts
Normal file
90
packages/gaming/games/ecs/src/system/system.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
QueryDescriptor,
|
||||
SystemDefinition,
|
||||
SystemExecuteFn,
|
||||
SystemPhase,
|
||||
} from "../types/ecs.types.js";
|
||||
|
||||
/** Options accepted by {@link defineSystem}. */
|
||||
export interface DefineSystemOptions {
|
||||
name: string;
|
||||
phase?: SystemPhase;
|
||||
priority?: number;
|
||||
enabled?: boolean;
|
||||
query: QueryDescriptor;
|
||||
execute: SystemExecuteFn;
|
||||
}
|
||||
|
||||
/** Build a {@link SystemDefinition} with phase/priority/enabled defaults. */
|
||||
export function defineSystem(options: DefineSystemOptions): SystemDefinition {
|
||||
return {
|
||||
name: options.name,
|
||||
phase: options.phase ?? "update",
|
||||
priority: options.priority ?? 0,
|
||||
enabled: options.enabled ?? true,
|
||||
query: options.query,
|
||||
execute: options.execute,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase- and priority-ordered system scheduler.
|
||||
*
|
||||
* Phases run in fixed order — `preUpdate → update → postUpdate → render`.
|
||||
* Within a phase, lower-priority numbers run first. Insertion order is the
|
||||
* stable tiebreaker for systems with equal priority.
|
||||
*/
|
||||
export class SystemScheduler {
|
||||
private readonly systems: SystemDefinition[] = [];
|
||||
private sorted = false;
|
||||
|
||||
private static readonly PHASE_ORDER: Record<SystemPhase, number> = {
|
||||
preUpdate: 0,
|
||||
update: 1,
|
||||
postUpdate: 2,
|
||||
render: 3,
|
||||
};
|
||||
|
||||
/** Register a system. Caller is responsible for unique names. */
|
||||
public add(system: SystemDefinition): void {
|
||||
this.systems.push(system);
|
||||
this.sorted = false;
|
||||
}
|
||||
|
||||
/** Remove a system by name. Returns `false` if no match. */
|
||||
public remove(name: string): boolean {
|
||||
const index = this.systems.findIndex((s) => s.name === name);
|
||||
if (index === -1) return false;
|
||||
this.systems.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Return the registered systems in execution order. */
|
||||
public getOrdered(): ReadonlyArray<SystemDefinition> {
|
||||
if (!this.sorted) {
|
||||
this.systems.sort((a, b) => {
|
||||
const phaseA = SystemScheduler.PHASE_ORDER[a.phase];
|
||||
const phaseB = SystemScheduler.PHASE_ORDER[b.phase];
|
||||
if (phaseA !== phaseB) return phaseA - phaseB;
|
||||
return a.priority - b.priority;
|
||||
});
|
||||
this.sorted = true;
|
||||
}
|
||||
return this.systems;
|
||||
}
|
||||
|
||||
/** Read-only snapshot of every registered system. */
|
||||
public getAll(): ReadonlyArray<SystemDefinition> {
|
||||
return this.systems;
|
||||
}
|
||||
|
||||
/** True when a system with `name` is registered. */
|
||||
public has(name: string): boolean {
|
||||
return this.systems.some((s) => s.name === name);
|
||||
}
|
||||
|
||||
/** Number of registered systems. */
|
||||
public get count(): number {
|
||||
return this.systems.length;
|
||||
}
|
||||
}
|
||||
84
packages/gaming/games/ecs/src/types/ecs.types.md
Normal file
84
packages/gaming/games/ecs/src/types/ecs.types.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# ECS Types
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the canonical home for every type alias and interface in `@suaveplan/ecs`. Keeping the type vocabulary in a single file makes the surface easy to scan and lets consumers import every public shape from `@suaveplan/ecs/types` (or the package root) without learning the internal folder layout. The file is types-only — no runtime exports, no side effects — which keeps the build output empty and tree-shake-safe.
|
||||
|
||||
The types fall into three groups: identity primitives (`Entity`, `ComponentId`, `ComponentData`, `ArchetypeId`), descriptor shapes (`ComponentSchema`, `QueryDescriptor`, `SystemDefinition`), and persistence/event shapes (`EntitySnapshot`, `WorldSnapshot`, `WorldEventMap`, `WorldApi`).
|
||||
|
||||
## Features
|
||||
|
||||
- Branded-style integer aliases (`Entity`, `ComponentId`) for documentation without runtime cost.
|
||||
- `ComponentSchema<T>` is generic over the component data shape so `World.getComponent` infers the right return type from a descriptor reference.
|
||||
- `WorldEventMap` extends `Record<string, unknown>` so the `@suaveplan/events` `EventBus<TMap>` type accepts it directly while still typing each known event payload.
|
||||
- `WorldApi` is the contract `World` implements — systems consume it through `SystemContext.world` to avoid coupling system code to the full `World` class surface.
|
||||
- `SystemPhase` is a string-literal union mirroring the scheduler's fixed phase order.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Entity,
|
||||
ComponentSchema,
|
||||
QueryDescriptor,
|
||||
} from "@suaveplan/ecs";
|
||||
|
||||
interface Position { x: number; y: number }
|
||||
|
||||
const Position: ComponentSchema<Position> = {
|
||||
name: "Position",
|
||||
defaults: Object.freeze({ x: 0, y: 0 }),
|
||||
};
|
||||
|
||||
const query: QueryDescriptor = { include: [Position] };
|
||||
const entity: Entity = 1;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { SystemDefinition, SystemContext, WorldEventMap } from "@suaveplan/ecs";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
const bus = new EventBus<WorldEventMap>();
|
||||
bus.on("componentAdded", (payload) => {
|
||||
// payload is fully typed: { entity: number; component: string }
|
||||
console.log(payload.entity, payload.component);
|
||||
});
|
||||
|
||||
function makeSystem(execute: SystemDefinition["execute"]): SystemDefinition {
|
||||
return {
|
||||
name: "custom",
|
||||
phase: "update",
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
query: { include: [] },
|
||||
execute,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Type | Purpose |
|
||||
|---|---|
|
||||
| `Entity` | Positive integer ID. |
|
||||
| `ComponentId` | Stable integer assigned by `ComponentRegistry`. |
|
||||
| `ComponentData` | `Record<string, unknown>` — generic component payload. |
|
||||
| `ArchetypeId` | Sorted-comma-separated component-ID string. |
|
||||
| `ComponentSchema<T>` | `{ name, defaults, schema? }` descriptor. |
|
||||
| `QueryDescriptor` | `{ include, exclude? }` declarative query. |
|
||||
| `SystemPhase` | `"preUpdate" \| "update" \| "postUpdate" \| "render"`. |
|
||||
| `SystemDefinition` | Frozen system contract used by the scheduler. |
|
||||
| `SystemContext` | Argument passed to `execute({ entities, world, dt })`. |
|
||||
| `SystemExecuteFn` | Type alias for the system execute callback. |
|
||||
| `EntitySnapshot` / `WorldSnapshot` | Persistence wire shapes. |
|
||||
| `WorldApi` | World surface exposed to systems. |
|
||||
| `WorldEventMap` | Single-object event payload map for `world.events`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All types use TypeScript-level branding (`type Entity = number`) rather than full nominal branding (`{ __brand: ... }`). The full-brand approach adds friction (`entity as Entity` everywhere) without much payoff for an integer that originates exclusively inside the package.
|
||||
- `ComponentSchema.schema?` is a `z.ZodType<T>`, optional at the type level so descriptors without a validator skip a property entirely. The world's validation step only fires when the property is defined.
|
||||
- `WorldEventMap`'s `[key: string]: unknown` index signature is required by `@suaveplan/events`' `EventMap = Record<string, unknown>` constraint. Known events keep their precise object shape; extra keys can be defined by consumers without leaking through.
|
||||
- The file deliberately avoids importing from any other file in the package — it is a pure leaf at the bottom of the dep graph. The single external import is `zod` for `z.ZodType<T>` in `ComponentSchema.schema`.
|
||||
105
packages/gaming/games/ecs/src/types/ecs.types.ts
Normal file
105
packages/gaming/games/ecs/src/types/ecs.types.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
/** Unique entity identifier — opaque positive integer assigned by the world. */
|
||||
export type Entity = number;
|
||||
|
||||
/** Component data — any plain object indexed by string keys. */
|
||||
export type ComponentData = Record<string, unknown>;
|
||||
|
||||
/** Stable integer ID assigned to a {@link ComponentSchema} by the registry. */
|
||||
export type ComponentId = number;
|
||||
|
||||
/** Sorted, comma-separated component-ID string used as an archetype map key. */
|
||||
export type ArchetypeId = string;
|
||||
|
||||
/**
|
||||
* Schema definition for a component — provides a stable name, default values,
|
||||
* and an optional Zod validator. Schemas are deep-frozen and identity-stable.
|
||||
*/
|
||||
export interface ComponentSchema<T extends ComponentData = ComponentData> {
|
||||
readonly name: string;
|
||||
readonly defaults: Readonly<T>;
|
||||
readonly schema?: z.ZodType<T>;
|
||||
}
|
||||
|
||||
/** Execution phase. Phases run in the order declared by `PHASE_ORDER`. */
|
||||
export type SystemPhase = "preUpdate" | "update" | "postUpdate" | "render";
|
||||
|
||||
/** Arguments passed to a system's `execute` callback. */
|
||||
export interface SystemContext {
|
||||
readonly entities: ReadonlyArray<Entity>;
|
||||
readonly world: WorldApi;
|
||||
readonly dt: number;
|
||||
}
|
||||
|
||||
/** System execute function signature. */
|
||||
export type SystemExecuteFn = (ctx: SystemContext) => void;
|
||||
|
||||
/** System definition registered with the world. */
|
||||
export interface SystemDefinition {
|
||||
readonly name: string;
|
||||
readonly phase: SystemPhase;
|
||||
readonly priority: number;
|
||||
readonly query: QueryDescriptor;
|
||||
readonly execute: SystemExecuteFn;
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
/** Declarative query — which components to include and/or exclude. */
|
||||
export interface QueryDescriptor {
|
||||
readonly include: ReadonlyArray<ComponentSchema>;
|
||||
readonly exclude?: ReadonlyArray<ComponentSchema>;
|
||||
}
|
||||
|
||||
/** Serialized form of a single entity for snapshot transport. */
|
||||
export interface EntitySnapshot {
|
||||
readonly id: Entity;
|
||||
readonly components: ReadonlyArray<{
|
||||
readonly name: string;
|
||||
readonly data: ComponentData;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Serialized form of an entire world. */
|
||||
export interface WorldSnapshot {
|
||||
readonly entities: ReadonlyArray<EntitySnapshot>;
|
||||
readonly nextEntityId: number;
|
||||
}
|
||||
|
||||
/** Public surface exposed to systems via {@link SystemContext.world}. */
|
||||
export interface WorldApi {
|
||||
spawn(): Entity;
|
||||
despawn(entity: Entity): void;
|
||||
isAlive(entity: Entity): boolean;
|
||||
addComponent<T extends ComponentData>(
|
||||
entity: Entity,
|
||||
schema: ComponentSchema<T>,
|
||||
data?: Partial<T>
|
||||
): void;
|
||||
removeComponent(entity: Entity, schema: ComponentSchema): void;
|
||||
getComponent<T extends ComponentData>(
|
||||
entity: Entity,
|
||||
schema: ComponentSchema<T>
|
||||
): T | undefined;
|
||||
setComponent<T extends ComponentData>(
|
||||
entity: Entity,
|
||||
schema: ComponentSchema<T>,
|
||||
data: Partial<T>
|
||||
): void;
|
||||
hasComponent(entity: Entity, schema: ComponentSchema): boolean;
|
||||
query(descriptor: QueryDescriptor): ReadonlyArray<Entity>;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventBus payload map emitted by `world.events`.
|
||||
*
|
||||
* Genesis events use single-object payloads (not tuples) per the
|
||||
* `@suaveplan/events` `EventMap = Record<string, unknown>` contract.
|
||||
*/
|
||||
export type WorldEventMap = {
|
||||
entitySpawned: { entity: Entity };
|
||||
entityDespawned: { entity: Entity };
|
||||
componentAdded: { entity: Entity; component: string };
|
||||
componentRemoved: { entity: Entity; component: string };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
16
packages/gaming/games/ecs/src/types/index.ts
Normal file
16
packages/gaming/games/ecs/src/types/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type {
|
||||
ArchetypeId,
|
||||
ComponentData,
|
||||
ComponentId,
|
||||
ComponentSchema,
|
||||
Entity,
|
||||
EntitySnapshot,
|
||||
QueryDescriptor,
|
||||
SystemContext,
|
||||
SystemDefinition,
|
||||
SystemExecuteFn,
|
||||
SystemPhase,
|
||||
WorldApi,
|
||||
WorldEventMap,
|
||||
WorldSnapshot,
|
||||
} from "./ecs.types.js";
|
||||
5
packages/gaming/games/ecs/src/world/index.ts
Normal file
5
packages/gaming/games/ecs/src/world/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { WorldConfig } from "./world.js";
|
||||
export {
|
||||
createWorld,
|
||||
World,
|
||||
} from "./world.js";
|
||||
108
packages/gaming/games/ecs/src/world/world.md
Normal file
108
packages/gaming/games/ecs/src/world/world.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# World
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`World` is the central container for an ECS instance. It owns the component registry, entity pool, archetype graph, query cache, system scheduler, and the typed `EventBus` that fires entity-lifecycle events. Every public mutator is instrumented with OpenTelemetry — `spawn` / `despawn` increment a counter, `execute` records into a histogram — so production behaviour is observable through the same in-memory `MeterProvider` used in tests.
|
||||
|
||||
The `createWorld(config?)` factory is the canonical entry point. `World` itself is exported so consumers can subclass or interrogate the instance.
|
||||
|
||||
## Features
|
||||
|
||||
- Archetype-graph mutations: `addComponent` / `removeComponent` move entities between archetypes lazily.
|
||||
- Idempotent `addComponent` — no-op when the component already exists.
|
||||
- Component validation via descriptor-attached Zod schemas; throws `ECS.COMPONENT_VALIDATION_FAILED` on rejection.
|
||||
- System scheduling with phase + priority ordering through `SystemScheduler`.
|
||||
- `snapshot` / `restore` round-trip with Zod-validated payloads.
|
||||
- Typed `EventBus<WorldEventMap>` — single-object payloads matching the `@suaveplan/events` shape.
|
||||
- OpenTelemetry counter (`ecs.entity.lifecycle_total`) and histogram (`ecs.world.execute_duration_ms`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent, defineSystem } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 1, dy: 0 });
|
||||
|
||||
const world = createWorld();
|
||||
|
||||
world.addSystem(defineSystem({
|
||||
name: "physics",
|
||||
query: { include: [Position, Velocity] },
|
||||
execute: ({ entities, world, dt }) => {
|
||||
for (const entity of entities) {
|
||||
const p = world.getComponent(entity, Position)!;
|
||||
const v = world.getComponent(entity, Velocity)!;
|
||||
world.setComponent(entity, Position, {
|
||||
x: p.x + v.dx * dt,
|
||||
y: p.y + v.dy * dt,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const player = world.spawn();
|
||||
world.addComponent(player, Position, { x: 0, y: 0 });
|
||||
world.addComponent(player, Velocity, { dx: 5, dy: 0 });
|
||||
world.execute(1 / 60);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const world = createWorld({ maxComponents: 128 });
|
||||
world.registerComponent(Position);
|
||||
|
||||
world.events.on("entitySpawned", ({ entity }) => {
|
||||
console.log("spawned", entity);
|
||||
});
|
||||
world.events.on("componentAdded", ({ entity, component }) => {
|
||||
console.log(component, "->", entity);
|
||||
});
|
||||
|
||||
const snapshot = world.snapshot();
|
||||
// ... ship snapshot over the wire ...
|
||||
|
||||
const replay = createWorld();
|
||||
replay.registerComponent(Position);
|
||||
replay.restore(snapshot);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `createWorld(config?)` / `new World(config)` | Build a world. `maxComponents` defaults to 256. |
|
||||
| `events` | `EventBus<WorldEventMap>` for entity lifecycle. |
|
||||
| `registry` | Underlying `ComponentRegistry`. |
|
||||
| `registerComponent(schema)` | Pre-register a schema (idempotent). |
|
||||
| `spawn()` | Allocate an entity and emit `entitySpawned`. |
|
||||
| `despawn(entity)` | Despawn and recycle the entity. No-op when unknown. |
|
||||
| `isAlive(entity)` | Boolean liveness check. |
|
||||
| `addComponent(entity, schema, data?)` | Attach a component; validates Zod-attached schemas. |
|
||||
| `removeComponent(entity, schema)` | Detach a component. |
|
||||
| `getComponent(entity, schema)` | Read a component (or `undefined`). |
|
||||
| `setComponent(entity, schema, data)` | Merge partial data; revalidates. |
|
||||
| `hasComponent(entity, schema)` | Membership check. |
|
||||
| `query(descriptor)` | One-off entity query. |
|
||||
| `addSystem(system)` / `removeSystem(name)` | System lifecycle. |
|
||||
| `execute(dt)` | Run every enabled system; records duration histogram. |
|
||||
| `snapshot()` / `restore(snap)` | Persistence pair. `restore` Zod-validates the input. |
|
||||
| `clear()` | Wipe entities + archetypes, keep systems. |
|
||||
| `entityCount` / `archetypeCount` / `getSystems()` | Inspection accessors. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Archetype identity is the bitset key (`mask.toKey()`); `getOrCreateArchetype` deduplicates archetypes through a `Map<string, Archetype>`.
|
||||
- `addComponent` and `removeComponent` go through a swap-remove from the old archetype, mutate the component map, then insert into the new archetype. The destination archetype is materialised lazily.
|
||||
- `setComponent` does **not** change archetype membership — it merges into the existing column slot and re-validates against the descriptor's Zod schema.
|
||||
- `restore` is the only persistence boundary that validates input with Zod (`WorldSnapshotSchema`). Failures throw `ECS.SNAPSHOT_INVALID` with the Zod issues in `context`. Unknown component names in the snapshot are silently skipped, so adding new component types to consumer code does not break older snapshots.
|
||||
- OTel instruments resolve through the global `MeterProvider`. In tests, the package's `tests/setup/polyfills.ts` registers an in-memory `MeterProvider` before any module imports so the instruments bind to it.
|
||||
- The world's `EventBus` uses single-object payloads (`{ entity }`, `{ entity, component }`) consistent with the `@suaveplan/events` contract, rather than upstream's tuple shape.
|
||||
@@ -0,0 +1,149 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import type { DataPoint, ResourceMetrics } from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
expectTypeOf,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
flushTestMetrics,
|
||||
testInMemoryMetricExporter,
|
||||
testMeterProvider,
|
||||
} from "../../tests/setup/polyfills.js";
|
||||
import { defineComponent } from "../schema/schema.js";
|
||||
import type { WorldEventMap } from "../types/ecs.types.js";
|
||||
import { createWorld } from "./world.js";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
|
||||
function findMetric(
|
||||
snapshots: ResourceMetrics[],
|
||||
name: string
|
||||
): DataPoint<number> | DataPoint<unknown> | undefined {
|
||||
for (const rm of snapshots) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name === name) {
|
||||
return m.dataPoints[0] as DataPoint<number>;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe("World — spec conformance", () => {
|
||||
afterEach(() => {
|
||||
testInMemoryMetricExporter.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testMeterProvider.shutdown();
|
||||
metrics.disable();
|
||||
});
|
||||
|
||||
it("execute records into the ecs.world.execute_duration_ms histogram via an in-memory MeterProvider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const world = createWorld();
|
||||
for (let i = 0; i < 5; i++) world.execute(0.016);
|
||||
const snapshots = await flushTestMetrics();
|
||||
const hist = findMetric(snapshots, "ecs.world.execute_duration_ms");
|
||||
expect(hist).toBeDefined();
|
||||
const point = hist as DataPoint<{ count: number; sum: number }>;
|
||||
expect(point.value.count).toBe(5);
|
||||
expect(point.value.sum).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("spawn and despawn increment the ecs.entity.lifecycle_total counter via an in-memory MeterProvider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const world = createWorld();
|
||||
const a = world.spawn();
|
||||
world.spawn();
|
||||
world.despawn(a);
|
||||
const snapshots = await flushTestMetrics();
|
||||
const counter = findMetric(snapshots, "ecs.entity.lifecycle_total");
|
||||
let spawnTotal = 0;
|
||||
let despawnTotal = 0;
|
||||
for (const rm of snapshots) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name !== "ecs.entity.lifecycle_total")
|
||||
continue;
|
||||
for (const dp of m.dataPoints) {
|
||||
const op = (dp.attributes as { op?: string }).op;
|
||||
if (op === "spawn") spawnTotal += dp.value as number;
|
||||
if (op === "despawn")
|
||||
despawnTotal += dp.value as number;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(counter).toBeDefined();
|
||||
expect(spawnTotal).toBe(2);
|
||||
expect(despawnTotal).toBe(1);
|
||||
});
|
||||
|
||||
it("spawn emits entitySpawned with a single-object payload", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
let received: { entity: number } | undefined;
|
||||
world.events.on("entitySpawned", (payload) => {
|
||||
received = payload;
|
||||
});
|
||||
const entity = world.spawn();
|
||||
expect(received).toEqual({ entity });
|
||||
expectTypeOf<WorldEventMap["entitySpawned"]>().toEqualTypeOf<{
|
||||
entity: number;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("componentAdded fires after the component is queryable on the entity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
let visible = false;
|
||||
world.events.on("componentAdded", (payload) => {
|
||||
const { entity, component } = payload as {
|
||||
entity: number;
|
||||
component: string;
|
||||
};
|
||||
if (component === "Position") {
|
||||
visible = world.hasComponent(entity, Position);
|
||||
}
|
||||
});
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
expect(visible).toBe(true);
|
||||
});
|
||||
|
||||
it("componentRemoved fires with entity and component name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const events: Array<{ entity: number; component: string }> = [];
|
||||
world.events.on("componentRemoved", (payload) => {
|
||||
events.push(payload as { entity: number; component: string });
|
||||
});
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
world.removeComponent(entity, Position);
|
||||
expect(events).toEqual([{ entity, component: "Position" }]);
|
||||
});
|
||||
|
||||
it("entityDespawned fires for every despawn", { timeout: 5000 }, () => {
|
||||
const world = createWorld();
|
||||
const seen: number[] = [];
|
||||
world.events.on("entityDespawned", (payload) => {
|
||||
seen.push((payload as { entity: number }).entity);
|
||||
});
|
||||
const a = world.spawn();
|
||||
world.despawn(a);
|
||||
expect(seen).toEqual([a]);
|
||||
});
|
||||
});
|
||||
455
packages/gaming/games/ecs/src/world/world.test.ts
Normal file
455
packages/gaming/games/ecs/src/world/world.test.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import { ECS_CODES, EcsError } from "../errors/ecs-error.js";
|
||||
import { defineQuery } from "../query/query.js";
|
||||
import { defineComponent } from "../schema/schema.js";
|
||||
import { defineSystem } from "../system/system.js";
|
||||
import { createWorld, World } from "./world.js";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 0, dy: 0 });
|
||||
const Disabled = defineComponent("Disabled", { d: true });
|
||||
const Health = defineComponent(
|
||||
"Health",
|
||||
{ hp: 100 },
|
||||
z.object({ hp: z.number().min(0) })
|
||||
);
|
||||
|
||||
describe("World", () => {
|
||||
it("createWorld returns a World instance", { timeout: 5000 }, () => {
|
||||
expect(createWorld()).toBeInstanceOf(World);
|
||||
});
|
||||
|
||||
it("spawn and addComponent place the entity in the matching archetype", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position, { x: 5 });
|
||||
expect(world.hasComponent(entity, Position)).toBe(true);
|
||||
expect(world.getComponent(entity, Position)).toEqual({
|
||||
x: 5,
|
||||
y: 0,
|
||||
});
|
||||
const matches = world.query(defineQuery([Position]));
|
||||
expect([...matches]).toEqual([entity]);
|
||||
});
|
||||
|
||||
it("spawn placement defaults to the empty archetype", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
world.spawn();
|
||||
expect(world.archetypeCount).toBe(1);
|
||||
expect(world.entityCount).toBe(1);
|
||||
});
|
||||
|
||||
it("addComponent is idempotent when the component already exists", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position, { x: 5 });
|
||||
world.addComponent(entity, Position, { x: 9 });
|
||||
expect(world.getComponent(entity, Position)).toEqual({
|
||||
x: 5,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("addComponent throws ECS.ENTITY_NOT_ALIVE for a despawned entity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.despawn(entity);
|
||||
try {
|
||||
world.addComponent(entity, Position);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EcsError);
|
||||
expect((err as EcsError).code).toBe(ECS_CODES.ENTITY_NOT_ALIVE);
|
||||
}
|
||||
});
|
||||
|
||||
it("addComponent throws ECS.COMPONENT_VALIDATION_FAILED for data that fails the descriptor's Zod schema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
try {
|
||||
world.addComponent(entity, Health, { hp: -5 });
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EcsError);
|
||||
expect((err as EcsError).code).toBe(
|
||||
ECS_CODES.COMPONENT_VALIDATION_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("removeComponent moves the entity to a smaller archetype", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
world.addComponent(entity, Velocity);
|
||||
world.removeComponent(entity, Velocity);
|
||||
expect(world.hasComponent(entity, Velocity)).toBe(false);
|
||||
expect(world.hasComponent(entity, Position)).toBe(true);
|
||||
});
|
||||
|
||||
it("removeComponent is a no-op when the component is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
world.registerComponent(Velocity);
|
||||
const before = world.archetypeCount;
|
||||
world.removeComponent(entity, Velocity);
|
||||
expect(world.archetypeCount).toBe(before);
|
||||
expect(world.hasComponent(entity, Position)).toBe(true);
|
||||
});
|
||||
|
||||
it("removeComponent throws ECS.ENTITY_NOT_ALIVE for a despawned entity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
world.despawn(entity);
|
||||
try {
|
||||
world.removeComponent(entity, Position);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as EcsError).code).toBe(ECS_CODES.ENTITY_NOT_ALIVE);
|
||||
}
|
||||
});
|
||||
|
||||
it("setComponent merges partial data and re-validates", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position, { x: 1, y: 2 });
|
||||
world.setComponent(entity, Position, { x: 9 });
|
||||
expect(world.getComponent(entity, Position)).toEqual({
|
||||
x: 9,
|
||||
y: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("setComponent delegates to addComponent when the component is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.setComponent(entity, Position, { x: 4 });
|
||||
expect(world.hasComponent(entity, Position)).toBe(true);
|
||||
expect(world.getComponent(entity, Position)).toEqual({ x: 4, y: 0 });
|
||||
});
|
||||
|
||||
it("setComponent throws ECS.ENTITY_NOT_ALIVE when the entity is despawned", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
world.despawn(entity);
|
||||
try {
|
||||
world.setComponent(entity, Position, { x: 1 });
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as EcsError).code).toBe(ECS_CODES.ENTITY_NOT_ALIVE);
|
||||
}
|
||||
});
|
||||
|
||||
it("setComponent rejects updates that fail the descriptor schema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Health);
|
||||
try {
|
||||
world.setComponent(entity, Health, { hp: -1 });
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as EcsError).code).toBe(
|
||||
ECS_CODES.COMPONENT_VALIDATION_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("getComponent returns undefined for absent components and despawned entities", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.registerComponent(Position);
|
||||
expect(world.getComponent(entity, Position)).toBeUndefined();
|
||||
world.despawn(entity);
|
||||
expect(world.getComponent(entity, Position)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("despawn is a no-op for unknown entities", { timeout: 5000 }, () => {
|
||||
const world = createWorld();
|
||||
world.despawn(9999);
|
||||
expect(world.entityCount).toBe(0);
|
||||
});
|
||||
|
||||
it("isAlive reflects the entity pool", { timeout: 5000 }, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
expect(world.isAlive(entity)).toBe(true);
|
||||
world.despawn(entity);
|
||||
expect(world.isAlive(entity)).toBe(false);
|
||||
});
|
||||
|
||||
it("hasComponent returns false for a despawned entity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
world.despawn(entity);
|
||||
expect(world.hasComponent(entity, Position)).toBe(false);
|
||||
});
|
||||
|
||||
it("query results respect include and exclude", { timeout: 5000 }, () => {
|
||||
const world = createWorld();
|
||||
const a = world.spawn();
|
||||
const b = world.spawn();
|
||||
world.addComponent(a, Position);
|
||||
world.addComponent(b, Position);
|
||||
world.addComponent(b, Disabled);
|
||||
|
||||
const enabled = world.query(defineQuery([Position], [Disabled]));
|
||||
expect([...enabled]).toEqual([a]);
|
||||
});
|
||||
|
||||
it("addSystem throws ECS.SYSTEM_NAME_CONFLICT on duplicate name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const sys = defineSystem({
|
||||
name: "tick",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
});
|
||||
world.addSystem(sys);
|
||||
try {
|
||||
world.addSystem(sys);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as EcsError).code).toBe(ECS_CODES.SYSTEM_NAME_CONFLICT);
|
||||
}
|
||||
});
|
||||
|
||||
it("addSystem registers schemas from include and exclude clauses", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "tick",
|
||||
query: { include: [Position], exclude: [Disabled] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
expect(world.registry.has(Position)).toBe(true);
|
||||
expect(world.registry.has(Disabled)).toBe(true);
|
||||
});
|
||||
|
||||
it("addSystem is a no-op for schemas already registered", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
world.registerComponent(Position);
|
||||
world.registerComponent(Disabled);
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "tick",
|
||||
query: { include: [Position], exclude: [Disabled] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
expect(world.registry.count).toBe(2);
|
||||
});
|
||||
|
||||
it("addSystem with no exclude clause registers only include schemas", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "tick",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
expect(world.registry.has(Position)).toBe(true);
|
||||
expect(world.registry.count).toBe(1);
|
||||
});
|
||||
|
||||
it("removeSystem returns false for unknown names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
expect(world.removeSystem("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("execute runs registered systems in scheduler order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
const order: string[] = [];
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "render",
|
||||
phase: "render",
|
||||
query: { include: [Position] },
|
||||
execute: () => order.push("render"),
|
||||
})
|
||||
);
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "pre",
|
||||
phase: "preUpdate",
|
||||
query: { include: [Position] },
|
||||
execute: () => order.push("pre"),
|
||||
})
|
||||
);
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "update",
|
||||
query: { include: [Position] },
|
||||
execute: ({ dt }) => order.push(`update:${dt}`),
|
||||
})
|
||||
);
|
||||
world.execute(0.016);
|
||||
expect(order).toEqual(["pre", "update:0.016", "render"]);
|
||||
});
|
||||
|
||||
it("execute skips disabled systems", { timeout: 5000 }, () => {
|
||||
const world = createWorld();
|
||||
let fired = false;
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "off",
|
||||
enabled: false,
|
||||
query: { include: [Position] },
|
||||
execute: () => {
|
||||
fired = true;
|
||||
},
|
||||
})
|
||||
);
|
||||
world.execute(0.016);
|
||||
expect(fired).toBe(false);
|
||||
});
|
||||
|
||||
it("snapshot and restore round-trip preserves entities and components", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = createWorld();
|
||||
a.registerComponent(Position);
|
||||
const e1 = a.spawn();
|
||||
const e2 = a.spawn();
|
||||
a.addComponent(e1, Position, { x: 1, y: 2 });
|
||||
a.addComponent(e2, Position, { x: 3, y: 4 });
|
||||
a.despawn(e1);
|
||||
|
||||
const snap = a.snapshot();
|
||||
|
||||
const b = createWorld();
|
||||
b.registerComponent(Position);
|
||||
b.restore(snap);
|
||||
expect(b.entityCount).toBe(1);
|
||||
const restored = b.query(defineQuery([Position]));
|
||||
expect(restored.length).toBe(1);
|
||||
expect(b.getComponent(restored[0] ?? 0, Position)).toEqual({
|
||||
x: 3,
|
||||
y: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("restore silently skips snapshot components with unknown names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = createWorld();
|
||||
const entity = a.spawn();
|
||||
a.addComponent(entity, Position, { x: 7, y: 8 });
|
||||
const snap = a.snapshot();
|
||||
|
||||
const b = createWorld();
|
||||
// Only register Position, not Velocity — snapshot only has Position so OK.
|
||||
// Inject a phantom component name to exercise the skip branch.
|
||||
const tampered = {
|
||||
...snap,
|
||||
entities: snap.entities.map((e) => ({
|
||||
...e,
|
||||
components: [
|
||||
...e.components,
|
||||
{ name: "Ghost", data: { ghost: true } },
|
||||
],
|
||||
})),
|
||||
};
|
||||
b.registerComponent(Position);
|
||||
b.restore(tampered);
|
||||
expect(b.entityCount).toBe(1);
|
||||
});
|
||||
|
||||
it("restore throws ECS.SNAPSHOT_INVALID for a malformed snapshot", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
try {
|
||||
world.restore({
|
||||
entities: [],
|
||||
nextEntityId: -1,
|
||||
});
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as EcsError).code).toBe(ECS_CODES.SNAPSHOT_INVALID);
|
||||
}
|
||||
});
|
||||
|
||||
it("clear removes entities and archetypes but keeps systems", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld();
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "tick",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
world.clear();
|
||||
expect(world.entityCount).toBe(0);
|
||||
expect(world.archetypeCount).toBe(0);
|
||||
expect(world.getSystems().length).toBe(1);
|
||||
});
|
||||
|
||||
it("getSystems returns the registered systems", { timeout: 5000 }, () => {
|
||||
const world = createWorld();
|
||||
world.addSystem(
|
||||
defineSystem({
|
||||
name: "tick",
|
||||
query: { include: [Position] },
|
||||
execute: () => undefined,
|
||||
})
|
||||
);
|
||||
expect(world.getSystems().length).toBe(1);
|
||||
});
|
||||
|
||||
it("WorldConfig.maxComponents threads through to the registry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const world = createWorld({ maxComponents: 4 });
|
||||
expect(world.registry.maxComponents).toBe(4);
|
||||
});
|
||||
});
|
||||
391
packages/gaming/games/ecs/src/world/world.ts
Normal file
391
packages/gaming/games/ecs/src/world/world.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { Archetype } from "../archetype/archetype.js";
|
||||
import { BitSet } from "../bitset/bitset.js";
|
||||
import { ComponentRegistry } from "../component-registry/component-registry.js";
|
||||
import { EntityPool } from "../entity-pool/entity-pool.js";
|
||||
import { ECS_CODES, EcsError } from "../errors/ecs-error.js";
|
||||
import {
|
||||
recordExecuteDuration,
|
||||
recordLifecycle,
|
||||
} from "../observability/otel.js";
|
||||
import { Query } from "../query/query.js";
|
||||
import { WorldSnapshotSchema } from "../schemas/world.schemas.js";
|
||||
import { SystemScheduler } from "../system/system.js";
|
||||
import type {
|
||||
ComponentData,
|
||||
ComponentId,
|
||||
ComponentSchema,
|
||||
Entity,
|
||||
EntitySnapshot,
|
||||
QueryDescriptor,
|
||||
SystemDefinition,
|
||||
WorldApi,
|
||||
WorldEventMap,
|
||||
WorldSnapshot,
|
||||
} from "../types/ecs.types.js";
|
||||
|
||||
/** Configuration accepted by {@link createWorld} / {@link World}. */
|
||||
export interface WorldConfig {
|
||||
/** Hard cap on the number of distinct component types. Default 256. */
|
||||
readonly maxComponents?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The World is the central container for an ECS instance. It owns the
|
||||
* component registry, entity pool, archetype graph, query cache, and system
|
||||
* scheduler, and emits typed lifecycle events through `world.events`.
|
||||
*
|
||||
* Public mutators ({@link spawn}, {@link despawn}, {@link addComponent},
|
||||
* {@link removeComponent}, {@link execute}, {@link snapshot},
|
||||
* {@link restore}) are instrumented with OpenTelemetry counters / histograms.
|
||||
*/
|
||||
export class World implements WorldApi {
|
||||
public readonly events = new EventBus<WorldEventMap>();
|
||||
public readonly registry: ComponentRegistry;
|
||||
|
||||
private readonly pool: EntityPool;
|
||||
private readonly scheduler: SystemScheduler;
|
||||
private readonly archetypes: Archetype[] = [];
|
||||
private readonly archetypeMap = new Map<string, Archetype>();
|
||||
private readonly entityArchetype = new Map<Entity, Archetype>();
|
||||
|
||||
public constructor(config?: WorldConfig) {
|
||||
this.registry = new ComponentRegistry(config?.maxComponents);
|
||||
this.pool = new EntityPool();
|
||||
this.scheduler = new SystemScheduler();
|
||||
}
|
||||
|
||||
/** Register a component schema. Idempotent by schema identity. */
|
||||
public registerComponent<T extends ComponentData>(
|
||||
schema: ComponentSchema<T>
|
||||
): void {
|
||||
this.registry.register(schema);
|
||||
}
|
||||
|
||||
/** Spawn a new entity in the empty archetype and emit `entitySpawned`. */
|
||||
public spawn(): Entity {
|
||||
const entity = this.pool.allocate();
|
||||
const emptyArch = this.getOrCreateArchetype(
|
||||
new BitSet(this.registry.maxComponents),
|
||||
[]
|
||||
);
|
||||
emptyArch.addEntity(entity, new Map());
|
||||
this.entityArchetype.set(entity, emptyArch);
|
||||
recordLifecycle("spawn");
|
||||
this.events.emit("entitySpawned", { entity });
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** Despawn `entity`, freeing its ID and emitting `entityDespawned`. */
|
||||
public despawn(entity: Entity): void {
|
||||
const arch = this.entityArchetype.get(entity);
|
||||
if (arch === undefined) return;
|
||||
arch.removeEntity(entity);
|
||||
this.entityArchetype.delete(entity);
|
||||
this.pool.free(entity);
|
||||
|
||||
recordLifecycle("despawn");
|
||||
this.events.emit("entityDespawned", { entity });
|
||||
}
|
||||
|
||||
/** True when `entity` is currently alive in this world. */
|
||||
public isAlive(entity: Entity): boolean {
|
||||
return this.pool.isAlive(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a component to `entity`, moving it to a new archetype as needed.
|
||||
* Throws `ECS.ENTITY_NOT_ALIVE` if the entity has been despawned.
|
||||
* No-op when the entity already has the component.
|
||||
*/
|
||||
public addComponent<T extends ComponentData>(
|
||||
entity: Entity,
|
||||
schema: ComponentSchema<T>,
|
||||
data?: Partial<T>
|
||||
): void {
|
||||
if (!this.registry.has(schema)) this.registry.register(schema);
|
||||
const componentId = this.registry.getId(schema);
|
||||
const oldArch = this.entityArchetype.get(entity);
|
||||
if (oldArch === undefined) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.ENTITY_NOT_ALIVE,
|
||||
"error.ecs.entity_not_alive",
|
||||
{ context: { entity } }
|
||||
);
|
||||
}
|
||||
|
||||
if (oldArch.hasComponent(componentId)) return;
|
||||
|
||||
const merged = { ...schema.defaults, ...data } as T;
|
||||
this.validateComponent(schema, merged);
|
||||
|
||||
const oldData = oldArch.removeEntity(entity) as Map<
|
||||
ComponentId,
|
||||
ComponentData
|
||||
>;
|
||||
oldData.set(componentId, merged);
|
||||
|
||||
const newMask = oldArch.mask.clone();
|
||||
newMask.set(componentId);
|
||||
const newIds = [...oldArch.getComponentIds(), componentId].sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
const newArch = this.getOrCreateArchetype(newMask, newIds);
|
||||
newArch.addEntity(entity, oldData);
|
||||
this.entityArchetype.set(entity, newArch);
|
||||
|
||||
this.events.emit("componentAdded", {
|
||||
entity,
|
||||
component: schema.name,
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a component from `entity`, moving it to a new archetype. */
|
||||
public removeComponent(entity: Entity, schema: ComponentSchema): void {
|
||||
const componentId = this.registry.getId(schema);
|
||||
const oldArch = this.entityArchetype.get(entity);
|
||||
if (oldArch === undefined) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.ENTITY_NOT_ALIVE,
|
||||
"error.ecs.entity_not_alive",
|
||||
{ context: { entity } }
|
||||
);
|
||||
}
|
||||
|
||||
if (!oldArch.hasComponent(componentId)) return;
|
||||
|
||||
const oldData = oldArch.removeEntity(entity) as Map<
|
||||
ComponentId,
|
||||
ComponentData
|
||||
>;
|
||||
oldData.delete(componentId);
|
||||
const newMask = oldArch.mask.clone();
|
||||
newMask.unset(componentId);
|
||||
const newIds = oldArch
|
||||
.getComponentIds()
|
||||
.filter((id) => id !== componentId);
|
||||
|
||||
const newArch = this.getOrCreateArchetype(newMask, newIds);
|
||||
newArch.addEntity(entity, oldData);
|
||||
this.entityArchetype.set(entity, newArch);
|
||||
|
||||
this.events.emit("componentRemoved", {
|
||||
entity,
|
||||
component: schema.name,
|
||||
});
|
||||
}
|
||||
|
||||
/** Read a component value, or `undefined` if absent. */
|
||||
public getComponent<T extends ComponentData>(
|
||||
entity: Entity,
|
||||
schema: ComponentSchema<T>
|
||||
): T | undefined {
|
||||
const componentId = this.registry.getId(schema);
|
||||
const arch = this.entityArchetype.get(entity);
|
||||
return arch?.getComponent(entity, componentId) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially merge new data into an existing component value.
|
||||
* Throws `ECS.ENTITY_NOT_ALIVE` if the entity is absent and
|
||||
* `ECS.COMPONENT_VALIDATION_FAILED` when the descriptor's Zod schema rejects
|
||||
* the merged result.
|
||||
*/
|
||||
public setComponent<T extends ComponentData>(
|
||||
entity: Entity,
|
||||
schema: ComponentSchema<T>,
|
||||
data: Partial<T>
|
||||
): void {
|
||||
if (!this.registry.has(schema)) this.registry.register(schema);
|
||||
const componentId = this.registry.getId(schema);
|
||||
const arch = this.entityArchetype.get(entity);
|
||||
if (arch === undefined) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.ENTITY_NOT_ALIVE,
|
||||
"error.ecs.entity_not_alive",
|
||||
{ context: { entity } }
|
||||
);
|
||||
}
|
||||
const current = arch.getComponent(entity, componentId) as T | undefined;
|
||||
if (current === undefined) {
|
||||
this.addComponent(entity, schema, data);
|
||||
return;
|
||||
}
|
||||
const merged = { ...current, ...data } as T;
|
||||
this.validateComponent(schema, merged);
|
||||
arch.setComponent(entity, componentId, merged);
|
||||
}
|
||||
|
||||
/** True when `entity` has the component. */
|
||||
public hasComponent(entity: Entity, schema: ComponentSchema): boolean {
|
||||
const componentId = this.registry.getId(schema);
|
||||
const arch = this.entityArchetype.get(entity);
|
||||
return arch?.hasComponent(componentId) ?? false;
|
||||
}
|
||||
|
||||
/** Evaluate a one-off query — for repeated queries register a system. */
|
||||
public query(descriptor: QueryDescriptor): ReadonlyArray<Entity> {
|
||||
const q = new Query(descriptor, this.registry);
|
||||
return q.evaluate(this.archetypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a system. Throws `ECS.SYSTEM_NAME_CONFLICT` if another system
|
||||
* shares the same name.
|
||||
*/
|
||||
public addSystem(system: SystemDefinition): void {
|
||||
if (this.scheduler.has(system.name)) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.SYSTEM_NAME_CONFLICT,
|
||||
"error.ecs.system_name_conflict",
|
||||
{ context: { name: system.name } }
|
||||
);
|
||||
}
|
||||
const schemas: ComponentSchema[] = [
|
||||
...system.query.include,
|
||||
...(system.query.exclude ?? []),
|
||||
];
|
||||
for (const schema of schemas) {
|
||||
if (!this.registry.has(schema)) this.registry.register(schema);
|
||||
}
|
||||
this.scheduler.add(system);
|
||||
}
|
||||
|
||||
/** Remove a system by name. Returns `false` if no match. */
|
||||
public removeSystem(name: string): boolean {
|
||||
return this.scheduler.remove(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every enabled system in scheduler order. Records the wall-clock
|
||||
* duration in the `ecs.world.execute_duration_ms` histogram.
|
||||
*/
|
||||
public execute(dt: number): void {
|
||||
const start = performance.now();
|
||||
const ordered = this.scheduler.getOrdered();
|
||||
for (const system of ordered) {
|
||||
if (!system.enabled) continue;
|
||||
const q = new Query(system.query, this.registry);
|
||||
const entities = q.evaluate(this.archetypes);
|
||||
system.execute({ entities, world: this, dt });
|
||||
}
|
||||
recordExecuteDuration(performance.now() - start);
|
||||
}
|
||||
|
||||
/** Capture a structured snapshot of every alive entity. */
|
||||
public snapshot(): WorldSnapshot {
|
||||
const entities: EntitySnapshot[] = [];
|
||||
for (const [entity, arch] of this.entityArchetype) {
|
||||
const components: {
|
||||
readonly name: string;
|
||||
readonly data: ComponentData;
|
||||
}[] = [];
|
||||
for (const cid of arch.getComponentIds()) {
|
||||
const schema = this.registry.getSchema(cid) as ComponentSchema;
|
||||
const data = arch.getComponent(entity, cid) as ComponentData;
|
||||
components.push({
|
||||
name: schema.name,
|
||||
data: structuredClone(data),
|
||||
});
|
||||
}
|
||||
entities.push({ id: entity, components });
|
||||
}
|
||||
return { entities, nextEntityId: this.pool.peekNextId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore world state from a snapshot. The snapshot is Zod-validated;
|
||||
* `ECS.SNAPSHOT_INVALID` is thrown on failure. Component schemas
|
||||
* referenced by name must already be registered — unknown names are
|
||||
* silently skipped.
|
||||
*/
|
||||
public restore(snap: WorldSnapshot): void {
|
||||
const parsed = WorldSnapshotSchema.safeParse(snap);
|
||||
if (!parsed.success) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.SNAPSHOT_INVALID,
|
||||
"error.ecs.snapshot_invalid",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
this.clear();
|
||||
this.pool.setNextId(parsed.data.nextEntityId);
|
||||
for (const es of parsed.data.entities) {
|
||||
const entity = this.pool.allocate();
|
||||
const emptyArch = this.getOrCreateArchetype(
|
||||
new BitSet(this.registry.maxComponents),
|
||||
[]
|
||||
);
|
||||
emptyArch.addEntity(entity, new Map());
|
||||
this.entityArchetype.set(entity, emptyArch);
|
||||
for (const comp of es.components) {
|
||||
const schema = this.registry.getSchemaByName(comp.name);
|
||||
if (schema !== undefined) {
|
||||
this.addComponent(entity, schema, comp.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove every entity and archetype. Systems and registrations are kept. */
|
||||
public clear(): void {
|
||||
this.entityArchetype.clear();
|
||||
this.archetypes.length = 0;
|
||||
this.archetypeMap.clear();
|
||||
this.pool.clear();
|
||||
}
|
||||
|
||||
/** Number of alive entities. */
|
||||
public get entityCount(): number {
|
||||
return this.pool.aliveCount;
|
||||
}
|
||||
|
||||
/** Number of distinct archetypes currently held. */
|
||||
public get archetypeCount(): number {
|
||||
return this.archetypes.length;
|
||||
}
|
||||
|
||||
/** Read-only snapshot of every registered system. */
|
||||
public getSystems(): ReadonlyArray<SystemDefinition> {
|
||||
return this.scheduler.getAll();
|
||||
}
|
||||
|
||||
private getOrCreateArchetype(
|
||||
mask: BitSet,
|
||||
componentIds: ReadonlyArray<ComponentId>
|
||||
): Archetype {
|
||||
const key = mask.toKey();
|
||||
let arch = this.archetypeMap.get(key);
|
||||
if (arch === undefined) {
|
||||
arch = new Archetype(mask, componentIds);
|
||||
this.archetypes.push(arch);
|
||||
this.archetypeMap.set(key, arch);
|
||||
}
|
||||
return arch;
|
||||
}
|
||||
|
||||
private validateComponent<T extends ComponentData>(
|
||||
schema: ComponentSchema<T>,
|
||||
value: T
|
||||
): void {
|
||||
if (schema.schema === undefined) return;
|
||||
const result = schema.schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new EcsError(
|
||||
ECS_CODES.COMPONENT_VALIDATION_FAILED,
|
||||
"error.ecs.component_validation_failed",
|
||||
{
|
||||
context: {
|
||||
name: schema.name,
|
||||
issues: result.error.issues,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Factory wrapper around `new World(config)`. */
|
||||
export function createWorld(config?: WorldConfig): World {
|
||||
return new World(config);
|
||||
}
|
||||
28
packages/gaming/games/ecs/tests/dep-graph.test.ts
Normal file
28
packages/gaming/games/ecs/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const ALLOWED_RUNTIME_DEPS = new Set([
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/opentelemetry",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
|
||||
describe("dependency graph", () => {
|
||||
it("runtime dependencies are restricted to the declared allowlist", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = JSON.parse(
|
||||
await readFile(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies: Record<string, string> };
|
||||
const declared = new Set(Object.keys(pkg.dependencies));
|
||||
const extras = [...declared].filter(
|
||||
(d) => !ALLOWED_RUNTIME_DEPS.has(d)
|
||||
);
|
||||
expect(extras).toEqual([]);
|
||||
});
|
||||
});
|
||||
163
packages/gaming/games/ecs/tests/property.test.ts
Normal file
163
packages/gaming/games/ecs/tests/property.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { BitSet } from "../src/bitset/bitset.js";
|
||||
import { defineQuery } from "../src/query/query.js";
|
||||
import { defineComponent } from "../src/schema/schema.js";
|
||||
import { createWorld } from "../src/world/world.js";
|
||||
|
||||
const MAX_BITS = 128;
|
||||
const MAX_ENTITIES = 32;
|
||||
|
||||
function bitsetFromBits(size: number, bits: ReadonlyArray<number>): BitSet {
|
||||
const set = new BitSet(size);
|
||||
for (const b of bits) set.set(b);
|
||||
return set;
|
||||
}
|
||||
|
||||
function bruteContainsAll(a: BitSet, b: BitSet, size: number): boolean {
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (b.has(i) && !a.has(i)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function bruteIntersects(a: BitSet, b: BitSet, size: number): boolean {
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (a.has(i) && b.has(i)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe("property invariants", () => {
|
||||
it("bitset algebra matches brute-force on random masks", {
|
||||
timeout: 10000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: MAX_BITS }),
|
||||
fc.array(fc.nat(MAX_BITS - 1), { maxLength: 16 }),
|
||||
fc.array(fc.nat(MAX_BITS - 1), { maxLength: 16 }),
|
||||
(rawSize, aBits, bBits) => {
|
||||
const size = Math.max(rawSize, ...aBits, ...bBits, 0) + 1;
|
||||
const a = bitsetFromBits(size, aBits);
|
||||
const b = bitsetFromBits(size, bBits);
|
||||
expect(a.containsAll(b)).toBe(bruteContainsAll(a, b, size));
|
||||
expect(a.intersects(b)).toBe(b.intersects(a));
|
||||
expect(a.intersects(b)).toBe(bruteIntersects(a, b, size));
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it("query result equals brute-force scan on random worlds", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const Position = defineComponent("Position", { x: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 0 });
|
||||
const Disabled = defineComponent("Disabled", { d: true });
|
||||
const schemas = [Position, Velocity, Disabled] as const;
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.tuple(fc.boolean(), fc.boolean(), fc.boolean()), {
|
||||
maxLength: MAX_ENTITIES,
|
||||
}),
|
||||
fc.subarray([Position, Velocity, Disabled], {
|
||||
minLength: 1,
|
||||
maxLength: 3,
|
||||
}),
|
||||
fc.subarray([Position, Velocity, Disabled], {
|
||||
minLength: 0,
|
||||
maxLength: 3,
|
||||
}),
|
||||
(entitySpec, include, exclude) => {
|
||||
const world = createWorld();
|
||||
for (const s of schemas) world.registerComponent(s);
|
||||
const entities: number[] = [];
|
||||
for (const flags of entitySpec) {
|
||||
const entity = world.spawn();
|
||||
entities.push(entity);
|
||||
for (let i = 0; i < schemas.length; i++) {
|
||||
if (flags[i] === true) {
|
||||
world.addComponent(entity, schemas[i]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
const descriptor = defineQuery(
|
||||
include,
|
||||
exclude.length > 0 ? exclude : undefined
|
||||
);
|
||||
const queried = [...world.query(descriptor)].sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
const expected = entities
|
||||
.filter((e) => {
|
||||
for (const s of include) {
|
||||
if (!world.hasComponent(e, s)) return false;
|
||||
}
|
||||
for (const s of exclude) {
|
||||
if (world.hasComponent(e, s)) return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a - b);
|
||||
expect(queried).toEqual(expected);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it("entity count tracks spawn / despawn monotonically", {
|
||||
timeout: 10000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.boolean(), { maxLength: MAX_ENTITIES }),
|
||||
(ops) => {
|
||||
const world = createWorld();
|
||||
const live: number[] = [];
|
||||
for (const op of ops) {
|
||||
if (op || live.length === 0) {
|
||||
live.push(world.spawn());
|
||||
} else {
|
||||
const last = live.pop();
|
||||
if (last !== undefined) world.despawn(last);
|
||||
}
|
||||
}
|
||||
expect(world.entityCount).toBe(live.length);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it("snapshot then restore preserves observable state", {
|
||||
timeout: 15000,
|
||||
}, () => {
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.record({ x: fc.integer(), y: fc.integer() }), {
|
||||
maxLength: MAX_ENTITIES,
|
||||
}),
|
||||
(positions) => {
|
||||
const a = createWorld();
|
||||
a.registerComponent(Position);
|
||||
for (const p of positions) {
|
||||
const entity = a.spawn();
|
||||
a.addComponent(entity, Position, p);
|
||||
}
|
||||
const snap = a.snapshot();
|
||||
const b = createWorld();
|
||||
b.registerComponent(Position);
|
||||
b.restore(snap);
|
||||
expect(b.entityCount).toBe(a.entityCount);
|
||||
expect(b.archetypeCount).toBe(a.archetypeCount);
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
52
packages/gaming/games/ecs/tests/purity.test.ts
Normal file
52
packages/gaming/games/ecs/tests/purity.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC_ROOT = join(__dirname, "..", "src");
|
||||
const BANNED_PATTERNS = [
|
||||
/\bfrom\s+["']node:/,
|
||||
/\bfrom\s+["']fs["']/,
|
||||
/\bfrom\s+["']path["']/,
|
||||
/\bfrom\s+["']crypto["']/,
|
||||
/\brequire\(["']node:/,
|
||||
/\bprocess\.[a-zA-Z]/,
|
||||
/\bBuffer\.[a-zA-Z]/,
|
||||
/\bwindow\./,
|
||||
/\bdocument\./,
|
||||
/\bnavigator\./,
|
||||
];
|
||||
|
||||
async function* walk(dir: string): AsyncGenerator<string> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
yield* walk(full);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
entry.name.endsWith(".ts") &&
|
||||
!entry.name.endsWith(".test.ts") &&
|
||||
!entry.name.endsWith(".spec.ts") &&
|
||||
!entry.name.endsWith(".bench.ts")
|
||||
) {
|
||||
yield full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("purity guards", () => {
|
||||
it("built dist references no Node or browser globals", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const offenders: Array<{ file: string; pattern: string }> = [];
|
||||
for await (const file of walk(SRC_ROOT)) {
|
||||
const source = await readFile(file, "utf8");
|
||||
for (const pattern of BANNED_PATTERNS) {
|
||||
if (pattern.test(source)) {
|
||||
offenders.push({ file, pattern: pattern.source });
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
40
packages/gaming/games/ecs/tests/setup/polyfills.ts
Normal file
40
packages/gaming/games/ecs/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/ecs.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meter resolved on first call binds to it. Also
|
||||
* provides a `webcrypto`-backed `globalThis.crypto` for bare-Node runs.
|
||||
*/
|
||||
import { webcrypto } from "node:crypto";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
12
packages/gaming/games/ecs/tsconfig.lib.json
Normal file
12
packages/gaming/games/ecs/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
39
packages/gaming/games/ecs/vite.config.ts
Normal file
39
packages/gaming/games/ecs/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/games-ecs",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/opentelemetry",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/gaming/games/ecs/vitest.config.ts
Normal file
32
packages/gaming/games/ecs/vitest.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user