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

View File

@@ -0,0 +1,95 @@
# OpenSpec Change: enterprise-ecs
- **Change ID**: enterprise-ecs
- **Package**: `@suaveplan/ecs`
- **Category**: games (universal-tier)
- **Target path**: `packages/games/ecs`
- **Status**: PROPOSED
- **Date**: 2026-05-19
- **Spec delta**: `./specs/ecs/spec.md`
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/ecs.md`
This change supersedes the earlier `enterprise-gaming-ecs` proposal (`openspec/changes/enterprise-gaming-ecs/`) which predates the `packages/<plugin>/<pkg>` folder layout and the source-mapping / `Verified by:` discipline. The superseded proposal will be archived under the same date prefix.
---
## Why
`@suaveplan/ecs` is the foundational gameplay runtime — an archetype-based Entity Component System with bitset queries, archetype-graph transitions, system scheduling, and entity-ID recycling. The upstream `../suaveplan/packages/universal/ecs` package (1.0.1, 95.98% coverage) ships the algorithmic core but lacks the genesis enterprise floor: no `SuaveplanError` integration, no Zod-validated component schemas, no OTel instrumentation, no injectable clock, no property-based tests, and event payloads typed as tuples instead of `EventBus`-shaped envelopes.
Migrating this package on genesis unblocks the `games`-plugin work (procedural-world systems, fauna behaviour, fruit-machine state) and gives every downstream gameplay package a single, observable, deterministic ECS substrate.
## What changes
This is a fresh genesis package — no prior version exists on the branch, so every public symbol is ADDED.
- ADDED: `createWorld(config?: WorldConfig)` / `class World` — central container managing entities, archetypes, components, queries, and systems with structured errors, OTel spans on `world.spawn` / `world.execute` / `world.snapshot` / `world.restore`, and an `@suaveplan/events` `EventBus` for entity lifecycle events.
- ADDED: `class BitSet` — fixed-size `Uint32Array`-backed bitset with `set` / `unset` / `has` / `containsAll` / `intersects` / `equals` / `count` / `clone` / `clear` / iterator / `toKey`.
- ADDED: `class Archetype` — column-oriented (SoA) storage with O(1) swap-remove, mask-keyed identity, and per-component column arrays.
- ADDED: `class ComponentRegistry` — stable integer IDs for component schemas with name-uniqueness enforcement, `maxComponents` cap, and lookup by id / schema / name.
- ADDED: `class EntityPool` — entity-ID allocator with recycling and per-ID generation counters for stale-reference detection.
- ADDED: `class Query` + `defineQuery(include, exclude?)` — compiled bitset queries with cached archetype matching and `invalidate()` for archetype-graph mutations.
- ADDED: `class SystemScheduler` + `defineSystem(options)` — phase-ordered (`preUpdate` → `update` → `postUpdate` → `render`), priority-sorted system scheduler.
- ADDED: `defineComponent(name, defaults, schema?)` — Zod-validated component schema factory (genesis addition: accepts an optional `z.ZodType` validator wired through `World.addComponent` / `setComponent` / `restore`).
- ADDED: `EcsError` extending `SuaveplanError` with stable `ECS.*` 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`).
- ADDED: `WorldEvents` — `EventBus<WorldEventMap>` payloads as single objects (`{ entity }` / `{ entity, component }`) per the genesis `@suaveplan/events` shape — replaces upstream's `[{ entity }]` tuple payloads.
- ADDED: Public Zod schemas for `WorldSnapshot`, `ComponentDescriptor`, `QueryDescriptor`, and `WorldConfig`.
- ADDED: OTel instrumentation via `@suaveplan/opentelemetry` — a counter for entity spawns/despawns, a histogram for `world.execute` duration, and spans on `spawn` / `despawn` / `execute` / `snapshot` / `restore`.
## Out of scope
- **`@suaveplan/streams` integration / async tick** — upstream `ecs.md` § 3 mentions `AbortSignal` and streams for "network / IO helper". The ECS core is fully synchronous (a tick is a CPU loop, not IO). No async surface exists in upstream source; adding one without a concrete consumer would be speculative. Will be added when the first async-IO system lands.
- **`@suaveplan/feature-flags` rollout gates** — applies only to "risky new behaviour" per the source. The current public surface is a faithful port of an already-shipped 1.0.1 design; no risky branch needs gating.
- **`migrate` codemod** — applicable only when the public API changes. Fresh package, no prior consumers on genesis.
- **Benchmark suite** — covered by an `__benchmarks__` slot in the file layout but the perf-regression gate is wired in a follow-up change once the benchmark harness graduates across the monorepo. Not blocking enterprise sign-off because the package's foundational hot paths (BitSet ops, archetype lookups) are pinned by deterministic unit tests.
## Source mapping
This change derives from `../suaveplan/enterprise/ecs.md` (116 lines) and the upstream implementation at `../suaveplan/packages/universal/ecs/`.
| Requirement | Source lines | Notes |
|---|---|---|
| Universal-tier purity (zero browser/Node/React deps) | `ecs.md` L37 | verbatim ("Zero browser / Node / React dependencies — pure TypeScript, runs in Bun, Node, and browser") |
| Zod schemas for public types | `ecs.md` L38 | verbatim ("Zod schemas for all public types, ... versioned evolution") |
| Property-based tests (fast-check) | `ecs.md` L39 | verbatim |
| Deterministic behaviour / injectable clock | `ecs.md` L40 | verbatim ("no `Math.random()` / `Date.now()` at module scope; injectable clock and RNG") |
| OpenTelemetry instrumentation | `ecs.md` L41 | verbatim ("via `@suaveplan/opentelemetry` for all public entrypoints") |
| Structured errors with stable codes | `ecs.md` L42 | verbatim ("extending `@suaveplan/error` with stable error codes") |
| 100% test coverage | `ecs.md` L50 | verbatim cross-cutting (Istanbul, statement + branch) |
| 100% docs coverage (co-located `.md`) | `ecs.md` L51 | verbatim cross-cutting |
| Archetype-based ECS with bitset queries | `ecs.md` L12 | one-line summary expanded into the World/Archetype/BitSet/Query requirements via upstream `world.ts`, `archetype.ts`, `bitset.ts`, `query.ts` |
| Entity pooling with recycling | upstream `entity-pool.ts` L1-79 | implementation-level evidence for §1 purpose ("entity pooling") — surface preserved |
| System scheduling (phases + priorities) | upstream `system.ts` L29-80 | implementation-level evidence for §1 purpose ("system scheduling") — surface preserved |
| WorldSnapshot / serialization round-trip | upstream `world.ts` L199-236 | preserved as a stable persistence boundary |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `EventBus`-shape entity events (`{ entity }` single-object payload) | The genesis `@suaveplan/events` package replaced the upstream `EventEmitter` (which used tuple payloads `[{ entity }]`) with a single-payload `EventBus<EventMap>` shape. This is a forced rewrite, not an additive requirement; payload shape follows the genesis events contract. |
| 100% coverage threshold (not the upstream 95.98%) | Genesis-wide policy per `CLAUDE.md` Rules §Quality. |
| `@suaveplan/testing/runner` test-import gate | Genesis-wide CI rule per `CLAUDE.md` rule 11 / `openspec/AGENTS.md` §7. |
| Explicit-named feature-module barrels (no `export *`) | Genesis-wide policy per `CLAUDE.md` rule 10 / `openspec/AGENTS.md` §6a. |
## Verification approach
Every requirement in `specs/ecs/spec.md` carries a `**Verified by:**` line at authoring time. Test layout:
- `src/bitset/bitset.test.ts` — every `BitSet` operator and the iterator contract.
- `src/bitset/bitset.spec-conformance.test.ts` — universal-tier purity + iterator type pinning.
- `src/component-registry/component-registry.test.ts` — registration, ID stability, name-uniqueness, cap enforcement.
- `src/entity-pool/entity-pool.test.ts` — allocate/free/recycle behaviour and generation counters.
- `src/archetype/archetype.test.ts` — column storage, swap-remove identity, mask integrity.
- `src/query/query.test.ts` — include / exclude / cached evaluation / invalidation.
- `src/system/system.test.ts` — `defineSystem` defaults, phase ordering, priority sort, name conflict.
- `src/world/world.test.ts` — spawn / despawn / addComponent / removeComponent / getComponent / setComponent / hasComponent / query / addSystem / removeSystem / execute / snapshot / restore / clear.
- `src/world/world.spec-conformance.test.ts` — OTel emission (real `MeterProvider`), error-code coverage, EventBus payload typing, injectable Clock.
- `src/schema/schema.test.ts` — `defineComponent` factory with and without Zod validator.
- `src/errors/errors.test.ts` — every `ECS.*` code is thrown from at least one production path.
- `tests/property.test.ts` — fast-check: entity count monotonicity, bitset algebra, query consistency vs exhaustive scan, snapshot/restore round-trip.
- `tests/purity.test.ts` — built dist references no `node:*` / `Buffer` / `process`.
- `tests/dep-graph.test.ts` — runtime deps limited to the declared allowlist.
## Migration impact
None — additive only. No genesis package currently depends on `@suaveplan/ecs`. Downstream `games`-plugin consumers (procedural-world, fauna, fruit-machine, dialogue, economy) are not yet on genesis and will adopt the new surface when they migrate.

