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

21 KiB
Raw Permalink Blame History

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 ComponentIds. 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 BitSets 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"