Files
2026-05-27 23:29:53 +01:00

295 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: ecs (enterprise-ecs)
**Package**: `@suaveplan/ecs`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time (`openspec/AGENTS.md` §13).
## ADDED 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 -->