Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

295
openspec/specs/ecs/spec.md Normal file
View File

@@ -0,0 +1,295 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: ecs
**Package**: `@suaveplan/ecs`
**Status**: ACTIVE
**Last delta**: `2026-05-19-enterprise-ecs`
> This spec was folded from the `enterprise-ecs` change delta. Every requirement carries a `**Verified by:**` line pointing at concrete `it(...)` titles.
## Requirements
### Requirement: Universal-tier purity
The `@suaveplan/ecs` package SHALL be pure TypeScript that runs identically in Bun, Node, and the browser. It SHALL NOT import any `node:*` module, `process`, `Buffer`, or any browser-only or React-only global. Its runtime `dependencies` SHALL be limited to `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/opentelemetry`, `tslib`, and `zod`.
#### Scenario: Built dist references no Node or browser globals
- **WHEN** the built `dist/` is grep'd
- **THEN** it MUST NOT reference `node:fs`, `node:path`, `node:crypto`, `node:os`, `process`, `Buffer`, `window`, `document`, or `navigator`
#### Scenario: Runtime dependency allowlist is closed
- **WHEN** `tests/dep-graph.test.ts` inspects `package.json#dependencies`
- **THEN** every key is in the allowlist `{@suaveplan/error, @suaveplan/types, @suaveplan/events, @suaveplan/opentelemetry, tslib, zod}` — extra keys fail
**Verified by:** `tests/purity.test.ts::"built dist references no Node or browser globals"` AND `tests/dep-graph.test.ts::"runtime dependencies are restricted to the declared allowlist"`
---
### Requirement: BitSet primitive
The package SHALL export a `BitSet` class backed by `Uint32Array` storage. The constructor SHALL accept a positive integer `size` and allocate `Math.ceil(size / 32)` words. The class SHALL provide `set(bit)`, `unset(bit)`, `has(bit)`, `clear()`, `clone()`, `equals(other)`, `count()`, `containsAll(other)`, `intersects(other)`, `toKey()`, and a `Symbol.iterator` that yields set bit indices in ascending order. `containsAll` and `intersects` SHALL be safe against differently sized operands.
#### Scenario: set / has / unset are O(1) bit operations
- **WHEN** a caller sets and unsets bit indices across multiple words
- **THEN** `has(bit)` returns `true` for set bits and `false` for unset bits, including bits in higher words
#### Scenario: containsAll and intersects respect mismatched sizes
- **WHEN** `a.containsAll(b)` is called where `b.size > a.size`
- **THEN** it returns `true` only if every bit set in `b` is also set in `a` (extra words in `b` must be zero)
#### Scenario: iterator yields set bits in ascending order
- **WHEN** a `BitSet` has bits `{2, 31, 32, 64}` set
- **THEN** `[...bitset]` returns `[2, 31, 32, 64]`
**Verified by:** `src/bitset/bitset.test.ts::"set, has, and unset propagate across multiple Uint32 words"` AND `src/bitset/bitset.test.ts::"containsAll returns false when other has bits in trailing words this lacks"` AND `src/bitset/bitset.test.ts::"the iterator yields set bit indices in ascending order"`
---
### Requirement: Component registry assigns stable IDs
The package SHALL export a `ComponentRegistry` class whose `register(schema)` SHALL assign a stable, monotonically increasing integer ID to a `ComponentSchema` on first registration and SHALL return the same ID on every subsequent registration of the same schema (idempotent by schema identity). `register` SHALL throw `EcsError` with code `ECS.COMPONENT_NAME_CONFLICT` if a schema with a different identity but the same `name` is already registered. `register` SHALL throw `EcsError` with code `ECS.COMPONENT_LIMIT_REACHED` once `maxComponents` distinct schemas have been registered (default 256). `getId(schema)` SHALL throw `EcsError` with code `ECS.COMPONENT_NOT_REGISTERED` when the schema is unknown.
#### Scenario: register is idempotent by schema identity
- **WHEN** the same `ComponentSchema` reference is passed to `register` twice
- **THEN** the second call returns the ID assigned by the first call, and the registry size does not grow
#### Scenario: name collision across distinct schemas throws ECS.COMPONENT_NAME_CONFLICT
- **WHEN** two distinct `ComponentSchema` objects share the same `name`
- **THEN** the second `register` call throws `EcsError` whose `code === "ECS.COMPONENT_NAME_CONFLICT"` and `context` includes the conflicting name
#### Scenario: exceeding maxComponents throws ECS.COMPONENT_LIMIT_REACHED
- **WHEN** a registry constructed with `maxComponents: 4` accepts a fifth distinct schema
- **THEN** the fifth `register` call throws `EcsError` whose `code === "ECS.COMPONENT_LIMIT_REACHED"`
**Verified by:** `src/component-registry/component-registry.test.ts::"register returns the same id for the same schema reference"` AND `src/component-registry/component-registry.test.ts::"register throws ECS.COMPONENT_NAME_CONFLICT for a duplicate name"` AND `src/component-registry/component-registry.test.ts::"register throws ECS.COMPONENT_LIMIT_REACHED past maxComponents"`
---
### Requirement: Entity pool recycles IDs with generation counters
The package SHALL export an `EntityPool` class whose `allocate()` returns a positive integer entity ID, preferring recycled IDs over fresh ones. `free(entity)` SHALL return the ID to a recycled-list and SHALL return `false` if the entity is not currently alive. Each allocation SHALL bump a per-ID generation counter so callers can detect stale references via `generation(entity)`. `clear()` SHALL reset the pool to its initial state.
#### Scenario: a freed ID is recycled on the next allocate
- **WHEN** `allocate()` returns `1`, then `free(1)`, then `allocate()` is called again
- **THEN** the second `allocate()` returns `1`
#### Scenario: generation increments on every allocation of the same ID
- **WHEN** entity `1` is allocated, freed, and allocated again
- **THEN** `generation(1)` returns `2`
#### Scenario: free returns false for a non-alive entity
- **WHEN** `free(999)` is called for an entity that was never allocated
- **THEN** the call returns `false` and `recycledCount` does not change
**Verified by:** `src/entity-pool/entity-pool.test.ts::"allocate prefers a recycled id over a fresh id"` AND `src/entity-pool/entity-pool.test.ts::"generation increments on every allocation of the same id"` AND `src/entity-pool/entity-pool.test.ts::"free returns false for a non-alive entity"`
---
### Requirement: Archetype provides O(1) swap-remove column storage
The package SHALL export an `Archetype` class whose constructor accepts a `BitSet` mask and a sorted readonly array of `ComponentId`s. Entities SHALL be stored dense-packed; each component column SHALL be a parallel array indexed by entity position. `removeEntity(entity)` SHALL perform a swap-remove in O(1), maintaining the dense-packing invariant by moving the last entity into the removed slot and popping the tail, and SHALL return a `Map<ComponentId, ComponentData>` of the removed entity's column values. `setComponent` SHALL merge a partial update into the existing column value at the entity's index.
#### Scenario: swap-remove preserves dense packing
- **WHEN** entities `{1, 2, 3}` are in an archetype and `removeEntity(2)` is called
- **THEN** `getEntities()` returns `[1, 3]` in that order, `getComponent(3, ...)` still returns the correct data, and `count === 2`
#### Scenario: removeEntity returns the removed component map
- **WHEN** `removeEntity(2)` is called for an entity with components `{position, velocity}`
- **THEN** it returns a `Map` whose values are the entity's position and velocity at the moment of removal
#### Scenario: setComponent partially merges
- **WHEN** entity `1` has `position = { x: 0, y: 0 }` and `setComponent(1, positionId, { x: 5 })` is called
- **THEN** `getComponent(1, positionId)` returns `{ x: 5, y: 0 }`
**Verified by:** `src/archetype/archetype.test.ts::"removeEntity swap-removes and preserves dense packing"` AND `src/archetype/archetype.test.ts::"removeEntity returns a map of every column value at the removed slot"` AND `src/archetype/archetype.test.ts::"setComponent merges a partial update into the existing column value"`
---
### Requirement: Query matches archetypes by include / exclude masks
The package SHALL export a `Query` class and a `defineQuery(include, exclude?)` factory. A query SHALL match an archetype if and only if (a) the archetype mask `containsAll` of the query's include mask AND (b) the archetype mask does not `intersects` the query's exclude mask (if any). `evaluate(archetypes)` SHALL collect entities from every matching archetype in archetype-discovery order. `invalidate()` SHALL clear the cached match list so the next `evaluate` re-scans.
#### Scenario: include-only matches every superset archetype
- **WHEN** a query with include `{Position}` runs against archetypes `[{Position}, {Position, Velocity}, {Velocity}]`
- **THEN** it returns entities from the first two archetypes only
#### Scenario: exclude filters out matching archetypes
- **WHEN** a query with include `{Position}` and exclude `{Disabled}` runs against archetypes containing entities with and without `Disabled`
- **THEN** entities in `{Position, Disabled}` are excluded from the result
#### Scenario: invalidate forces a re-scan after archetype-graph mutation
- **WHEN** a query is evaluated, a new matching archetype is created, the query is `invalidate()`-d, and the query is evaluated again
- **THEN** the second evaluation includes entities from the new archetype
**Verified by:** `src/query/query.test.ts::"include-only matches every superset archetype"` AND `src/query/query.test.ts::"exclude filters out archetypes whose mask intersects the exclude mask"` AND `src/query/query.test.ts::"invalidate clears the cached match list and forces a re-scan"`
---
### Requirement: System scheduler orders by phase then priority
The package SHALL export `defineSystem(options)` and `SystemScheduler`. `defineSystem` SHALL default `phase` to `"update"`, `priority` to `0`, and `enabled` to `true`. `SystemScheduler.getOrdered()` SHALL return systems sorted first by `phase` in the order `preUpdate < update < postUpdate < render` and then by ascending `priority`. `remove(name)` SHALL return `false` for unknown names.
#### Scenario: phases run in fixed order
- **WHEN** three systems are added with phases `render`, `preUpdate`, `update` and equal priority
- **THEN** `getOrdered()` returns them in the order `preUpdate`, `update`, `render`
#### Scenario: ties within a phase break by ascending priority
- **WHEN** two `update`-phase systems are added with priorities `5` and `2`
- **THEN** `getOrdered()` returns the priority-`2` system first
#### Scenario: remove returns false for unknown system name
- **WHEN** `remove("nope")` is called for a name that was never added
- **THEN** the call returns `false` and `count` is unchanged
**Verified by:** `src/system/system.test.ts::"phases run in fixed order preUpdate update postUpdate render"` AND `src/system/system.test.ts::"ties within a phase break by ascending priority"` AND `src/system/system.test.ts::"remove returns false for unknown system name"`
---
### Requirement: defineComponent factory with optional Zod validator
The package SHALL export `defineComponent(name, defaults, schema?)` returning a frozen `ComponentSchema<T>` whose `name`, `defaults`, and optional `schema` (a `z.ZodType<T>`) are stable across calls. The returned object and its `defaults` SHALL be deep-frozen. When a `schema` is supplied, `World.addComponent` and `World.setComponent` SHALL validate the merged-and-final component data through the schema before persisting it, and SHALL throw `EcsError` with code `ECS.COMPONENT_VALIDATION_FAILED` on failure (carrying the Zod issues in `context`).
#### Scenario: defineComponent freezes the descriptor and defaults
- **WHEN** `defineComponent("Position", { x: 0, y: 0 })` returns a descriptor
- **THEN** assigning to `descriptor.defaults.x` throws in strict mode (descriptor and defaults are frozen)
#### Scenario: invalid component data throws ECS.COMPONENT_VALIDATION_FAILED
- **WHEN** a component is defined with a Zod schema `z.object({ hp: z.number().min(0) })` and `addComponent(entity, schema, { hp: -1 })` is called
- **THEN** it throws `EcsError` whose `code === "ECS.COMPONENT_VALIDATION_FAILED"` and `context` contains the Zod issues
**Verified by:** `src/schema/schema.test.ts::"defineComponent returns a frozen descriptor with frozen defaults"` AND `src/world/world.test.ts::"addComponent throws ECS.COMPONENT_VALIDATION_FAILED for data that fails the descriptor's Zod schema"`
---
### Requirement: World orchestrates entity, component, query, and system state
The package SHALL export `createWorld(config?)` and a `World` class implementing `WorldApi`. `World` SHALL provide `spawn() → Entity`, `despawn(entity)`, `isAlive(entity)`, `registerComponent(schema)`, `addComponent`, `removeComponent`, `getComponent`, `setComponent`, `hasComponent`, `query(descriptor)`, `addSystem`, `removeSystem`, `execute(dt)`, `snapshot()`, `restore(snap)`, `clear()`, `entityCount`, `archetypeCount`, and `getSystems()`. `addComponent` to a not-alive entity SHALL throw `EcsError` with code `ECS.ENTITY_NOT_ALIVE`. `addComponent` SHALL be a no-op when the entity already has the component. `restore(snap)` SHALL invoke `clear()` then rebuild every entity and component from the snapshot — schemas referenced by snapshot component names SHALL be looked up via `getSchemaByName` and skipped silently if not registered.
#### Scenario: spawn / addComponent / query reach the entity through its archetype
- **WHEN** an entity is spawned, `Position` is added, and `query({include: [Position]})` is called
- **THEN** the entity appears in the query result and `hasComponent(entity, Position) === true`
#### Scenario: addComponent on a despawned entity throws ECS.ENTITY_NOT_ALIVE
- **WHEN** an entity is spawned then despawned, and `addComponent(entity, Position)` is called
- **THEN** it throws `EcsError` whose `code === "ECS.ENTITY_NOT_ALIVE"`
#### Scenario: snapshot round-trip preserves entities and components
- **WHEN** a world is populated, `snapshot()` is called, a new world with the same schemas registered calls `restore(snap)`
- **THEN** the new world has the same entity count, the same component data per entity, and the same `nextEntityId`
#### Scenario: execute runs systems in scheduler order
- **WHEN** three systems are registered across `preUpdate`, `update`, `render` and `execute(0.016)` is called
- **THEN** the systems' `execute` callbacks fire in `preUpdate → update → render` order with `dt === 0.016`
**Verified by:** `src/world/world.test.ts::"spawn and addComponent place the entity in the matching archetype"` AND `src/world/world.test.ts::"addComponent throws ECS.ENTITY_NOT_ALIVE for a despawned entity"` AND `src/world/world.test.ts::"snapshot and restore round-trip preserves entities and components"` AND `src/world/world.test.ts::"execute runs registered systems in scheduler order"`
---
### Requirement: Structured errors with stable codes
The package SHALL export `EcsError` extending `SuaveplanError` and an `ECS_CODES` registry. Every code declared in the registry SHALL be thrown from at least one production code path: `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 error SHALL carry a `context` object documenting the relevant inputs (entity id, component name, system name, schema-validation issues, etc.).
#### Scenario: addSystem duplicate name throws ECS.SYSTEM_NAME_CONFLICT
- **WHEN** two systems are added with the same `name`
- **THEN** the second `addSystem` throws `EcsError` whose `code === "ECS.SYSTEM_NAME_CONFLICT"` and `context.name` equals the conflicting name
#### Scenario: restore on a malformed snapshot throws ECS.SNAPSHOT_INVALID
- **WHEN** `restore(snap)` is called with a snapshot that fails its Zod schema (e.g., negative `nextEntityId`)
- **THEN** it throws `EcsError` whose `code === "ECS.SNAPSHOT_INVALID"` with the Zod issues in `context`
#### Scenario: every registered code is reachable
- **WHEN** `src/errors/ecs-error.test.ts` enumerates `ECS_CODES`
- **THEN** for every code there exists at least one test that triggers it from a production path
**Verified by:** `src/errors/ecs-error.test.ts::"every ECS_CODES entry is thrown by at least one production code path"` AND `src/world/world.test.ts::"addSystem throws ECS.SYSTEM_NAME_CONFLICT on duplicate name"` AND `src/world/world.test.ts::"restore throws ECS.SNAPSHOT_INVALID for a malformed snapshot"`
---
### Requirement: OTel instrumentation on public entrypoints
The package SHALL emit OpenTelemetry signals via `@suaveplan/opentelemetry`: a counter `ecs.entity.lifecycle_total` (attribute `op` in `{spawn, despawn}`), a histogram `ecs.world.execute_duration_ms` recorded on every `execute(dt)` call, and spans on `spawn` / `despawn` / `execute` / `snapshot` / `restore`. Instrumentation SHALL accept an injectable `MeterProvider` / `TracerProvider` from `World` construction options (defaulting to the OTel API global no-op providers), so tests can use a real in-memory `MeterProvider` without mocks.
#### Scenario: execute records into the duration histogram
- **WHEN** `World` is constructed with an in-memory `MeterProvider`, three systems are registered, `execute(0.016)` is called five times, and the meter is read
- **THEN** the histogram `ecs.world.execute_duration_ms` has `count === 5` and a finite sum
#### Scenario: spawn / despawn increments the lifecycle counter
- **WHEN** an in-memory `MeterProvider` is wired and two entities are spawned then one despawned
- **THEN** the counter `ecs.entity.lifecycle_total` has `value === 2` for `op === "spawn"` and `value === 1` for `op === "despawn"`
**Verified by:** `src/world/world.spec-conformance.test.ts::"execute records into the ecs.world.execute_duration_ms histogram via an in-memory MeterProvider"` AND `src/world/world.spec-conformance.test.ts::"spawn and despawn increment the ecs.entity.lifecycle_total counter via an in-memory MeterProvider"`
---
### Requirement: EventBus entity lifecycle events with single-object payloads
The package SHALL expose `world.events`, an `EventBus<WorldEventMap>` from `@suaveplan/events`. `WorldEventMap` SHALL declare events `entitySpawned`, `entityDespawned`, `componentAdded`, and `componentRemoved`. Every payload SHALL be a single object (not a tuple): `{ entity }` for spawn/despawn; `{ entity, component }` (where `component` is the component schema name) for add/remove. `World.spawn`, `World.despawn`, `World.addComponent`, and `World.removeComponent` SHALL emit the corresponding event after their state mutation, never before.
#### Scenario: spawn emits entitySpawned with the new entity id
- **WHEN** a caller subscribes to `entitySpawned` via `world.events.on(...)` and `spawn()` returns id `1`
- **THEN** the listener fires exactly once with `{ entity: 1 }`
#### Scenario: addComponent emits componentAdded after the component is queryable
- **WHEN** a `componentAdded` listener queries `world.hasComponent(entity, Position)` from inside the callback
- **THEN** the query returns `true` (the event fires after mutation, not before)
**Verified by:** `src/world/world.spec-conformance.test.ts::"spawn emits entitySpawned with a single-object payload"` AND `src/world/world.spec-conformance.test.ts::"componentAdded fires after the component is queryable on the entity"`
---
### Requirement: Property-based invariants
The package's `tests/property.test.ts` SHALL use `fast-check` to assert invariants that pure-unit tests cannot exhaustively cover: BitSet algebra (`containsAll` is reflexive and matches a brute-force scan; `intersects` is symmetric), query consistency (a query's result equals the brute-force scan of every alive entity filtered by `hasComponent`), entity-count monotonicity across spawn/despawn sequences (final `entityCount === spawns − despawns_of_alive`), and snapshot/restore round-trip equality.
#### Scenario: bitset algebra matches brute-force on random masks
- **WHEN** fast-check generates 100 pairs of `BitSet`s of random size up to 128
- **THEN** `a.containsAll(b)` equals the brute-force check that every bit in `b` is also in `a`, and `a.intersects(b)` is symmetric with `b.intersects(a)`
#### Scenario: query result equals brute-force scan
- **WHEN** fast-check generates a random world with up to 64 entities and a random include/exclude descriptor
- **THEN** the query result, sorted, equals the brute-force-scan result, sorted
#### Scenario: snapshot then restore preserves observable state
- **WHEN** fast-check generates a random world, captures `snapshot()`, restores into a fresh world with the same schemas, and compares
- **THEN** the new world has equal `entityCount`, equal `archetypeCount`, and equal component data per entity name
**Verified by:** `tests/property.test.ts::"bitset algebra matches brute-force on random masks"` AND `tests/property.test.ts::"query result equals brute-force scan on random worlds"` AND `tests/property.test.ts::"snapshot then restore preserves observable state"`
<!-- markdownlint-enable MD024 -->