View File

@@ -0,0 +1,294 @@
<!-- 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 -->

View File

@@ -0,0 +1,81 @@
# Tasks — enterprise-ecs
> Every checkbox MUST reflect work actually done in this change. Retroactive ticking is forbidden (`openspec/AGENTS.md` §13).
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/ecs.md` end-to-end
- [x] 1.2 Confirm `openspec/specs/ecs/spec.md` does not yet exist (fresh capability)
- [x] 1.3 Read `openspec/AGENTS.md` §13 + §14
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.5 Fill `## Source mapping` — every requirement cites a line range or is in the "no source line" table
- [x] 1.6 Draft `specs/ecs/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Implementation
- [x] 2.1 `packages/games/ecs/package.json` — `@suaveplan/ecs@0.1.0`, `sideEffects: false`, deps `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/opentelemetry`, `tslib`, `zod`
- [x] 2.2 `tsconfig.lib.json` + `vite.config.ts` + `vitest.config.ts` + `tests/setup/polyfills.ts`
- [x] 2.3 `src/errors/ecs-error.ts` — `EcsError extends SuaveplanError` + `ECS_CODES` registry
- [x] 2.4 `src/types/ecs.types.ts` — `Entity`, `ComponentId`, `ComponentSchema<T>`, `ArchetypeId`, `QueryDescriptor`, `SystemDefinition`, `SystemPhase`, `SystemContext`, `SystemExecuteFn`, `WorldApi`, `WorldEventMap`, `EntitySnapshot`, `WorldSnapshot`
- [x] 2.5 `src/bitset/bitset.ts` — `Uint32Array`-backed `BitSet` with operators and iterator
- [x] 2.6 `src/component-registry/component-registry.ts`
- [x] 2.7 `src/entity-pool/entity-pool.ts`
- [x] 2.8 `src/archetype/archetype.ts`
- [x] 2.9 `src/query/query.ts` + `defineQuery`
- [x] 2.10 `src/system/system.ts` + `defineSystem` + `SystemScheduler`
- [x] 2.11 `src/schema/schema.ts` — `defineComponent` with optional Zod validator
- [x] 2.12 `src/world/world.ts` — `createWorld`, `World` class, OTel spans/counters/histogram, EventBus events
- [x] 2.13 `src/observability/otel.ts` — counter/histogram/span helpers
- [x] 2.14 `src/schemas/*.ts` — Zod schemas for `WorldSnapshot`, `QueryDescriptor`, `WorldConfig`, `ComponentDescriptor`
- [x] 2.15 `src/index.ts` — explicit-named barrel (no `export *`)
- [x] 2.16 Per-feature `index.ts` barrels with explicit named re-exports
## 3. Tests
- [x] 3.1 `src/bitset/bitset.test.ts` + `src/bitset/bitset.spec-conformance.test.ts`
- [x] 3.2 `src/component-registry/component-registry.test.ts`
- [x] 3.3 `src/entity-pool/entity-pool.test.ts`
- [x] 3.4 `src/archetype/archetype.test.ts`
- [x] 3.5 `src/query/query.test.ts`
- [x] 3.6 `src/system/system.test.ts`
- [x] 3.7 `src/schema/schema.test.ts`
- [x] 3.8 `src/world/world.test.ts` + `src/world/world.spec-conformance.test.ts`
- [x] 3.9 `src/errors/ecs-error.test.ts`
- [x] 3.10 `tests/property.test.ts` (fast-check)
- [x] 3.11 `tests/purity.test.ts` (dist grep guard)
- [x] 3.12 `tests/dep-graph.test.ts` (runtime dep allowlist)
- [x] 3.13 Every `it(...)` carries `{ timeout: 5000 }` (30000 for genuinely async)
- [x] 3.14 All test files import runner primitives from `@suaveplan/testing/runner`
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, API reference)
- [x] 4.2 `README.md` linking every co-located `.md` under `## Modules`
- [x] 4.3 Update `CLAUDE.md` package listing to add `ecs` under a `### Games (universal-tier)` section
## 5. Pre-archive 5-gate triple
- [x] 5.1 `bun run check` — Biome 0/0/0 across monorepo
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/ecs` — green
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/ecs` — 100/100/100/100
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/ecs` — exit 0
- [x] 5.5 `bunx turbo build --filter=@suaveplan/ecs` — green
## 6. Pre-archive checklist (AGENTS.md §13)
- [x] 6.1 Generics threaded; `expectTypeOf` on every literal-type claim
- [x] 6.2 Zod schemas wired at every declared boundary (component validators, snapshot restore, query options)
- [x] 6.3 Every `ECS.*` code thrown by ≥1 production path
- [x] 6.4 OTel instruments emitted from production paths; asserted with a real in-memory `MeterProvider`
- [x] 6.5 Lifecycle dedup tested (idempotent `registerComponent`, `addComponent` no-op on existing component)
- [x] 6.6 No `export *` in any feature-module barrel
- [x] 6.7 No direct `vitest` / `bun:test` imports in test files (CLAUDE.md rule 11)
- [x] 6.8 No retroactive ticking — every box reflects work done in this change
## 7. Archive
- [x] 7.1 Fold spec delta into `openspec/specs/ecs/spec.md`
- [x] 7.2 Archive superseded `openspec/changes/enterprise-gaming-ecs/` → `openspec/archive/2026-05-19-enterprise-gaming-ecs/` with a `SUPERSEDED.md` note pointing at this change
- [x] 7.3 Move `openspec/changes/enterprise-ecs/` → `openspec/archive/2026-05-19-enterprise-ecs/`
- [x] 7.4 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`