/** * Composable -- ECS Composition over Existing Primitives * * Universal composition API leveraging existing deterministic primitives. * Zero boilerplate, type-safe, content-addressed entity composition. * * @module */ import type { ContentAddress } from './brands.js'; import type { Token } from './token.js'; import type { Style } from './style.js'; import type { World } from './ecs.js'; import type { EntityId } from './ecs.js'; import { Token as TokenNS } from './token.js'; import { Style as StyleNS } from './style.js'; import { Boundary } from './boundary.js'; import { Part } from './ecs.js'; import { contentAddressOf } from './content-address.js'; import { ValidationError } from '@czap/error'; import { Effect } from 'effect'; // --------------------------------------------------------------------------- // Entity Composition Types // --------------------------------------------------------------------------- /** * Component map for a {@link ComposableEntity} — well-known slots for czap * primitives plus arbitrary user-defined keys. */ export interface EntityComponents { readonly boundary?: Boundary.Shape; readonly token?: Token.Shape; readonly style?: Style.Shape; readonly [key: string]: unknown; } /** * Content-addressed entity: the identity is an FNV-1a hash over its components, * so two entities with structurally equal components share the same `id`. */ export interface ComposableEntity { readonly id: ContentAddress; readonly components: T; readonly _tag: 'ComposableEntity'; } // --------------------------------------------------------------------------- // Composable Factory // --------------------------------------------------------------------------- interface ComposableFactory { make(components: T): ComposableEntity; compose(entity1: ComposableEntity, entity2: ComposableEntity): ComposableEntity; merge(...entities: ComposableEntity[]): ComposableEntity; } function makeEntityId(components: EntityComponents): ContentAddress { // Identity routes through the one shared content-addressing kernel // (canonicalize → CanonicalCbor → fnv1a, CUT B1) so EntityId, BoundaryDef.id, // and DocumentGraph ids cannot diverge. return contentAddressOf(components); } function _make(components: T): ComposableEntity { const id = makeEntityId(components); return { id, components, _tag: 'ComposableEntity', }; } function _compose( entity1: ComposableEntity, entity2: ComposableEntity, ): ComposableEntity { // Merge components with entity2 taking precedence const merged = { ...entity1.components, ...entity2.components }; return _make(merged); } function _merge(...entities: ComposableEntity[]): ComposableEntity { if (entities.length === 0) { throw ValidationError( 'Composable.merge', 'called with no entities — pass at least one ComposableEntity, e.g. Composable.merge(a, b).', ); } const first = entities[0]; if (!first) { throw ValidationError( 'Composable.merge', 'entities[0] is undefined — you likely passed a sparse or filtered array. ' + 'Filter out undefined before merging: Composable.merge(...entities.filter(Boolean)).', ); } return entities.slice(1).reduce((acc, entity) => _compose(acc, entity), first); } // --------------------------------------------------------------------------- // ECS Integration // --------------------------------------------------------------------------- /** * Convert a runtime `Map` (from ECS query results) into a typed * `Pick`. The ECS query filters guarantee the required keys are present; * this helper contains the one boundary cast where runtime shape joins the type lattice. */ function entriesToPick( components: ReadonlyMap, ): Pick { return Object.fromEntries(components) as Pick; } interface TypedComposableWorld { spawn(components: T): Effect.Effect>; spawnWith(entity: ComposableEntity): Effect.Effect>; query(...componentTypes: K[]): Effect.Effect>[]>; evaluate( entity: ComposableEntity, input: Record, ): Effect.Effect>; } function makeComposableWorld( world: World.Shape, ): TypedComposableWorld { // Mapping from ContentAddress to ECS EntityId for query reconstruction const addressToEntityId = new Map(); return { spawn(components: T): Effect.Effect> { return Effect.gen(function* () { const entity = _make(components); const ecsId = yield* world.spawn(components); addressToEntityId.set(entity.id, ecsId); return entity; }); }, spawnWith(entity: ComposableEntity): Effect.Effect> { return Effect.gen(function* () { const ecsId = yield* world.spawn(entity.components); addressToEntityId.set(entity.id, ecsId); return entity; }); }, query(...componentTypes: K[]): Effect.Effect>[]> { return Effect.gen(function* () { const names = [...componentTypes].map((k) => String(k)).sort(); const entities = yield* world.query(...names); return [...entities] .sort((left, right) => left.id.localeCompare(right.id)) .map((entityShape) => { // world.query guarantees entityShape.components contains at least the K keys // that were queried for; convert the runtime Map to the typed // Pick via a single contained cast (runtime shape is validated by // the ECS query filter). const components = entriesToPick(entityShape.components); return _make(components); }); }); }, evaluate( entity: ComposableEntity, input: Record, ): Effect.Effect> { return Effect.gen(function* () { const results: Record = {}; // Evaluate boundary component: quantize continuous input to discrete state let boundaryState: string | undefined; if (entity.components.boundary) { const boundary = entity.components.boundary; const boundaryInput = input[boundary.input] ?? 0; const state = Boundary.evaluate(boundary, boundaryInput); results[boundary.input] = state; boundaryState = state; } // Evaluate token component: resolve axis values or fall back if (entity.components.token) { const token = entity.components.token; // Build axis values from input keys. Token.tap expects string axis values, // so we convert matching numeric inputs to strings. const axisValues: Record = {}; for (const axis of token.axes) { if (axis in input) { axisValues[axis] = String(input[axis]); } } // Use Token.tap for proper axis-key lookup with fallback const resolved = TokenNS.tap(token, axisValues); results[token.name] = String(resolved); } // Evaluate style component: resolve properties for the current boundary state if (entity.components.style) { const style = entity.components.style; const resolvedProps = StyleNS.tap(style, boundaryState); for (const [prop, val] of Object.entries(resolvedProps)) { results[prop] = val; } } return results; }); }, }; } // --------------------------------------------------------------------------- // Dense Store Integration // --------------------------------------------------------------------------- interface ComposableDenseStore { create(name: string, capacity: number): Effect.Effect; store(entity: ComposableEntity, value: number): Effect.Effect; retrieve(entity: ComposableEntity): Effect.Effect; } function makeComposableDenseStore(world: World.Shape): ComposableDenseStore { // Maintain a mapping from ContentAddress to ECS EntityId for dense store ops const addressToEntityId = new Map(); let denseStore: Part.Dense | undefined; return { create(name: string, capacity: number): Effect.Effect { return Effect.gen(function* () { const store = Part.dense(name, capacity); yield* world.addDenseStore(store); denseStore = store; return store; }); }, store(entity: ComposableEntity, value: number): Effect.Effect { return Effect.gen(function* () { if (!denseStore) { throw ValidationError( 'ComposableWorld.store', 'no dense store exists — call world.create(name, capacity) before world.store(entity, value).', ); } // Ensure we have an ECS EntityId for this composable entity let ecsId = addressToEntityId.get(entity.id); if (!ecsId) { // Spawn into the world to get an EntityId, then track mapping ecsId = yield* world.spawn(entity.components); addressToEntityId.set(entity.id, ecsId); } denseStore.set(ecsId, value); }); }, retrieve(entity: ComposableEntity): Effect.Effect { return Effect.gen(function* () { if (!denseStore) { return undefined; } const ecsId = addressToEntityId.get(entity.id); if (!ecsId) { return undefined; } return denseStore.get(ecsId); }); }, }; } // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- /** * Composable — content-addressed entity algebra over czap primitives. * * Build entities from a bag of components (boundaries, tokens, styles, …), * merge them associatively via `Composable.compose` / `Composable.merge`, and * rely on the content address to deduplicate structurally-equal entities. */ export const Composable: ComposableFactory = { /** Content-address a component bag into a {@link ComposableEntity}. */ make: _make, /** Pairwise merge — right-biased; produces a new entity with a fresh content address. */ compose: _compose, /** Variadic `Composable.compose`. Throws if called with zero entities. */ merge: _merge, }; /** * Bridge between a raw ECS {@link World} and typed {@link ComposableEntity} * operations (`spawn`, `query`, `evaluate`) plus a thin dense-store integration. */ export const ComposableWorld = { /** Wrap a {@link World} with the typed composable-entity API. */ make: makeComposableWorld, /** Build a dense-store bridge over a {@link World} for per-entity numeric data. */ dense: makeComposableDenseStore, }; export declare namespace ComposableWorld { /** Structural shape of the typed world returned by {@link ComposableWorld.make}. */ export type Shape = TypedComposableWorld; } // Type exports -- keep legacy alias for backward compatibility export type { TypedComposableWorld as ComposableWorldShape };