diff --git a/.claude/rules/app.md b/.claude/rules/app.md new file mode 120000 index 000000000..548c401c9 --- /dev/null +++ b/.claude/rules/app.md @@ -0,0 +1 @@ +../../packages/data-ai/.claude/rules/app.md \ No newline at end of file diff --git a/package.json b/package.json index f64769a1d..d994ef8e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.10.21", + "version": "0.10.22", "private": true, "engines": { "node": ">=24" diff --git a/packages/data-ai/.claude-plugin/plugin.json b/packages/data-ai/.claude-plugin/plugin.json index 3b09d57ba..1038fe0d6 100644 --- a/packages/data-ai/.claude-plugin/plugin.json +++ b/packages/data-ai/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "adobe-data-ai", - "version": "0.10.21", + "version": "0.10.22", "description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.", "author": { "name": "Adobe" diff --git a/packages/data-ai/.claude/rules/README.md b/packages/data-ai/.claude/rules/README.md index f10041c5d..8f2d37011 100644 --- a/packages/data-ai/.claude/rules/README.md +++ b/packages/data-ai/.claude/rules/README.md @@ -7,43 +7,38 @@ frontmatter glob, injects them only when a matching file is in context. ## Layout mirrors the feature-folder structure -The `features/` subtree mirrors the four feature layers one-to-one, so the -guidance for a folder lives at the same path shape as the code it governs. A -folder's own `index.md` holds just enough to understand that folder; each -child gets its own file (recursing wherever the source tree does): +The `features/` subtree mirrors a feature's layers one-to-one, so the guidance for a +folder lives at the same path shape as the code it governs. A folder's `index.md` +holds just enough to understand that folder; each child gets its own file: ``` global/ # always-on conventions — deliberately repo-wide globs namespace.md cohesion.md type-casts.md function-references.md react.md features/ - index.md # the feature layering as a whole (ui → services → data) + index.md # the layering as a whole: data → services → spec | ecs → ui → app data/ - index.md # data-type namespaces (foundation) - state.md # the State aggregate: pure transforms & derivations - services/ - index.md # the services layer: main-service + capability contracts - main-service/ # the feature service (ECS implementation) - index.md # the service + its layered database plugins - components.md resources.md archetypes.md - computed.md indexes.md transactions.md services.md actions.md - systems.md conformance.md - ui/index.md # UI (points to element / presentation / lazy-element file rules) + index.md # pure Data declarations + values.md components.md resources.md entities.md + services/index.md # service namespaces: interface, create, createFake + spec/index.md # the State spec: actions, derivations, cases (test tier) + ecs/ + index.md # the layered database plugins + MainService + core.md indexes.md transactions.md computed.md services.md actions.md + systems.md conformance.md + ui/ # element / presentation / lazy-wrapper rules +app.md # src/app/: the composition root ``` -The `global/` rules (`namespace.md`, `cohesion.md`, `type-casts.md`, -`function-references.md`, `react.md`) are **intentionally repo-wide** — their -`paths:` globs (`**/*.ts` etc.) apply everywhere, by design, so the folder name -makes that explicit. Alongside `features/`, the remaining rules-root `.md` files -hold the other **cross-cutting** patterns the feature rules reference — -`data-modelling.md`, `archetypes.md` (row iteration), `plugin-modelling.md`, -`versioning.md` (schema history & upgrade-on-load), and -the UI file rules (`element.md`, `lazy-element.md`, `presentation.md`). They live -here so the bundle is self-contained; this repo symlinks them into its own -`.claude/rules/` (as it does `features/`). +The `global/` rules are **intentionally repo-wide** — their `paths:` globs (`**/*.ts` +etc.) apply everywhere, by design. Alongside `features/`, the remaining rules-root +`.md` files hold **cross-cutting** patterns the feature rules reference — +`app.md`, `data-modelling.md`, `archetypes.md` (the query and iteration model), +`plugin-modelling.md`, `versioning.md`, and the UI rules `element.md` and +`lazy-element.md` (`features/ui/` holds `presentation.md` and the binding-element and +lazy-wrapper rules). They live here so the bundle is +self-contained; this repo symlinks them into its own `.claude/rules/`. -Each rule's `paths:` glob is scoped to `**/features/*//…`, so these -rules apply only once an application opts into the feature-folder pattern -(source organized under `features//`). Editing -`features//services/main-service/core-database/components.ts` pulls in both -`main-service/index.md` (the layer overview) and `main-service/components.md` -(the specifics) — at creation and at every later edit. +Each feature rule's `paths:` glob is scoped to `**/features/*//…`, so these +rules apply once an application organizes its source under `features//`. +Editing `features//data/components/name.ts` pulls in both `data/index.md` and +`data/components.md` — at creation and at every later edit. diff --git a/packages/data-ai/.claude/rules/app.md b/packages/data-ai/.claude/rules/app.md new file mode 100644 index 000000000..6f93eb735 --- /dev/null +++ b/packages/data-ai/.claude/rules/app.md @@ -0,0 +1,46 @@ +--- +paths: + - '**/src/app/**/*.ts' + - '**/src/app/**/*.tsx' +--- + +# src/app/ — the composition root + +The app root composes the features. It is the only place that knows every feature, +and no feature imports it. + +``` +src/app/ + schema.ts the app's composed plugin (when there are several features) + versioning/ versions.ts + versions.test.ts (versioning.md) + persistence.test.ts a save/load round-trip, when the app persists + *.test.ts cross-feature tests (e.g. a todo op ignores a user id) + main.ts the entry: create the database, inject services, render +``` + +```ts +// schema.ts — main's full behavior, plus the schema of features that load lazily +const appSchemaPlugin = Database.Plugin.create({ + imports: AssignCoreDatabase.plugin, + extends: MainService.plugin, +}); +export namespace AppSchema { + export const plugin = appSchemaPlugin; +} + +// main.ts +const service = Database.create(AppSchema.plugin, { + versioning: createVersionUpgrader(versions), + services: { game }, // a service whose `create` throws until the app injects it +}); +``` + +- **`imports`, not `extends`, for a lazily loaded feature**: the store knows its + columns up front (its data persists and loads), while its behavior, types and code + stay out until one of its elements connects and extends the database. +- **Inject every service whose `create` throws** at `Database.create`. Services can't + be injected by a later `extend`. +- **Versioning** folds the whole persisted schema, so it lives here, built from + `AppSchema.plugin`. +- A single-feature app needs no `schema.ts`; `main.ts` uses the feature's + `MainService.plugin`. diff --git a/packages/data-ai/.claude/rules/archetypes.md b/packages/data-ai/.claude/rules/archetypes.md index 002545c05..a3cd823e8 100644 --- a/packages/data-ai/.claude/rules/archetypes.md +++ b/packages/data-ai/.claude/rules/archetypes.md @@ -4,13 +4,15 @@ paths: - '**/*-plugin/**/*.ts' - '**/*-database.ts' - '**/*-database/**/*.ts' + - '**/features/*/data/entities/**/*.ts' + - '**/features/*/ecs/core/**/*.ts' --- # Archetypes — the ECS query & iteration model -General to all `@adobe/data` ECS use, feature-organized or not. The -`core-database/archetypes.ts` *file* conventions live in -`features/services/main-service/archetypes.md`. +General to all `@adobe/data` ECS use, feature-organized or not. The feature file +conventions live in `features/data/entities.md` (entity tuples) and +`features/ecs/core.md` (archetype packing). An archetype is a named set of component keys — a kind of entity — and also a **query**: `queryArchetypes(include, { exclude })` (and the `select` / `count` diff --git a/packages/data-ai/.claude/rules/data-modelling.md b/packages/data-ai/.claude/rules/data-modelling.md index 2f879ed3c..3a85b7aa9 100644 --- a/packages/data-ai/.claude/rules/data-modelling.md +++ b/packages/data-ai/.claude/rules/data-modelling.md @@ -1,6 +1,7 @@ --- paths: - - '**/data/*.ts' + - '**/features/*/data/**/*.ts' + - '**/features/*/spec/**/*.ts' --- # Data modelling — locality of knowledge @@ -54,15 +55,15 @@ single source of truth, not a downstream comparison flag: may be a plain `number` or the ECS `Entity` type (a branded `number`) — reusing `Entity`, and reference bundles like `CoeditingRelations.OrderedChild`, is allowed: they are plain value types, not ECS machinery, and keep the spec aligned with the ECS - (see `features/data/state.md`). Also used for deterministic-key lookups (an enum, a - name, a stable string). + (see `features/spec/index.md`). In a `State`, every `ReadonlyMap` field is an entity + collection; a lookup keyed by an enum or name belongs inside a value type. **Entities are keyed, never id-bearing values.** An entity's identity is its map key, so entity value types (`Todo`, `Bullet`) have no `id` field, and there is no -`ReadonlySet`/`ReadonlyArray` *of entity values* — the single -`ReadonlyMap` store is the only home for entities, and queries return -their **ids** (`ReadonlySet` unordered, `ReadonlyArray` ordered). -See `features/data/state.md` for the full `State` shape. +`ReadonlySet`/`ReadonlyArray` *of entity values* in `State` — a +`ReadonlyMap` per entity kind is the only home for entities. A derivation +may return entity values for display (the ECS computed then emits ids, hydrated by +conformance). See `features/spec/index.md` for the full `State` shape. These are first-class `Data` (see `features/data/index.md`) — serialize a Set/Map-bearing value with `Data.stringify` / `Data.parse` (plain `JSON.stringify` diff --git a/packages/data-ai/.claude/rules/ecs.md b/packages/data-ai/.claude/rules/ecs.md index 15dcb1ae1..3e47be416 100644 --- a/packages/data-ai/.claude/rules/ecs.md +++ b/packages/data-ai/.claude/rules/ecs.md @@ -7,6 +7,8 @@ paths: # Database.Plugin authoring +> Inside a feature (`features//ecs/`), the `features/ecs/` rules govern file layout and naming: layer files are `-database.ts` exporting an `XDatabase` namespace, and systems are camelCase. This file covers general plugin mechanics. + Plugins are created with `Database.Plugin.create()` from `@adobe/data/ecs`. ## Property order (enforced at runtime) @@ -19,7 +21,7 @@ Properties **must** appear in this exact order. All are optional. | 2 | `extends` | `Plugin` | Base plugin to extend (types re-exported) | | 3 | `services` | `(db) => ServiceInstance` | Singleton service factories | | 4 | `components` | schema object | ECS component schemas | -| 5 | `resources` | `{ default: value as Type }` | Global resource schemas | +| 5 | `resources` | schema with a `default` | Global resource schemas | | 6 | `archetypes` | `['comp1', 'comp2']` | Standard ECS archetypes | | 7 | `indexes` | `{ key, order?, unique?, archetype? }` | Sorted/filtered entity indexes | | 8 | `computed` | `(db) => Observe` | Computed observables | @@ -99,7 +101,7 @@ const particleDataPlugin = Database.Plugin.create({ ``` - **Components**: per-entity data. Use schema imports (`Vec3`, `Vec4`, `F32` from `@adobe/data/math`) or type namespaces for custom shapes. -- **Resources**: global state. Use **only** `{ default: value as Type }`. +- **Resources**: global state. Every resource carries a `default` (`{ ...Boolean.schema }`, or `{ default: 'dark' as ThemeColor }` to widen a literal). - **Archetypes**: one per entity kind. List all components that kind requires. --- @@ -108,23 +110,25 @@ const particleDataPlugin = Database.Plugin.create({ ### components -Non-persistable values (e.g. HTML elements, DOM refs) must use `ephemeral: true` on the schema. +Non-persistable values (e.g. HTML elements, DOM refs) are session scope — spread +`Scope.session` (`nonPersistent` + `nonShared`) into the schema. ```ts components: { layout: Layout.schema, - layoutElement: { default: null as unknown as HTMLElement, ephemeral: true }, + layoutElement: { default: null as HTMLElement | null, ...Scope.session }, }, ``` ### resources -Use `as Type` to provide the compile-time type. Use `null as unknown as Type` for resources initialized later in a system initializer. +Use `as Type` to widen a literal default to its type. A resource initialized later +(in a system initializer) defaults to `null` widened to `Type | null`. ```ts resources: { themeColor: { default: 'dark' as ThemeColor }, - connection: { default: null as unknown as WebSocket }, + connection: { default: null as WebSocket | null, ...Scope.session }, }, ``` @@ -156,7 +160,7 @@ UI components that call actions must never consume returned values — see `feat --- -## Naming conventions +## Naming conventions (standalone plugins) | Item | Convention | Example | | ----------- | ----------------------------------------------------- | ---------------------------- | diff --git a/packages/data-ai/.claude/rules/element.md b/packages/data-ai/.claude/rules/element.md index b5e26642e..1700bc9ad 100644 --- a/packages/data-ai/.claude/rules/element.md +++ b/packages/data-ai/.claude/rules/element.md @@ -3,6 +3,7 @@ paths: - '**/*-element.ts' - '**/elements/**/*.ts' - '**/ui/**/*.ts' + - '**/ui/**/*.tsx' --- # Container element authoring @@ -84,9 +85,9 @@ function **by reference**, don't wrap it in an identity arrow: ```ts // ❌ identity wrapper — adds nothing -setOperation: (op) => this.service.transactions.setOperation(op), +setOperation: (op) => this.service.actions.setOperation(op), // ✅ same signature, pass it through -setOperation: this.service.transactions.setOperation, +setOperation: this.service.actions.setOperation, ``` Only write an arrow when you genuinely adapt the call (drop an event @@ -117,14 +118,14 @@ transaction, or hook. | Framework | Container is | Subscription hook | Locator inputs | Render delegation | | --------- | ------------------------ | --------------------- | -------------------- | ------------------------------------------------ | | Lit | `DatabaseElement

` | `useObservableValues` | `@property` only | `presentation.render({ ...values, ...callbacks })` | -| React | function component | `useObservableValues` | function arguments | `` | -| Solid | component function | framework-specific | function arguments | `` | +| React | function component | `useObservableValues` | function arguments | `presentation.render({ ...values, ...callbacks })` | +| Solid | component function | framework-specific | function arguments | `presentation.render({ ...values, ...callbacks })` | Lit-only: `static styles = styles` from a sibling `*.css.ts`. `@property` exists *only* for locator inputs — never for state, never for derived values, never for parent-forwarded flags. The `P` in `DatabaseElement

` (and the `get plugin()` it returns) is the surface the element *consumes* — never the -varying topmost-layer name (see `features/services/main-service/index.md`): `typeof +varying topmost-layer name (see `features/ecs/index.md`): `typeof MainService.plugin` for a self-contained feature. **Exception:** an element meant to be *extended* — injected with various databases that build on this feature (a peer app that adds an agent, a p2p/presence build) — types on the diff --git a/packages/data-ai/.claude/rules/features/data/components.md b/packages/data-ai/.claude/rules/features/data/components.md new file mode 100644 index 000000000..6ebf7df96 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/data/components.md @@ -0,0 +1,51 @@ +--- +paths: + - '**/features/*/data/components/**/*.ts' +--- + +# data/components/ — one component schema per file + +A component is a named column of entity state. Each lives in its own file, with a +single export: its schema. The file owns that schema object, and every other feature +reuses it by importing the object, since the ECS compares schemas by identity. + +```ts +// components/name.ts — document scope: a value type's schema, re-exported by identity +export const name = Name.schema; + +// components/drag-position.ts — session scope: flags spread in the file +export const dragPosition = { ...DragPosition.schema, ...Scope.session } satisfies Schema; + +// components/todo.ts — a tag +export const todo = True.schema; + +// components/index.ts — the feature's vocabulary +export const components = { todo, name, complete, order, dragPosition }; +``` + +- **Scope** is declared per file with `Scope` from `@adobe/data/ecs`: + + | scope | shared | durable | spread | + |---|:-:|:-:|---| + | document | yes | yes | nothing (the default) | + | settings | no | yes | `...Scope.settings` | + | presence | yes | no | `...Scope.presence` | + | session | no | no | `...Scope.session` | + + On a component, the flags scope the **column**. An entity's own scope comes from + its marker components (`entities.md`). +- **Every column lives here**, including implementation-only ones (a tag, a drag + offset). `ecs/core/` only chooses how they are packed. +- **A tag** is a component with `True.schema`. Add one only when an entity's + component set would otherwise be contained in another's (`entities.md`). +- **The name describes the role**, the schema the shape (`position` of a `Vec3`). +- **Reserved names:** `id`, `nonPersistent`, `nonShared` (built into the ECS), and + `index` (it would collide with the `index.ts` barrel). +- **A feature built on another** spreads the lower barrel first and pins that its own + names don't shadow it (a spread would overwrite silently): + ```ts + export const components = { ...mainComponents, user, assignees }; + type _NoShadow = Assert, never>>; + ``` +- The barrel never includes the built-in `nonPersistent` / `nonShared` markers; the + ECS reserves those names and throws. diff --git a/packages/data-ai/.claude/rules/features/data/entities.md b/packages/data-ai/.claude/rules/features/data/entities.md new file mode 100644 index 000000000..e67460524 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/data/entities.md @@ -0,0 +1,37 @@ +--- +paths: + - '**/features/*/data/entities/**/*.ts' +--- + +# data/entities/ — entities as component tuples + +An entity is declared by the components it has. Its value type is the aggregate of +those components' types, derived rather than written: + +```ts +// entities/todo.ts +export const Todo = ["name", "complete", "order"] as const satisfies Database.EntityComponents; +export type Todo = Database.EntityType; +// → { readonly name: string; readonly complete: boolean; readonly order: number } + +// entities/index.ts +export const entities = { Todo }; +``` + +The same-named `const` and `type` pair counts as one export. + +- **Declare the spec-visible entity.** Implementation-only columns (a tag, a drag + offset) are added when `ecs/core/` packs the archetype. +- **An entity's kind is its component set.** Queries match every entity that has *at + least* the listed components. So when one entity's set is contained in another's + (`User = [name]` inside `Todo = [name, complete, order]`), give one of them a tag + (`User = ["user", "name"]`) or query with `exclude`. +- **Entity scope** comes from the built-in marker components in the tuple: + `["cursor", "position", "nonPersistent", "nonShared"]` is a session entity. Markers + are omitted from the value type; tags are kept. +- **A feature built on another** may declare an entity over the lower feature's + components, e.g. `AssignedTodo = ["name", "complete", "order", "assignees"]`. +- **The entity takes the name.** When a value type has the same shape, don't declare + both: helpers on its fields go on the field types' `values/` namespaces. +- The archetype model (supersets, base archetypes, row iteration) is in the + rules-root `archetypes.md`. diff --git a/packages/data-ai/.claude/rules/features/data/index.md b/packages/data-ai/.claude/rules/features/data/index.md index 85c6ee3da..47d596195 100644 --- a/packages/data-ai/.claude/rules/features/data/index.md +++ b/packages/data-ai/.claude/rules/features/data/index.md @@ -3,79 +3,27 @@ paths: - '**/features/*/data/**/*.ts' --- -# data/ — the data model +# data/ — the feature's pure Data declarations -The foundation layer: the feature's **data types** and the pure declarations -over them. A data type is a readonly, serializable value suitable for persistence -and for communication over the wire — no functions, no handles, just plain data: -JSON primitives/arrays/objects plus `ReadonlySet`, `ReadonlyMap`, and `Blob` -(serialize a Set/Map-bearing value with `Data.stringify` / `Data.parse`). A `data/` **type** depends on nothing but `@adobe/data` and other -`data/` types, and needs no knowledge of anything built on top of it. The -**transitions** over those types are less restricted: when one injects a service -it may import freely from `services/` — the service type and any utilities its -namespace exposes (see `state.md`). What it must **never** touch is `ui/`: -`data/` never depends on presentation — that isolation is strict and inviolable. -Only *services themselves* are never authored here; that is what makes this the -value-type layer. +The foundation layer. Everything here is a declaration about plain, serializable +Data: JSON primitives/arrays/objects plus `ReadonlySet`, `ReadonlyMap`, and `Blob` +(serialize with `Data.stringify` / `Data.parse`). No functions in values, no services, +no ECS machinery. The one exception is a session-scoped resource holding a runtime +handle (a canvas, a live database): `{ default: null as Handle | null, ...Scope.session }`. +It depends only on `@adobe/data` and the `data/` of features this one builds on. -Each data type is its own namespace folder (see `global/namespace.md`), holding -its hand-authored type, its (optional, matching) schema, and its pure -synchronous helpers together: - -``` -data/player-mark/ - player-mark.ts # HAND-AUTHORED type + `export * as PlayerMark from "./public.js"` - schema.ts # (optional / later) schema matching the type, pinned via Assert> - public.ts # re-exports schema + helpers - is.ts values.ts opponent.ts … # one pure helper per file ``` - -- The **type is authored by hand** in `.ts` — the readable source of - truth. Write it with the richest types available (`F32`, `Vec3`, unions, - branded aliases) so members carry their semantic meaning and get strong - IntelliSense / hover docs. -- The **schema is derived from the type, not the reverse.** It is written to - match the type and **pinned** to it with a compile-time assertion so the two - cannot drift (see below). A field typed as a branded primitive reuses that - type's schema — `F32` → `F32.schema`, `Vec3` → `Vec3.schema`. -- **Schemas are optional until a runtime boundary needs one.** A pure `data/` - type is complete with `.ts` alone. The schema is added only when - persistence, the wire, or an ECS component/resource requires it — and is - typically written by the agent in a later phase (e.g. when implementing the - ECS), not by the human up front. -- Helpers are synchronous and pure; each has a sibling `*.test.ts`. -- Objects of only 32-bit numeric values / sub-structs: the schema uses - `Schema.fromStructProperties` (guarantees a valid struct schema, stored in - linear memory). - -## Pinning the schema to the type - -The type is written first and by hand; the schema is written to match it and -is locked to it at compile time, so schema authoring (whenever it happens) -cannot silently drift from the type readers rely on. - -```ts -// player-mark/player-mark.ts — HAND-AUTHORED, the source of truth -export type PlayerMark = 'X' | 'O'; -export * as PlayerMark from './public.js'; - -// player-mark/schema.ts — written to MATCH the type (often later, by the agent) -import { Schema } from '@adobe/data/schema'; -import type { Assert, Equal } from '@adobe/data/types'; -import type { PlayerMark } from './player-mark.js'; - -export const schema = { type: 'string', enum: ['X', 'O'] } as const satisfies Schema; - -// Compile-time pin: fails to build if schema and type diverge. -type _Pin = Assert, PlayerMark>>; +data/ + values// Data types + pure helpers (values.md) + components/.ts one component schema each (components.md) + resources/.ts one resource schema each (resources.md) + entities/.ts component-name tuples (entities.md) ``` -The `Assert>` line is the whole safety net: `Schema.ToType` must be exactly the hand-authored type (including readonly/optional), -or the file does not compile. `Schema.ToType` is used **only** inside the -assertion — never as the exported type. Consumers always import the -hand-authored `PlayerMark`. +Order within `data/`: `values → components / resources → entities`. A value never +imports a component, resource or entity. Where a value helper needs an entity's +fields, it takes them structurally +(`Iterable<{ readonly mark: PlayerMark; readonly cellIndex: CellIndex }>`). -One folder is special: **`data/state/`** holds the feature's single `State` -aggregate and its pure transforms/derivations (its own rule, `state.md`); -the individual type folders here are its building blocks. +Components and resources share **one namespace** across every feature in an app: +a name is one column or one resource, never both, and has one owning feature. diff --git a/packages/data-ai/.claude/rules/features/data/resources.md b/packages/data-ai/.claude/rules/features/data/resources.md new file mode 100644 index 000000000..0decf5f36 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/data/resources.md @@ -0,0 +1,34 @@ +--- +paths: + - '**/features/*/data/resources/**/*.ts' +--- + +# data/resources/ — one resource schema per file + +A resource is a singleton value: one per database. Each lives in its own file, with a +single export: its schema, which must carry a `default`. + +```ts +// resources/display-completed.ts +export const displayCompleted = { ...Boolean.schema, ...Scope.settings } satisfies ResourceSchema; + +// resources/selected-todo.ts — an entity reference +export const selectedTodo = { ...Entity.schema, default: Entity.none, ...Scope.session } satisfies ResourceSchema; + +// resources/count.ts — a primitive needs no values/ type +export const count = { type: "number", default: 0 } as const satisfies ResourceSchema; + +// resources/index.ts +export const resources = { displayCompleted, selectedTodo }; +``` + +- **`satisfies ResourceSchema`** checks the `default` at the declaration. +- **Scope** is spread from `Scope`, as for components (`components.md`). On a resource + the flags also place its singleton entity in that quadrant, so a `session` resource + resets on reload and a `settings` one stays on the device. +- **An entity reference** spreads `Entity.schema`. The `entity` mark is how + conformance knows the value is an id. +- Every singleton in the spec's `State` is a resource of the same name + (`spec/index.md`), including a single game object like a ship or player. +- A resource and a component never share a name (`index.md`). +- There is only one instance, so never choose a struct schema for efficiency here. diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md deleted file mode 100644 index abbfd1173..000000000 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ /dev/null @@ -1,209 +0,0 @@ ---- -paths: - - '**/features/*/data/state/**/*.ts' ---- - -# data/state/ — the State specification - -`State` is the whole feature as **one immutable object** — the pure, fully-tested -source of truth. Each transition is a read→write **patch** over state; each -derivation a pure selector. The ECS implementation is proven equivalent to it by -conformance (`../services/main-service/conformance.md`). Reference: -`data-lit-todo`'s `data/state/`. - -The presence of this folder makes the feature **state-based**. A feature without -`data/state/` is **ECS-based** (no spec, no conformance) — see `../index.md`. - -## The `State` shape - -```ts -// data/state/state.ts -export type State = { - readonly displayCompleted: boolean; // a SINGLETON → an ECS resource of the same name - readonly entities: ReadonlyMap; // ALL entities, keyed by id; the value has no id - readonly selectedTodo: Entity; // a reference singleton (Entity is a branded number) -}; -export * as State from "./public.js"; -``` - -- **Singletons** — every non-`entities` field → an ECS **resource** of the same name. -- **`entities`** — one `ReadonlyMap` holding every entity, keyed - by a numeric id; the value type is the union of the feature's entity value types and - carries **no `id`** (identity is the key). Omit `entities` for a feature with no - entities (all singletons). - -`State` may reuse plain ECS value types (the `Entity` id type, a `{ parent, order }` -bundle) but never ECS machinery (a store, a transaction, an index). Entity value types -are their own `data//` namespaces with a structural `is` guard (`../index.md`). - -Two conventional exports drive conformance, re-exported through `public.js`: -- **`create(): State`** (`create.ts`) — the default; every case's `before`/`input` is a - delta over it. -- **`samples: readonly State[]`** (`samples.ts`) — representative full states the ecs - projection round-trips (`toState ∘ fromState ≡ identity`). - -## Files: the pure function and its cases are SEPARATE - -Each conformed function is two files: - -- **`.ts`** — the pure function, a **single export**, with **zero test-tier - imports** (no `@adobe/data-testing`, no fakes). It depends only on `@adobe/data`, - other `data/` types, and — for a transition — the service interfaces it injects. -- **`.cases.ts`** — the spec-owned cases as **inert data**, imported by the - manifest. It imports the case type with `import type` and never constructs a live - service. - -This split is enforced by the package's project-reference wall (`tsconfig.build.json` -excludes `*.cases.ts`): a runtime file importing test-tier code is a **compile error**. - -### The transform — `.ts` - -```ts -// toggle-complete.ts -import type { Services } from "../../services/services.js"; -import type { State } from "./state.js"; - -// Reads entities, writes entities — an `{ entities }` patch — and logs `todoToggled`. -export const toggleComplete = ( - state: Pick, - { id, analytics }: { readonly id: number } & Pick, -): Pick => { - analytics.todoToggled(); - const target = state.entities.get(id); - if (target === undefined) return { entities: state.entities }; - return { entities: new Map(state.entities).set(id, { ...target, complete: !target.complete }) }; -}; -``` - -- **Transition signature** `(state: Pick, args) => Pick` — - a read→write patch. The parameter is the smallest `Pick` it reads; the return - is only the fields it writes. No ` => T` generic, no `...state` spread — return the - patch and let the runner merge it. All non-state inputs go in the single `args` object; - a transition that takes neither args nor services takes only `(state)`. -- **Inject services with `Pick`** — never an inline `{ analytics: … }`. Data - args sit alongside via intersection: `{ readonly count: number } & Pick`. -- **Guard no-ops by returning an empty/unchanged patch**, never by throwing. Reserve - `throw` for a genuine precondition violation a case names with `throws`. -- **Derivation signature** `(state) => value` — a pure selector over **two or more** - `State` fields. An entity query returns entity **ids** (`readonly number[]` / - `ReadonlySet`); look values up in `entities`. Value math over a **single** - `data/` lives on that type's namespace, not here. - -### The cases — `.cases.ts` - -```ts -// toggle-complete.cases.ts -import { Entity } from "@adobe/data/ecs"; -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { toggleComplete } from "./toggle-complete.js"; - -export const cases: Conformance.SpecCases = { - args: { type: "object", properties: { id: Entity.schema } }, // marks entity-reference args - cases: [ - { name: "marks an incomplete todo complete", - before: { entities: new Map([[1, { name: "a", complete: false, order: 0 }]]) }, - args: { id: 1 }, // DATA only — services are never here - after: { entities: new Map([[1, { name: "a", complete: true, order: 0 }]]) }, - effects: { analytics: [["todoToggled"]] } }, // service calls to assert - ], -}; -``` - -- Annotate `Conformance.SpecCases` for a transition, - `Conformance.SpecDerivations` for a derivation. That annotation is the - transition/derivation discriminator — nothing is inferred from the signature. -- **`before` is a delta over `State.create()`**; **`after` is the writes patch** (only - the fields the transition changes). A derivation case is `{ name, input, value }`. -- **`args` is data only.** Injected services are stripped from the case's `args` type; - the runner synthesizes each service's recording double from the manifest's `services` - templates. A transition with only services takes no `args`. -- **Service returns are scheduled as data in `responses`**, keyed by service then method; - only value-returning methods take a schedule (a per-call FIFO — exhausting it throws). - **Service calls to assert go in `effects`** as `[method, ...args]` tuples (`Array` = - ordered, `Set` = any order); a value-returning read you don't assert is simply omitted. - The case thus **owns both the input and the expectation** — the double invents nothing. - ```ts - // create-random-todo.cases.ts (a value-returning service) - responses: { nameGenerator: { generateName: ["random task"] }, - analytics: { randomTodoRequested: [{ startedAt: 0 }] } }, - effects: { analytics: [["randomTodoRequested"], - ["randomTodoAdded", { timing: { startedAt: 0 }, name: "random task" }]] }, - ``` -- **Entity ids are plain spec-id numbers everywhere** — map keys in `before`/`after`/ - `input`, and reference fields hold the id they point at (`{ selectedTodo: 2 }`). The - ecs mints its own ids, so conformance compares up to an id-bijection. An - entity-addressed transition adds the `args` schema (above) marking each `Entity.schema` - field; the case then writes the id plain (`args: { id: 2 }`). For a minted scalar a - case won't pin (a timestamp), use `Match.anyNumber` / `Match.anyString`. -- **A `throws` case** declares `throws: true` (any error) or `throws: "substring"` - instead of `after`/`effects`/`responses` — the two are mutually exclusive. - -## `transforms.ts` — the conformed-function barrel - -```ts -// transforms.ts — exactly the conformed transforms and derivations, nothing else -export { createTodo } from "./create-todo.js"; -export { toggleComplete } from "./toggle-complete.js"; -export { visibleTodos } from "./visible-todos.js"; -// … -``` - -Its keys are the conformance surface. A genuine **non-transition helper** (`appendTodo`, -`create`, `samples`) is *not* listed here and carries no cases; it keeps its own -`*.test.ts`. - -## `spec.ts` — the conformance manifest - -The single object both `spec.test.ts` and the ecs `conformance.test.ts` import. It is a -**test-tier** file (excluded from the runtime build) — the one place the feature touches -`@adobe/data-testing`. - -```ts -// spec.ts -import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import * as transforms from "./transforms.js"; -import { MainService } from "../../services/main-service/main-service.js"; -import { ComputedDatabase } from "../../services/main-service/computed-database/computed-database.js"; -import { projection } from "../../services/main-service/conformance/projection.js"; -import { createFake as analytics } from "../../services/analytics-service/analytics.fake.js"; -import { cases as createTodo } from "./create-todo.cases.js"; -import { cases as toggleComplete } from "./toggle-complete.cases.js"; -import { cases as visibleTodos } from "./visible-todos.cases.js"; -// …one import per conformed fn - -export const spec = Conformance.feature({ - state: State, - fns: transforms, - plugin: MainService.plugin, - computedPlugin: ComputedDatabase.plugin, // required iff the feature has a derivation - projection, - hydrate: ["visibleTodos"], // EXACTLY the entity-id-list derivations - services: { analytics, nameGenerator }, // required iff a transition injects a service - cases: { createTodo, toggleComplete, visibleTodos, /* …one per fn */ }, -}); -``` - -`Conformance.feature` enforces four guards at **compile time**: -- **`cases` coverage** — exactly one case module per `fns` entry (missing or extra → error). -- **`hydrate` exactness** — exactly the derivations that return a collection of the - projection's entity value (its ecs computed emits ids); miss one or add a non-hydrating - name → error. -- **`services` completeness** — exactly the injected-service keys the transitions declare, - each a `() => Service` template; absent for a service-free feature. -- **`computedPlugin` presence** — required when any case module is a derivation. - -## `spec.test.ts` — the pure spec - -```ts -// spec.test.ts -import { Conformance } from "@adobe/data-testing"; -import { spec } from "./spec.js"; -Conformance.checkSpec(spec); -``` - -One call verifies every transform and derivation against its cases (seeding `before` -over `State.create()`, synthesizing service doubles from `services` + `responses`, -asserting `after`/`value`/`effects`/`throws`). The ecs side is one matching call in -`conformance.test.ts` (`../services/main-service/conformance.md`). diff --git a/packages/data-ai/.claude/rules/features/data/values.md b/packages/data-ai/.claude/rules/features/data/values.md new file mode 100644 index 000000000..acb1264ff --- /dev/null +++ b/packages/data-ai/.claude/rules/features/data/values.md @@ -0,0 +1,36 @@ +--- +paths: + - '**/features/*/data/values/**/*.ts' +--- + +# data/values/ — Data types and pure helpers + +Each Data type is its own namespace folder (`global/namespace.md`): its hand-written +type, its schema pinned to it, and its pure synchronous helpers. + +``` +data/values/player-mark/ + player-mark.ts # HAND-WRITTEN type + `export * as PlayerMark from "./public.js"` + schema.ts # schema matching the type, pinned with Assert> + public.ts # re-exports schema + helpers + opponent.ts opponent.test.ts … # one pure helper per file, each tested +``` + +- **Write the type by hand** in `.ts`. It is the readable source of truth; use + the richest types available (`F32`, `Vec3`, unions, branded aliases). +- **Write the schema to match, and pin it**, so the two can't drift: + ```ts + // schema.ts + export const schema = { type: "string", enum: ["X", "O"] } as const satisfies Schema; + type _Pin = Assert, PlayerMark>>; + ``` + `Schema.ToType` yields `readonly` properties and arrays, so write the type + `readonly` too. `Schema.ToType` appears only in the pin, never as the exported type. +- **Add a schema when something needs one**: a component, a resource, persistence, or + the wire. A type used only by the spec may have none. +- **Helpers are synchronous and pure**, each with a sibling `*.test.ts`. They are the + home for logic the spec and the ECS share. +- **A helper-only namespace** (`motion`, `collision`) with no type of its own also + lives here. +- Objects of only 32-bit numeric values or sub-structs use + `Schema.fromStructProperties` (linear-memory storage). diff --git a/packages/data-ai/.claude/rules/features/ecs/actions.md b/packages/data-ai/.claude/rules/features/ecs/actions.md new file mode 100644 index 000000000..93654c43e --- /dev/null +++ b/packages/data-ai/.claude/rules/features/ecs/actions.md @@ -0,0 +1,31 @@ +--- +paths: + - '**/features/*/ecs/actions/**/*.ts' +--- + +# ecs/actions/ — the operations the app performs + +One action per file: a function taking the whole database and Data args. Actions are +the app-facing operations: the UI calls `service.actions.*`, and each spec action +conforms against the action of the same name. + +```ts +export const createRandomTodo = async (db: ServiceDatabase) => { + const timing = await db.services.analytics.randomTodoRequested(); + const name = await db.services.nameGenerator.generateName(); + db.transactions.createTodo({ name }); + db.services.analytics.randomTodoAdded({ timing, name }); +}; +``` + +- **Every spec action has a same-named action**, even when it only forwards to one + transaction. When the UI drives a richer op (a streamed drag), add a thin action for + the spec op as well (`reorderTodo` beside `dragTodo`). +- **Type `db` on the lowest layer** exposing what it calls, usually `ServiceDatabase`. + Never the action layer itself. +- **Call services here**: await ports, sequence calls, time a slow call. Then commit + through **at most one transaction**, so undo/redo stays one step per operation. +- **Fire-and-forget.** Results flow back through observables, never return values. +- **Read current state synchronously** from the store (`db.resources`, `db.read`, + `db.select`), never from a cached computed, which refreshes only on commit. +- The `index.ts` barrel feeds the `actions` facet. diff --git a/packages/data-ai/.claude/rules/features/ecs/computed.md b/packages/data-ai/.claude/rules/features/ecs/computed.md new file mode 100644 index 000000000..d3b9008de --- /dev/null +++ b/packages/data-ai/.claude/rules/features/ecs/computed.md @@ -0,0 +1,31 @@ +--- +paths: + - '**/features/*/ecs/computed/**/*.ts' +--- + +# ecs/computed/ — derived observable values + +One derived value per file: a `cached` function of a database layer returning an +`Observe`. Each spec derivation conforms against the computed of the same name. + +```ts +export const status = cached((db: IndexDatabase) => + Observe.withFilter(db.observe.resources.board, BoardState.deriveStatus), +); +``` + +- **Wire `data/values/` helpers** to the minimal observables they read. The spec + derivation calls the same helper, so the two agree by construction. Never import + from `spec/`. +- **Observe every input.** A computed that reads two resources must observe both, or + it goes stale when only one changes. +- **Performance picks the wiring.** Hand-wire minimal resource or index reads on hot + or large paths. For "how many" use `db.observe.count(components, { where })`, not a + selected array's `.length`. +- **An entity-list computed emits ids.** Name it in the implementation's `hydrate`, so + conformance maps each id through `toData`. +- A computed with no spec derivation (one helper applied to one field) is covered by + that helper's unit test. +- **Type `db` on the lowest layer** that exposes what it reads. A computed may read + service observables (`db.services.*`), since services sit below it. +- The `index.ts` barrel feeds the `computed` facet. diff --git a/packages/data-ai/.claude/rules/features/ecs/conformance.md b/packages/data-ai/.claude/rules/features/ecs/conformance.md new file mode 100644 index 000000000..69babcace --- /dev/null +++ b/packages/data-ai/.claude/rules/features/ecs/conformance.md @@ -0,0 +1,85 @@ +--- +paths: + - '**/features/*/ecs/conformance/**/*.ts' +--- + +# ecs/conformance/ — proving the ECS matches the spec + +Test tier. The spec's cases are replayed against the ECS: every spec action against +the `MainService` action of the same name, and every derivation against its +computed. The folder holds: + +``` +conformance/ + projection.ts the store ⇄ State mapping + implementation.ts the spec paired with its ECS build + frame.cases.ts whole-frame cases (real-time features) + conformance.test.ts Conformance.checkFeature(implementation) +``` + +## `projection.ts` + +```ts +export const projection = { fromState, toState, toData }; +``` + +- `fromState(store, state)` clears the store, inserts each entity (supplying tags and + implementation-only columns), sets the resources, and returns the + `spec id → entity` map it built. The runner resolves case ids through it. +- `toState(store)` reads the whole store back as a `State`. +- `toData(store, entity)` reads one entity as its value. It is needed only when a + computed emits entity ids (`hydrate`). + +## `implementation.ts` + +```ts +export const implementation = Conformance.implementation(spec, { + plugin: MainService.plugin, + computedPlugin: ComputedDatabase.plugin, // required when the spec has derivations + projection, + hydrate: ["visibleTodos"], // exactly the computeds that emit entity ids + services: { game: GameService.createFake }, // base fakes for app-injected services + frame: { args: { dt: (db, dt) => { db.store.resources.frameDelta = dt; } }, + unmodelled: { physics: "wasm rigid-body step" }, + cases: frameCases }, // real-time features +}); +``` + +- **`computedPlugin`** is the `ComputedDatabase` layer, built on its own, so a + `withCache` value from a layer above can't go stale across the seed. +- **`services`** are fakes injected into every database the runner builds. They are + needed for a service whose `create` throws. Each case's recording doubles override + them. +- **`frame`** is required when the spec has `systems`. `args` writes each frame data + arg into the store; `unmodelled` names, with a reason, each system the spec doesn't + model; `cases` are whole-frame cases (`frame.cases.ts` here, typed + `Conformance.FrameCases`). Each spec system conforms by + running just that system; each frame case runs every modelled system once in + `db.system.order` (`systems.md`). Systems keep their in-place column writes. +- `hydrate` exactness and `computedPlugin` requiredness are checked at compile time. + +## `conformance.test.ts` + +```ts +Conformance.checkFeature(implementation); +``` + +This single call conforms every spec action and derivation and round-trips every +`State.samples` entry through the projection. A spec op with no same-named action or +computed fails by name, as does an ECS system that is neither modelled nor unmodelled. So when the UI's real op is richer (`dragTodo`), +add a thin same-named action (`reorderTodo`) for the spec op to pair with. The UI +calls actions, so conforming actions covers the path the app runs. + +- **Comparison.** `ReadonlyArray` is positional; `ReadonlySet` and `ReadonlyMap` are + order-independent. Ids compare up to a bijection. Floats use + `match: { tolerance }`, set on the spec (default `0.01`). +- **Extra ECS tests** (collision selection, a refill after the field clears) live here + too, since they need the spec or the projection. + +## Escape hatch: per-case ambient context + +A feature whose action reads ambient per-peer context (a `userId` seeded under a +custom concurrency, as in p2p presence) can't use `checkFeature`. It drives +`Conformance.runActions` directly, feeding it +`Conformance.adaptCases(spec.fns, spec.cases, spec.services, true)` with its own +`makeDb` and `seedContext`. This is the only sanctioned use of the lower-level runners. diff --git a/packages/data-ai/.claude/rules/features/ecs/core.md b/packages/data-ai/.claude/rules/features/ecs/core.md new file mode 100644 index 000000000..87dce8cad --- /dev/null +++ b/packages/data-ai/.claude/rules/features/ecs/core.md @@ -0,0 +1,43 @@ +--- +paths: + - '**/features/*/ecs/core/**/*.ts' +--- + +# ecs/core/ — the schema + +The core database builds the feature's `data/` components and resources into a plugin +and chooses how entities are packed into archetypes. + +```ts +// core/archetypes.ts — the spec entity plus implementation-only columns +export const archetypes = Database.archetypes(components, { + Todo: ["todo", ...Todo], +}); + +// core/core-database.ts +const coreDatabasePlugin = Database.Plugin.create({ components, resources, archetypes }); + +export type CoreDatabase = Database.Plugin.ToDatabase; +type CoreComponents = Store.Components>; + +export namespace CoreDatabase { + export const plugin = coreDatabasePlugin; + export type Store = Database.Plugin.ToStore; + export type Index = Database.Index; +} +``` + +- **Import `components` and `resources` from `data/`.** Never author schemas here. +- **Spread the entity tuple** from `data/entities/` and add implementation-only + columns (a tag). `UpperCase` archetype names. +- **Never pack a `nonPersistent` column into a persistent entity's archetype** unless + it has a real, non-null default. On load such a column is stripped, and every + reloaded entity then drops out of that archetype and out of every query on it. Add + the column on demand with `t.update` instead. A persisting feature keeps a save/load + round-trip test (`data-lit-todo`'s `app/persistence.test.ts`). +- **A feature built on another** `extends` the lower feature's core plugin and adds its + own `components` barrel (which already spreads the lower one) and archetypes. +- **`Component` / `Archetype` key types**, when needed, derive from the `components` + barrel (`Extract`), never from the plugin: the + plugin is built from `archetypes`, so a plugin-derived key type is circular when used + to check an archetype. diff --git a/packages/data-ai/.claude/rules/features/ecs/index.md b/packages/data-ai/.claude/rules/features/ecs/index.md new file mode 100644 index 000000000..af0f3fbb0 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/ecs/index.md @@ -0,0 +1,72 @@ +--- +paths: + - '**/features/*/ecs/main-service.ts' + - '**/features/*/ecs/*/*-database.ts' +--- + +# ecs/ — the implementation + +The feature implemented as a reactive Entity-Component-System, conformed to +`spec/`. It is a chain of `Database.Plugin` layers, each extending the one before and +adding one facet: + +``` +ecs/ + core/ core-database.ts, archetypes.ts the schema (core.md) + indexes/ index-database.ts + one file per index + index.ts + transactions/ transaction-database.ts + one file per transaction + index.ts + services/ service-database.ts + database-bound service factories + computed/ computed-database.ts + one file per computed + index.ts + actions/ action-database.ts + one file per action + index.ts + systems/ system-database.ts (systems declared inline) + main-service.ts + conformance/ test tier (conformance.md) +``` + +The order is fixed: `core → indexes → transactions → services → computed → actions +→ systems`. Services sit below computed because computeds may derive from service +observables; a service factory therefore never reads a computed. A feature creates +only the layers it uses. Each layer's file exports a namespace mirroring its plugin: + +```ts +const indexDatabasePlugin = Database.Plugin.create({ extends: CoreDatabase.plugin, indexes }); +export type IndexDatabase = Database.Plugin.ToDatabase; +export namespace IndexDatabase { + export const plugin = indexDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} +``` + +Runtime code here imports `data/` and `services/`, never `spec/`. + +## `MainService` — the assembled database + +`main-service.ts` aliases the top layer, so consumers never name it: + +```ts +export { ActionDatabase as MainService } from "./actions/action-database.js"; +``` + +`ui/`, `app/` and `conformance/` reference `MainService.plugin` / `MainService.Store`. +Adding or dropping a layer changes this one line. When another feature or package +imports a feature's databases, it aliases them feature-qualified +(`import { CoreDatabase as MainCoreDatabase }`). + +## What each op takes + +- **Transactions** take the store: `CoreDatabase.Store`, or `IndexDatabase.Store` once + they read an index. A store is the transaction context (it carries index handles and + `t.userId`). +- **Computed, service factories and actions** take the whole database, typed on the + lowest layer that exposes what they read or call — never their own layer. + +## Folders are flat until they aren't + +A behavioural folder holds one file per op plus an `index.ts` barrel that feeds the +facet. When a cohesive cluster grows past about half a dozen files, give it a +subfolder with its own `index.ts` (e.g. `transactions/order/`). A helper several ops +share lives beside them but stays **out of the barrel**, or it would be registered +as an op. + +The per-layer rules are in this folder. How a plugin's authored and derived surfaces +are modelled is in the rules-root `plugin-modelling.md`. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/indexes.md b/packages/data-ai/.claude/rules/features/ecs/indexes.md similarity index 85% rename from packages/data-ai/.claude/rules/features/services/main-service/indexes.md rename to packages/data-ai/.claude/rules/features/ecs/indexes.md index 317e50e86..75d482520 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/indexes.md +++ b/packages/data-ai/.claude/rules/features/ecs/indexes.md @@ -1,24 +1,23 @@ --- paths: - - '**/features/*/services/main-service/index-database/indexes/**/*.ts' + - '**/features/*/ecs/indexes/**/*.ts' --- -# database/indexes/ — ECS indexes +# ecs/indexes/ — ECS indexes One index descriptor per file: an object `satisfies CoreDatabase.Index` that tells the store to maintain a lookup keyed on a component, so queries and computed values can find entities without scanning. ```ts -import type { CoreDatabase } from "../../core-database/core-database.js"; +import type { CoreDatabase } from "../core/core-database.js"; export const byComplete = { key: "complete", } as const satisfies CoreDatabase.Index; ``` -`CoreDatabase.Index` is bound to every component (all scopes live in the one -`core-database`). Archetype-scoped indexes carry an extra `archetype` field the +`CoreDatabase.Index` is bound to every component in the core database. Archetype-scoped indexes carry an extra `archetype` field the `Index` helper can't express, so declare those with a bare `as const` (no `satisfies`) and let `index-database.ts`'s `create()` validate them. @@ -37,7 +36,7 @@ Derive a discrete **bucket key** — but prefer a **computed-key extractor** recomputes the key from its source columns, so the index stays correct with no maintenance; a stored bucket column must instead be recomputed at every insertion/update site (or by a system) and silently drifts if one is missed. The -derivation itself is a pure `data/` helper. +derivation itself is a pure `data/values/` helper. Bucketed broad-phase only pays off at scale (hundreds+ candidates per query); below that a linear scan over an archetype wins. Index when the scan is a @@ -58,6 +57,6 @@ below that a linear scan over an archetype wins. Index when the scan is a multi-value index — one bucket entry per element — and `find({ col: element })` takes the element, not the array. - **Model a many-to-many as a denormalized array + two indexes.** A - `todo.assignees: string[]` (owned/displayed by one feature) plus a unique + `todo.assignees: string[]` (owned by one feature) plus a unique `usersByName` (name → user) and a multi-value `todosByAssignee` (assignee → todos) gives efficient navigation in both directions from one join key. diff --git a/packages/data-ai/.claude/rules/features/ecs/services.md b/packages/data-ai/.claude/rules/features/ecs/services.md new file mode 100644 index 000000000..250f74eb2 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/ecs/services.md @@ -0,0 +1,36 @@ +--- +paths: + - '**/features/*/ecs/services/**/*.ts' +--- + +# ecs/services/ — registering services on the database + +`service-database.ts` registers the feature's services under the `services` facet, +keyed as the spec's `Services` map, and pins the two together: + +```ts +const serviceDatabasePlugin = Database.Plugin.create({ + extends: TransactionDatabase.plugin, + services: { + nameGenerator: NameGeneratorService.create, + analytics: AnalyticsService.create, + }, +}); +export type ServiceDatabase = Database.Plugin.ToDatabase; +type _ServicesPin = Assert>; +``` + +- **A `services/` service** registers its `create`. If `create` throws (the service + needs runtime input), the app injects the real one (`../../app.md`), and conformance + injects a fake (`conformance.md`). +- **A database-bound service** (it reads observables or calls transactions, such as + an agent) is a factory here, one per file: `createAgentService(db, …)`, with `db` + typed on the lowest layer it needs. It sits below computed, so it derives what it + observes from the store (`db.derive`) and `data/values/` helpers, sharing a read + helper with the transactions where useful (tictactoe's `readBoard`). The `index.ts` + barrel re-exports the factories. +- **A service built from another service** needs that service registered first, so + register it in a second `Plugin.create` extending the first: a factory only sees + the services of the plugins it extends. +- Register each service once, in the feature that owns it, under one key. Combined + plugins require the same factory per key. diff --git a/packages/data-ai/.claude/rules/features/ecs/systems.md b/packages/data-ai/.claude/rules/features/ecs/systems.md new file mode 100644 index 000000000..3bf0250a9 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/ecs/systems.md @@ -0,0 +1,157 @@ +--- +paths: + - '**/features/*/ecs/systems/**/*.ts' +--- + +# ecs/systems/ — the real-time tick loop + +Systems are the framework's real-time backbone: functions the scheduler runs +**every frame**, in dependency order, to advance a simulation. A feature only +has a `systems/` folder when it is real-time (a game, a physics/particle sim). +Turn-based features never need one — they mutate through transactions on user +input and stop. + +A system is a `SystemDeclaration` — `{ create, schedule?: { before?, after?, during? } }`. +`create` runs ONCE at construction (capture closures / query args); it returns the +per-frame function (or `void` for an init-only system, e.g. seeding the first +entities). Ordering lives under **`schedule`** (not top-level). + +**Declare the `systems` map inline in `system-database.ts`.** The `create: (db) => …` +arrow must be written inline in `Plugin.create` for two reasons: only there is `db` +strongly typed (to the assembled plugin's database, with a **writable `store`**), and +a pre-typed standalone `create` reference breaks the scheduler's system-name inference +(the name union collapses to `never`, which then rejects every `schedule` entry). Keep +each `create` thin by delegating math to pure `data/values/` helpers (never `spec/`); +if an ecs-specific body is substantial, extract it to a `systems/.ts` helper +`(db) => () => void` (annotate its `db`) and call it from the inline `create`. + +```ts +// in system-database.ts +import { Database, scheduler } from "@adobe/data/ecs"; +import { Motion } from "../../data/values/motion/motion.js"; + +const plugin = Database.Plugin.create({ + // combine scheduler with the current top layer — ActionDatabase, since every + // spec action has an action (systems come last). + extends: Database.Plugin.combine(ActionDatabase.plugin, scheduler), + systems: { + control: { create: (db) => () => { /* input → ship; db.transactions.fireBullet() */ } }, + movement: { + schedule: { after: ["control"], before: ["collision"] }, + create: (db) => () => { + const dt = db.store.resources.frameDelta; + for (const arch of db.store.queryArchetypes(["position", "velocity"])) { + const pos = arch.columns.position, vel = arch.columns.velocity; + for (let i = 0; i < arch.rowCount; i++) { + pos.set(i, Motion.advance(pos.get(i), vel.get(i), dt)); // in-place, no migration + } + } + }, + }, + collision: { schedule: { after: ["movement"] }, create: (db) => () => { /* … */ } }, + }, +}); +``` + +- **Systems mutate `db.store` directly** — that is the writable surface a system + receives (unlike the readonly `.store` consumers get). In-place column writes + (`col.set(i, …)`) for hot per-row work that stays in the same archetype; + `db.store.update / insert / delete` for lifecycle (spawning, dying) that changes + archetype membership. Resources: `db.store.resources.x = …`. +- **Dispatch a transaction for a discrete atomic event** — when the effect is a + self-contained event (score a hit, lose a life, spawn a wave, fire a bullet), call + `db.transactions.*`: observers (a reactive HUD) are notified. Reserve direct `db.store` writes for the hot per-row paths + (movement, aging) that run on every entity every frame and shouldn't pay + per-entity transaction overhead. +- **Optimize hot paths for speed.** With many entities, query with efficient + `queryArchetypes` and read/write minimal columns directly; avoid intermediate + allocations. Never turn per-row work into per-entity transactions to make it + conformable: `frame` conformance checks in-place writes as they are. (Only + transactions are observable — direct store writes fire no observers.) +- **Iterate archetypes per `archetypes.md`.** Express selection with + `queryArchetypes(include, { exclude })`; when a system destroys/migrates rows + (bullets expiring, entities dying) iterate **tail → head** so hole-fills don't + invalidate the cursor. A per-frame system touching many rows is exactly where + those rules pay off. **A `db.transactions.*` call mid-scan migrates rows too** — + a transaction that inserts/deletes invalidates any live archetype cursor, so + snapshot the ids you'll act on (or reverse-iterate) *before* dispatching + transactions inside a per-frame loop. +- **Every system guards itself** (e.g. early-return once the game is over), as its + spec function does — a frozen frame is only a true no-op if *every* system honors + the guard. +- **Index reads go through `db.indexes`** (plural) — the writable `db.store` a + system receives is typed *without* its index handles, so a broad-phase query is + `db.indexes.byCell.find({ cell })`, not `db.store.indexes.…`. +- **Ordering is declared under `schedule`, not implied.** `schedule.before` / + `schedule.after` are hard constraints (name sibling systems); `schedule.during` + is a soft same-tier hint. Systems with no ordering relation share a tier + (conceptually parallel) — never rely on declaration order; systems sharing a tier + must commute (conformance checks it). The schedule is the only statement of order: + the spec never re-sequences systems, and frame conformance composes them in the + order the schedule derives. Watch the subtle case: if a spawn (fire) is followed by + a step that also processes the just-spawned entity (the new bullet is aged and + advanced this same tick), you can't advance *all* bodies in one system before the + spawn — split that entity's advance out so it runs *after* the spawn. +- Immediate-mode rendering: store the canvas in a session resource and render to + it inside a system (see @adobe/data-gpu for patterns). + +## Driving the loop — the scheduler + +Systems don't run themselves. Combine the built-in scheduler so they run on +`requestAnimationFrame`: + +```ts +import { Database, scheduler } from "@adobe/data/ecs"; +// in system-database.ts: +Database.Plugin.create({ extends: Database.Plugin.combine(ActionDatabase.plugin, scheduler), systems }); +``` + +The scheduler adds a `schedulerState` resource (`"running" | "paused" | +"disposed"`) — gate start/pause/resume through it (start `"paused"` if the game +begins on a user action). A headless host (tests, server sim) drives frames itself: for each tier in +`db.system.order` (an array of tiers), call `db.system.functions[name]()` for each +name, skipping `schedulerSystem` and init-only systems (no function). So a simulation +is fully runnable and testable with no rAF and no rendering attached. + +## Layer + +`system-database.ts` extends the feature's top ecs layer (`ActionDatabase`, since every spec +action has an action; systems come last in the pipeline), combined with +`scheduler`, and declares the `systems` map **inline** (see above — +this is the one facet not split one-per-file, because `create`'s `db` is only typed +inline and standalone declarations break name inference). Extracted per-frame body +helpers (`(db) => () => void`) sit beside it only when an inline body grows too large. + +## Conformance + +Each system has a same-named pure function in the spec's `systems` group +(`../spec/index.md`), and the implementation declares how frame args reach the store +and which systems the spec does not model (`conformance.md`): + +```ts +frame: { + args: { dt: (db, dt) => { db.store.resources.frameDelta = dt; }, + input: (db, input) => { db.store.resources.input = input; } }, + unmodelled: { physics: "wasm rigid-body step", seedLevel: "init-only" }, + cases: frameCases, // ecs/conformance/frame.cases.ts +}, +``` + +- **Per system:** each case seeds the store, writes the case's args, runs **just that + system**, and compares with its spec function. Selection and detection logic (which + entities collide) is tested here, with seeded edge-case geometries. +- **Per frame:** each frame case (`ecs/conformance/frame.cases.ts`) folds the spec + system functions in `db.system.order` and also runs one real frame; both must give + `after`. The order comes only from the `schedule` declarations. +- **Shared guards:** the spec's `systems.noOp` states (game over) are checked against + every system, on both sides. +- **Unmodelled systems** (wasm, init-only) are skipped on both sides and reported as + skipped tests. Every system is modelled or unmodelled — a compile error otherwise. + To test an unmodelled system's glue, put the wasm call behind a service and model it + with a fake. + +## Iterating archetype rows + +A per-frame system reads/writes archetype columns directly. The row-iteration +rules — declarative `queryArchetypes` selection, tail → head migration, snapshot +avoidance — are general ECS concerns in the rules-root `archetypes.md`. diff --git a/packages/data-ai/.claude/rules/features/ecs/transactions.md b/packages/data-ai/.claude/rules/features/ecs/transactions.md new file mode 100644 index 000000000..3840c4634 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/ecs/transactions.md @@ -0,0 +1,32 @@ +--- +paths: + - '**/features/*/ecs/transactions/**/*.ts' +--- + +# ecs/transactions/ — atomic mutations + +One mutation per file: a function taking the transaction store and Data args, +returning `void`. All its writes commit or roll back together. + +```ts +export const toggleComplete = (t: CoreDatabase.Store, { id }: { readonly id: Entity }) => { + const row = t.read(id, t.archetypes.Todo); + if (row === null) return; // not a todo: no-op, as the spec says + t.update(id, { complete: !row.complete }); +}; +``` + +- **Type the store** `CoreDatabase.Store`, or `IndexDatabase.Store` once it reads an + index. +- **Validate and silently return** on illegal input rather than throwing, so replays + under sync stay idempotent. +- **Guard by entity kind.** `t.read(id)` returns any entity. Check the entity is the + kind the op expects (`t.read(id, t.archetypes.Todo)`, or its tag) before writing, or + an id naming another kind gets written to. +- **Decisions come from `data/values/` helpers**; the transaction reads the touched + slice, applies the helper, and writes the result. +- **Transactions are an implementation detail.** Conformance checks the action that + calls them (`actions.md`), so a transaction needs no spec op of its own. Give one a + unit test only when it has logic no action covers. +- **The barrel re-exports only mutations.** A read helper shared by several + transactions (`readBoard`) lives beside them, outside the barrel. diff --git a/packages/data-ai/.claude/rules/features/index.md b/packages/data-ai/.claude/rules/features/index.md index 1eef607f3..0d53fb55c 100644 --- a/packages/data-ai/.claude/rules/features/index.md +++ b/packages/data-ai/.claude/rules/features/index.md @@ -1,161 +1,115 @@ --- paths: - - '**/features/*/data/**/*.ts' - - '**/features/*/services/**/*.ts' - - '**/features/*/ui/**/*.ts' + - '**/features/*/**/*.ts' + - '**/features/*/**/*.tsx' --- -# Feature architecture — a verifiable spec and an optimized implementation - -Each feature is built twice in one codebase: a **pure specification** you can -trust, and an **efficient implementation** proven equivalent to it. - -- **`data/` is the specification.** The whole feature modelled as one immutable - `State` with pure transformations over it — correct by construction, fully - unit-tested, performance irrelevant. This is the source of truth. -- **`services/main-service/` is the implementation.** The same model physically - arranged for mutation efficiency — a reactive Entity-Component-System, an - implementation detail behind the service. Its reads and writes wrap the - `data/` functions where practical; where they must be hand-optimized, unit - tests verify they still agree with the `data/` precedent. - -Because the optimized `main-service` is largely mechanical given the `data/` -spec, it can be generated and kept honest by AI rules, with the conformance -tests as the safety net. Net result: write a slow-but-verifiable app, then -derive a fast one that provably behaves the same. - -## Two modes: state-based vs. ECS-based - -The "built twice" structure above is the **state-based** mode. Every feature is in -one of two modes, discriminated by **the presence of `data/state/`**: - -- **State-based** — a **Functional State Specification (FSS)** is the source of - truth: the pure `data/State` aggregate with its transitions and derivations, and - the ECS is a conformance-verified implementation of it. Adds `data/state/` (State, - the pure transforms with sibling `*.cases.ts`, the `transforms.ts` barrel, the `spec.ts` - manifest, `spec.test.ts`), `services/main-service/conformance/`, and the `state` - projection computed. **This is how every new feature is authored.** -- **ECS-based** — the **ECS is the source of truth**, authored directly: - **no `data/state/`**, no FSS, no conformance. This is a **legacy** shape — features - written before the state-based approach existed. New features are **never** authored - this way; ECS-based exists only to describe and maintain those older features. - -**Discriminator:** `data/state/` present → state-based; absent → ECS-based. The -`data/` value folders (the serializable types backing components/resources, -the wire, and persistence) exist in **both** modes — only the aggregate `State` -spec and the conformance layer are mode-specific. `data/` never disappears; the -*aggregate spec* does. - -**Testing follows the mode.** A state-based feature is verified by conformance: the -shared `cases` drive both the pure spec and the ECS, so a transaction or action with -a same-named transition needs no test of its own. An **ECS-based feature has no -conformance cases, so every transaction and every action carries its own unit -test** — the direct substitute for the conformance oracle (see `services/main-service/transactions.md` -and `actions.md`). - -## The layers - -**The layers organize by the *kind of type* each folder holds, not by a strict -dependency wall.** `data/` holds **value types** (pure, serializable data) and -the pure declarations over them; `services/` holds **service types** (interfaces -**and** implementations); `ui/` holds presentation. The rule is about *what -lives where*: a service is never authored under `data/`, a value type is never -authored under `services/`. - -**`ui/` isolation is strict and inviolable.** `ui/` sits at the top: neither -`data/` nor `services/` may ever import from `ui/`, no exceptions. Presentation -depends on the model; the model never depends on presentation. - -Beneath that hard line, `data/` and `services/` are **not** strictly ordered. -Value types are fundamental — a `data/` **type** depends on nothing but -`@adobe/data` and other `data/` types. But the **transition functions** over -them may depend on `services/` **without restriction**: they import whatever they -need — service interfaces they inject, and any associated utilities the service -namespace exposes — as ordinary imports, not type-only. So the strict wall is -`ui/` above `data/` + `services/`; the `data/ ↔ services/` boundary is the loose -one. A feature creates only the layers it uses. - -| Layer | Role | -|-------|------| -| `data/` | The spec: `State`, pure transforms & derivations, entity sub-types. Pure, tested. | -| `services/main-service/` | The implementation: the ECS materialisation + reactive reads/writes. The **sole entrypoint** — the only service the `ui/` binds to. | -| `services/-service/` | Async capability contracts (ports to the outside world). Reached only *through* `main-service` (its actions/services wire them in), never by the UI. Optional. | -| `ui/` | Presentation. | - -`services/` holds two kinds of service: the one `main-service/` (ECS-backed -state, its own subtree of rules) and any async capability contracts. The UI, and -every external consumer, sees only `main-service`. +# Feature architecture — layered, specified, implemented + +Each feature is built up in layers, each simple and testable on its own: + +- **`data/`** declares the feature's pure Data: value types, components, resources, + and entities (component tuples). +- **`services/`** declares and implements the async services the feature talks to. +- **`spec/`** is the feature's **specification**: one immutable `State` and pure + functions over it. It is test tier only. +- **`ecs/`** is the **implementation**: a reactive Entity-Component-System, proven + equivalent to the spec by conformance. +- **`ui/`** is presentation. + +Write the slow, obviously-correct spec first; the ECS is then largely mechanical, and +conformance keeps it honest. + +## Layout + +``` +src/ + features// + data/ runtime values/ components/ resources/ entities/ + services/ runtime -service/ namespaces, services.ts + spec/ TEST TIER State, actions, derivations, cases, spec.ts + ecs/ runtime core/ indexes/ transactions/ services/ computed/ actions/ systems/ + main-service.ts, conformance/ (TEST TIER) + ui// runtime -presentation.ts, -element.ts + app/ runtime schema.ts, versioning/, main.ts +``` + +A feature creates only the layers and folders it uses. + +## Dependencies + +``` +runtime: data → services → ecs → ui → app +spec: data + services → spec (test tier) +tests: ecs/conformance → spec + ecs +``` + +- **Each layer imports only from the layers before it.** A higher feature's layer + may import the same or a lower layer of a feature it builds on. +- **No runtime file imports `spec/`.** The spec is consumed only by tests. Logic the + spec and the implementation share (ordering math, name formatting) lives in + `data/values/` helpers, which both import. +- **Presentations import only `data/` and sibling `ui/` lazy wrappers** (a + convention); elements bind to `ecs/main-service`. +- **A base feature never imports a feature built on it.** The one exception is a + presentation calling a higher feature's lazy wrapper (`lazy-element.md`), which + loads that feature's element and plugin on first render. + +## Building a feature, one layer at a time + +Each step has a gate that must pass before the next: + +| # | Step | Gate | +|---|---|---| +| 1 | `data/values` | pins compile; helper tests | +| 2 | `data/components`, `resources`, `entities` | compiles | +| 3 | `services/` | per-service unit tests | +| 4 | `spec/`: State, `create`, `samples`, then one action or derivation + cases at a time | `checkSpec` | +| 5 | `ecs/core` + `ecs/conformance/projection.ts` | samples round-trip | +| 6 | `ecs` indexes, transactions, services, computed, actions — one op at a time | `checkFeature` | +| 7 | `ecs/systems` (real-time only) | per-system and frame conformance | +| 8 | `ui/` presentations, then elements | presentation tests | +| 9 | `app/` | versioning and persistence tests; the app builds | ## One app, many features -An application is a set of **features**, each its own `features//` folder -with the same layers. One base feature (`features/main/`) is the host; the rest -are peers that load lazily. - -**Keep each feature small; grow by adding features, not by bloating one.** A -feature is meant to fit in the head — a handful of files per layer. When a -part of a feature keeps growing, that is usually the signal to split it into -its own peer feature rather than let one feature's folders balloon. - -- **Dependencies point toward the base, never out of it.** A peer feature may - build on another feature's `data/` types and declarations (kept acyclic). The - base must not depend on its children — with one sanctioned exception below. -- **The base `imports` every peer's *schema* plugin** — - `Database.Plugin.create({ imports })`, not `extends`. `imports` merges the - peer's `services/main-service/core-database` (components / resources / - archetypes) into the shared store at runtime **without** pulling its types or - behavior into the base's type or bundle (`extends` would do both, and cost - quadratically). So one store knows every feature's schema — data coexists, - persists, and syncs — while the base stays decoupled. Import the peer's - `services/main-service/core-database.ts` plugin (schema only) — under its - feature-qualified name `CoreDatabase` (see the cross-feature naming rule - in `services/main-service/index.md`): its indexes, transactions, computed, - services, and UI stay out until the feature loads. A column two features share - (e.g. `name`) lives in `data/` and is referenced by identity, so - `combinePlugins` dedupes it. -- **Peers load lazily by being used.** `DatabaseElement`, on connect, walks up to - the nearest ancestor database and `extend`s it with its own plugin — so the - first time a feature element renders, its full plugin (indexes, transactions, - computed, services) is added to the shared live database and its code chunk is - fetched. Gate that first render behind a user action (a button, a tab) so the - load is genuinely on-demand. -- **The base reaches a child only through a lazy element wrapper** — a tiny - `Foo()` that `void import()`s the child element. That dynamic import is the one - allowed core→child seam; the heavy element and its main-service stay in the - child's own chunk. - -## Spec and implementation, kept honest - -The tie between `data/` (spec) and `main-service` (implementation) is -**conformance**, one property — -`toState(apply(fromState(before), args)) ≡ transform(before, args)`: each -main-service mutation, seeded and read back through a test-only store↔`State` -projection, equals the pure `data/` transform it stands for. The spec-owned cases -are authored as inert `data/state/*.cases.ts` and gathered by the `data/state/spec.ts` -**manifest**, which both the pure `spec.test.ts` (`Conformance.checkSpec(spec)`) and the -ecs `services/main-service/conformance/conformance.test.ts` -(`Conformance.checkFeature(spec)`) import. `checkFeature` pairs each ECS op to its -same-named transition, replays the shared cases, and round-trips the projection (see -`services/main-service/conformance.md`); the manifest's compile-time guards keep the two -calls the whole conformance surface. So conforming the implementation is "substitute the -implementation, reuse the expectations" — `main-service` is largely mechanical and -agent-generated, with the spec as oracle. *How* to author each layer lives in the -per-folder rules below. +An app is a set of features under `features//`. One base feature +(`features/main/`) hosts; the rest build on it and load lazily. + +- **Keep each feature small; grow by adding features.** +- **One owner per component and resource name.** The lowest feature that needs a + name declares it in its `data/`; features built on it import that schema object + (identity is what the ECS compares) and spread the owner's barrel into their own. +- **`app/` composes the features** (`../app.md`): its schema combines each feature's + plugins, it owns versioning, and it injects services that must be provided at + runtime. +- **Features built on the base load lazily**: a feature's element extends the shared + live database with its plugin the first time it connects. Gate that first render + behind a user action. + +## Enforcement + +One TS project per layer (see `data-lit-todo`'s tsconfigs): + +- **`tsconfig.data.json`** — `data/` + `services/`, composite. +- **`tsconfig.spec.json`** — `spec/`, references data. +- **`tsconfig.ecs.json`** — `ecs/` minus `conformance/`, references data. +- **`tsconfig.test.json`** — everything else, non-emitting: `ui/`, `app/`, + `ecs/conformance/`, and every `*.test.ts`. It references ecs + spec. + +So `spec/` importing `ecs/`, or `ecs/` importing `spec/`, is a compile error (TS6307). +`ui/` and `app/` sit in the test project, so for them "never import `spec/`" is a +convention, not a compile error. The package's `typecheck` script is +`tsc -b tsconfig.test.json`, which builds all of them. ## Reference implementations Working samples ship inside `@adobe/data` at -`node_modules/@adobe/data/references//src/` — read them for concrete, -current examples of this structure. `data-lit-todo` is the most complete -(multi-feature, capability services, indexes, conformance tests); -`data-lit-tictactoe` is the minimal turn-based reference. - -## Per-layer detail - -See the rules under each folder: `data/`, `services/` — the `main-service/` -subtree (components, resources, archetypes, computed, indexes, transactions, -systems, conformance) and capability-contract services — and `ui/`. -Always-on conventions live in `global/` (`namespace.md`, `cohesion.md`, -`type-casts.md`, `function-references.md`, `react.md` — deliberately repo-wide); -other cross-cutting patterns at the rules root — `data-modelling.md`, -`archetypes.md` (row iteration). +`node_modules/@adobe/data/references//src/`: + +- **`data-lit-todo`** — the most complete: two features, services, indexes, cross-feature composition, and persistence. +- **`data-lit-tictactoe`** — the minimal turn-based reference. +- **`data-lit-space-rock-game`** and **`data-gpu-hopper`** — real-time, with per-system and frame conformance. + +The per-folder rules live beside this file (`data/`, `services/`, `spec/`, `ecs/`, +`ui/`). Always-on conventions are in `global/`. diff --git a/packages/data-ai/.claude/rules/features/services/index.md b/packages/data-ai/.claude/rules/features/services/index.md index 348a13724..97d60a60e 100644 --- a/packages/data-ai/.claude/rules/features/services/index.md +++ b/packages/data-ai/.claude/rules/features/services/index.md @@ -1,103 +1,71 @@ --- paths: - - '**/features/*/services/services.ts' - - '**/features/*/services/*-service/**/*.ts' + - '**/features/*/services/**/*.ts' --- # services/ — the feature's services -Everything a feature exposes as a service lives here. Two kinds: +A service is the boundary between the feature and the outside world (generation, +analytics, networking). Each is a namespace folder (`global/namespace.md`) holding its +interface, its implementation and a fake. Services depend only on `data/`. -- **`main-service/`** — the one entrypoint. The ECS-backed reactive state - service (materialisation + reads/writes over it); the only service the `ui/` - and external consumers bind to. It has its own subtree of rules - (`main-service/index.md` and the layer files under it). Every feature with - state has exactly one. -- **`-service/`** — async **capability contracts**: ports to the outside - world (processing, persistence, observation, generation). Reached only - *through* `main-service` (its service/action layers wire them in), never by the - UI. A feature has zero or more. +``` +services/ + analytics-service/ + analytics-service.ts # the interface + `export * as AnalyticsService from "./public.js"` + create.ts # the implementation + create-fake.ts # the fake + public.ts # export { create }; export { createFake }; + services.ts # type Services = { … } +``` -The rest of this rule governs the capability contracts; `main-service` follows -its own subtree. +## The interface — an async data service -## `services/services.ts` — the injectable service map +```ts +export interface AnalyticsService extends Service { + todoCreated: (args: { readonly name: string }) => void; + randomTodoRequested: () => Promise; +} +type _Valid = Assert>; +export * as AnalyticsService from "./public.js"; +``` -The folder root exports one `Services` type: the feature's capability services -keyed by short name (the `-service` suffix dropped), the single source of truth for -service injection. +- Members are async only: `void | Promise | AsyncGenerator | Observe`. They + send and receive Data, or async callbacks and observers of Data, which keeps a + service portable across processes and lazily loadable. +- The interface is the only place `interface` is used. Validate it with + `AsyncDataService.IsValid`. +- A service that carries non-Data configuration the app injects (a host plugin, a + callback) cannot pass `IsValid`; skip the assert for it, and keep such services rare. +- A type only one service uses lives in its interface file (`Timing` beside + `AnalyticsService`). + +## `create` and `createFake` + +- **`create`** is the implementation. When it can't be built without runtime input + (a transport, a host-provided plugin), make `create` throw, telling the reader + which input is missing; the app injects the real one with + `Database.create(plugin, { services })`. +- **`createFake`** returns the service with every method present and inert. It is an + ordinary export (the bundler drops it when unused). Conformance calls it only to + list the service's methods; each case supplies the returns (`spec/index.md`). +- Access them statically (`AnalyticsService.createFake`) so tree-shaking works. + +## `services.ts` — the injectable map ```ts -// services/services.ts -import type { AnalyticsService } from "./analytics-service/analytics-service.js"; -import type { NameGeneratorService } from "./name-generator-service/name-generator-service.js"; export type Services = { readonly analytics: AnalyticsService; readonly nameGenerator: NameGeneratorService; }; ``` -- **Transitions inject with `Pick`** (`data/state.md`), never a - re-declared inline `{ analytics: AnalyticsService }` — so the key/type live in one - place. -- **The ecs `service-database` is pinned to it.** After its `ServiceDatabase` type, - a drift-guard asserts the resolved services match the map, so `db.services` (what - actions call) and `Services` (what transitions inject) can't diverge: - `type _Pin = Assert>`. -- **Inherited services**, when a peer feature builds on another, intersect the - parent map: `export type Services = MainServices & { readonly baz: BazService }`. -- A feature with no capability services needs no `services.ts`. - -## Capability contracts - -Each is a namespace folder (`global/namespace.md`); the export and folder both carry the -`-service` suffix. A service is the boundary between pure feature code and the -outside world, which is why its members are async — enabling cross-process -portability and lazy loading (`AsyncDataService.createLazy`). - -- The contract is an `interface` — the only place `interface` is used in the - codebase — validated immediately with - `Assert>`. -- Members are async only: `void | Promise | AsyncGenerator | Observe`. -- Provide `create*` factories. - -## Test doubles are a test-tier `*.fake.ts` — never in the production barrel - -A service is the seam consumers swap out under test. Ship a **shape-only recording -template** alongside the interface, in the same namespace folder: `.fake.ts`, a -**single export** `createFake` that returns the service with each method present but -inert (void methods do nothing; value methods return a placeholder). It is **NOT** -re-exported through `public.ts` — so `MyService.createFake` is never reachable from -runtime code — and it imports the contract with `import type` only. The conformance -manifest (`data/state.md`) imports it directly; nothing else does. - -```ts -// analytics-service/analytics.fake.ts — test tier, not on the namespace -import type { AnalyticsService } from "./analytics-service.js"; -export const createFake = (): AnalyticsService => ({ - serviceName: "analytics", - todoToggled: () => {}, // void method: inert - randomTodoRequested: () => Promise.resolve({ startedAt: 0 }), // value method: placeholder - // …one entry per method -}); -``` - -The template supplies only the service's **shape** (the runner calls it once to -enumerate methods — no Proxy). It invents **no return values**: a conformance case -schedules each value-returning method's returns in its `responses` and asserts the -calls in its `effects` (`data/state.md`), so the case owns both the input and the -expectation. A `*.fake.ts` therefore takes no response parameter and hardcodes nothing -a case asserts. - -## Where the I/O types live - -Most input/output types belong to a single service — declare them **on that -service's namespace** (`MyService.SomeInput`) and expose them only when something -external actually references them; not everything does. - -A **service type** may be non-serializable (callbacks, function signatures) — -that's what distinguishes it from a `data/` type. If an I/O value is a plain -serializable value, prefer a `data/` type instead. +- Spec actions inject with `Pick`. +- `ecs/services/` registers the same keys, pinned with + `Assert>`. +- A feature built on another intersects the lower map: + `type Services = MainServices & { readonly baz: BazService }`. +- A feature with no services has no `services/` folder. -A non-service type sits *directly* in `services/` **only** when it is genuinely -shared across more than one service — rare, but it happens. +A service that reads the database or calls its transactions is not a `services/` +service. It is a database-bound factory in `ecs/services/`. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/actions.md b/packages/data-ai/.claude/rules/features/services/main-service/actions.md deleted file mode 100644 index 94a990e1f..000000000 --- a/packages/data-ai/.claude/rules/features/services/main-service/actions.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -paths: - - '**/features/*/services/main-service/action-database/actions/**/*.ts' ---- - -# services/main-service/actions/ — async orchestration - -One action per file: a function taking the **whole database** as its first -argument and pure `data/` args. Actions orchestrate anything *outside* a -single transaction — awaiting a `services/` port, sequencing calls, deriving -timing — and then commit the result through a transaction. - -**Every *app-facing, transaction-backed* transition has a corresponding -same-named action** — the async realization the UI drives. It reads the same -services the transition injects from `db.services`, so it reproduces both the -transition's state change (through a transaction) and its side effects. It may -reuse another transition's transaction (`createRandomTodo` reuses `createTodo`) — -there need not be a same-named transaction; transactions are the looser layer. - -**Per-frame / system transitions are exempt.** In a real-time feature the `step*` -/ physics / collision transitions are realized by the **systems** tick loop, not -by an action, and are conformed by the tick-loop test (`systems.md`), not the -action surface of `checkFeature`. Give an action only to transitions a user/UI invokes directly -(and skip it too when the realization needs more than one transaction — e.g. a -`newGame` that both sets bounds and resets is conformed via its transaction). - -```ts -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -export const addRandomTodo = async (service: ServiceDatabase) => { - const name = await service.services.nameGenerator.generateName(); // await a services/ port - service.transactions.createTodo({ name }); // then exactly one commit -}; -``` - -- Type the `service` parameter on the lowest database layer exposing what the - action touches — usually `ServiceDatabase` (services **and** - transactions). Never the action layer itself (that would be a cycle). -- **Call at most one transaction** per action, so undo/redo stays one step - per operation. -- **Fire-and-forget.** An action's return value is not consumed; results flow - back through observables. `service.services.*` calls are `void` or - awaited-internally, never surfaced to the caller. -- Do the outside-world work here: await/sequence `services/` calls, and if a - slow call needs timing, compute it here around the call. -- **Read current state synchronously from the store** (`db.resources` / `db.read` - / `db.select`, then a pure `data/` helper) — never from a cached `computed`. - Reactive computeds refresh only on a committed transaction, so an imperative - read of one can hand back a stale shared cache (and it's the UI's layer, not - the action's). This also keeps the action correct under the conformance seed. -- **ECS-based feature (no `data/state/`):** no conformance cases exist, so **every - action carries its own unit test** — build a db, run the action with fake - `services`, and assert the committed state and the recorded service calls. The - bullet below applies only to **state-based** features (see `../../index.md`, Two modes). -- **Conformance** is the action surface of the feature's single - `conformance/conformance.test.ts` `Conformance.checkFeature(spec)` call. It pulls - actions off **`plugin.actions`** and pairs each to the **same-named** `data/state` - transition. **The action is the primary seam** — it reads injected services from - `db.services`, so the runner synthesizes each recording double from the manifest's - `services` templates + the case's `responses` and builds - `Database.toSystemDatabase(Database.create(plugin, { services }))`; it runs the action - with the case's data `args`, then `Match.assert`s `toState ≡ after` **and** checks the - recorded calls against the case's `effects`. No per-op wiring, no coverage guard. A thin - **same-named** action gives a transaction-only or renamed transition something to - pair with (todo's `reorderTodo`). A streaming/capability action with no transition - is skipped. **A per-transition action kept out of the facet** (to bound the - plugin's type) is supplied via the manifest's `ops.actions` glob — - `ops: { actions: import.meta.glob([".../actions/*.ts", "!.../actions/index.ts"], { eager: true }) }` - (p2p negotiation). That is the *only* reason to set `ops` (see `conformance.md`). -- An `index.ts` barrel feeds the `actions` plugin facet. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/archetypes.md b/packages/data-ai/.claude/rules/features/services/main-service/archetypes.md deleted file mode 100644 index 3ed46b678..000000000 --- a/packages/data-ai/.claude/rules/features/services/main-service/archetypes.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -paths: - - '**/features/*/services/main-service/**/core-database/archetypes.ts' ---- - -# core-database/archetypes.ts — the feature's archetypes - -One file, one export: `archetypes`, built with `Database.archetypes(components, …)`, -which validates every key against the component map and preserves each archetype's -literal tuple (so no per-archetype `as const satisfies`). The archetype **model** — -query/superset semantics, base archetypes for logical supertypes, row iteration — is -the rules-root `archetypes.md`; this file is the declaration convention. - -```ts -import { Database } from "@adobe/data/ecs"; -import { components } from "./components.js"; - -export const archetypes = Database.archetypes(components, { - Todo: ["todo", "name", "complete", "order", "dragPosition", "assignees"], -}); -``` - -- `UpperCase` names — an archetype names a *shape*, like a class. -- Archetypes may **span scopes**: a document entity can include an ephemeral - `session` column — `components` merges every scope, so any-scope key validates. - -## satisfies CoreDatabase.Archetype - -Type-check a shared base tuple against `CoreDatabase.Archetype` (= `readonly Component[]`; -see `core-database.md`) — the same type checks every `count` / `select` query key downstream: - -```ts -const layer = ["placement", "name", "visible", "opacity"] as const satisfies CoreDatabase.Archetype; -``` - -## Drift guard against the data-type - -An archetype that mirrors a `data/` row type carries a **non-exported** compile-time -check — infer the row shape from the archetype and assert it equals the data-type, so -the two can't drift: - -```ts -import { Schema } from "@adobe/data/schema"; -import type { Assert, Equal } from "@adobe/data/types"; -import type { PlacedMark } from "../../data/placed-mark/placed-mark.js"; - -const _schema = Schema.fromArchetype(components, archetypes.PlacedMark); -type _Check = Assert, PlacedMark>>; -``` diff --git a/packages/data-ai/.claude/rules/features/services/main-service/components.md b/packages/data-ai/.claude/rules/features/services/main-service/components.md deleted file mode 100644 index 57fe2ef6d..000000000 --- a/packages/data-ai/.claude/rules/features/services/main-service/components.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -paths: - - '**/features/*/services/main-service/**/core-database/components.ts' ---- - -# core-database/components.ts — the feature's components - -One file, one export: `components`, built with `Database.components(...)`. A -component binds a `data/` type into ECS column storage, grouped by schema scope. -The scope groups are optional — declare only the ones you use. - -Bind each schema straight from its `data/` type (or a primitive schema); don't -author shapes here. Component entries are any `Schema` — no `default` required -(that's resources): - -```ts -import { Database } from "@adobe/data/ecs"; -import { Boolean, True } from "@adobe/data/schema"; -import { PlayerMark } from "../../data/player-mark/player-mark.js"; -import { DragPosition } from "../../data/drag-position/drag-position.js"; - -export const components = Database.components({ - document: { mark: PlayerMark.schema, complete: Boolean.schema }, - session: { dragPosition: DragPosition.schema }, -}); -``` - -- A component name may differ from the type it stores (`position` of a `Vec3`, - `mark` of a `PlayerMark`) — the field name describes the role, the schema the - shape. -- **Scope = which group it sits in** (see `services/main-service/index.md` for the four scopes and - their flags). `document` is the common case; hover a scope key for its - meaning. Never write `nonShared` / `nonPersistent` by hand — the group applies - them. -- **Shared cross-feature columns live in `data/`** and go in `document` (which - preserves schema identity) so `combinePlugins` dedupes them. -- Struct vs. object storage and schema selection are properties of the `data/` - type, decided there. -- Don't infer entity type based upon component membership. - Consider using a const: true "tag" component or a type/kind enumerated value if needed. - Note that *many* good ecs designs don't care the type of the entity and only act on components. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/computed.md b/packages/data-ai/.claude/rules/features/services/main-service/computed.md deleted file mode 100644 index bc85fae4b..000000000 --- a/packages/data-ai/.claude/rules/features/services/main-service/computed.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -paths: - - '**/features/*/services/main-service/computed-database/computed/**/*.ts' ---- - -# database/computed/ — derived observable values - -One derived value per file: a `cached` function of a database layer that -returns an `Observe` of state projected through pure `data/` helpers. -Derivation logic itself lives in `data/`; a computed only wires a service -observable to it. - -**Reuse tested pure logic; let performance pick the wiring.** Prefer wiring the -pure **`data/` helper** (or composing indexes) directly — the tictactoe -computeds delegate their observable straight to -`BoardState.deriveStatus`/`getWinner`/`currentPlayer` — because a helper reads -exactly the resource/entities it needs, and both the computed and the matching -`state/` derivation call it, so they agree by construction. - -You **may** also import a pure `state/` derivation `(state) => value` into a -computed where performance is adequate: a small-N or resource/scalar composition, -not a hot per-entity or large-N path. Reusing a tested derivation is good — the -only cost is that it takes the **whole `State`**, so the computed must observe the -full-state projection and re-runs on *any* field change (fine for a small feature, -wasteful on a large or hot one, where you hand-wire the minimal resource/index -reads instead). A derivation takes **no services**, so its sibling `*.cases.ts` is -inert `{ input, value }` data (no test-doubles); like every `*.cases.ts` it is -test-tier and excluded from the runtime build by the project-reference wall, so it -never reaches the app bundle. **Performance is the first-class constraint**: reuse -freely where it doesn't matter, hand-wire minimal reads where it does. - -```ts -import { cached } from "@adobe/data/cache"; -import { Observe } from "@adobe/data/observe"; -import { BoardState } from "../../../data/board-state/board-state.js"; -import type { IndexDatabase } from "../../index-database/index-database.js"; - -export const status = cached((service: IndexDatabase) => - Observe.withFilter(service.observe.resources.board, BoardState.deriveStatus), -); -``` - -Type the parameter on the lowest database layer that exposes what it -reads. An `index.ts` barrel re-exports every computed; `computed-database.ts` -registers it under the `computed` facet. - -For a computed that only needs *how many* entities match an archetype (an -empty-state flag, a badge count), read `db.observe.count(archetype.components, -{ where })` — an `Observe` that sums archetype row counts and re-emits -only when the count changes, allocating no entity array. Prefer it over -`db.observe.select(...)` mapped through `.length`. - -**Conform a computed to its `data/state` derivation** whenever one exists. The -derivation's sibling `*.cases.ts` holds `{ input, value }` cases -(`Conformance.SpecDerivations`), and the feature's single -`conformance/conformance.test.ts` `Conformance.checkFeature(spec)` call conforms -computeds: it pulls them off **`computedPlugin.computed`**, pairs each to its -**same-named** derivation, seeds the store from `input`, reads the computed's -synchronous emission, and `Match.assert`s it against `value` (see `conformance.md`). -There is no `define`/`conforms` wiring. **`computedPlugin` is the `ComputedDatabase` -layer** — the runner builds computed conformance from that layer, not the assembled -`MainService`, so a `withCache` above can't hand back a stale pre-seed value (a -direct `fromState` seed emits no transaction to invalidate it). Comparison is -identity by default; a computed that emits an **entity-id list** names itself in -`hydrate: [...]` (todo's `visibleTodos`) so the runner projects each id through -`toData`. A computed with **no `state/` derivation is skipped** — -single-`data/` math is covered by that helper's own test. A feature with no -`state/` derivation omits `computedPlugin` entirely. - -**What needs conformance is proportional to wiring logic.** A computed that -composes/branches over the aggregate *is* a `state/` derivation (composes ≥2 -fields) and is conformed here. A computed that **trivially applies one -`data/` helper to one field** (`winner` ← `BoardState.getWinner(board)`) is -already covered by its parts — the field by `toState` conformance, the math by -that helper's unit test — so an added conformance would just re-invoke the same -helper on both sides (tautological); the helper's unit test is sufficient. Two -exceptions: (1) to pin a particular non-obvious wiring, promote it to a thin -`state/` derivation `(state) => Type.helper(state.field)` and the runner conforms -it — no new mechanism; (2) if a computed folds **entities into a value with no -intermediate `State` field**, that projection *is* unconformed — promote the -intermediate to a `State` field so `toState` covers it, making it a normal -`state/` derivation. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md deleted file mode 100644 index e54ccd708..000000000 --- a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -paths: - - '**/features/*/services/main-service/conformance/**/*.ts' ---- - -# services/main-service/conformance/ — keeping the ECS honest against the spec - -**State-based features only** (a feature with a `data/state/` spec). Test-only: imported -only by `*.test.ts`, in no facet barrel. The `data/state` cases (authored per -`../../../data/state.md`, gathered by the `spec.ts` manifest) are the shared truth; the -`@adobe/data-testing` runner replays them against the ECS. This folder holds only the -**feature-specific projection** (`projection.ts`) plus one `conformance.test.ts`. -Reference: `data-lit-tictactoe` (minimal), `data-lit-todo` (entities + `hydrate`). - -**The projection helpers and `Match` are test-only — never a runtime branch.** -`fromState`/`toState` rewrite the whole store out-of-band; runtime code reads through -observables/indexes and writes through transactions. - -## `projection.ts` — the store ⇄ State mapping (the one per-feature piece) - -A single aggregated export — three test-only helpers, one `export`: - -```ts -export const projection = { fromState, toState, toData }; -``` - -- `fromState(store, state)` seeds a store to a `State` (clear, insert entities, set - resources) and **returns the `id → entity` map** it built (`ReadonlyMap`). - The ecs mints its own ids, so the runner turns this map into the resolver that maps a - case's spec-id to the seeded entity — no feature writes id resolution by hand. Return - `void` for an index-addressed / singleton feature (ids then resolve to `Entity.none`). -- `toState(store)` reads the whole store back, built on `toData`. -- `toData(store, entity)` reads one entity as its `data/` value — the single place the - ecs↔data mapping lives. Its **return type** is what tells conformance which derivations - are entity-list computeds. Present only when the feature has entities. - -## `conformance.test.ts` — one call - -```ts -import { Conformance } from "@adobe/data-testing"; -import { spec } from "../../../data/state/spec.js"; -Conformance.checkFeature(spec); -``` - -`checkFeature` replays the manifest's shared cases against the ECS: it pulls the -transactions/actions off `spec.plugin`, the computeds off `spec.computedPlugin`, seeds -each case's `before` over `State.create()`, synthesizes each service's recording double -from `spec.services` + the case's `responses`, round-trips `State.samples` through -`spec.projection`, and hydrates the id-emitting computeds named in `spec.hydrate` through -`toData`. It shares `spec` with `spec.test.ts` — same cases, substituted implementation. - -Everything `checkFeature` needs lives on the manifest (`../../../data/state.md`): -`plugin`, `computedPlugin`, `projection`, `hydrate`, `services`, `state`, `fns`, `cases`. -The manifest's compile-time guards mean the two calls (`checkSpec` + `checkFeature`) are -the whole conformance surface — no per-op wiring, no coverage guard to hand-maintain. - -- **Name-pairing.** Each ecs op conforms to its **same-named** transition/derivation. An - op with no same-named transition (infrastructure like `setInput`, a streaming action, - a richer UI op) is skipped — so when the real op is renamed (`dragTodo`), add a thin - **same-named** op (`reorderTodo`) for the transition to pair with; never a per-item - adapter. -- **Projection round-trip.** When `State.samples` is non-empty the runner adds a - `toState ∘ fromState ≡ identity` test per sample. -- **Comparison.** Ordering is carried by the value's type — `ReadonlyArray` positional, - `ReadonlySet`/`ReadonlyMap` order-independent. Entity ids compare up to an id-bijection - from the schema-marked spec-ids (no matcher). Float noise is absorbed by - `match?: { tolerance }` (default `0.01`). A minted scalar uses `Match.anyNumber` / - `Match.anyString`. - -## The escape hatch — per-surface `seedContext` / custom concurrency - -A feature that needs **ambient per-case context differing per surface** — a user-scoped -`userId` seeded before the raw transaction *and* independently before the action dispatch -(**p2p presence**) — cannot use `checkFeature` (it has no place to thread two different -`seedContext`s or a custom concurrency). It drives the lower-level runners directly, -feeding them the manifest's cases via `Conformance.adaptCases`: - -```ts -import { Conformance } from "@adobe/data-testing"; -import { spec } from "../../../data/state/spec.js"; - -Conformance.runTransactions({ - createStore, fromState, toState, - transitions: Conformance.adaptCases(spec.fns, spec.cases, spec.services, false), - transactions, - seedContext: (store, _before, args) => seedUserId(store, (args as { mark: string }).mark), -}); -``` - -`adaptCases(fns, cases, services, injectServices)` turns the manifest into the discovery -map the runners consume — `injectServices: false` for the transaction store (a -transaction never receives services), `true` for the action db. `runActions` / -`runComputeds` take the same shape and their own `makeDb` / `seedContext` / concurrency. -This is the **only** sanctioned use of the lower runners; ordinary features use -`checkFeature`. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/core-database.md b/packages/data-ai/.claude/rules/features/services/main-service/core-database.md deleted file mode 100644 index 3ca1d6113..000000000 --- a/packages/data-ai/.claude/rules/features/services/main-service/core-database.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -paths: - - '**/features/*/services/main-service/**/core-database/core-database.ts' ---- - -# core-database/core-database.ts — the assembled schema + its namespace - -A thin assembler: `Database.Plugin.create({ components?, resources?, archetypes? })` -over the facet files, exposed through the `CoreDatabase` namespace. This is the -single-file namespace form (one value export — `plugin` — everything else a type; -see `global/namespace.md`). - -```ts -import { Database, type Store } from "@adobe/data/ecs"; -import { components } from "./components.js"; -import { resources } from "./resources.js"; -import { archetypes } from "./archetypes.js"; - -const coreDatabasePlugin = Database.Plugin.create({ components, resources, archetypes }); - -export type CoreDatabase = Database.Plugin.ToDatabase; -// Resolved component map, at module scope so the imported `Store` namespace isn't -// shadowed by `CoreDatabase.Store` below. -type CoreComponents = Store.Components>; - -export namespace CoreDatabase { - export const plugin = coreDatabasePlugin; - /** The store transactions operate on — a store *is* the transaction context. */ - export type Store = Database.Plugin.ToStore; - /** Index-declaration type bound to this database's components. */ - export type Index = Database.Index; - /** A component name, and a component-key tuple — an archetype's defining shape, or the key - * list of a `count` / `select` query. */ - export type Component = Extract; - export type Archetype = readonly Component[]; -} -``` - -## Derive `Component` / `Archetype` from `components`, never the plugin - -Read the keys straight from the leaf `components` module -(`Extract`). Do **not** derive them from the -plugin (`Store.Components>`, i.e. `CoreComponents`): the plugin is -built *from* `archetypes`, so a plugin-derived `Archetype` is **circular** the -moment it is used to `satisfies`-check an archetype tuple — and the cycle -collapses the entire database type to `{}`. The leaf derivation is cycle-free and -usable both in `archetypes.ts` (to check shared bases) and downstream (to check -`count` / `select` query keys). diff --git a/packages/data-ai/.claude/rules/features/services/main-service/index.md b/packages/data-ai/.claude/rules/features/services/main-service/index.md deleted file mode 100644 index cf3b9d390..000000000 --- a/packages/data-ai/.claude/rules/features/services/main-service/index.md +++ /dev/null @@ -1,187 +0,0 @@ ---- -paths: - - '**/features/*/services/main-service/main-service.ts' - - '**/features/*/services/main-service/*-database/*-database.ts' ---- - -# services/main-service/ — the feature service (ECS implementation) - -`main-service/` is the feature's single entrypoint service (see `../index.md`), -implemented as a reactive Entity-Component-System — an implementation detail -behind the service. Everything ECS-specific lives here: the schema that stores `data/` types as -entity state (`core-database/`), the derived and mutating logic over it -(`indexes/`, `transactions/`, `computed/`), the database-bound service factories -(`services/`), the async orchestrators (`actions/`), and the layered plugins -that compose it all. - -The distinction from `data/`: a `data/` type is ECS-agnostic (a plain -serializable value); an ECS construct is *how that type is stored and operated -on* inside a database. `mark: PlayerMark.schema` is not a data-model fact — it -is "the ECS keeps a PlayerMark here". - -## The core schema — `core-database/` - -A feature's whole schema — every component, resource, and archetype — lives in -one `core-database/` folder: `core-database.ts` plus one file per facet the -feature uses (all three facets are optional on `Database.Plugin.create`): - -``` -core-database/ - core-database.ts # Database.Plugin.create({ components?, resources?, archetypes? }) → CoreDatabase - components.ts # export const components = Database.components({ … }) - resources.ts # export const resources = Database.resources({ … }) - archetypes.ts # export const archetypes = Database.archetypes(components, { … }) -``` - -`core-database.ts` is a thin assembler; each facet file exports one typed object -built by a `Database.*` helper. No per-item files, no barrels. Create only the -facet files the feature needs — a singleton feature with no entities is just -`resources.ts` + `core-database.ts` (no empty `components({})` boilerplate). - -### Schema scopes - -State divides on two orthogonal axes — **scope** (shared with peers vs. local to -this client) and **lifetime** (durable across reloads vs. ephemeral) — giving -four scopes, each stamping a flag pair: - -| scope | shared? | durable? | flags | -|-------|:--:|:--:|-------| -| **document** | yes | yes | — | -| **settings** | no | yes | `nonShared` | -| **presence** | yes | no | `nonPersistent` | -| **session** | no | no | `nonPersistent` + `nonShared` | - -`Database.components` / `Database.resources` group declarations by scope and -stamp the flags. Every scope key is optional — declare only the ones you use -(most features are document-only): - -```ts -// components.ts -export const components = Database.components({ - document: { todo: True.schema, name: Name.schema, complete: Boolean.schema }, - session: { dragPosition: DragPosition.schema }, // omit settings / presence -}); - -// resources.ts — entries must carry a `default` (enforced at the call) -export const resources = Database.resources({ - settings: { displayCompleted: Boolean.schema }, -}); -``` - -A component/resource *value* is a plain `data/`-schema re-export (or a small -inline literal); its scope — and therefore its flags — is stated only by which -group it sits in. Hover a scope key for its meaning. The database does not yet -act on these flags (see the `nonShared` schema note); they model -shared/local × durable/ephemeral now, and the durable-shared subset -(`document`) is the set a bare store could be rebuilt from for reconciliation. - -**Shared columns live in `data/`.** A column two features share (e.g. a `name` -on both todos and users) is a `data/` type both reference by identity -(`Name.schema`), so `combinePlugins` dedupes it. Only the `document` scope -preserves schema identity (it applies no flags) — never share a -settings/presence/session column across features by identity. - -### Archetypes - -`archetypes.ts` declares the feature's entity shapes with -`Database.archetypes(components, { … })`, which validates every key against the -component map and preserves each archetype's literal tuple — no per-archetype -`as const`. Archetypes are a packing convenience, not serialized data; one may -span scopes (a document entity with an ephemeral `dragPosition` slot). An -archetype mirroring a `data/` row type carries a private compile-time -drift-guard (see `archetypes.md`). - -## Layered composition (behaviour) - -Above the schema, behaviour is a chain of `Database.Plugin`s, each its own -**namespace folder** — `-database/` holding the eponymous -`-database.ts` (the plugin, which `extends` the previous layer) plus the -facet folder it adds. **Name each layer for the facet it adds — never for the -feature or app.** A feature creates only the layers it uses: - -``` -main-service/ - core-database/ # the schema (above) - index-database/ # extends core; indexes/ - transaction-database/ # extends index; transactions/ - service-database/ # extends transaction; services/ - computed-database/ # extends service; computed/ - action-database/ # extends computed; actions/ -``` - -Each exports a namespace mirroring the plugin — the composed object is a -`Database` (the `@adobe/data` term) even though the folder is `services/main-service/`: - -```ts -export type IndexDatabase = Database.Plugin.ToDatabase; -export namespace IndexDatabase { - export const plugin = indexDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} -``` - -### The assembled database — `MainService` - -*Which* layer is topmost varies with the facets a feature uses (service / -computed / action / system). Consumers — `ui/`, the app entry, `services/main-service/conformance/` -— need the *whole* feature database but must not name that layer, or adding or -dropping a layer rewrites them all. Alias it once from the **folder-eponymous -`main-service.ts`** (per `global/namespace.md`, a folder's primary export lives in -its same-named file), as a namespace so consumers reach `MainService.plugin` / -`MainService.Store`: - -```ts -// services/main-service/main-service.ts -export { SystemDatabase as MainService } from "./system-database/system-database.js"; -``` - -Every consumer references **`MainService`** (`.plugin`, `.Store`) — never the -topmost layer. Add or drop a layer → change only this one line. - -**Cross-feature naming.** Inside a feature the core and assembled databases are -the bare `CoreDatabase` / `MainService`. When *another* feature or package -imports one — a peer built on it, the base `imports` a peer's `core-database`, a -downstream package — reference it **feature-qualified**: `CoreDatabase` -/ `MainService`, re-exported so from the feature's barrel -(`export { CoreDatabase as TodoCoreDatabase } from "…/core-database.js"`) — so -two features' `CoreDatabase`s never collide. - -## What each layer takes as input - -- **`plugin`** — every layer `extends` the previous layer's `plugin`; that is - the one universal thread up the chain. -- **`Store`** (via `ToStore`) — used *only* by **transactions** (`t: - .Store`). A store carries the index handles and the initiating - `userId`, so a store *is* the transaction context; there is no separate - transaction-context type. Use **`CoreDatabase.Store`** for a transaction that - reads/writes entities, resources, or archetypes; **`IndexDatabase.Store`** the - moment it reads an index. -- **the whole `Database`** — **services**, **computed**, and **actions** each - take `service: ` (the lowest layer whose database exposes what they - read/call): services read observables and call transactions; computed reads - `service.observe.*` and, once it needs a service's own reactive surface, - `service.services.*`; actions call `service.services.*` then - `service.transactions.*`. - -Each subfolder has its own rule. Modelling a plugin's authored vs. derived -surface is covered by `plugin-modelling.md`. - -## Facets are flat until they aren't - -A **behavioural** facet folder (`indexes/`, `transactions/`, `computed/`, -`actions/`) holds one file per item plus an `index.ts` barrel — flat while that -reads well, roughly up to half a dozen items. When a cohesive cluster grows past -that, promoting it into a themed subfolder with its own `index.ts` (re-exported -by the facet barrel) is a standard move — only once the folder actually feels -crowded: - -``` -transactions/ - order/ # a cohesive cluster: fractional-ordering helpers - index.ts next-order.ts normalize-order.ts select-ordered-todos.ts - create-todo.ts drag-todo.ts … -``` - -The **schema** facets (components / resources / archetypes) are *single files* -in `core-database/`, not folders. A whole feature should stay small enough that -subfoldering rarely bites — grow by adding features, not by bloating one. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/resources.md b/packages/data-ai/.claude/rules/features/services/main-service/resources.md deleted file mode 100644 index 1ccd98d67..000000000 --- a/packages/data-ai/.claude/rules/features/services/main-service/resources.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -paths: - - '**/features/*/services/main-service/**/core-database/resources.ts' ---- - -# core-database/resources.ts — the feature's resources - -A resource is a singleton component — one instance held on a single entity. One -file, one export: `resources`, built with `Database.resources(...)`. It is -identical to `Database.components` except **every entry must carry a `default`** -— a missing `default` is a compile error here, at the declaration site. - -```ts -import { Database } from "@adobe/data/ecs"; -import { Boolean } from "@adobe/data/schema"; - -export const resources = Database.resources({ - settings: { displayCompleted: Boolean.schema }, // per-device view toggle -}); -``` - -- Give each resource a `default` — spread the data-type's schema and add one, or - bind a `data/` / primitive schema that already carries a sensible default. -- **Scope = which group it sits in** (see `services/main-service/index.md`). A per-device - preference is `settings` (durable, `nonShared`); a synced-but-unsaved value is - `presence`; a saved shared value is `document`. -- Because there is only ever one instance, linear-memory packing buys nothing — - never choose a struct schema for efficiency here. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/services.md b/packages/data-ai/.claude/rules/features/services/main-service/services.md deleted file mode 100644 index b38908c53..000000000 --- a/packages/data-ai/.claude/rules/features/services/main-service/services.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -paths: - - '**/features/*/services/main-service/service-database/services/**/*.ts' ---- - -# database/services/ — database-bound service factories - -One factory per file: `createService(service, …)` that binds a service -implementation to a live database — reading its observables and invoking -its transactions. This is where the async `services/` contracts (or -framework services like `AgenticService`) are wired to ECS state. - -```ts -export const createAgentService = (service: TransactionDatabase, mark: PlayerMark): AgenticService => - /* … reads service.observe.*, calls service.transactions.* … */; -``` - -Type `service` on the lowest layer exposing what the factory reads/calls (never -the `service-database` itself — that would be a cycle). - -An `index.ts` barrel re-exports the factories; `service-database.ts` -registers them under the `services` facet, each keyed by the name -consumers read it as (`service.services.agent`). Defining a standalone -service contract (not yet bound to a database) belongs in the feature -`services/` layer. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/systems.md b/packages/data-ai/.claude/rules/features/services/main-service/systems.md deleted file mode 100644 index c9cade2f5..000000000 --- a/packages/data-ai/.claude/rules/features/services/main-service/systems.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -paths: - - '**/features/*/services/main-service/system-database/**/*.ts' ---- - -# database/systems/ — the real-time tick loop - -Systems are the framework's real-time backbone: functions the scheduler runs -**every frame**, in dependency order, to advance a simulation. A feature only -has a `systems/` folder when it is real-time (a game, a physics/particle sim). -Turn-based features never need one — they mutate through transactions on user -input and stop. - -A system is a `SystemDeclaration` — `{ create, schedule?: { before?, after?, during? } }`. -`create` runs ONCE at construction (capture closures / query args); it returns the -per-frame function (or `void` for an init-only system, e.g. seeding the first -entities). Ordering lives under **`schedule`** (not top-level). - -**Declare the `systems` map inline in `system-database.ts`.** The `create: (db) => …` -arrow must be written inline in `Plugin.create` for two reasons: only there is `db` -strongly typed (to the assembled plugin's database, with a **writable `store`**), and -a pre-typed standalone `create` reference breaks the scheduler's system-name inference -(the name union collapses to `never`, which then rejects every `schedule` entry). Keep -each `create` thin by delegating the real per-frame work to a pure `data/` step -function; if an ecs-specific body is substantial, extract it to a `systems/.ts` -helper `(db) => () => void` (annotate its `db`) and call it from the inline `create`. - -```ts -// in system-database.ts -import { Database, scheduler } from "@adobe/data/ecs"; -import { Motion } from "../../data/motion/motion.js"; - -const plugin = Database.Plugin.create({ - // combine scheduler with the CURRENT TOP layer — ComputedDatabase here, but - // ActionDatabase / ServiceDatabase if the feature built those (systems come last). - extends: Database.Plugin.combine(ComputedDatabase.plugin, scheduler), - systems: { - control: { create: (db) => () => { /* input → ship; db.transactions.fireBullet() */ } }, - movement: { - schedule: { after: ["control"], before: ["collision"] }, - create: (db) => () => { - const dt = db.store.resources.frameDelta; - for (const arch of db.store.queryArchetypes(["position", "velocity"])) { - const pos = arch.columns.position, vel = arch.columns.velocity; - for (let i = 0; i < arch.rowCount; i++) { - pos.set(i, Motion.advance(pos.get(i), vel.get(i), dt)); // in-place, no migration - } - } - }, - }, - collision: { schedule: { after: ["movement"] }, create: (db) => () => { /* … */ } }, - }, -}); -``` - -- **Systems mutate `db.store` directly** — that is the writable surface a system - receives (unlike the readonly `.store` consumers get). In-place column writes - (`col.set(i, …)`) for hot per-row work that stays in the same archetype; - `db.store.update / insert / delete` for lifecycle (spawning, dying) that changes - archetype membership. Resources: `db.store.resources.x = …`. -- **Dispatch a transaction for a discrete atomic event** — when the effect is a - self-contained event that already has a conformant transaction (score a hit, - lose a life, spawn a wave, fire a bullet), call `db.transactions.*` instead of - re-implementing it: you reuse the `data/`-verified logic and observers (a reactive - HUD) are notified. Reserve direct `db.store` writes for the hot per-row paths - (movement, aging) that run on every entity every frame and shouldn't pay - per-entity transaction overhead. -- **Optimize hot paths for speed.** With many entities, query with efficient - `queryArchetypes` and read/write minimal columns directly; avoid intermediate - allocations. On a hot path treat `data/` step functions as **spec only** — use - them when the path isn't hot or you can't beat them by writing the store - directly; off hot paths, reuse them freely. (Only transactions are observable — - direct store writes fire no observers.) -- **Iterate archetypes per `archetypes.md`.** Express selection with - `queryArchetypes(include, { exclude })`; when a system destroys/migrates rows - (bullets expiring, entities dying) iterate **tail → head** so hole-fills don't - invalidate the cursor. A per-frame system touching many rows is exactly where - those rules pay off. **A `db.transactions.*` call mid-scan migrates rows too** — - a transaction that inserts/deletes invalidates any live archetype cursor, so - snapshot the ids you'll act on (or reverse-iterate) *before* dispatching - transactions inside a per-frame loop. -- **Replicate the oracle's top-level guard in every system.** If the `data/` - `step` opens with `if (isGameOver(state)) return state`, each split system must - early-return on the same condition — a frozen frame is only a true no-op if - *every* system honors the guard. -- **Index reads go through `db.indexes`** (plural) — the writable `db.store` a - system receives is typed *without* its index handles, so a broad-phase query is - `db.indexes.byCell.find({ cell })`, not `db.store.indexes.…`. -- **Ordering is declared under `schedule`, not implied.** `schedule.before` / - `schedule.after` are hard constraints (name sibling systems); `schedule.during` - is a soft same-tier hint. Systems with no ordering relation share a tier - (conceptually parallel) — never rely on declaration order. When you split a - monolithic `data/` `step` into several ordered systems, the schedule **must - mirror the step's internal sequence** (advance → fire → age → collide → refill) - — otherwise the tick diverges from the oracle even though each system is - individually correct. Watch the subtle case: if a spawn step (fire) is followed - by a step that also processes the just-spawned entity (the new bullet is aged - and advanced this same tick), you can't advance *all* bodies in one system - before the spawn — split that entity's advance out so it runs *after* the spawn, - alongside the step that owns it. -- Immediate-mode rendering: store the canvas in a session resource and render to - it inside a system (see @adobe/data-gpu for patterns). - -## Driving the loop — the scheduler - -Systems don't run themselves. Combine the built-in scheduler so they run on -`requestAnimationFrame`: - -```ts -import { Database, scheduler } from "@adobe/data/ecs"; -// in system-database.ts: -Database.Plugin.create({ extends: Database.Plugin.combine(ComputedDatabase.plugin, scheduler), systems }); -``` - -The scheduler adds a `schedulerState` resource (`"running" | "paused" | -"disposed"`) — gate start/pause/resume through it (start `"paused"` if the game -begins on a user action). A headless host (tests, server sim) omits the -scheduler and drives frames itself by calling `db.system.functions[name]()` for -each `name` in `db.system.order` — so a simulation is fully runnable and testable -with no rAF and no rendering attached. - -## Layer - -`system-database.ts` extends the feature's **current top** main-service layer (`ActionDatabase` / -`ServiceDatabase` / `ComputedDatabase` — whichever it built; systems come last in the pipeline, so -they must sit atop any service/action layers, not a hardcoded `ComputedDatabase`), combined with -`scheduler`, and declares the `systems` map **inline** (see above — -this is the one facet not split one-per-file, because `create`'s `db` is only typed -inline and standalone declarations break name inference). A `systems/` folder is -optional: it holds extracted per-frame body helpers (`(db) => () => void`) only when -an inline body grows too large — the schedule and registration still live inline in -`system-database.ts`. The headless conformance test drives one frame via -`db.system.order` / `db.system.functions` and compares to the `data/` `step` oracle, -**reusing the feature projection** (`services/main-service/conformance.md`); it lives at -`system-database/tick-loop.test.ts`. Test **selection/detection** logic (which -entities collide, which pair resolves) separately, with seeded edge-case geometries -— it's a system concern, not a transaction, and where subtle bugs hide. - -## Iterating archetype rows - -A per-frame system reads/writes archetype columns directly. The row-iteration -rules — declarative `queryArchetypes` selection, tail → head migration, snapshot -avoidance — are general ECS concerns in the rules-root `archetypes.md`. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/transactions.md b/packages/data-ai/.claude/rules/features/services/main-service/transactions.md deleted file mode 100644 index 5e34f038b..000000000 --- a/packages/data-ai/.claude/rules/features/services/main-service/transactions.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -paths: - - '**/features/*/services/main-service/transaction-database/transactions/**/*.ts' ---- - -# database/transactions/ — atomic mutations - -One mutation per file: a function taking the transaction store as its -first argument and pure `data/` args, returning `void`. It reads and -writes `t.resources` / entities; all its writes commit or roll back -together. - -```ts -import { PlayMoveArgs } from "../../../data/play-move-args/play-move-args.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => { - // guard, then mutate t.resources / entities / archetypes -}; -``` - -- Type the store parameter as `.Store`: **`CoreDatabase.Store`** for a - transaction that reads/writes entities, resources, or archetypes (the common - case — all schema lives in `core-database`); **`IndexDatabase.Store`** the - moment it reads an index. A store *is* the transaction context: it carries the - index handles and the initiating `t.userId`. There is no separate - transaction-context type. -- Keep transactions idempotent under replay where sync/P2P applies — - validate and silently return on illegal or out-of-turn input rather - than throwing. -- Decisions come from pure `data/` helpers; the transaction only applies the - result — read the touched slice, call the `data/` transform, write the diff. -- Keep transaction files **single-export** (the `transactions/` barrel is - `export *`-ed into the plugin facet, so a second export would pollute it). -- **ECS-based feature (no `data/state/`):** there are no conformance cases, so - **every transaction carries its own unit test** — `Store.create(plugin)`, run the - transaction, assert the resulting resources/entities/archetypes. That test is the - direct substitute for the conformance oracle (see `../../index.md`, Two modes). The - bullet below applies only to **state-based** features. -- **Conformance is wired once, centrally, and auto-paired** — not per-file. The - feature's single `conformance/conformance.test.ts` `Conformance.checkFeature(spec)` - call conforms transactions: it pulls them off **`plugin.transactions`** (the - registered facet), pairs each to the **same-named** `data/state` transition, and - conforms it — seed `fromState(before)`, apply, `Match.assert` `toState ≡ after` - (state only; service effects are asserted through the action). There is **no** - `define`/`conforms` adapter and **no** `covers` guard. A transaction taking - **entity ids** takes them under the transition's own arg key (`{ id }`); the runner - resolves each id the case's `args` schema marks via the id→entity map `fromState` returns. A - transaction with **no same-named transition** is infrastructure (`setInput`, - `setBounds`) or the drag UI op (`dragTodo`) or system-dispatched — it is simply - **skipped**, no guard needed. Don't add a per-item adapter for a renamed op; add a - thin same-named transaction instead (see `conformance.md`). -- An `index.ts` barrel feeds the `transactions` plugin facet — so it must - re-export **only** the mutations. A read/query helper shared by several - transactions (`readShip`, `readBoard` — a `(t) => value` function) may live - beside them in `transactions/`, but keep it **out of the barrel**, or it gets - registered as a dispatchable transaction. (These are related helpers, so they - belong here, not in a separate folder — `global/cohesion.md`'s peer-level caution - doesn't apply.) diff --git a/packages/data-ai/.claude/rules/features/spec/index.md b/packages/data-ai/.claude/rules/features/spec/index.md new file mode 100644 index 000000000..25dd9c01e --- /dev/null +++ b/packages/data-ai/.claude/rules/features/spec/index.md @@ -0,0 +1,161 @@ +--- +paths: + - '**/features/*/spec/**/*.ts' +--- + +# spec/ — the feature's specification + +The whole feature as **one immutable `State`** plus pure functions over it: an +**action** spec for every operation the app performs, and a **derivation** for every +derived value it shows. It is slow, obvious and fully tested, and it is the oracle the +ECS is conformed against (`../ecs/conformance.md`). + +`spec/` is **test tier**: no runtime file imports it. It depends on `data/`, +`services/` (types and fakes) and `@adobe/data-testing`. + +``` +spec/ + state.ts create.ts samples.ts public.ts # State namespace: create + samples + toggle-complete.ts toggle-complete.cases.ts # one action or derivation + its cases + visible-todos.ts visible-todos.cases.ts + transforms.ts # barrel of every action + derivation + systems.ts .cases.ts # real-time only: one function per ECS system + spec.ts spec.test.ts # the manifest + Conformance.checkSpec +``` + +## `State` + +```ts +export type State = { + readonly displayCompleted: boolean; // singleton → resource `displayCompleted` + readonly selectedTodo: Entity; // a reference singleton + readonly entities: ReadonlyMap; // an entity collection, keyed by id +}; +type _Pin = Assert>; +export * as State from "./public.js"; +``` + +- **Every non-`Map` field is a singleton, named for its resource.** A single game + object (a ship, a frog) is a resource too. +- **Every `ReadonlyMap` field is an entity collection**, keyed by numeric id. Its + value is an `data/entities` type with no `id` (identity is the key), and its field + names are component names. All collections share one id space, so cases use + distinct ids across maps. +- **Pin it** with `Conformance.StateMatches`. Conformance finds schemas by these names, + so the pin is what makes a mismatch a compile error. A feature with no components + passes `{}`. +- A value computed from other state (a board picture folded from placed marks) is a + **derivation**, not a `State` field. +- `create()` is the default each case deltas over; `samples` are full states the + projection round-trips. + +## Actions and derivations — `.ts` + +```ts +export const toggleComplete = ( + state: Pick, + { id, analytics }: { readonly id: number } & Pick, +): Pick => { + analytics.todoToggled(); + const target = state.entities.get(id); + if (target === undefined) return { entities: state.entities }; + return { entities: new Map(state.entities).set(id, { ...target, complete: !target.complete }) }; +}; +``` + +- **An action** is `(state: Pick, args) => Pick`, sync + or async. It specs one `MainService` action of the same name. Return only the + fields it writes; no `...state` spread. +- **Inject services with `Pick`** alongside the data args, and call their + methods. A spec never needs a service's helpers. +- **Guard a no-op by returning an unchanged patch**, never by throwing. Reserve + `throw` for a precondition a case names with `throws`. +- **A derivation** is `(state) => value`. It specs one computed of the same name. +- **Per-frame work** in a real-time feature is a `systems.ts` barrel: one pure + function per ECS system, named the same, with the ordinary transition signature + (below). There is no hand-written `step`: the frame order comes from the systems' + `schedule`. +- Share logic with the ECS through `data/values/` helpers, never by the ECS importing + the spec. + +## Cases — `.cases.ts` + +Inert data, annotated `Conformance.SpecCases` for an action or +`Conformance.SpecDerivations` for a derivation (the annotation is what +tells them apart). + +```ts +export const cases: Conformance.SpecCases = { + args: { type: "object", properties: { id: Entity.schema } }, // marks entity-reference args + cases: [{ + name: "marks an incomplete todo complete", + before: { entities: new Map([[1, { name: "a", complete: false, order: 0 }]]) }, + args: { id: 1 }, + after: { entities: new Map([[1, { name: "a", complete: true, order: 0 }]]) }, + effects: { analytics: [["todoToggled"]] }, + }], +}; +``` + +- **`before`** is a delta over `State.create()`; **`after`** is the writes patch; a + derivation case is `{ name, input, value }`. +- **`args` is data only.** Services never appear in cases. A value-returning service + method's results are scheduled in **`responses`** — calling one with nothing + scheduled, or more times than scheduled, fails the case — and calls to assert go in + **`effects`** (`Array` = ordered, `Set` = any order). +- **Ids are plain numbers** everywhere; conformance compares them up to an id + bijection. Mark entity-reference args in the `args` schema. +- **`throws: true | "substring"`** replaces `after` for a precondition failure. +- A spec-only fixture helper (`marksOf("XX O ")`) may build case values. + +## `transforms.ts`, `spec.ts`, `spec.test.ts` + +```ts +// transforms.ts — exactly the conformed actions and derivations +export { toggleComplete } from "./toggle-complete.js"; +export { visibleTodos } from "./visible-todos.js"; + +// spec.ts +export const spec = Conformance.spec({ + state: State, + fns: transforms, + schemas: { ...components, ...resources }, // when State holds entity references + services: { analytics: AnalyticsService.createFake }, // when an action injects one + cases: { toggleComplete, visibleTodos }, +}); + +// spec.test.ts +Conformance.checkSpec(spec); +``` + +`Conformance.spec` checks at compile time that every fn has exactly one case module, +and that `services` lists exactly the services the actions and systems inject. + +## Systems (real-time features) + +```ts +// systems.ts — one pure function per ECS system, each guarding itself +export const movement = whilePlaying((s, { dt }) => ({ asteroids: stepAsteroids(s, { dt }) })); +export const collision = whilePlaying((s) => ({ ...resolveHits(s) })); + +// spec.ts +export const spec = Conformance.spec({ …, + systems: { + fns: systems, + cases: { movement, collision }, + noOp: [{ name: "frozen once the game is over", before: { status: "over" }, args: { dt: 1 } }], + }, +}); +``` + +- **Mirror the ECS systems one to one.** When a system fuses two logical steps, its + function composes both; when two systems split one step, split the function too + (the handoff must be a `State` field). +- **One `SpecCases` module per system**; `checkSpec` runs them against the functions. +- **`noOp`** lists states every system must leave unchanged (game over, paused). Each + is checked against every system, so a shared guard needs one case, not one per + system. +- **Whole-frame cases** depend on the ECS's schedule, so they live with the + implementation (`../ecs/conformance.md`), not here. +- A system the spec can't model (wasm physics, init-only seeding) has no function; + the implementation declares it unmodelled (`../ecs/conformance.md`). diff --git a/packages/data-ai/.claude/rules/features/ui/binding-element.md b/packages/data-ai/.claude/rules/features/ui/binding-element.md index c7370e1d3..6e4cecabb 100644 --- a/packages/data-ai/.claude/rules/features/ui/binding-element.md +++ b/packages/data-ai/.claude/rules/features/ui/binding-element.md @@ -1,8 +1,8 @@ --- paths: - - 'src/**/*element*.ts' - - 'src/**/elements/**/*.ts' - - 'src/**/ui/**/*-element.ts' + - '**/src/**/*element*.ts' + - '**/src/**/elements/**/*.ts' + - '**/src/**/ui/**/*-element.ts' --- # Binding elements @@ -101,7 +101,7 @@ render() { // UI action call with simple ternary — good jump: (heroMode: boolean) => this.service.actions.move(heroMode ? 100 : 10), // transaction call - good - play: this.service.transactions.playTrack + play: this.service.actions.playTrack }); } ``` @@ -218,7 +218,7 @@ export class BadControlElement extends FooElement { // violation: logic in callback + onX name const normalized = Math.round(v * 100) / 100; if (!Number.isFinite(normalized)) return; - this.service.transactions.updateThing({ value: normalized }); + this.service.actions.updateThing({ value: normalized }); }; return presentation.render({ total: clamped, onSave }); } diff --git a/packages/data-ai/.claude/rules/features/ui/index.md b/packages/data-ai/.claude/rules/features/ui/index.md index 1bb3097ed..d38b07caf 100644 --- a/packages/data-ai/.claude/rules/features/ui/index.md +++ b/packages/data-ai/.claude/rules/features/ui/index.md @@ -1,13 +1,18 @@ --- paths: - '**/features/*/ui/**/*.ts' + - '**/features/*/ui/**/*.tsx' --- # ui/ — user interface -The top layer: UI that binds the `services/main-service/` database to what the user sees. It -owns no business logic — it subscribes to state, hands it to a pure -presentation, and wires actions back to transactions. +The top layer: UI that binds the feature's `MainService` database (`ecs/main-service.ts`) +to what the user sees. It owns no business logic — it subscribes to state, hands it to +a pure presentation, and wires user input to `service.actions.*`. + +Each feature has one base element (Lit: `ui/-element.ts`, a +`DatabaseElement` typed on its `MainService.plugin`; React: a `use-main-service.ts` +hook) that every element of the feature builds on. The folder name is framework-neutral; its contents are framework-specific (Lit elements, React components, …). Each UI unit is its own folder, named diff --git a/packages/data-ai/.claude/rules/features/ui/lazy-wrapper.md b/packages/data-ai/.claude/rules/features/ui/lazy-wrapper.md index 8815d87d8..7934e4749 100644 --- a/packages/data-ai/.claude/rules/features/ui/lazy-wrapper.md +++ b/packages/data-ai/.claude/rules/features/ui/lazy-wrapper.md @@ -1,7 +1,7 @@ --- paths: - - 'src/**/elements/**/*.ts' - - 'src/**/ui/**/*.ts' + - '**/src/**/elements/**/*.ts' + - '**/src/**/ui/**/*.ts' --- # Lazy Wrapper diff --git a/packages/data-ai/.claude/rules/features/ui/presentation-test.md b/packages/data-ai/.claude/rules/features/ui/presentation-test.md index d552faf97..bc269ab41 100644 --- a/packages/data-ai/.claude/rules/features/ui/presentation-test.md +++ b/packages/data-ai/.claude/rules/features/ui/presentation-test.md @@ -1,6 +1,7 @@ --- paths: - '**/*presentation.test.ts' + - '**/*presentation.test.tsx' --- ## Testing diff --git a/packages/data-ai/.claude/rules/features/ui/presentation.md b/packages/data-ai/.claude/rules/features/ui/presentation.md index 150381473..3d6217ab0 100644 --- a/packages/data-ai/.claude/rules/features/ui/presentation.md +++ b/packages/data-ai/.claude/rules/features/ui/presentation.md @@ -1,6 +1,7 @@ --- paths: - '**/*presentation.ts' + - '**/*presentation.tsx' --- # Presentation authoring diff --git a/packages/data-ai/.claude/rules/global/function-references.md b/packages/data-ai/.claude/rules/global/function-references.md index d90f36924..7b2b1a9d6 100644 --- a/packages/data-ai/.claude/rules/global/function-references.md +++ b/packages/data-ai/.claude/rules/global/function-references.md @@ -11,12 +11,12 @@ When a value is already a function with the signature the callee wants, pass it ```ts // ❌ identity wrappers — the arrow adds nothing -expectConforms({ spec: (s, dt) => State.resolveBulletHits(s, dt), apply: (store, a) => hitAsteroid(store, a) }); +cached((db) => Observe.withFilter(db.observe.resources.board, (b) => BoardState.deriveStatus(b))); onClick={() => save()} todos.map((t) => render(t)) // ✅ pass the function itself -expectConforms({ spec: State.resolveBulletHits, apply: hitAsteroid }); +cached((db) => Observe.withFilter(db.observe.resources.board, BoardState.deriveStatus)); onClick={save} todos.map(render) ``` diff --git a/packages/data-ai/.claude/rules/global/namespace.md b/packages/data-ai/.claude/rules/global/namespace.md index 1e234678c..6226ef80f 100644 --- a/packages/data-ai/.claude/rules/global/namespace.md +++ b/packages/data-ai/.claude/rules/global/namespace.md @@ -6,7 +6,8 @@ paths: # Type namespace pattern Single import surface per type: `/.ts`. Schema in `-schema.ts`, namespace re-export from `public.js`. Types live in -the `types/` layer. +the `types/` layer. **In a feature**, Data types live in `features//data/values//` with the schema in `schema.ts` +(`features/data/values.md`). ## Authority @@ -161,5 +162,4 @@ Example — a type merged with one value export and any number of type-only memb } The canonical use is a feature's `core-database.ts` / layered `*-database.ts` (a -`plugin` value plus `Store` / `Index` / `Component` / `Archetype` types) — see -`features/services/main-service/core-database.md`. +`plugin` value plus `Store` / `Index` types) — see `features/ecs/core.md`. diff --git a/packages/data-ai/.claude/rules/global/react.md b/packages/data-ai/.claude/rules/global/react.md index 59c380dd1..601f96fa3 100644 --- a/packages/data-ai/.claude/rules/global/react.md +++ b/packages/data-ai/.claude/rules/global/react.md @@ -5,7 +5,7 @@ paths: # React component authoring -React components live in the `components/` layer. Consume Observe and void actions from plugins. Use `@adobe/data-react` for `useDatabase`, +React components live in the `ui/` layer (`features/ui/`). Consume Observe and void actions from plugins. Use `@adobe/data-react` for `useDatabase`, `useObservableValues`, and `DatabaseProvider`. --- @@ -61,7 +61,7 @@ function Counter() { count: db.observe.resources.count, })); if (!values) return null; - return presentation.render({ ...values, increment: db.transactions.increment }); + return presentation.render({ ...values, increment: db.actions.increment }); } ``` @@ -81,8 +81,8 @@ Use **verb** or **verbNoun** names — never `on*` prefix. See `features/ui/pres **Pass function references directly** when the signature matches; wrap in an arrow function only to supply arguments: ```tsx -increment: db.transactions.increment; // reference — no wrapper needed -toggleSprite: () => db.transactions.toggleSpriteActive({ entity }); // args required — wrap +increment: db.actions.increment; // reference — no wrapper needed +toggleSprite: () => db.actions.toggleSpriteActive({ entity }); // args required — wrap ``` --- diff --git a/packages/data-ai/.claude/rules/lit.md b/packages/data-ai/.claude/rules/lit.md index b14b8986b..0f25166fa 100644 --- a/packages/data-ai/.claude/rules/lit.md +++ b/packages/data-ai/.claude/rules/lit.md @@ -7,7 +7,7 @@ paths: # Lit element authoring -Lit elements live in the `components/` layer per the structure rule. Consume Observe and void actions from plugins per the service rule. +Lit elements live in the `ui/` layer (`features/ui/`). Consume Observe and void actions from plugins per the service rule. --- @@ -58,7 +58,7 @@ render() { userProfile: this.service.services.authentication.userProfile, })); if (!values) return; - return presentation.render({ ...values, toggleView: () => this.service.transactions.setViewVisible({ name, visible: true }) }); + return presentation.render({ ...values, toggleView: () => this.service.actions.setViewVisible({ name, visible: true }) }); } ``` diff --git a/packages/data-ai/.claude/rules/plugin-modelling.md b/packages/data-ai/.claude/rules/plugin-modelling.md index 65e509704..1ca37e3c4 100644 --- a/packages/data-ai/.claude/rules/plugin-modelling.md +++ b/packages/data-ai/.claude/rules/plugin-modelling.md @@ -31,6 +31,8 @@ authoring abstraction. ## File naming +Outside `features/`. Inside a feature, the `features/ecs/` naming applies (`-database.ts`). + Each file whose primary export is a `Database.Plugin` (created or combined) ends in `-plugin.ts` — e.g. `node-plugin.ts`, `model-plugin.ts`, `pbr-core-plugin.ts`. The suffix is for discovery: cursor rules, diff --git a/packages/data-ai/.claude/rules/service.md b/packages/data-ai/.claude/rules/service.md index 477136f25..6f357110a 100644 --- a/packages/data-ai/.claude/rules/service.md +++ b/packages/data-ai/.claude/rules/service.md @@ -6,6 +6,8 @@ paths: # Service authoring +> Inside a feature, `features/services/index.md` governs the folder layout: a small service keeps `create.ts` and `create-fake.ts` flat beside the interface (the single-file exception below); one with helpers moves its implementation into a sub-folder. Database-bound factories in `ecs/services/` follow `features/ecs/services.md`. + Asynchronous data services. Live in the `services/` layer. Adhere to the namespace rule for type and function organization. **Data** = readonly JSON values, `ReadonlySet`, `ReadonlyMap`, or Blobs. diff --git a/packages/data-ai/.claude/rules/versioning.md b/packages/data-ai/.claude/rules/versioning.md index d38837ca8..1e755086c 100644 --- a/packages/data-ai/.claude/rules/versioning.md +++ b/packages/data-ai/.claude/rules/versioning.md @@ -12,6 +12,9 @@ persisted document can be upgraded to the current schema on load. You (human or agent) should almost never have to reason about this by hand: **change the schema, run the tests, and the failing test prints the exact fix to apply.** +The history lives in the app root, `src/app/versioning/` (`app.md`), because the +persisted data covers every feature's schema in the database. + ## The pieces (all from `@adobe/data/ecs`) - **`versions.ts`** — `export const versions: readonly VersionEntry[]`. `entries[i]` @@ -59,7 +62,7 @@ run the tests, and the failing test prints the exact fix to apply.** writes to SEVERAL components (a cross-component move, deriving a new component from others, splitting one into many) must be hand-written to your upgrade algorithm: query the input components, compute, and write the outputs directly on the store. - Add a test case either way (rule 6). + Add a test case either way (the guard test below). 5. **Removing a component drops its data.** If the data still matters, migrate it in a handler BEFORE the removal entry. 6. **Versioned schemas must be REAL** — declare a `type` (or `enum`/`const`), and the @@ -83,7 +86,7 @@ history. Its message is a literal recipe. To fix it: and the `changes` block the error printed verbatim. `db.version` follows the history automatically (`= versions.length - 1`) — there is no resource default to update. 2. If the error marks a change **BREAKING — needs a handler**, add a `handler` to that - entry AND add a matching test case under `testUpgradeHandlers` (rule 6). The error + entry AND add a matching test case under `testUpgradeHandlers` (the guard test below). The error may point at `Store.remapComponent(...)` for an isolated single-component change, but that is only a suggestion — write whatever upgrade code the change actually needs. @@ -97,17 +100,17 @@ schema (schema coverage) AND every entry with a `handler` has a passing test cas ```ts import { Database, assertVersioning, createVersionUpgrader } from "@adobe/data/ecs"; -import { MainService } from "../main-service.js"; +import { AppSchema } from "../schema.js"; import { versions } from "./versions.js"; describe("database schema versions", () => { it("the version history is consistent (schema folds + every handler tested)", () => assertVersioning({ // the version-configured db, so db.version = the history's current version - database: Database.create(MainService.plugin, { versioning: createVersionUpgrader(versions) }), + database: Database.create(AppSchema.plugin, { versioning: createVersionUpgrader(versions) }), entries: versions, handlers: { - // Append a case per version that adds a `handler` (rule 6): + // Append a case per version that adds a `handler` (the guard test below): // : { setup: (store) => , // expect: (store, setup) => }, }, @@ -116,7 +119,6 @@ describe("database schema versions", () => { ``` (`assertVersionsMatchSchema` and `testUpgradeHandlers` remain exported for bespoke use.) -``` ## Example: a breaking change (number → object) at version 1 diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index d11e8661c..7a565c387 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.10.21", + "version": "0.10.22", "description": "Cross-agent architecture skills for @adobe/data — installable as a Claude Code plugin or copied into any Agent-Skills-compatible agent (Cursor, Codex).", "type": "module", "private": false, diff --git a/packages/data-ai/skills/build-actions/SKILL.md b/packages/data-ai/skills/build-actions/SKILL.md index 791e40fcf..798051183 100644 --- a/packages/data-ai/skills/build-actions/SKILL.md +++ b/packages/data-ai/skills/build-actions/SKILL.md @@ -1,16 +1,15 @@ --- name: build-actions -description: Build a feature's action-database — async orchestration over services and transactions. If the feature has async flows or side effects outside of the main store. +description: Build a feature's ecs/actions layer — the operations the app performs. input: feature output: feature --- -Skip if this feature doesn't contain or require actions. +Create `ecs/actions/`: `action-database.ts` (extends the previous layer, adds `actions` +from `./index.js`), one action per file, and a barrel. Every spec action has a +same-named action. It awaits services and commits through at most one transaction +(add any transaction `build-transactions` missed). Point `ecs/main-service.ts` at the top layer. -Create `services/main-service/action-database/`: `action-database.ts` (extends `ServiceDatabase`, adds `actions` -from `./actions/index.js`) plus an `actions/` folder — one async orchestrator per file. An action -takes `db: ServiceDatabase`, awaits `services/` ports, then commits **exactly one** transaction. +Gate: `checkFeature` passes for each op as it is added. -The top main-service layer; comes after `service-database`. - -The how is in the auto-loading `features/services/main-service/actions.md` rule. +The how is in the auto-loading `features/ecs/actions.md` rule. diff --git a/packages/data-ai/skills/build-app-entry/SKILL.md b/packages/data-ai/skills/build-app-entry/SKILL.md index 63201c995..11797c649 100644 --- a/packages/data-ai/skills/build-app-entry/SKILL.md +++ b/packages/data-ai/skills/build-app-entry/SKILL.md @@ -1,15 +1,19 @@ --- name: build-app-entry -description: Wire built features into a running application — import peer schemas, lazy-load peers, and mount the root UI. +description: Build an application's src/app/ composition root. input: app output: app --- -- The base (`features/main`) core-database `imports` each peer feature's core-database - plugin, so all schemas coexist, persist, and sync while the base type stays decoupled. -- The base reaches a peer only through a lazy element wrapper (dynamic import), so peers - load on demand. -- The entry point creates the live database from `MainService.plugin` (the base - feature's assembled database — see `features/services/main-service/index.md`) and mounts the root UI. +Create `src/app/`: -See `features/index.md` (one-app-many-features) for the wiring rules. +- `schema.ts` — the composed plugin: the base feature's `MainService.plugin`, `imports` + of the core schema of each feature that loads lazily (single-feature apps skip this). +- `versioning/` — `versions.ts` + `versions.test.ts` over the composed schema, when + the app persists. +- `persistence.test.ts` — a save/load round-trip, when the app persists. +- `main.ts` — creates the database, injects every service whose `create` throws, and + mounts the root UI. Point `index.html` at it. + +The how is in the auto-loading `app.md` and `versioning.md` rules, and +`features/index.md` (one app, many features). diff --git a/packages/data-ai/skills/build-computed/SKILL.md b/packages/data-ai/skills/build-computed/SKILL.md index 84447742d..e84494032 100644 --- a/packages/data-ai/skills/build-computed/SKILL.md +++ b/packages/data-ai/skills/build-computed/SKILL.md @@ -1,16 +1,17 @@ --- name: build-computed -description: Build a feature's computed-database — derived observable values. +description: Build a feature's ecs/computed layer. Optional. input: feature output: feature --- -Skip if this feature doesn't contain or require computed values. +Skip if the spec has no derivations and the UI needs no derived values. -Create `services/main-service/computed-database/`: `computed-database.ts` (extends the previous layer, adds -`computed` from `./computed/index.js`) plus a `computed/` folder — one `cached` derived value -per file + barrel. Derivation logic lives in `data/`; a computed only wires a db observable to it. +Create `ecs/computed/`: `computed-database.ts` (extends the previous layer, adds +`computed` from `./index.js`), one `cached` derived value per file, and a barrel. Wire +`data/values/` helpers to the minimal observables. Name each entity-id-list computed in +the implementation's `hydrate`, and pass `computedPlugin: ComputedDatabase.plugin`. -Comes after `transaction-database`. +Gate: each derivation's computed conforms. -The how is in the auto-loading `features/services/main-service/computed.md` rule. +The how is in the auto-loading `features/ecs/computed.md` rule. diff --git a/packages/data-ai/skills/build-core-database/SKILL.md b/packages/data-ai/skills/build-core-database/SKILL.md index 5dc302c45..c603eeb16 100644 --- a/packages/data-ai/skills/build-core-database/SKILL.md +++ b/packages/data-ai/skills/build-core-database/SKILL.md @@ -1,24 +1,27 @@ --- name: build-core-database -description: Build a feature's schema — the core-database (components, resources, archetypes). The first main-service layer. +description: Build a feature's ECS schema (ecs/core) and its conformance wiring. The first ecs layer. input: feature output: feature --- -Create `services/main-service/core-database/` — `core-database.ts` plus one single-export file per facet -the feature actually uses (`components`, `resources`, `archetypes` are each optional on -`Database.Plugin.create`). Create only the facet files you need; omit the rest rather than -shipping empty `Database.components({})` boilerplate. A feature with **no entities** (a -singleton whose whole state is resources) is just `resources.ts` + `core-database.ts`. +Create: -- `components.ts` → `Database.components({ document, settings, presence, session })` (scopes optional) — only if the feature has entities. -- `resources.ts` → `Database.resources({ … })` (every entry needs a `default`) — for singleton / global state. -- `archetypes.ts` → `Database.archetypes(components, { … })` — only alongside components. -- `core-database.ts` → `Database.Plugin.create({ imports?, components?, resources?, archetypes? })`, exporting the `CoreDatabase` namespace. +- `ecs/core/archetypes.ts` — `Database.archetypes(components, { … })`, spreading each + `data/entities` tuple and adding implementation-only columns (never a + `nonPersistent` column without a real default). +- `ecs/core/core-database.ts` — `Database.Plugin.create({ extends?, components?, + resources?, archetypes? })` from the `data/` barrels, exporting the `CoreDatabase` + namespace. A feature built on another extends that feature's core. +- `ecs/main-service.ts` — `export { CoreDatabase as MainService }`, moved up as layers + are added. +- `ecs/conformance/` — `projection.ts`, `implementation.ts` + (`Conformance.implementation(spec, …)`) and `conformance.test.ts` + (`Conformance.checkFeature`). -The schema root; comes after `data/` (and `services/`). Bind each schema from its `data/` -type — don't author shapes here. Components use JSON schemas; resources use the -`{ default }` convention (a schemaless aggregate binds via `{ default: State.create() }`). +Gate: the samples round-trip through the projection. Until the later layers exist, +`checkFeature` lists each spec op without an implementation by name; that list is +the work remaining. -The how is in the auto-loading `features/services/main-service/index.md` + `components.md` / `resources.md` / -`archetypes.md` rules. +The how is in the auto-loading `features/ecs/index.md`, `core.md` and `conformance.md` +rules. diff --git a/packages/data-ai/skills/build-data/SKILL.md b/packages/data-ai/skills/build-data/SKILL.md index e75c24299..61e76762f 100644 --- a/packages/data-ai/skills/build-data/SKILL.md +++ b/packages/data-ai/skills/build-data/SKILL.md @@ -1,22 +1,23 @@ --- name: build-data -description: Build a feature's data/ layer — data-type namespaces and the State spec. The first, foundational feature phase. +description: Build a feature's data/ layer — values, components, resources, entities. The first, foundational phase. input: feature output: feature --- -Create the feature's `data/` layer (pure spec; depends on nothing but `@adobe/data` -and other `data/` declarations): +Create the feature's `data/` layer: pure Data declarations, depending only on +`@adobe/data` and the `data/` of features this one builds on. -- one namespace folder per data type — `/{.ts, public.ts, .ts}` (+ `schema.ts` only when a runtime boundary needs it); -- `data/state/` — the `State` aggregate plus its pure transforms/derivations. +- `values//` — one namespace per Data type: a hand-written type, a schema pinned + to it (`type _Pin = Assert, >>`) when + something needs one, and pure helpers, each with a test. +- `components/.ts` — one schema per file, scope spread from `Scope`, plus an + `index.ts` barrel. Include implementation-only columns (tags, drag offsets). +- `resources/.ts` — one schema per file, `satisfies ResourceSchema`, plus a barrel. +- `entities/.ts` — the spec-visible entity as a component-name tuple, with its + type derived by `Database.EntityType`, plus a barrel. -**Always create `data/state/`** — even for a shell feature whose only field is a -boolean or enum. A feature with ECS resources/transactions but no `State` is -incomplete: the transaction must wrap a pure transform, not invent the logic. In rare circumstances, there may be no real state in a feature in which case the state type = {} +Create only the folders the feature needs. Gate: it compiles and the helper tests +pass. -The hand-authored type in `.ts` is the source of truth. The schema is optional up front — add `schema.ts` only when a runtime boundary (persistence, wire, or an ECS component/resource) needs one, write it to match the type, and pin it with `type _Pin = Assert, >>` so it cannot drift. `Schema.ToType` is used only in that assertion, never as the exported type. -Helpers are pure and unit-tested. Run this first — every other layer imports `data/`. - -The how is in the auto-loading rules: `features/data/index.md`, `features/data/state.md`, and -`global/namespace.md`. +The how is in the auto-loading `features/data/` rules and `global/namespace.md`. diff --git a/packages/data-ai/skills/build-feature/SKILL.md b/packages/data-ai/skills/build-feature/SKILL.md index 34a1f0510..2ef8b6165 100644 --- a/packages/data-ai/skills/build-feature/SKILL.md +++ b/packages/data-ai/skills/build-feature/SKILL.md @@ -8,11 +8,12 @@ output: feature /graph-execute /build-data /build-services + /build-spec /build-core-database /build-indexes /build-transactions - /build-computed /build-service-database + /build-computed /build-actions /build-systems /build-ui diff --git a/packages/data-ai/skills/build-game/SKILL.md b/packages/data-ai/skills/build-game/SKILL.md index 0679d9787..eeb713629 100644 --- a/packages/data-ai/skills/build-game/SKILL.md +++ b/packages/data-ai/skills/build-game/SKILL.md @@ -7,15 +7,17 @@ output: game /build-application — a game is an application. -Map game concepts to feature layers: data/ = state + rules (+ per-tick step math), -components/archetypes = entity kinds, indexes = spatial/lookup queries, -transactions = moves/spawns, computed = score/status/winner, ui = view + input. +Map game concepts to feature layers: data/ = value types, rules math, components, +resources (the board, the player, the score) and entities (the pieces); spec/ = the +game state + moves (+ one pure function per system); indexes = spatial/lookup queries; +transactions + actions = moves/spawns; computed = score/status/winner; ui = view + input. - **Turn-based** uses the reactive transaction → computed → ui loop, no systems (see data-lit-tictactoe). - **Real-time** adds the `build-systems` phase: a per-frame tick loop (movement, collision, lifetime) driven by the scheduler, over many entities in distinct archetypes. Systems must stay O(1) per touched entity — never project the whole - `State` into/out of the store each frame (see `features/services/main-service/systems.md`). + `State` into/out of the store each frame, and never turn per-row work into per-entity + transactions. Each system conforms alone and as a frame (see `features/ecs/systems.md`). This is where components, archetypes, indexes, and systems all carry real weight. diff --git a/packages/data-ai/skills/build-indexes/SKILL.md b/packages/data-ai/skills/build-indexes/SKILL.md index b13090e2c..57a93ad4f 100644 --- a/packages/data-ai/skills/build-indexes/SKILL.md +++ b/packages/data-ai/skills/build-indexes/SKILL.md @@ -1,16 +1,15 @@ --- name: build-indexes -description: Build a feature's index-database — component/archetype indexes for O(1) lookup. As needed. +description: Build a feature's ecs/indexes layer. Optional. input: feature output: feature --- -Skip if this feature doesn't contain or require indexes. +Skip if the feature needs no indexed lookups. -Create `services/main-service/index-database/`: `index-database.ts` (extends `CoreDatabase`, adds the `indexes` -facet from `./indexes/index.js`) plus an `indexes/` folder — one index per file + an `index.ts` -barrel. +Create `ecs/indexes/`: `index-database.ts` (extends `CoreDatabase`, adds `indexes` from +`./index.js`), one index per file, and an `index.ts` barrel. -Only if the feature needs indexed lookups. Comes after `core-database`. +Gate: conformance unchanged, plus index tests where an index has logic. -The how is in the auto-loading `features/services/main-service/indexes.md` rule. +The how is in the auto-loading `features/ecs/indexes.md` rule. diff --git a/packages/data-ai/skills/build-service-database/SKILL.md b/packages/data-ai/skills/build-service-database/SKILL.md index 4cd585030..5854880c9 100644 --- a/packages/data-ai/skills/build-service-database/SKILL.md +++ b/packages/data-ai/skills/build-service-database/SKILL.md @@ -1,16 +1,16 @@ --- name: build-service-database -description: Build a feature's service-database — db-bound service factories. If the feature exposes services. +description: Build a feature's ecs/services layer — registering services on the database. Optional. input: feature output: feature --- -Create `services/main-service/service-database/`: `service-database.ts` (extends the previous layer, registers the -`services` facet). Two forms: +Skip if the feature has no services. -- db-bound factories `createService(db, …)` in `service-database/services/` (one per file + barrel), for services that read `db.observe.*` / call `db.transactions.*`; or -- plain async ports registered straight from the feature's `services/` contracts. +Create `ecs/services/service-database.ts` (extends the previous layer), registering +each `services/` service's `create` under its `Services` key, plus any database-bound +factories (one per file + barrel) for services that read observables or call +transactions. Pin with `Assert>`. Give +the implementation base fakes for services whose `create` throws. -Comes after `computed-database`. - -The how is in the auto-loading `features/services/main-service/services.md` rule. +The how is in the auto-loading `features/ecs/services.md` rule. diff --git a/packages/data-ai/skills/build-services/SKILL.md b/packages/data-ai/skills/build-services/SKILL.md index 957b77324..f6b84b2fe 100644 --- a/packages/data-ai/skills/build-services/SKILL.md +++ b/packages/data-ai/skills/build-services/SKILL.md @@ -1,16 +1,18 @@ --- name: build-services -description: Build a feature's services/ layer — async capability contracts (ports to the outside world). Optional. +description: Build a feature's services/ layer — async data services (interface, create, createFake). Optional. input: feature output: feature --- -Skip if this feature doesn't contain or require services. +Skip if the feature talks to nothing outside itself. -Create the feature's `services/` layer: one `-service/` namespace folder per async -contract — an `interface` (validated with `AsyncDataService.IsValid`), async-only members, -and `create*` factories. +Create one `services/-service/` namespace per service: the interface (an async +data service, validated with `AsyncDataService.IsValid`), `create` (the +implementation, or a factory that throws when the app must inject it), +`createFake`, and `public.ts`. Add `services/services.ts` with the `Services` map when +spec actions inject services. -Only if the feature talks to the outside world. Comes after `data/`, before `services/main-service/`. +Depends only on `data/`. Gate: per-service unit tests. -The how is in the auto-loading `features/services/index.md` rule (and `global/namespace.md`). +The how is in the auto-loading `features/services/index.md` rule. diff --git a/packages/data-ai/skills/build-spec/SKILL.md b/packages/data-ai/skills/build-spec/SKILL.md new file mode 100644 index 000000000..120799151 --- /dev/null +++ b/packages/data-ai/skills/build-spec/SKILL.md @@ -0,0 +1,23 @@ +--- +name: build-spec +description: Build a feature's spec/ — the State, its actions and derivations, and their cases. Test tier. +input: feature +output: feature +--- + +Create the feature's `spec/` folder, the specification the ECS will be conformed to: + +- `state.ts` — `State` built from `data/` types (singletons named for resources, + `ReadonlyMap` entity collections), pinned with `Conformance.StateMatches`; `create.ts`, + `samples.ts`, `public.ts`. +- One file per **action** (each operation the app performs) and per **derivation** + (each derived value it shows), each with its inert `*.cases.ts`. A real-time feature + adds `systems.ts`: one pure function per ECS system it will have, named the same, + each with cases, plus `noOp` states every system leaves unchanged. +- `transforms.ts` (the barrel of every action and derivation), `spec.ts` + (`Conformance.spec`) and `spec.test.ts` (`Conformance.checkSpec`). + +Build one action or derivation plus its cases at a time. Logic the ECS will share goes +into `data/values/` helpers, never here. Gate: `checkSpec` passes. + +The how is in the auto-loading `features/spec/index.md` rule. diff --git a/packages/data-ai/skills/build-systems/SKILL.md b/packages/data-ai/skills/build-systems/SKILL.md index 7ea4867c4..902d86714 100644 --- a/packages/data-ai/skills/build-systems/SKILL.md +++ b/packages/data-ai/skills/build-systems/SKILL.md @@ -1,27 +1,21 @@ --- name: build-systems -description: Build a feature's system-database — the real-time tick loop (systems the scheduler runs each frame). For real-time features only. +description: Build a real-time feature's ecs/systems layer — the per-frame tick loop. Optional. input: feature output: feature --- -Skip if this feature doesn't contain or require systems. -Most games that use a canvas will use systems but most applications will not. +Skip unless the feature is real-time (games, simulations). -Create `services/main-service/system-database/system-database.ts`: `Database.Plugin.create({ extends: -Database.Plugin.combine(.plugin, scheduler), systems })` — combine `scheduler` with the -feature's **current top** main-service layer (`ActionDatabase` / `ServiceDatabase` / `ComputedDatabase`, -whichever it built), never a hardcoded `ComputedDatabase`, so systems *and* any services/actions -compose into the one `MainService`. The `systems` -map is declared **inline** (see `features/services/main-service/systems.md` — inline is required for `db` to be -typed and for system-name inference; a `systems/` folder is optional, only for extracted -per-frame body helpers). +Create `ecs/systems/system-database.ts`: `Database.Plugin.create({ extends: +Database.Plugin.combine(.plugin, scheduler), systems: { … } })`, with the +`systems` map declared **inline** so `db` and the system names are inferred. Hot +per-row work writes columns in place; discrete events dispatch transactions. Order +systems under `schedule`; it is the only statement of order. -- A system is a `SystemDeclaration`: `{ create, schedule?: { before?, after?, during? } }`. - `create(db)` runs once (capture queries / index handles / closures); the returned function - advances the world one tick. Return `void` for an init-only system (seeding entities). -- Order under `schedule` (mirror the `data/` step's internal sequence); drive the loop by - combining `scheduler` (rAF), gated by the `schedulerState` resource. +Add `frame: { args, unmodelled?, cases? }` to the implementation: a writer per frame +arg, each system the spec can't model (wasm, init-only) with its reason, and +whole-frame cases in `ecs/conformance/frame.cases.ts`. Gate: every +system's own cases and the frame cases pass. -**Only for real-time features** (games, sims) — turn-based features skip this phase entirely. -Comes after `build-computed`. The how is in the auto-loading `features/services/main-service/systems.md` rule. +The how is in the auto-loading `features/ecs/systems.md` rule. diff --git a/packages/data-ai/skills/build-transactions/SKILL.md b/packages/data-ai/skills/build-transactions/SKILL.md index e9b094557..e7248ce32 100644 --- a/packages/data-ai/skills/build-transactions/SKILL.md +++ b/packages/data-ai/skills/build-transactions/SKILL.md @@ -1,16 +1,16 @@ --- name: build-transactions -description: Build a feature's transaction-database — atomic mutations over the store. +description: Build a feature's ecs/transactions layer. input: feature output: feature --- -Create `services/main-service/transaction-database/`: `transaction-database.ts` (extends the -preceding layer — `IndexDatabase` if the feature built indexes, else `CoreDatabase` — adds -`transactions` from `./transactions/index.js`) plus a `transactions/` folder — one mutation per -file + barrel. Type the store param `CoreDatabase.Store` (entities/resources/archetypes) or -`IndexDatabase.Store` (reads an index). +Create `ecs/transactions/`: `transaction-database.ts` (extends the previous layer, adds +`transactions` from `./index.js`), one mutation per file, and a barrel. Type the store +`CoreDatabase.Store`, or `IndexDatabase.Store` once it reads an index. Guard by entity +kind, and apply `data/values/` helpers; never import `spec/`. -Comes after `index-database` (or `core-database` if no indexes). +Build the transactions the spec actions will need. They are conformed through the +actions that call them, so their gate is `build-actions`; add any missing one there. -The how is in the auto-loading `features/services/main-service/transactions.md` rule. +The how is in the auto-loading `features/ecs/transactions.md` rule. diff --git a/packages/data-ai/skills/build-ui/SKILL.md b/packages/data-ai/skills/build-ui/SKILL.md index e177e5c99..ef2f2eb57 100644 --- a/packages/data-ai/skills/build-ui/SKILL.md +++ b/packages/data-ai/skills/build-ui/SKILL.md @@ -1,13 +1,14 @@ --- name: build-ui -description: Build a feature's ui/ layer — presentation bound to the main-service database. The final, top layer. +description: Build a feature's ui/ layer. input: feature output: feature --- Create `ui/`: one folder per UI unit — `/{.ts (lazy wrapper), -element.ts (container), -presentation.ts (pure render), .css.ts}`. -The element subscribes to state and delegates to the pure presentation; no business logic here. +The element subscribes to `MainService` state and calls `service.actions.*`; the +presentation imports only `data/` and sibling `ui/` lazy wrappers. No business logic here. -Comes last. The how is in the auto-loading `features/ui/index.md` rule (and `element.md`, -`lazy-element.md`, `presentation.md`). +Comes last. Gate: presentation tests. The how is in the auto-loading +`features/ui/index.md` rule (and `element.md`, `lazy-element.md`, `presentation.md`). diff --git a/packages/data-ai/skills/review/SKILL.md b/packages/data-ai/skills/review/SKILL.md index 3c45439e8..2e288d84c 100644 --- a/packages/data-ai/skills/review/SKILL.md +++ b/packages/data-ai/skills/review/SKILL.md @@ -11,9 +11,9 @@ rules (each layer's rule and the cross-cutting rules — `namespace`, `data-mode `type-casts`, `cohesion`, …): - **Correctness** — does it typecheck and follow the layer's required shape? -- **Adherence** — single public export per file, naming, the scope helpers - (`Database.components` / `resources` / `archetypes`), `satisfies` targets, barrels, - store-layer typing, etc. +- **Adherence** — single public export per file, naming, per-file scope flags + (`Scope`), `satisfies` targets, barrels, the layer dependency order (no runtime + import of `spec/`), store-layer typing, etc. - **Generality** — no leaked type identities; follows the cross-cutting patterns. Report either **OPTIMAL**, or a short list of issues. Tag each issue with where the diff --git a/packages/data-gpu-hopper/index.html b/packages/data-gpu-hopper/index.html index 7f0fe0936..ec36c06c3 100644 --- a/packages/data-gpu-hopper/index.html +++ b/packages/data-gpu-hopper/index.html @@ -11,6 +11,6 @@

- + diff --git a/packages/data-gpu-hopper/src/app/main.ts b/packages/data-gpu-hopper/src/app/main.ts new file mode 100644 index 000000000..8ba6d23b6 --- /dev/null +++ b/packages/data-gpu-hopper/src/app/main.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { render } from "lit"; +import { Hopper } from "../features/main/ui/hopper-app/hopper-app.js"; + +// Mount the top-level element through its lazy wrapper (no side-effect import). +const app = document.getElementById("app"); +if (app) render(Hopper(), app); diff --git a/packages/data-gpu-hopper/src/features/main/data/color/color.ts b/packages/data-gpu-hopper/src/features/main/data/color/color.ts deleted file mode 100644 index e497c3dba..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/color/color.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type Color = Schema.ToType; -export * as Color from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/color/schema.ts b/packages/data-gpu-hopper/src/features/main/data/color/schema.ts deleted file mode 100644 index be518f831..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/color/schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -// An RGBA colour, each channel 0..1. The array form `[r, g, b, a]` is what -// data-gpu's colour materials consume directly. -export const schema = { - type: "array", - items: { type: "number" }, - minItems: 4, - maxItems: 4, - default: [1, 1, 1, 1], -} as const satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/components/index.ts b/packages/data-gpu-hopper/src/features/main/data/components/index.ts new file mode 100644 index 000000000..ac6bc8b87 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/components/index.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { kind } from "./kind.js"; +import { lane } from "./lane.js"; +import { x } from "./x.js"; +import { width } from "./width.js"; +import { velocity } from "./velocity.js"; + +export const components = { kind, lane, x, width, velocity }; diff --git a/packages/data-gpu-hopper/src/features/main/data/components/kind.ts b/packages/data-gpu-hopper/src/features/main/data/components/kind.ts new file mode 100644 index 000000000..eed35fecd --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/components/kind.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { Schema } from "@adobe/data/schema"; +import { HazardKind } from "../values/hazard-kind/hazard-kind.js"; + +// Which hazard an entity is. Every hopper column is session scope: a local arcade run. +export const kind = { ...HazardKind.schema, ...Scope.session } satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/components/lane.ts b/packages/data-gpu-hopper/src/features/main/data/components/lane.ts new file mode 100644 index 000000000..b1d6ea42b --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/components/lane.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { Schema } from "@adobe/data/schema"; + +// The board row a hazard moves along. +export const lane = { type: "integer", minimum: 0, ...Scope.session } as const satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/components/velocity.ts b/packages/data-gpu-hopper/src/features/main/data/components/velocity.ts new file mode 100644 index 000000000..89c2c8f9f --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/components/velocity.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import { F32, type Schema } from "@adobe/data/schema"; + +// A hazard's signed speed in cells/second. +export const velocity = { ...F32.schema, ...Scope.session } satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/components/width.ts b/packages/data-gpu-hopper/src/features/main/data/components/width.ts new file mode 100644 index 000000000..1b86313a3 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/components/width.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import { F32, type Schema } from "@adobe/data/schema"; + +// A hazard's width in cells. +export const width = { ...F32.schema, ...Scope.session } satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/components/x.ts b/packages/data-gpu-hopper/src/features/main/data/components/x.ts new file mode 100644 index 000000000..c7545b956 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/components/x.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import { F32, type Schema } from "@adobe/data/schema"; + +// A hazard's left edge, a continuous column. +export const x = { ...F32.schema, ...Scope.session } satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/direction/direction.ts b/packages/data-gpu-hopper/src/features/main/data/direction/direction.ts deleted file mode 100644 index 267d91050..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/direction/direction.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type Direction = Schema.ToType; -export * as Direction from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/direction/schema.ts b/packages/data-gpu-hopper/src/features/main/data/direction/schema.ts deleted file mode 100644 index 774c0a4ae..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/direction/schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -// A single hop input. `ui/` maps WASD / arrow keys onto these members. -export const schema = { - type: "string", - enum: ["up", "down", "left", "right"], - default: "up", -} as const satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/entities/hazard.ts b/packages/data-gpu-hopper/src/features/main/data/entities/hazard.ts new file mode 100644 index 000000000..0f5f93424 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/entities/hazard.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Database } from "@adobe/data/ecs"; +import type { components } from "../components/index.js"; + +// A moving obstacle on one lane: plain data with no id (identity is the entity key). +// A session entity: never saved, never shared. +export const Hazard = ["nonPersistent", "nonShared", "kind", "lane", "x", "width", "velocity"] as const satisfies Database.EntityComponents; +export type Hazard = Database.EntityType; diff --git a/packages/data-gpu-hopper/src/features/main/data/entities/index.ts b/packages/data-gpu-hopper/src/features/main/data/entities/index.ts new file mode 100644 index 000000000..5bd5d8955 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/entities/index.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Hazard } from "./hazard.js"; + +export const entities = { Hazard }; diff --git a/packages/data-gpu-hopper/src/features/main/data/frog/frog.ts b/packages/data-gpu-hopper/src/features/main/data/frog/frog.ts deleted file mode 100644 index 04c2237dd..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/frog/frog.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type Frog = Schema.ToType; -export * as Frog from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/frog/public.ts b/packages/data-gpu-hopper/src/features/main/data/frog/public.ts deleted file mode 100644 index 762a059cf..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/frog/public.ts +++ /dev/null @@ -1,3 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { schema } from "./schema.js"; -export { frogColor } from "./frog-color.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/frog/schema.ts b/packages/data-gpu-hopper/src/features/main/data/frog/schema.ts deleted file mode 100644 index e8aaaad27..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/frog/schema.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -// The frog's continuous world position: `x` is the (fractional) column, `y` the -// row. Hops snap `x` to the grid; riding a log moves `x` smoothly. -export const schema = { - type: "object", - properties: { - x: { type: "number" }, - y: { type: "number" }, - }, - required: ["x", "y"], - additionalProperties: false, -} as const satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/game-status/game-status.ts b/packages/data-gpu-hopper/src/features/main/data/game-status/game-status.ts deleted file mode 100644 index c25982593..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/game-status/game-status.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type GameStatus = Schema.ToType; -export * as GameStatus from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/game-status/schema.ts b/packages/data-gpu-hopper/src/features/main/data/game-status/schema.ts deleted file mode 100644 index ad5d05a4d..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/game-status/schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -// Whether the game is in play, cleared, or over (all lives spent). -export const schema = { - type: "string", - enum: ["playing", "won", "gameOver"], - default: "playing", -} as const satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard-kind/hazard-kind.ts b/packages/data-gpu-hopper/src/features/main/data/hazard-kind/hazard-kind.ts deleted file mode 100644 index 62daf72b2..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/hazard-kind/hazard-kind.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type HazardKind = Schema.ToType; -export * as HazardKind from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard-kind/schema.ts b/packages/data-gpu-hopper/src/features/main/data/hazard-kind/schema.ts deleted file mode 100644 index e0ed57dc3..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/hazard-kind/schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -// The two moving hazards: a `car` (kills on contact) and a `log` (rideable). -export const schema = { - type: "string", - enum: ["car", "log"], - default: "car", -} as const satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard/advance.test.ts b/packages/data-gpu-hopper/src/features/main/data/hazard/advance.test.ts deleted file mode 100644 index 01ac86bde..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/advance.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { Hazard } from "./hazard.js"; - -const car: Hazard = { kind: "car", lane: 1, x: 3, width: 1, velocity: 2 }; - -describe("Hazard.advance", () => { - it("moves the left edge by velocity * dt", () => { - expect(Hazard.advance(car, 1, 9).x).toBe(5); - }); - it("wraps a positive overshoot around the board width", () => { - expect(Hazard.advance({ ...car, x: 8 }, 1, 9).x).toBe(1); - }); - it("wraps a negative velocity back around the right edge", () => { - expect(Hazard.advance({ ...car, x: 0, velocity: -1 }, 1, 9).x).toBe(8); - }); - it("leaves every other field untouched", () => { - expect(Hazard.advance(car, 1, 9)).toMatchObject({ kind: "car", lane: 1, width: 1, velocity: 2 }); - }); -}); diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard/advance.ts b/packages/data-gpu-hopper/src/features/main/data/hazard/advance.ts deleted file mode 100644 index 6b54e90c3..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/advance.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Hazard } from "./hazard.js"; -import { nextX } from "./next-x.js"; - -// Move the hazard by `velocity * dt`, wrapping its left edge around -// [0, boardWidth) so lanes scroll endlessly. -export const advance = (hazard: Hazard, dt: number, boardWidth: number): Hazard => ({ - ...hazard, - x: nextX(hazard.x, hazard.velocity, dt, boardWidth), -}); diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard/covers-at.test.ts b/packages/data-gpu-hopper/src/features/main/data/hazard/covers-at.test.ts deleted file mode 100644 index 1f1a1d73a..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/covers-at.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { Hazard } from "./hazard.js"; - -describe("Hazard.coversAt", () => { - it("includes the left edge", () => { - expect(Hazard.coversAt(2, 3, 2)).toBe(true); - }); - it("includes a point inside the span", () => { - expect(Hazard.coversAt(2, 3, 4.9)).toBe(true); - }); - it("excludes the right edge (half-open span)", () => { - expect(Hazard.coversAt(2, 3, 5)).toBe(false); - }); - it("excludes a point left of the span", () => { - expect(Hazard.coversAt(2, 3, 1.9)).toBe(false); - }); -}); diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard/covers-at.ts b/packages/data-gpu-hopper/src/features/main/data/hazard/covers-at.ts deleted file mode 100644 index 91b19462c..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/covers-at.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -// Whether `point` lies within the half-open span [hazardX, hazardX + width). The -// primitive shared by `covers` (the data spec) and the ecs movement system's -// carrier scan (which reads columns directly), so the coverage rule is defined -// once. -export const coversAt = (hazardX: number, hazardWidth: number, point: number): boolean => - point >= hazardX && point < hazardX + hazardWidth; diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard/covers.test.ts b/packages/data-gpu-hopper/src/features/main/data/hazard/covers.test.ts deleted file mode 100644 index 6cace97dd..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/covers.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { Hazard } from "./hazard.js"; - -const log: Hazard = { kind: "log", lane: 5, x: 2, width: 3, velocity: 1 }; - -describe("Hazard.covers", () => { - it("includes the left edge", () => { - expect(Hazard.covers(log, 2)).toBe(true); - }); - it("includes a point inside the span", () => { - expect(Hazard.covers(log, 4.5)).toBe(true); - }); - it("excludes the right edge (half-open span)", () => { - expect(Hazard.covers(log, 5)).toBe(false); - }); - it("excludes a point left of the span", () => { - expect(Hazard.covers(log, 1.9)).toBe(false); - }); -}); diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard/covers.ts b/packages/data-gpu-hopper/src/features/main/data/hazard/covers.ts deleted file mode 100644 index 8a6d1b642..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/covers.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Hazard } from "./hazard.js"; -import { coversAt } from "./covers-at.js"; - -// Whether the continuous column `x` lies within this hazard's span [x, x+width). -export const covers = (hazard: Hazard, x: number): boolean => - coversAt(hazard.x, hazard.width, x); diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard/hazard.ts b/packages/data-gpu-hopper/src/features/main/data/hazard/hazard.ts deleted file mode 100644 index 2135dbf44..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/hazard.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type Hazard = Schema.ToType; -export * as Hazard from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard/is.ts b/packages/data-gpu-hopper/src/features/main/data/hazard/is.ts deleted file mode 100644 index ec6fabe19..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/is.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Hazard } from "./hazard.js"; - -// A structural type guard, re-exported so it reads as `Hazard.is`. Discriminates a -// hazard entity value by the presence and primitive shape of its own components — -// no id, no tag (identity is the `State.entities` map key). -export const is = (v: unknown): v is Hazard => - typeof v === "object" && - v !== null && - "kind" in v && - typeof v.kind === "string" && - "lane" in v && - typeof v.lane === "number" && - "x" in v && - typeof v.x === "number" && - "width" in v && - typeof v.width === "number" && - "velocity" in v && - typeof v.velocity === "number"; diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard/next-x.test.ts b/packages/data-gpu-hopper/src/features/main/data/hazard/next-x.test.ts deleted file mode 100644 index ce0d2a3f8..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/next-x.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { Hazard } from "./hazard.js"; - -describe("Hazard.nextX", () => { - it("moves by velocity * dt", () => { - expect(Hazard.nextX(3, 2, 1, 9)).toBe(5); - }); - it("wraps a positive overshoot around the board width", () => { - expect(Hazard.nextX(8, 1, 1, 9)).toBe(0); - }); - it("wraps a negative velocity back around the right edge", () => { - expect(Hazard.nextX(0, -1, 1, 9)).toBe(8); - }); - it("is stationary at dt 0", () => { - expect(Hazard.nextX(3.5, 4, 0, 9)).toBe(3.5); - }); -}); diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard/next-x.ts b/packages/data-gpu-hopper/src/features/main/data/hazard/next-x.ts deleted file mode 100644 index 63f07564b..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/next-x.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -// The wrapped left-edge position of a hazard after moving `velocity * dt`, kept -// within [0, boardWidth). The primitive shared by `advance` (the data spec) and -// the ecs movement system (which writes columns directly, with no per-row -// allocation) — so the two can't drift. -export const nextX = (x: number, velocity: number, dt: number, boardWidth: number): number => { - const raw = x + velocity * dt; - return ((raw % boardWidth) + boardWidth) % boardWidth; -}; diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard/public.ts b/packages/data-gpu-hopper/src/features/main/data/hazard/public.ts deleted file mode 100644 index 733151524..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/public.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { schema } from "./schema.js"; -export { is } from "./is.js"; -export { covers } from "./covers.js"; -export { coversAt } from "./covers-at.js"; -export { advance } from "./advance.js"; -export { nextX } from "./next-x.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard/schema.ts b/packages/data-gpu-hopper/src/features/main/data/hazard/schema.ts deleted file mode 100644 index 404b7c8b9..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/schema.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { HazardKind } from "../hazard-kind/hazard-kind.js"; - -// A moving obstacle occupying one lane (row): its left edge `x` (continuous -// column), `width` in cells, and signed `velocity` in cells/second. Identity is -// the ECS entity itself, so there is no logical id field to mirror. -export const schema = { - type: "object", - properties: { - kind: HazardKind.schema, - lane: { type: "integer", minimum: 0 }, - x: { type: "number" }, - width: { type: "number", minimum: 0 }, - velocity: { type: "number" }, - }, - required: ["kind", "lane", "x", "width", "velocity"], - additionalProperties: false, -} as const satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/lane-kind/lane-kind.ts b/packages/data-gpu-hopper/src/features/main/data/lane-kind/lane-kind.ts deleted file mode 100644 index a15c8bad1..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/lane-kind/lane-kind.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type LaneKind = Schema.ToType; -export * as LaneKind from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/lane-kind/public.ts b/packages/data-gpu-hopper/src/features/main/data/lane-kind/public.ts deleted file mode 100644 index 6b9fcac3c..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/lane-kind/public.ts +++ /dev/null @@ -1,5 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { schema } from "./schema.js"; -export { laneColor } from "./lane-color.js"; -export { emptyOutcome } from "./empty-outcome.js"; -export { coveredOutcome } from "./covered-outcome.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/lane-kind/schema.ts b/packages/data-gpu-hopper/src/features/main/data/lane-kind/schema.ts deleted file mode 100644 index 85e43b4a3..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/lane-kind/schema.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -// The terrain type of a board row: -// grass — solid, always safe (start row and the median) -// road — cars cross it; touching one is fatal -// river — open water; fatal unless riding a log -// goal — the top row the frog is trying to reach -export const schema = { - type: "string", - enum: ["grass", "road", "river", "goal"], - default: "grass", -} as const satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/lane/lane.ts b/packages/data-gpu-hopper/src/features/main/data/lane/lane.ts deleted file mode 100644 index cda2d25c2..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/lane/lane.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type Lane = Schema.ToType; -export * as Lane from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/lane/schema.ts b/packages/data-gpu-hopper/src/features/main/data/lane/schema.ts deleted file mode 100644 index 60a9db184..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/lane/schema.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { LaneKind } from "../lane-kind/lane-kind.js"; - -// One board row: which row index it is, and its terrain type. -export const schema = { - type: "object", - properties: { - row: { type: "integer", minimum: 0 }, - kind: LaneKind.schema, - }, - required: ["row", "kind"], - additionalProperties: false, -} as const satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/outcome/is-fatal.ts b/packages/data-gpu-hopper/src/features/main/data/outcome/is-fatal.ts deleted file mode 100644 index d8634109b..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/outcome/is-fatal.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Outcome } from "./outcome.js"; - -// Which outcomes cost the frog a life. The step transform consumes this so it -// need not re-enumerate the fatal members. -export const isFatal: Record = { - safe: false, - ride: false, - collide: true, - drown: true, - win: false, -}; diff --git a/packages/data-gpu-hopper/src/features/main/data/outcome/outcome.ts b/packages/data-gpu-hopper/src/features/main/data/outcome/outcome.ts deleted file mode 100644 index 63a48b4c6..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/outcome/outcome.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type Outcome = Schema.ToType; -export * as Outcome from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/outcome/schema.ts b/packages/data-gpu-hopper/src/features/main/data/outcome/schema.ts deleted file mode 100644 index b0e7ff54f..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/outcome/schema.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -// The frog's fate on a given board this frame: -// safe — standing on solid terrain (grass / goal / a road tile with no car) -// ride — carried along by the log it is standing on -// collide — touched a car -// drown — over open water with no log (or carried off the board edge) -// win — reached the goal row -export const schema = { - type: "string", - enum: ["safe", "ride", "collide", "drown", "win"], - default: "safe", -} as const satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/frame-delta.ts b/packages/data-gpu-hopper/src/features/main/data/resources/frame-delta.ts new file mode 100644 index 000000000..637bcc15b --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/resources/frame-delta.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; + +// Seconds the next tick advances. The render loop feeds it from the frame clock; headless +// hosts set it directly. Implementation-only (no spec analogue). +export const frameDelta = { type: "number", default: 1 / 60, ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/frog.ts b/packages/data-gpu-hopper/src/features/main/data/resources/frog.ts new file mode 100644 index 000000000..1ebccdeb5 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/resources/frog.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Frog } from "../values/frog/frog.js"; + +// The single frog, a singleton position. +export const frog = { ...Frog.schema, default: { x: 0, y: 0 }, ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/height.ts b/packages/data-gpu-hopper/src/features/main/data/resources/height.ts new file mode 100644 index 000000000..c1b46ce47 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/resources/height.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; + +// Board height in rows. +export const height = { type: "integer", minimum: 1, default: 9, ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/index.ts b/packages/data-gpu-hopper/src/features/main/data/resources/index.ts new file mode 100644 index 000000000..034d72af4 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/resources/index.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { width } from "./width.js"; +import { height } from "./height.js"; +import { lanes } from "./lanes.js"; +import { frog } from "./frog.js"; +import { lives } from "./lives.js"; +import { score } from "./score.js"; +import { status } from "./status.js"; +import { frameDelta } from "./frame-delta.js"; + +export const resources = { width, height, lanes, frog, lives, score, status, frameDelta }; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/lanes.ts b/packages/data-gpu-hopper/src/features/main/data/resources/lanes.ts new file mode 100644 index 000000000..bea15b81f --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/resources/lanes.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Lane } from "../values/lane/lane.js"; + +// The static terrain: one Lane per row. +export const lanes = { type: "array", items: Lane.schema, default: [], ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/lives.ts b/packages/data-gpu-hopper/src/features/main/data/resources/lives.ts new file mode 100644 index 000000000..dff215368 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/resources/lives.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; + +export const lives = { type: "integer", minimum: 0, default: 3, ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/score.ts b/packages/data-gpu-hopper/src/features/main/data/resources/score.ts new file mode 100644 index 000000000..71bcf7288 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/resources/score.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; + +export const score = { type: "integer", minimum: 0, default: 0, ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/status.ts b/packages/data-gpu-hopper/src/features/main/data/resources/status.ts new file mode 100644 index 000000000..f7e76674b --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/resources/status.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { GameStatus } from "../values/game-status/game-status.js"; + +export const status = { ...GameStatus.schema, ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/width.ts b/packages/data-gpu-hopper/src/features/main/data/resources/width.ts new file mode 100644 index 000000000..22aed284e --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/resources/width.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; + +// Board width in columns. Every hopper resource is session scope. +export const width = { type: "integer", minimum: 1, default: 9, ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/create.test.ts b/packages/data-gpu-hopper/src/features/main/data/state/create.test.ts deleted file mode 100644 index 62d5b4e33..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/create.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { State } from "./state.js"; -import { LaneKind } from "../lane-kind/lane-kind.js"; - -describe("State.create", () => { - const state = State.create(); - - it("starts in play with three lives and no score", () => { - expect(state.status).toBe("playing"); - expect(state.lives).toBe(3); - expect(state.score).toBe(0); - }); - - it("spawns the frog centred on the bottom row", () => { - expect(state.frog).toEqual(State.startPosition(state)); - expect(state.frog.y).toBe(0); - }); - - it("has a lane for every row of the board", () => { - for (let row = 0; row < state.height; row++) { - expect(State.laneAt(state, row)).toBeDefined(); - } - }); - - it("places cars only on roads and logs only on rivers", () => { - for (const hazard of state.entities.values()) { - const kind = State.laneAt(state, hazard.lane)?.kind; - const expected = hazard.kind === "car" ? "road" : "river"; - expect(kind).toBe(expected); - } - }); - - it("gives every moving lane at least one hazard", () => { - const carrying = new Set(["road", "river"]); - for (const lane of state.lanes) { - if (!carrying.has(lane.kind)) continue; - const populated = [...state.entities.values()].some((hazard) => hazard.lane === lane.row); - expect(populated, `lane ${lane.row} (${lane.kind}) has no hazard`).toBe(true); - } - }); - - it("colours every terrain kind used by the board", () => { - for (const lane of state.lanes) { - expect(LaneKind.laneColor[lane.kind]).toHaveLength(4); - } - }); -}); diff --git a/packages/data-gpu-hopper/src/features/main/data/state/create.ts b/packages/data-gpu-hopper/src/features/main/data/state/create.ts deleted file mode 100644 index c972c88f7..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/create.ts +++ /dev/null @@ -1,55 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; -import type { Lane } from "../lane/lane.js"; -import type { Hazard } from "../hazard/hazard.js"; -import { startPosition } from "./start-position.js"; - -const width = 9; -const height = 9; - -// Terrain per row, bottom (0, the start) to top (8, the goal): a grass start, -// three car lanes, a grass median, three log lanes, then the goal. -const lanes: readonly Lane[] = [ - { row: 0, kind: "grass" }, - { row: 1, kind: "road" }, - { row: 2, kind: "road" }, - { row: 3, kind: "road" }, - { row: 4, kind: "grass" }, - { row: 5, kind: "river" }, - { row: 6, kind: "river" }, - { row: 7, kind: "river" }, - { row: 8, kind: "goal" }, -]; - -// Two hazards per moving lane, evenly spaced, with direction and speed varying -// by lane. Cars are one cell wide; logs are wider so the frog can ride them. -const hazardList: readonly Hazard[] = [ - { kind: "car", lane: 1, x: 0, width: 1, velocity: 1.5 }, - { kind: "car", lane: 1, x: 5, width: 1, velocity: 1.5 }, - { kind: "car", lane: 2, x: 2, width: 1, velocity: -2 }, - { kind: "car", lane: 2, x: 7, width: 1, velocity: -2 }, - { kind: "car", lane: 3, x: 1, width: 1, velocity: 2.5 }, - { kind: "car", lane: 3, x: 6, width: 1, velocity: 2.5 }, - { kind: "log", lane: 5, x: 0, width: 3, velocity: 1.5 }, - { kind: "log", lane: 5, x: 5, width: 3, velocity: 1.5 }, - { kind: "log", lane: 6, x: 2, width: 3, velocity: -1 }, - { kind: "log", lane: 6, x: 7, width: 2, velocity: -1 }, - { kind: "log", lane: 7, x: 1, width: 3, velocity: 2 }, - { kind: "log", lane: 7, x: 6, width: 3, velocity: 2 }, -]; - -// Each hazard is a per-entity ECS entity; the spec mints its own numeric ids (the -// map keys). The values are id-less — identity is the key. -const entities: ReadonlyMap = new Map(hazardList.map((hazard, index) => [index + 1, hazard])); - -// The initial, full game state: frog at the bottom, three lives, nothing scored. -export const create = (): State => ({ - width, - height, - lanes, - entities, - frog: startPosition({ width }), - lives: 3, - score: 0, - status: "playing", -}); diff --git a/packages/data-gpu-hopper/src/features/main/data/state/frog-outcome.ts b/packages/data-gpu-hopper/src/features/main/data/state/frog-outcome.ts deleted file mode 100644 index edec736d4..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/frog-outcome.ts +++ /dev/null @@ -1,26 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { LaneKind } from "../lane-kind/lane-kind.js"; -import { Hazard } from "../hazard/hazard.js"; -import type { Outcome } from "../outcome/outcome.js"; -import type { State } from "./state.js"; -import { laneAt } from "./lane-at.js"; - -// The frog's fate given the current board. Terrain semantics (safe / collide / -// drown / ride / win) live on LaneKind as empty-vs-covered descriptors; this -// composes them with hazard coverage and an on-board test. A frog carried off -// the board edge is no longer "covered" by any hazard, so open water drowns it. -export const frogOutcome = < - T extends Pick, ->( - state: T, -): Outcome => { - const lane = laneAt(state, state.frog.y); - if (!lane) return "safe"; - const onBoard = state.frog.x >= 0 && state.frog.x <= state.width - 1; - const covered = - onBoard && - [...state.entities.values()].some( - (hazard) => Hazard.is(hazard) && hazard.lane === state.frog.y && Hazard.covers(hazard, state.frog.x), - ); - return covered ? LaneKind.coveredOutcome[lane.kind] : LaneKind.emptyOutcome[lane.kind]; -}; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/hop.ts b/packages/data-gpu-hopper/src/features/main/data/state/hop.ts deleted file mode 100644 index a20fda6d6..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/hop.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Direction } from "../direction/direction.js"; -import { GameStatus } from "../game-status/game-status.js"; -import type { State } from "./state.js"; - -const clamp = (value: number, max: number): number => Math.max(0, Math.min(max, value)); - -// Snap the frog one cell in `direction`, re-aligning a log-ridden fractional `x` -// back onto the grid and clamping to the board. A no-op unless the game is in -// play, keeping it idempotent. Writes only `frog`. -export const hop = ( - state: Pick, - { direction }: { readonly direction: Direction }, -): Pick => { - if (!GameStatus.isPlaying(state.status)) return { frog: state.frog }; - const { dx, dy } = Direction.delta[direction]; - return { - frog: { - x: clamp(Math.round(state.frog.x) + dx, state.width - 1), - y: clamp(state.frog.y + dy, state.height - 1), - }, - }; -}; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/lane-at.ts b/packages/data-gpu-hopper/src/features/main/data/state/lane-at.ts deleted file mode 100644 index 2bff7dd78..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/lane-at.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; -import type { Lane } from "../lane/lane.js"; - -// The lane (terrain) occupying `row`, if any. -export const laneAt = >(state: T, row: number): Lane | undefined => - state.lanes.find((lane) => lane.row === row); diff --git a/packages/data-gpu-hopper/src/features/main/data/state/lose-life.cases.ts b/packages/data-gpu-hopper/src/features/main/data/state/lose-life.cases.ts deleted file mode 100644 index 957bd21fb..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/lose-life.cases.ts +++ /dev/null @@ -1,30 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { loseLife } from "./lose-life.js"; - -// A 5-wide board, so the respawn column is `floor((5-1)/2) = 2`. loseLife reads -// only lives / status / frog / width; the rest is inert here. -const base: Omit = { - width: 5, - height: 3, - lanes: [], - entities: new Map(), - score: 0, -}; - -// Inert, spec-owned cases, shared with the ecs `loseLife` transaction. No args and -// no services. -export const cases: Conformance.SpecCases = { - cases: [ - { name: "spends a life and respawns the frog at the start", - before: { ...base, lives: 3, status: "playing", frog: { x: 1, y: 1 } }, - after: { lives: 2, status: "playing", frog: { x: 2, y: 0 } } }, - { name: "the last life ends the game without respawning", - before: { ...base, lives: 1, status: "playing", frog: { x: 3, y: 2 } }, - after: { lives: 0, status: "gameOver", frog: { x: 3, y: 2 } } }, - { name: "ignores a finished game (no-op)", - before: { ...base, lives: 0, status: "gameOver", frog: { x: 3, y: 2 } }, - after: { lives: 0, status: "gameOver", frog: { x: 3, y: 2 } } }, - ], -}; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts b/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts deleted file mode 100644 index 47972e465..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { GameStatus } from "../game-status/game-status.js"; -import type { State } from "./state.js"; -import { startPosition } from "./start-position.js"; - -// Spend one life: respawn the frog at the start, or end the game if that was the -// last life. A no-op once the game has finished. Writes only `lives` + `status` -// + `frog` (each branch supplies all three; some are unchanged). -export const loseLife = ( - state: Pick, -): Pick => { - if (!GameStatus.isPlaying(state.status)) - return { lives: state.lives, status: state.status, frog: state.frog }; - const lives = state.lives - 1; - return lives <= 0 - ? { lives: 0, status: "gameOver", frog: state.frog } - : { lives, status: state.status, frog: startPosition(state) }; -}; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/new-game.cases.ts b/packages/data-gpu-hopper/src/features/main/data/state/new-game.cases.ts deleted file mode 100644 index 02cd5bae1..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/new-game.cases.ts +++ /dev/null @@ -1,34 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { newGame } from "./new-game.js"; -import { create } from "./create.js"; - -// Inert, spec-owned cases, shared with the ecs `newGame` transaction. `before` is a -// fully-divergent mid-run state (dimensions, terrain, hazards, frog, lives, score, -// status all differ) so the reset is proven total. `after` is just the initial game -// (`create()`): its hazard-entity keys are plain spec-ids that conformance compares -// up to an id-bijection, so the ecs is free to mint its own. `newGame` takes no -// args (it ignores the prior state). -export const cases: Conformance.SpecCases = { - cases: [ - { - name: "resets a mid-game store to the initial game", - before: { - width: 3, - height: 3, - lanes: [ - { row: 0, kind: "grass" }, - { row: 1, kind: "river" }, - { row: 2, kind: "goal" }, - ], - entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 2, velocity: 1 }]]), - frog: { x: 1, y: 2 }, - lives: 0, - score: 7, - status: "gameOver", - }, - after: create(), - }, - ], -}; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/new-game.ts b/packages/data-gpu-hopper/src/features/main/data/state/new-game.ts deleted file mode 100644 index ec54e4a74..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/new-game.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; -import { create } from "./create.js"; - -// A "new game" TRANSITION: it deliberately **ignores** the prior `state` and -// produces the initial game (see `create`), which is exactly what the ecs -// `newGame` transaction it maps to does (it clears whatever was there). The prior -// state is still the first parameter so it fits the `(state, args) => state` shape -// the co-located conformance cases derive from. -export const newGame = (_state: State): State => create(); diff --git a/packages/data-gpu-hopper/src/features/main/data/state/public.ts b/packages/data-gpu-hopper/src/features/main/data/state/public.ts deleted file mode 100644 index 091178c5d..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/public.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { create } from "./create.js"; -export { samples } from "./samples.js"; -export { hop } from "./hop.js"; -export { step } from "./step.js"; -export { winGoal } from "./win-goal.js"; -export { loseLife } from "./lose-life.js"; -export { newGame } from "./new-game.js"; -export { startPosition } from "./start-position.js"; -export { laneAt } from "./lane-at.js"; -export { frogOutcome } from "./frog-outcome.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/samples.ts b/packages/data-gpu-hopper/src/features/main/data/state/samples.ts deleted file mode 100644 index dfc19daa9..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/samples.ts +++ /dev/null @@ -1,47 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; -import { create } from "./create.js"; - -// The projection round-trip (toState ∘ fromState ≡ identity) compares a sample -// against the store the ecs re-materialises, which mints its own entity ids — so a -// sample's hazard-entity keys are plain spec-ids that conformance compares up to an -// id-bijection (see conformance.md). -const initial = create(); - -// Representative full states for the projection round-trip. The initial game, a -// mid-run state with a fractional log-carried frog and depleted lives, and a -// minimal empty board — together exercising the whole ecs↔State map (resources, -// the frog entity, and the hazard entities). -export const samples: readonly State[] = [ - initial, - { - width: 5, - height: 3, - lanes: [ - { row: 0, kind: "grass" }, - { row: 1, kind: "river" }, - { row: 2, kind: "goal" }, - ], - entities: new Map([ - [1, { kind: "log", lane: 1, x: 1.5, width: 3, velocity: 1 }], - [2, { kind: "log", lane: 1, x: 4, width: 2, velocity: 1 }], - ]), - frog: { x: 2.5, y: 1 }, - lives: 1, - score: 4, - status: "playing", - }, - { - width: 4, - height: 2, - lanes: [ - { row: 0, kind: "grass" }, - { row: 1, kind: "goal" }, - ], - entities: new Map(), - frog: { x: 1, y: 0 }, - lives: 3, - score: 0, - status: "playing", - }, -]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/spec.test.ts b/packages/data-gpu-hopper/src/features/main/data/state/spec.test.ts deleted file mode 100644 index 24f6f691a..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/spec.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "./spec.js"; - -// The single pure-spec test for every transform in this folder. It runs the -// manifest's inert cases against the pure functions, asserting `after` (up to an -// id-bijection). The hazard bag is a `ReadonlyMap`, so the comparator matches it -// order-independently. -Conformance.checkSpec(spec); diff --git a/packages/data-gpu-hopper/src/features/main/data/state/spec.ts b/packages/data-gpu-hopper/src/features/main/data/state/spec.ts deleted file mode 100644 index 5bf728588..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import * as transforms from "./transforms.js"; -import { MainService } from "../../services/main-service/main-service.js"; -import { projection } from "../../services/main-service/conformance/projection.js"; - -import { cases as hop } from "./hop.cases.js"; -import { cases as step } from "./step.cases.js"; -import { cases as winGoal } from "./win-goal.cases.js"; -import { cases as loseLife } from "./lose-life.cases.js"; -import { cases as newGame } from "./new-game.cases.js"; - -// The feature's conformance manifest — the single object `spec.test.ts` (pure) and -// `conformance.test.ts` (ecs) both import. It wires each conformed fn to its inert cases module explicitly: `cases` names one module per conformed fn, -// checked at COMPILE TIME against `transforms` (a fn without cases, or cases without -// a fn, won't type). Hopper has no derivations (so no `computedPlugin`, no `hydrate`) -// and no capability services (so no `services`). `step` has no ecs transaction — the -// per-frame system loop conforms it — so `checkFeature` skips it while `checkSpec` -// verifies it against the pure function. -// -// This is a test-tier module (excluded from the runtime program) — the ONE place the -// feature touches `@adobe/data-testing`. No transform or `*.cases.ts` file does. -export const spec = Conformance.feature({ - state: State, - fns: transforms, - plugin: MainService.plugin, - projection, - cases: { - hop, - step, - winGoal, - loseLife, - newGame, - }, -}); diff --git a/packages/data-gpu-hopper/src/features/main/data/state/start-position.ts b/packages/data-gpu-hopper/src/features/main/data/state/start-position.ts deleted file mode 100644 index 393e18ecc..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/start-position.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; -import type { Frog } from "../frog/frog.js"; - -// Where the frog (re)spawns: centred on the bottom row. -export const startPosition = >(state: T): Frog => ({ - x: Math.floor((state.width - 1) / 2), - y: 0, -}); diff --git a/packages/data-gpu-hopper/src/features/main/data/state/state.ts b/packages/data-gpu-hopper/src/features/main/data/state/state.ts deleted file mode 100644 index 589d470a5..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/state.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Lane } from "../lane/lane.js"; -import type { Hazard } from "../hazard/hazard.js"; -import type { Frog } from "../frog/frog.js"; -import type { GameStatus } from "../game-status/game-status.js"; - -// The whole game modelled as one immutable value — the specification the ECS -// implementation is verified against. `width`/`height`/`frog`/`lives`/`score`/ -// `status` are singletons (ECS resources / the single frog entity); `lanes` is the -// fixed board terrain (an ECS resource, not per-entity). `entities` is the one -// identity-keyed map of the game's per-entity ECS entities — the moving hazards — -// keyed by a plain numeric id, the value id-less. -export type State = { - readonly width: number; - readonly height: number; - readonly lanes: readonly Lane[]; - readonly entities: ReadonlyMap; - readonly frog: Frog; - readonly lives: number; - readonly score: number; - readonly status: GameStatus; -}; -export * as State from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/step.cases.ts b/packages/data-gpu-hopper/src/features/main/data/state/step.cases.ts deleted file mode 100644 index 1da227bc3..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/step.cases.ts +++ /dev/null @@ -1,86 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { Lane } from "../lane/lane.js"; -import type { State } from "./state.js"; -import type { step } from "./step.js"; - -// Two 5-wide, 3-tall boards differing only in the middle lane's terrain. -const roadLanes: readonly Lane[] = [ - { row: 0, kind: "grass" }, - { row: 1, kind: "road" }, - { row: 2, kind: "goal" }, -]; -const riverLanes: readonly Lane[] = [ - { row: 0, kind: "grass" }, - { row: 1, kind: "river" }, - { row: 2, kind: "goal" }, -]; - -// Inert, spec-owned cases (args is `{ dt }`), shared with the ecs tick (the system -// loop conforms to this — see systems.md). Every step here uses dt = 1 so hazard/ -// carry displacements are exact. `before` overrides the default with a small test -// board; `after` is the writes patch (hazards / frog / score / status / lives). -// Covers: hazards scrolling while the frog stays safe, a car hit (life lost + -// respawn), the final-life game over, drowning over open water, riding a log, being -// carried off the board edge (with a wrapping log), reaching the goal, and the -// game-over no-op. -export const cases: Conformance.SpecCases = { - cases: [ - { name: "scrolls hazards while the frog rests on grass", - before: { width: 5, height: 3, lanes: roadLanes, - entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), - frog: { x: 2, y: 0 }, lives: 3, score: 0, status: "playing" }, - args: { dt: 1 }, - after: { entities: new Map([[1, { kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }]]), - frog: { x: 2, y: 0 }, lives: 3, score: 0, status: "playing" } }, - { name: "a car reaching the frog costs a life and respawns it", - before: { width: 5, height: 3, lanes: roadLanes, - entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), - frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, - args: { dt: 1 }, - after: { entities: new Map([[1, { kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }]]), - frog: { x: 2, y: 0 }, lives: 2, score: 0, status: "playing" } }, - { name: "a car hit on the last life ends the game", - before: { width: 5, height: 3, lanes: roadLanes, - entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), - frog: { x: 1, y: 1 }, lives: 1, score: 0, status: "playing" }, - args: { dt: 1 }, - after: { entities: new Map([[1, { kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }]]), - frog: { x: 1, y: 1 }, lives: 0, score: 0, status: "gameOver" } }, - { name: "open water with no log under the frog drowns it", - before: { width: 5, height: 3, lanes: riverLanes, - entities: new Map([[1, { kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }]]), - frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, - args: { dt: 1 }, - after: { entities: new Map([[1, { kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }]]), - frog: { x: 2, y: 0 }, lives: 2, score: 0, status: "playing" } }, - { name: "a log carries the frog along and keeps it safe", - before: { width: 5, height: 3, lanes: riverLanes, - entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 3, velocity: 1 }]]), - frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, - args: { dt: 1 }, - after: { entities: new Map([[1, { kind: "log", lane: 1, x: 1, width: 3, velocity: 1 }]]), - frog: { x: 2, y: 1 }, lives: 3, score: 0, status: "playing" } }, - { name: "a log carrying the frog past the edge drowns it", - before: { width: 5, height: 3, lanes: riverLanes, - entities: new Map([[1, { kind: "log", lane: 1, x: 3, width: 2, velocity: 2 }]]), - frog: { x: 4, y: 1 }, lives: 3, score: 0, status: "playing" }, - args: { dt: 1 }, - after: { entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 2, velocity: 2 }]]), - frog: { x: 2, y: 0 }, lives: 2, score: 0, status: "playing" } }, - { name: "reaching the goal scores and wins", - before: { width: 5, height: 3, lanes: roadLanes, - entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), - frog: { x: 2, y: 2 }, lives: 3, score: 0, status: "playing" }, - args: { dt: 1 }, - after: { entities: new Map([[1, { kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }]]), - frog: { x: 2, y: 2 }, lives: 3, score: 1, status: "won" } }, - { name: "does nothing once the game is over", - before: { width: 5, height: 3, lanes: roadLanes, - entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), - frog: { x: 2, y: 0 }, lives: 0, score: 0, status: "gameOver" }, - args: { dt: 1 }, - after: { entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), - frog: { x: 2, y: 0 }, lives: 0, score: 0, status: "gameOver" } }, - ], -}; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/step.ts b/packages/data-gpu-hopper/src/features/main/data/state/step.ts deleted file mode 100644 index d2dcdf7ea..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/step.ts +++ /dev/null @@ -1,69 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Hazard } from "../hazard/hazard.js"; -import { LaneKind } from "../lane-kind/lane-kind.js"; -import { Outcome } from "../outcome/outcome.js"; -import { GameStatus } from "../game-status/game-status.js"; -import type { State } from "./state.js"; -import { laneAt } from "./lane-at.js"; -import { frogOutcome } from "./frog-outcome.js"; -import { winGoal } from "./win-goal.js"; -import { loseLife } from "./lose-life.js"; - -// The slice `step` writes. Every branch supplies all five keys; a composer layers -// the outcome sub-patch (`winGoal` / `loseLife`) over the movement fields. -type StepPatch = Pick; - -// Advance the simulation by `dt` seconds: scroll the hazards, carry the frog if -// it is riding a log, then resolve its fate — score a win, or on a fatal outcome -// spend a life and respawn (or end the game once the last life is gone). A no-op -// once the game has ended, keeping it idempotent. -export const step = ( - state: Pick, - { dt }: { readonly dt: number }, -): StepPatch => { - if (!GameStatus.isPlaying(state.status)) { - return { - entities: state.entities, - frog: state.frog, - score: state.score, - status: state.status, - lives: state.lives, - }; - } - - // Advance every hazard, preserving its identity (the map key). Values are id-less. - const entities = new Map( - [...state.entities].map(([id, hazard]) => [id, Hazard.advance(hazard, dt, state.width)] as const), - ); - const lane = laneAt(state, state.frog.y); - - // Ride a log: on a carrying lane, the log the frog is standing on drags it - // along at the log's velocity. Determined from the pre-scroll positions — the - // log the frog was actually on this frame. - const carrier = - lane && LaneKind.coveredOutcome[lane.kind] === "ride" - ? [...state.entities.values()].find( - (hazard) => hazard.lane === state.frog.y && Hazard.covers(hazard, state.frog.x), - ) - : undefined; - const frog = carrier - ? { x: state.frog.x + carrier.velocity * dt, y: state.frog.y } - : state.frog; - - // The moved world (full read slice + updated entities/frog), fed to the outcome - // derivation and the winGoal / loseLife sub-transitions. - const moved = { ...state, entities, frog }; - // The movement-only patch the outcome sub-patch layers over. - const movedPatch: StepPatch = { - entities, - frog, - score: state.score, - status: state.status, - lives: state.lives, - }; - const outcome = frogOutcome(moved); - - if (outcome === "win") return { ...movedPatch, ...winGoal(moved) }; - if (Outcome.isFatal[outcome]) return { ...movedPatch, ...loseLife(moved) }; - return movedPatch; -}; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/transforms.ts b/packages/data-gpu-hopper/src/features/main/data/state/transforms.ts deleted file mode 100644 index 0c21f78b5..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/transforms.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -// The conformed functions of this feature — every pure transform the spec verifies, -// and nothing else (helpers like `create`, `samples`, `startPosition`, `laneAt`, and -// `frogOutcome` are deliberately absent; each keeps its own `*.test.ts`). Its keys -// ARE the conformance surface: the manifest's coverage guard (`Conformance.feature`) -// requires exactly one `*.cases.ts` module per key here, so adding a transform -// without cases — or cases without a transform — fails to compile. `step` has no ecs -// transaction (the per-frame system loop conforms it in -// `system-database/tick-loop.test.ts`), so `checkFeature` simply skips it; the pure -// `checkSpec` still verifies it here. -export { hop } from "./hop.js"; -export { step } from "./step.js"; -export { winGoal } from "./win-goal.js"; -export { loseLife } from "./lose-life.js"; -export { newGame } from "./new-game.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/win-goal.cases.ts b/packages/data-gpu-hopper/src/features/main/data/state/win-goal.cases.ts deleted file mode 100644 index 58023685d..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/win-goal.cases.ts +++ /dev/null @@ -1,25 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { winGoal } from "./win-goal.js"; - -// winGoal reads only score / status; the rest is inert here. -const base: Omit = { - width: 5, - height: 3, - lanes: [], - entities: new Map(), - lives: 3, - frog: { x: 2, y: 2 }, -}; - -// Inert, spec-owned cases, shared with the ecs `winGoal` transaction. No args and -// no services — a bare read→write patch over score / status. -export const cases: Conformance.SpecCases = { - cases: [ - { name: "scores the goal and wins", - before: { ...base, score: 2, status: "playing" }, after: { score: 3, status: "won" } }, - { name: "ignores a finished game (no-op)", - before: { ...base, score: 3, status: "won" }, after: { score: 3, status: "won" } }, - ], -}; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/win-goal.ts b/packages/data-gpu-hopper/src/features/main/data/state/win-goal.ts deleted file mode 100644 index 42cf4560e..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/win-goal.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { GameStatus } from "../game-status/game-status.js"; -import type { State } from "./state.js"; - -// Score the reached goal and end the game as won. A no-op once the game has -// finished. Writes only `score` + `status`. -export const winGoal = ( - state: Pick, -): Pick => { - if (!GameStatus.isPlaying(state.status)) return { score: state.score, status: state.status }; - return { score: state.score + 1, status: "won" }; -}; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/color/color.ts b/packages/data-gpu-hopper/src/features/main/data/values/color/color.ts new file mode 100644 index 000000000..6cbba87c9 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/color/color.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// An RGBA colour, each channel 0..1. +export type Color = readonly [number, number, number, number]; +export * as Color from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/color/public.ts b/packages/data-gpu-hopper/src/features/main/data/values/color/public.ts similarity index 100% rename from packages/data-gpu-hopper/src/features/main/data/color/public.ts rename to packages/data-gpu-hopper/src/features/main/data/values/color/public.ts diff --git a/packages/data-gpu-hopper/src/features/main/data/values/color/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/color/schema.ts new file mode 100644 index 000000000..996b2477b --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/color/schema.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Color } from "./color.js"; +import { Schema } from "@adobe/data/schema"; + +// An RGBA colour, each channel 0..1. The array form `[r, g, b, a]` is what +// data-gpu's colour materials consume directly. +export const schema = { + type: "array", + items: { type: "number" }, + minItems: 4, + maxItems: 4, + default: [1, 1, 1, 1], +} as const satisfies Schema; + +type _Pin = Assert, Color>>; diff --git a/packages/data-gpu-hopper/src/features/main/data/direction/delta.ts b/packages/data-gpu-hopper/src/features/main/data/values/direction/delta.ts similarity index 82% rename from packages/data-gpu-hopper/src/features/main/data/direction/delta.ts rename to packages/data-gpu-hopper/src/features/main/data/values/direction/delta.ts index 0cbdb64df..d499df019 100644 --- a/packages/data-gpu-hopper/src/features/main/data/direction/delta.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/direction/delta.ts @@ -2,7 +2,7 @@ import type { Direction } from "./direction.js"; // Grid displacement per hop. `up` moves toward the goal (increasing row); `x` -// grows to the right. The hop transform reads this instead of re-deriving it. +// grows to the right. `Frog.hop` reads this instead of re-deriving it. export const delta: Record = { up: { dx: 0, dy: 1 }, down: { dx: 0, dy: -1 }, diff --git a/packages/data-gpu-hopper/src/features/main/data/values/direction/direction.ts b/packages/data-gpu-hopper/src/features/main/data/values/direction/direction.ts new file mode 100644 index 000000000..c62a9812f --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/direction/direction.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type Direction = "up" | "down" | "left" | "right"; +export * as Direction from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/direction/public.ts b/packages/data-gpu-hopper/src/features/main/data/values/direction/public.ts similarity index 100% rename from packages/data-gpu-hopper/src/features/main/data/direction/public.ts rename to packages/data-gpu-hopper/src/features/main/data/values/direction/public.ts diff --git a/packages/data-gpu-hopper/src/features/main/data/values/direction/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/direction/schema.ts new file mode 100644 index 000000000..ec03d2fec --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/direction/schema.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Direction } from "./direction.js"; +import { Schema } from "@adobe/data/schema"; + +// A single hop input. `ui/` maps WASD / arrow keys onto these members. +export const schema = { + type: "string", + enum: ["up", "down", "left", "right"], + default: "up", +} as const satisfies Schema; + +type _Pin = Assert, Direction>>; diff --git a/packages/data-gpu-hopper/src/features/main/data/frog/frog-color.ts b/packages/data-gpu-hopper/src/features/main/data/values/frog/frog-color.ts similarity index 100% rename from packages/data-gpu-hopper/src/features/main/data/frog/frog-color.ts rename to packages/data-gpu-hopper/src/features/main/data/values/frog/frog-color.ts diff --git a/packages/data-gpu-hopper/src/features/main/data/values/frog/frog.ts b/packages/data-gpu-hopper/src/features/main/data/values/frog/frog.ts new file mode 100644 index 000000000..e48cb514d --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/frog/frog.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The frog's continuous world position: `x` is the (fractional) column, `y` the row. +export type Frog = { readonly x: number; readonly y: number }; +export * as Frog from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/frog/hop.ts b/packages/data-gpu-hopper/src/features/main/data/values/frog/hop.ts new file mode 100644 index 000000000..e17fc61ec --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/frog/hop.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Frog } from "./frog.js"; +import { Direction } from "../direction/direction.js"; + +const clamp = (value: number, max: number): number => Math.max(0, Math.min(max, value)); + +// The frog one cell in `direction`: a log-ridden fractional `x` snaps back onto the +// grid, and the result is clamped to a `width` x `height` board. +export const hop = (frog: Frog, direction: Direction, width: number, height: number): Frog => { + const { dx, dy } = Direction.delta[direction]; + return { + x: clamp(Math.round(frog.x) + dx, width - 1), + y: clamp(frog.y + dy, height - 1), + }; +}; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/frog/on-board.ts b/packages/data-gpu-hopper/src/features/main/data/values/frog/on-board.ts new file mode 100644 index 000000000..48581f135 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/frog/on-board.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Frog } from "./frog.js"; + +// Whether the frog's column is still on a board `width` columns wide. A log can carry +// it past either edge. +export const onBoard = (frog: Frog, width: number): boolean => frog.x >= 0 && frog.x <= width - 1; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/frog/public.ts b/packages/data-gpu-hopper/src/features/main/data/values/frog/public.ts new file mode 100644 index 000000000..706b01138 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/frog/public.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { schema } from "./schema.js"; +export { frogColor } from "./frog-color.js"; +export { start } from "./start.js"; +export { hop } from "./hop.js"; +export { onBoard } from "./on-board.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/frog/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/frog/schema.ts new file mode 100644 index 000000000..adfdb468f --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/frog/schema.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Frog } from "./frog.js"; +import { Schema } from "@adobe/data/schema"; + +// The frog's continuous world position: `x` is the (fractional) column, `y` the +// row. Hops snap `x` to the grid; riding a log moves `x` smoothly. +export const schema = { + type: "object", + properties: { + x: { type: "number" }, + y: { type: "number" }, + }, + required: ["x", "y"], + additionalProperties: false, +} as const satisfies Schema; + +type _Pin = Assert, Frog>>; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/frog/start.ts b/packages/data-gpu-hopper/src/features/main/data/values/frog/start.ts new file mode 100644 index 000000000..27b347aea --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/frog/start.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Frog } from "./frog.js"; + +// Where the frog (re)spawns on a board `width` columns wide: centred on the bottom row. +export const start = (width: number): Frog => ({ x: Math.floor((width - 1) / 2), y: 0 }); diff --git a/packages/data-gpu-hopper/src/features/main/data/values/game-status/game-status.ts b/packages/data-gpu-hopper/src/features/main/data/values/game-status/game-status.ts new file mode 100644 index 000000000..8c60b6dd4 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/game-status/game-status.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type GameStatus = "playing" | "won" | "gameOver"; +export * as GameStatus from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/game-status/is-playing.ts b/packages/data-gpu-hopper/src/features/main/data/values/game-status/is-playing.ts similarity index 100% rename from packages/data-gpu-hopper/src/features/main/data/game-status/is-playing.ts rename to packages/data-gpu-hopper/src/features/main/data/values/game-status/is-playing.ts diff --git a/packages/data-gpu-hopper/src/features/main/data/game-status/public.ts b/packages/data-gpu-hopper/src/features/main/data/values/game-status/public.ts similarity index 100% rename from packages/data-gpu-hopper/src/features/main/data/game-status/public.ts rename to packages/data-gpu-hopper/src/features/main/data/values/game-status/public.ts diff --git a/packages/data-gpu-hopper/src/features/main/data/values/game-status/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/game-status/schema.ts new file mode 100644 index 000000000..aa4eca935 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/game-status/schema.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { GameStatus } from "./game-status.js"; +import { Schema } from "@adobe/data/schema"; + +// Whether the game is in play, cleared, or over (all lives spent). +export const schema = { + type: "string", + enum: ["playing", "won", "gameOver"], + default: "playing", +} as const satisfies Schema; + +type _Pin = Assert, GameStatus>>; diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard-kind/hazard-color.ts b/packages/data-gpu-hopper/src/features/main/data/values/hazard-kind/hazard-color.ts similarity index 100% rename from packages/data-gpu-hopper/src/features/main/data/hazard-kind/hazard-color.ts rename to packages/data-gpu-hopper/src/features/main/data/values/hazard-kind/hazard-color.ts diff --git a/packages/data-gpu-hopper/src/features/main/data/values/hazard-kind/hazard-kind.ts b/packages/data-gpu-hopper/src/features/main/data/values/hazard-kind/hazard-kind.ts new file mode 100644 index 000000000..d55e597e7 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/hazard-kind/hazard-kind.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type HazardKind = "car" | "log"; +export * as HazardKind from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard-kind/public.ts b/packages/data-gpu-hopper/src/features/main/data/values/hazard-kind/public.ts similarity index 100% rename from packages/data-gpu-hopper/src/features/main/data/hazard-kind/public.ts rename to packages/data-gpu-hopper/src/features/main/data/values/hazard-kind/public.ts diff --git a/packages/data-gpu-hopper/src/features/main/data/values/hazard-kind/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/hazard-kind/schema.ts new file mode 100644 index 000000000..c2107b915 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/hazard-kind/schema.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { HazardKind } from "./hazard-kind.js"; +import { Schema } from "@adobe/data/schema"; + +// The two moving hazards: a `car` (kills on contact) and a `log` (rideable). +export const schema = { + type: "string", + enum: ["car", "log"], + default: "car", +} as const satisfies Schema; + +type _Pin = Assert, HazardKind>>; diff --git a/packages/data-gpu-hopper/src/features/main/data/lane-kind/covered-outcome.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/covered-outcome.ts similarity index 100% rename from packages/data-gpu-hopper/src/features/main/data/lane-kind/covered-outcome.ts rename to packages/data-gpu-hopper/src/features/main/data/values/lane-kind/covered-outcome.ts diff --git a/packages/data-gpu-hopper/src/features/main/data/lane-kind/empty-outcome.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/empty-outcome.ts similarity index 100% rename from packages/data-gpu-hopper/src/features/main/data/lane-kind/empty-outcome.ts rename to packages/data-gpu-hopper/src/features/main/data/values/lane-kind/empty-outcome.ts diff --git a/packages/data-gpu-hopper/src/features/main/data/lane-kind/lane-color.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/lane-color.ts similarity index 100% rename from packages/data-gpu-hopper/src/features/main/data/lane-kind/lane-color.ts rename to packages/data-gpu-hopper/src/features/main/data/values/lane-kind/lane-color.ts diff --git a/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/lane-kind.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/lane-kind.ts new file mode 100644 index 000000000..7bc8149dd --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/lane-kind.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type LaneKind = "grass" | "road" | "river" | "goal"; +export * as LaneKind from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/outcome.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/outcome.ts new file mode 100644 index 000000000..24acf3c2b --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/outcome.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { LaneKind } from "./lane-kind.js"; +import type { Outcome } from "../outcome/outcome.js"; +import { coveredOutcome } from "./covered-outcome.js"; +import { emptyOutcome } from "./empty-outcome.js"; + +// The frog's fate on terrain `kind`, depending on whether a hazard covers it. +export const outcome = (kind: LaneKind, covered: boolean): Outcome => + covered ? coveredOutcome[kind] : emptyOutcome[kind]; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/public.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/public.ts new file mode 100644 index 000000000..f66d7df3d --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/public.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { schema } from "./schema.js"; +export { laneColor } from "./lane-color.js"; +export { emptyOutcome } from "./empty-outcome.js"; +export { coveredOutcome } from "./covered-outcome.js"; +export { outcome } from "./outcome.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/schema.ts new file mode 100644 index 000000000..d81ad9608 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/schema.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { LaneKind } from "./lane-kind.js"; +import { Schema } from "@adobe/data/schema"; + +// The terrain type of a board row: +// grass — solid, always safe (start row and the median) +// road — cars cross it; touching one is fatal +// river — open water; fatal unless riding a log +// goal — the top row the frog is trying to reach +export const schema = { + type: "string", + enum: ["grass", "road", "river", "goal"], + default: "grass", +} as const satisfies Schema; + +type _Pin = Assert, LaneKind>>; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/lane/at.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane/at.ts new file mode 100644 index 000000000..0951c1ff8 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane/at.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Lane } from "./lane.js"; + +// The lane occupying `row`, if any. +export const at = (lanes: readonly Lane[], row: number): Lane | undefined => + lanes.find((lane) => lane.row === row); diff --git a/packages/data-gpu-hopper/src/features/main/data/values/lane/covers-at.test.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane/covers-at.test.ts new file mode 100644 index 000000000..3c678174a --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane/covers-at.test.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { Lane } from "./lane.js"; + +describe("Lane.coversAt", () => { + it("includes the left edge", () => { + expect(Lane.coversAt(2, 3, 2)).toBe(true); + }); + it("includes a point inside the span", () => { + expect(Lane.coversAt(2, 3, 4.9)).toBe(true); + }); + it("excludes the right edge (half-open span)", () => { + expect(Lane.coversAt(2, 3, 5)).toBe(false); + }); + it("excludes a point left of the span", () => { + expect(Lane.coversAt(2, 3, 1.9)).toBe(false); + }); +}); diff --git a/packages/data-gpu-hopper/src/features/main/data/values/lane/covers-at.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane/covers-at.ts new file mode 100644 index 000000000..6a5f8a5e1 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane/covers-at.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Whether `point` lies within the half-open span [spanX, spanX + spanWidth) — the +// coverage rule for a hazard over a lane column. +export const coversAt = (spanX: number, spanWidth: number, point: number): boolean => + point >= spanX && point < spanX + spanWidth; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/lane/lane.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane/lane.ts new file mode 100644 index 000000000..9272dffb4 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane/lane.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { LaneKind } from "../lane-kind/lane-kind.js"; + +// One board row: its row index and terrain. +export type Lane = { readonly row: number; readonly kind: LaneKind }; +export * as Lane from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/lane/next-x.test.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane/next-x.test.ts new file mode 100644 index 000000000..165d05362 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane/next-x.test.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { Lane } from "./lane.js"; + +describe("Lane.nextX", () => { + it("moves by velocity * dt", () => { + expect(Lane.nextX(3, 2, 1, 9)).toBe(5); + }); + it("wraps a positive overshoot around the board width", () => { + expect(Lane.nextX(8, 1, 1, 9)).toBe(0); + }); + it("wraps a negative velocity back around the right edge", () => { + expect(Lane.nextX(0, -1, 1, 9)).toBe(8); + }); + it("is stationary at dt 0", () => { + expect(Lane.nextX(3.5, 4, 0, 9)).toBe(3.5); + }); +}); diff --git a/packages/data-gpu-hopper/src/features/main/data/values/lane/next-x.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane/next-x.ts new file mode 100644 index 000000000..b3eed7257 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane/next-x.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// A hazard's left edge after scrolling `velocity * dt` along its lane, wrapped into +// [0, boardWidth). Takes plain numbers so the ecs step can apply it to columns +// without per-row allocation. +export const nextX = (x: number, velocity: number, dt: number, boardWidth: number): number => { + const raw = x + velocity * dt; + return ((raw % boardWidth) + boardWidth) % boardWidth; +}; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/lane/public.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane/public.ts new file mode 100644 index 000000000..3f982592f --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane/public.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { schema } from "./schema.js"; +export { at } from "./at.js"; +export { nextX } from "./next-x.js"; +export { coversAt } from "./covers-at.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/lane/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane/schema.ts new file mode 100644 index 000000000..c5dd16813 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane/schema.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Lane } from "./lane.js"; +import { Schema } from "@adobe/data/schema"; +import { LaneKind } from "../lane-kind/lane-kind.js"; + +// One board row: which row index it is, and its terrain type. +export const schema = { + type: "object", + properties: { + row: { type: "integer", minimum: 0 }, + kind: LaneKind.schema, + }, + required: ["row", "kind"], + additionalProperties: false, +} as const satisfies Schema; + +type _Pin = Assert, Lane>>; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/level/initial.ts b/packages/data-gpu-hopper/src/features/main/data/values/level/initial.ts new file mode 100644 index 000000000..ba43abae8 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/level/initial.ts @@ -0,0 +1,37 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Level } from "./level.js"; + +// The game's board, bottom (row 0, the start) to top (row 8, the goal): a grass start, +// three car lanes, a grass median, three log lanes, then the goal. Two hazards per +// moving lane, direction and speed varying by lane; cars are one cell wide, logs wider +// so the frog can ride them. +export const initial: Level = { + width: 9, + height: 9, + lanes: [ + { row: 0, kind: "grass" }, + { row: 1, kind: "road" }, + { row: 2, kind: "road" }, + { row: 3, kind: "road" }, + { row: 4, kind: "grass" }, + { row: 5, kind: "river" }, + { row: 6, kind: "river" }, + { row: 7, kind: "river" }, + { row: 8, kind: "goal" }, + ], + hazards: [ + { kind: "car", lane: 1, x: 0, width: 1, velocity: 1.5 }, + { kind: "car", lane: 1, x: 5, width: 1, velocity: 1.5 }, + { kind: "car", lane: 2, x: 2, width: 1, velocity: -2 }, + { kind: "car", lane: 2, x: 7, width: 1, velocity: -2 }, + { kind: "car", lane: 3, x: 1, width: 1, velocity: 2.5 }, + { kind: "car", lane: 3, x: 6, width: 1, velocity: 2.5 }, + { kind: "log", lane: 5, x: 0, width: 3, velocity: 1.5 }, + { kind: "log", lane: 5, x: 5, width: 3, velocity: 1.5 }, + { kind: "log", lane: 6, x: 2, width: 3, velocity: -1 }, + { kind: "log", lane: 6, x: 7, width: 2, velocity: -1 }, + { kind: "log", lane: 7, x: 1, width: 3, velocity: 2 }, + { kind: "log", lane: 7, x: 6, width: 3, velocity: 2 }, + ], + lives: 3, +}; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/level/level.ts b/packages/data-gpu-hopper/src/features/main/data/values/level/level.ts new file mode 100644 index 000000000..1ac76eff3 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/level/level.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Lane } from "../lane/lane.js"; +import type { HazardKind } from "../hazard-kind/hazard-kind.js"; + +// A board to play: its size, the terrain per row, the starting hazards and lives. +export type Level = { + readonly width: number; + readonly height: number; + readonly lanes: readonly Lane[]; + readonly hazards: readonly { + readonly kind: HazardKind; + readonly lane: number; + readonly x: number; + readonly width: number; + readonly velocity: number; + }[]; + readonly lives: number; +}; +export * as Level from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/level/public.ts b/packages/data-gpu-hopper/src/features/main/data/values/level/public.ts new file mode 100644 index 000000000..0c2e9f93e --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/level/public.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { schema } from "./schema.js"; +export { initial } from "./initial.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/level/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/level/schema.ts new file mode 100644 index 000000000..d0ae5bef3 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/level/schema.ts @@ -0,0 +1,35 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import { Schema } from "@adobe/data/schema"; +import type { Level } from "./level.js"; +import { Lane } from "../lane/lane.js"; +import { HazardKind } from "../hazard-kind/hazard-kind.js"; + +export const schema = { + type: "object", + properties: { + width: { type: "integer", minimum: 1 }, + height: { type: "integer", minimum: 1 }, + lanes: { type: "array", items: Lane.schema }, + hazards: { + type: "array", + items: { + type: "object", + properties: { + kind: HazardKind.schema, + lane: { type: "integer", minimum: 0 }, + x: { type: "number" }, + width: { type: "number", minimum: 0 }, + velocity: { type: "number" }, + }, + required: ["kind", "lane", "x", "width", "velocity"], + additionalProperties: false, + }, + }, + lives: { type: "integer", minimum: 1 }, + }, + required: ["width", "height", "lanes", "hazards", "lives"], + additionalProperties: false, +} as const satisfies Schema; + +type _Pin = Assert, Level>>; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/outcome/is-fatal.ts b/packages/data-gpu-hopper/src/features/main/data/values/outcome/is-fatal.ts new file mode 100644 index 000000000..5d8aa5ca3 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/outcome/is-fatal.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Outcome } from "./outcome.js"; + +// Which outcomes cost the frog a life, so callers need not re-enumerate the fatal +// members. +export const isFatal: Record = { + safe: false, + ride: false, + collide: true, + drown: true, + win: false, +}; diff --git a/packages/data-gpu-hopper/src/features/main/data/values/outcome/outcome.ts b/packages/data-gpu-hopper/src/features/main/data/values/outcome/outcome.ts new file mode 100644 index 000000000..16d7338d9 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/outcome/outcome.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type Outcome = "safe" | "ride" | "collide" | "drown" | "win"; +export * as Outcome from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/outcome/public.ts b/packages/data-gpu-hopper/src/features/main/data/values/outcome/public.ts similarity index 100% rename from packages/data-gpu-hopper/src/features/main/data/outcome/public.ts rename to packages/data-gpu-hopper/src/features/main/data/values/outcome/public.ts diff --git a/packages/data-gpu-hopper/src/features/main/data/values/outcome/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/outcome/schema.ts new file mode 100644 index 000000000..2355ce1c6 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/values/outcome/schema.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Outcome } from "./outcome.js"; +import { Schema } from "@adobe/data/schema"; + +// The frog's fate on a given board this frame: +// safe — standing on solid terrain (grass / goal / a road tile with no car) +// ride — carried along by the log it is standing on +// collide — touched a car +// drown — over open water with no log (or carried off the board edge) +// win — reached the goal row +export const schema = { + type: "string", + enum: ["safe", "ride", "collide", "drown", "win"], + default: "safe", +} as const satisfies Schema; + +type _Pin = Assert, Outcome>>; diff --git a/packages/data-gpu-hopper/src/features/main/ecs/actions/action-database.ts b/packages/data-gpu-hopper/src/features/main/ecs/actions/action-database.ts new file mode 100644 index 000000000..b142e1f6d --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/actions/action-database.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; +import * as actions from "./index.js"; + +const actionDatabasePlugin = Database.Plugin.create({ + extends: TransactionDatabase.plugin, + actions, +}); + +export type ActionDatabase = Database.Plugin.ToDatabase; + +export namespace ActionDatabase { + export const plugin = actionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-gpu-hopper/src/features/main/ecs/actions/hop.ts b/packages/data-gpu-hopper/src/features/main/ecs/actions/hop.ts new file mode 100644 index 000000000..919cabe65 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/actions/hop.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Direction } from "../../data/values/direction/direction.js"; +import type { TransactionDatabase } from "../transactions/transaction-database.js"; + +// The player's hop input. +export const hop = (db: TransactionDatabase, args: { readonly direction: Direction }) => { + db.transactions.hop(args); +}; diff --git a/packages/data-gpu-hopper/src/features/main/ecs/actions/index.ts b/packages/data-gpu-hopper/src/features/main/ecs/actions/index.ts new file mode 100644 index 000000000..053cfccfd --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/actions/index.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export * from "./hop.js"; +export * from "./new-game.js"; diff --git a/packages/data-gpu-hopper/src/features/main/ecs/actions/new-game.ts b/packages/data-gpu-hopper/src/features/main/ecs/actions/new-game.ts new file mode 100644 index 000000000..0e0d1d290 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/actions/new-game.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../transactions/transaction-database.js"; + +// Start over from the initial level. +export const newGame = (db: TransactionDatabase) => { + db.transactions.newGame(); +}; diff --git a/packages/data-gpu-hopper/src/features/main/ecs/conformance/conformance.test.ts b/packages/data-gpu-hopper/src/features/main/ecs/conformance/conformance.test.ts new file mode 100644 index 000000000..cae1a92fc --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/conformance/conformance.test.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { implementation } from "./implementation.js"; + +// Replays every spec case against its action / transaction, and round-trips +// `State.samples` through the projection. +Conformance.checkFeature(implementation); diff --git a/packages/data-gpu-hopper/src/features/main/ecs/conformance/frame.cases.ts b/packages/data-gpu-hopper/src/features/main/ecs/conformance/frame.cases.ts new file mode 100644 index 000000000..3d0d45bb8 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/conformance/frame.cases.ts @@ -0,0 +1,43 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "../../spec/state.js"; +import type * as systems from "../../spec/systems.js"; +import { board, riverLanes } from "../../spec/case-boards.js"; + +// Whole frames: `movement` then `collision`, in the order their `schedule`s declare. +// The hit, drown-past-edge and goal cases only hold in that order. dt = 1 so +// displacements are exact. +export const cases: Conformance.FrameCases = { + cases: [ + { name: "a car scrolling onto the frog costs a life and respawns it", + before: board({ frog: { x: 1, y: 1 }, + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]) }), + args: { dt: 1 }, + after: { entities: new Map([[1, { kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }]]), + frog: { x: 2, y: 0 }, lives: 2 } }, + { name: "a car scrolling onto the frog on the last life ends the game", + before: board({ frog: { x: 1, y: 1 }, lives: 1, + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]) }), + args: { dt: 1 }, + after: { entities: new Map([[1, { kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }]]), + frog: { x: 1, y: 1 }, lives: 0, status: "gameOver" } }, + { name: "a log carries the frog along and keeps it safe", + before: board({ lanes: riverLanes, frog: { x: 1, y: 1 }, + entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 3, velocity: 1 }]]) }), + args: { dt: 1 }, + after: { entities: new Map([[1, { kind: "log", lane: 1, x: 1, width: 3, velocity: 1 }]]), + frog: { x: 2, y: 1 }, lives: 3 } }, + { name: "a log carrying the frog past the edge drowns it", + before: board({ lanes: riverLanes, frog: { x: 4, y: 1 }, + entities: new Map([[1, { kind: "log", lane: 1, x: 3, width: 2, velocity: 2 }]]) }), + args: { dt: 1 }, + after: { entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 2, velocity: 2 }]]), + frog: { x: 2, y: 0 }, lives: 2 } }, + { name: "reaching the goal scores and wins while hazards scroll", + before: board({ frog: { x: 2, y: 2 }, + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]) }), + args: { dt: 1 }, + after: { entities: new Map([[1, { kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }]]), + score: 1, status: "won" } }, + ], +}; diff --git a/packages/data-gpu-hopper/src/features/main/ecs/conformance/implementation.ts b/packages/data-gpu-hopper/src/features/main/ecs/conformance/implementation.ts new file mode 100644 index 000000000..2c1ab15ba --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/conformance/implementation.ts @@ -0,0 +1,24 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "../../spec/spec.js"; +import { MainService } from "../main-service.js"; +import { projection } from "./projection.js"; +import { cases } from "./frame.cases.js"; + +// The spec paired with its ECS build: `hop` and `newGame` conform against the +// same-named actions, `movement` and `collision` against the same-named systems. A +// frame's `dt` reaches the systems as `frameDelta`, and the whole-frame cases run the +// systems in schedule order. `MainService.plugin` is headless (the render plugin's +// systems are layered on in the UI), so every system is modelled. +export const implementation = Conformance.implementation(spec, { + plugin: MainService.plugin, + projection, + frame: { + args: { + dt: (db, dt) => { + db.store.resources.frameDelta = dt; + }, + }, + cases, + }, +}); diff --git a/packages/data-gpu-hopper/src/features/main/ecs/conformance/projection.ts b/packages/data-gpu-hopper/src/features/main/ecs/conformance/projection.ts new file mode 100644 index 000000000..e5b989542 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/conformance/projection.ts @@ -0,0 +1,53 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "../../spec/state.js"; +import type { Hazard } from "../../data/entities/hazard.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Every hazard entity, keyed by its ecs entity id (compared up to an id-bijection). +const readEntities = (store: CoreDatabase.Store): Map => { + const entities = new Map(); + for (const arch of store.queryArchetypes(store.archetypes.Hazard.components)) { + for (let row = 0; row < arch.rowCount; row++) { + entities.set(arch.columns.id.get(row), { + kind: arch.columns.kind.get(row), + lane: arch.columns.lane.get(row), + x: arch.columns.x.get(row), + width: arch.columns.width.get(row), + velocity: arch.columns.velocity.get(row), + }); + } + } + return entities; +}; + +// The test-only ecs↔`State` projection. `fromState` clears every hazard (tail→head), +// sets the resources, and inserts one entity per hazard; `frameDelta` has no `State` +// analogue and keeps its value. Hopper ops take no entity ids, so no id map and no +// `toData`. +export const projection = { + fromState: (store: CoreDatabase.Store, state: State): void => { + for (const arch of store.queryArchetypes(store.archetypes.Hazard.components)) { + for (let row = arch.rowCount - 1; row >= 0; row--) store.delete(arch.columns.id.get(row)); + } + store.resources.width = state.width; + store.resources.height = state.height; + store.resources.lanes = state.lanes; + store.resources.frog = state.frog; + store.resources.lives = state.lives; + store.resources.score = state.score; + store.resources.status = state.status; + for (const hazard of state.entities.values()) { + store.archetypes.Hazard.insert({ nonPersistent: true, nonShared: true, ...hazard }); + } + }, + toState: (store: CoreDatabase.Store): State => ({ + width: store.resources.width, + height: store.resources.height, + lanes: store.resources.lanes, + entities: readEntities(store), + frog: store.resources.frog, + lives: store.resources.lives, + score: store.resources.score, + status: store.resources.status, + }), +}; diff --git a/packages/data-gpu-hopper/src/features/main/ecs/core/archetypes.ts b/packages/data-gpu-hopper/src/features/main/ecs/core/archetypes.ts new file mode 100644 index 000000000..e51879a7d --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/core/archetypes.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { components } from "../../data/components/index.js"; +import { Hazard } from "../../data/entities/hazard.js"; + +// Archetype packing: exactly the spec `Hazard` entity. +export const archetypes = Database.archetypes(components, { Hazard }); diff --git a/packages/data-gpu-hopper/src/features/main/ecs/core/core-database.ts b/packages/data-gpu-hopper/src/features/main/ecs/core/core-database.ts new file mode 100644 index 000000000..d9fee9075 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/core/core-database.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { components } from "../../data/components/index.js"; +import { resources } from "../../data/resources/index.js"; +import { archetypes } from "./archetypes.js"; + +// The core schema: the feature's data/ components and resources, packed into archetypes. +const coreDatabasePlugin = Database.Plugin.create({ + components, + resources, + archetypes, +}); + +export type CoreDatabase = Database.Plugin.ToDatabase; + +export namespace CoreDatabase { + export const plugin = coreDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-gpu-hopper/src/features/main/ecs/main-service.ts b/packages/data-gpu-hopper/src/features/main/ecs/main-service.ts new file mode 100644 index 000000000..cad587970 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/main-service.ts @@ -0,0 +1,2 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { SystemDatabase as MainService } from "./systems/system-database.js"; diff --git a/packages/data-gpu-hopper/src/features/main/ecs/systems/system-database.ts b/packages/data-gpu-hopper/src/features/main/ecs/systems/system-database.ts new file mode 100644 index 000000000..c1e3f5238 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/systems/system-database.ts @@ -0,0 +1,91 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database, scheduler } from "@adobe/data/ecs"; +import { ActionDatabase } from "../actions/action-database.js"; +import { Frog } from "../../data/values/frog/frog.js"; +import { Lane } from "../../data/values/lane/lane.js"; +import { LaneKind } from "../../data/values/lane-kind/lane-kind.js"; +import { Outcome } from "../../data/values/outcome/outcome.js"; +import { GameStatus } from "../../data/values/game-status/game-status.js"; + +// The real-time tick loop; each system conforms to its same-named spec function: +// +// movement → carry the frog on the log it rides (found from the pre-scroll +// positions) and scroll every hazard, writing columns in place. +// collision → classify the frog's fate and dispatch the discrete outcome (winGoal / +// loseLife) as a transaction so observers (the HUD) are notified. +// +// Each system is frozen once the game is over. The built-in `scheduler` drives them on +// requestAnimationFrame; a headless host drives frames itself by invoking +// `db.system.functions[name]()` for each name of each tier in `db.system.order` +// (skipping `schedulerSystem`). +const systemDatabasePlugin = Database.Plugin.create({ + extends: Database.Plugin.combine(ActionDatabase.plugin, scheduler), + systems: { + movement: { + create: (db) => () => { + const { resources } = db.store; + if (!GameStatus.isPlaying(resources.status)) return; + const dt = resources.frameDelta; + const { width, frog } = resources; + const hazards = db.store.archetypes.Hazard.components; + + const lane = Lane.at(resources.lanes, frog.y); + let carrierVelocity: number | undefined; + if (lane !== undefined && LaneKind.coveredOutcome[lane.kind] === "ride") { + for (const arch of db.store.queryArchetypes(hazards)) { + const { lane: laneCol, x: xCol, width: widthCol, velocity: velocityCol } = arch.columns; + for (let i = 0; i < arch.rowCount && carrierVelocity === undefined; i++) { + if (laneCol.get(i) === frog.y && Lane.coversAt(xCol.get(i), widthCol.get(i), frog.x)) { + carrierVelocity = velocityCol.get(i); + } + } + } + } + + for (const arch of db.store.queryArchetypes(hazards)) { + const { x: xCol, velocity: velocityCol } = arch.columns; + for (let i = 0; i < arch.rowCount; i++) { + xCol.set(i, Lane.nextX(xCol.get(i), velocityCol.get(i), dt, width)); + } + } + + if (carrierVelocity !== undefined) { + resources.frog = { x: frog.x + carrierVelocity * dt, y: frog.y }; + } + }, + }, + + collision: { + schedule: { after: ["movement"] }, + create: (db) => () => { + const { resources } = db.store; + if (!GameStatus.isPlaying(resources.status)) return; + const { frog } = resources; + const lane = Lane.at(resources.lanes, frog.y); + if (lane === undefined) return; + + let covered = false; + if (Frog.onBoard(frog, resources.width)) { + for (const arch of db.store.queryArchetypes(db.store.archetypes.Hazard.components)) { + const { lane: laneCol, x: xCol, width: widthCol } = arch.columns; + for (let i = 0; i < arch.rowCount && !covered; i++) { + covered = laneCol.get(i) === frog.y && Lane.coversAt(xCol.get(i), widthCol.get(i), frog.x); + } + } + } + + // Dispatched after the archetype reads, so no live cursor is held across them. + const outcome = LaneKind.outcome(lane.kind, covered); + if (outcome === "win") db.transactions.winGoal(); + else if (Outcome.isFatal[outcome]) db.transactions.loseLife(); + }, + }, + }, +}); + +export type SystemDatabase = Database.Plugin.ToDatabase; + +export namespace SystemDatabase { + export const plugin = systemDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-gpu-hopper/src/features/main/ecs/transactions/hop.ts b/packages/data-gpu-hopper/src/features/main/ecs/transactions/hop.ts new file mode 100644 index 000000000..d281a0d77 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/transactions/hop.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Frog } from "../../data/values/frog/frog.js"; +import type { Direction } from "../../data/values/direction/direction.js"; +import { GameStatus } from "../../data/values/game-status/game-status.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Hop the frog one cell (see `Frog.hop`); a no-op unless the game is in play. +export const hop = (t: CoreDatabase.Store, { direction }: { readonly direction: Direction }) => { + const { resources } = t; + if (!GameStatus.isPlaying(resources.status)) return; + resources.frog = Frog.hop(resources.frog, direction, resources.width, resources.height); +}; diff --git a/packages/data-gpu-hopper/src/features/main/ecs/transactions/index.ts b/packages/data-gpu-hopper/src/features/main/ecs/transactions/index.ts new file mode 100644 index 000000000..dcdf4337e --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/transactions/index.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export * from "./hop.js"; +export * from "./new-game.js"; +export * from "./win-goal.js"; +export * from "./lose-life.js"; diff --git a/packages/data-gpu-hopper/src/features/main/ecs/transactions/lose-life.ts b/packages/data-gpu-hopper/src/features/main/ecs/transactions/lose-life.ts new file mode 100644 index 000000000..2b41c2102 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/transactions/lose-life.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Frog } from "../../data/values/frog/frog.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Spend a life: respawn the frog at the start, or end the game on the last life. +// Dispatched by the collision system. +export const loseLife = (t: CoreDatabase.Store) => { + const { resources } = t; + const lives = resources.lives - 1; + if (lives <= 0) { + resources.lives = 0; + resources.status = "gameOver"; + return; + } + resources.lives = lives; + resources.frog = Frog.start(resources.width); +}; diff --git a/packages/data-gpu-hopper/src/features/main/ecs/transactions/new-game.ts b/packages/data-gpu-hopper/src/features/main/ecs/transactions/new-game.ts new file mode 100644 index 000000000..9e8afdcd2 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/transactions/new-game.ts @@ -0,0 +1,24 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Level } from "../../data/values/level/level.js"; +import { Frog } from "../../data/values/frog/frog.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Reset the store to `Level.initial`: clear every hazard, restore the board and the +// run's scalars, then insert the starting hazards. Clears tail→head so each delete +// is from the tail. +export const newGame = (t: CoreDatabase.Store) => { + const { width, height, lanes, hazards, lives } = Level.initial; + for (const arch of t.queryArchetypes(t.archetypes.Hazard.components)) { + for (let row = arch.rowCount - 1; row >= 0; row--) t.delete(arch.columns.id.get(row)); + } + t.resources.width = width; + t.resources.height = height; + t.resources.lanes = lanes; + t.resources.frog = Frog.start(width); + t.resources.lives = lives; + t.resources.score = 0; + t.resources.status = "playing"; + for (const hazard of hazards) { + t.archetypes.Hazard.insert({ nonPersistent: true, nonShared: true, ...hazard }); + } +}; diff --git a/packages/data-gpu-hopper/src/features/main/ecs/transactions/transaction-database.ts b/packages/data-gpu-hopper/src/features/main/ecs/transactions/transaction-database.ts new file mode 100644 index 000000000..60a2f8789 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/transactions/transaction-database.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { CoreDatabase } from "../core/core-database.js"; +import * as transactions from "./index.js"; + +// No index layer (a dozen hazards scan fine), so transactions extend the core directly. +const transactionDatabasePlugin = Database.Plugin.create({ + extends: CoreDatabase.plugin, + transactions, +}); + +export type TransactionDatabase = Database.Plugin.ToDatabase; + +export namespace TransactionDatabase { + export const plugin = transactionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-gpu-hopper/src/features/main/ecs/transactions/win-goal.ts b/packages/data-gpu-hopper/src/features/main/ecs/transactions/win-goal.ts new file mode 100644 index 000000000..084aaeebf --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/transactions/win-goal.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +// Score the reached goal and end the game as won. Dispatched by the collision system. +export const winGoal = (t: CoreDatabase.Store) => { + t.resources.score = t.resources.score + 1; + t.resources.status = "won"; +}; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/conformance.test.ts deleted file mode 100644 index 06b2d1d83..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/conformance.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "../../../data/state/spec.js"; - -// The whole ecs conformance for this feature in one call: `checkFeature` pairs each -// transaction on `MainService.plugin` (hop / winGoal / loseLife / newGame) with its -// same-named `data/state` transition, seeds each case's `before` over -// `State.create()`, and round-trips `State.samples` through the projection. The -// hazards live in the identity-keyed `entities` `ReadonlyMap`, so the comparator -// matches them order-independently (the ecs materialises them in nondeterministic -// row order, and mints its own ids the `after`/`samples` refs leave open). `step` has -// no transaction — the per-frame system loop conforms it in -// `system-database/tick-loop.test.ts` (see systems.md) — so it is simply skipped -// here. Hopper has no derivations, so no `computedPlugin`. -Conformance.checkFeature(spec); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/create-system-database.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/create-system-database.ts deleted file mode 100644 index 89f7ce4a0..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/create-system-database.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { MainService } from "../main-service.js"; - -// The assembled feature database viewed as its writable *system surface* — the -// same `Database & { store }` a system's `create(db)` receives — obtained -// cast-free through the library lens `Database.toSystemDatabase`. ONLY the system -// tick-loop / selection tests need it: they drive the systems through -// `db.system.functions` and reach the store via `db.store`. Transaction and -// projection conformance use `createStore` instead. Test-only. -export const createSystemDatabase = (): Database.Plugin.ToSystemDatabase< - typeof MainService.plugin -> => Database.toSystemDatabase(Database.create(MainService.plugin)); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/drive-frame.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/drive-frame.ts deleted file mode 100644 index 56cdff467..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/drive-frame.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { SystemDatabase } from "../system-database/system-database.js"; - -// Drive exactly one frame headlessly — no rAF, no rendering — the way the -// scheduler would: every system in dependency order (`db.system.order`), -// skipping the scheduler's own bookkeeping system. The documented headless-host -// pattern (see systems.md), used by the tick-loop and outcome-selection tests. -// Test-only. -export const driveFrame = (db: SystemDatabase): void => { - for (const tier of db.system.order) { - for (const name of tier) { - if (name === "schedulerSystem") continue; - // `name` comes from `db.system.order`, so it is always one of this - // database's declared system keys — an invariant the `string` element type - // of `order` cannot carry. - const fn = db.system.functions[name as keyof SystemDatabase["system"]["functions"]]; - if (typeof fn === "function") fn(); - } - } -}; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/projection.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/projection.ts deleted file mode 100644 index dd536d0a6..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/projection.ts +++ /dev/null @@ -1,81 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "../../../data/state/state.js"; -import type { Frog } from "../../../data/frog/frog.js"; -import type { Hazard } from "../../../data/hazard/hazard.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Read the single frog entity through the Frog archetype's full component set -// (`x`, `y`) — hazards lack `y`, so the shapes never alias. -const readFrog = (store: CoreDatabase.Store): Frog => { - for (const arch of store.queryArchetypes(store.archetypes.Frog.components)) { - for (let row = 0; row < arch.rowCount; row++) { - return { x: arch.columns.x.get(row), y: arch.columns.y.get(row) }; - } - } - throw new Error("frog entity missing from store"); -}; - -// Read every hazard entity back into the identity-keyed `entities` map. The key is -// the allocated ecs entity id (conformance compares it up to an id-bijection); the -// value is id-less. -const readEntities = (store: CoreDatabase.Store): Map => { - const entities = new Map(); - for (const arch of store.queryArchetypes(store.archetypes.Hazard.components)) { - for (let row = 0; row < arch.rowCount; row++) { - entities.set(arch.columns.id.get(row), { - kind: arch.columns.kind.get(row), - lane: arch.columns.lane.get(row), - x: arch.columns.x.get(row), - width: arch.columns.width.get(row), - velocity: arch.columns.velocity.get(row), - }); - } - } - return entities; -}; - -// The test-only ecs↔`State` projection, carried on the manifest and used by `Conformance.checkFeature` (and -// reused by the system tick-loop / outcome-selection tests). Hopper is not -// id-addressed (transactions take a direction, not an entity id), so `fromState` -// returns no id map and there are no id-list computeds — hence no `toData`. These -// are STRICTLY for conformance tests and MUST NEVER run in production code. -export const projection = { - // Seed a store to exactly match a `data/` `State`: clear the frog and every - // hazard, set the scalar resources and terrain, then insert one frog entity and - // one entity per hazard. The inverse of `toState`. Clearing iterates tail→head - // so each delete is from the tail (no hole-fill shift). The loop-plumbing - // resources (frameDelta / pendingDirection) have no `State` analogue and keep - // their defaults. - fromState: (store: CoreDatabase.Store, state: State): void => { - for (const arch of store.queryArchetypes(store.archetypes.Frog.components)) { - for (let row = arch.rowCount - 1; row >= 0; row--) store.delete(arch.columns.id.get(row)); - } - for (const arch of store.queryArchetypes(store.archetypes.Hazard.components)) { - for (let row = arch.rowCount - 1; row >= 0; row--) store.delete(arch.columns.id.get(row)); - } - - store.resources.width = state.width; - store.resources.height = state.height; - store.resources.lives = state.lives; - store.resources.score = state.score; - store.resources.status = state.status; - store.resources.lanes = state.lanes; - - store.archetypes.Frog.insert({ x: state.frog.x, y: state.frog.y }); - for (const hazard of state.entities.values()) { - store.archetypes.Hazard.insert(hazard); - } - }, - // Read a store back into a `data/` `State` — the inverse of `fromState`. The - // scalar resources join the singleton frog and the identity-keyed hazard entities. - toState: (store: CoreDatabase.Store): State => ({ - width: store.resources.width, - height: store.resources.height, - lanes: store.resources.lanes, - entities: readEntities(store), - frog: readFrog(store), - lives: store.resources.lives, - score: store.resources.score, - status: store.resources.status, - }), -}; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/core-database/archetypes.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/core-database/archetypes.ts deleted file mode 100644 index 792669fe5..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/core-database/archetypes.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { Schema } from "@adobe/data/schema"; -import type { Assert, Equal } from "@adobe/data/types"; -import type { Frog as FrogRow } from "../../../data/frog/frog.js"; -import type { Hazard as HazardRow } from "../../../data/hazard/hazard.js"; -import { components } from "./components.js"; - -// The two moving entity shapes. The frog is a single entity distinguished by its -// `y` column; hazards are many, each carrying its own `velocity` (per-entity, so -// cars and logs in a lane advance independently). -export const archetypes = Database.archetypes(components, { - Frog: ["x", "y"], - Hazard: ["kind", "lane", "x", "width", "velocity"], -}); - -// Drift guards: each archetype's inferred row shape must equal its data/ row -// type. Fails to compile if the component set and the type diverge. -const frogSchema = Schema.fromArchetype(components, archetypes.Frog); -type _FrogMatches = Assert, FrogRow>>; - -const hazardSchema = Schema.fromArchetype(components, archetypes.Hazard); -type _HazardMatches = Assert, HazardRow>>; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/core-database/components.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/core-database/components.ts deleted file mode 100644 index 6d9c280c5..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/core-database/components.ts +++ /dev/null @@ -1,24 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { F32 } from "@adobe/data/math"; -import { Schema } from "@adobe/data/schema"; -import { HazardKind } from "../../../data/hazard-kind/hazard-kind.js"; - -// The row a hazard occupies — a small non-negative integer index. -const laneRow = { type: "integer", minimum: 0 } as const satisfies Schema; - -// The columns that store the moving pieces. `x`/`y` are continuous world -// coordinates (F32) shared by the frog and the hazards; `kind`/`lane`/`width`/ -// `velocity` describe a hazard. Scope is `session` — this is a purely local -// arcade run: nothing is persisted or shared. Entity kind is read from -// components, never inferred from membership (the frog has `y`, hazards don't). -export const components = Database.components({ - session: { - x: F32.schema, - y: F32.schema, - kind: HazardKind.schema, - lane: laneRow, - width: F32.schema, - velocity: F32.schema, - }, -}); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/core-database/core-database.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/core-database/core-database.ts deleted file mode 100644 index a7b97cf73..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/core-database/core-database.ts +++ /dev/null @@ -1,35 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import type { Store } from "@adobe/data/ecs"; -import { components } from "./components.js"; -import { resources } from "./resources.js"; -import { archetypes } from "./archetypes.js"; - -// The core schema: every component, resource, and archetype for the feature, -// grouped by scope inside components.ts / resources.ts. Hopper is document-only -// (shared + durable) — no settings/presence/session state. -const coreDatabasePlugin = Database.Plugin.create({ - components, - resources, - archetypes, -}); - -export type CoreDatabase = Database.Plugin.ToDatabase; - -// Resolved component map, declared at module scope so the imported `Store` -// namespace isn't shadowed by `CoreDatabase.Store` below. -type CoreComponents = Store.Components< - Database.Plugin.ToStore ->; - -export namespace CoreDatabase { - export const plugin = coreDatabasePlugin; - /** - * The store this database's transactions operate on: entities, resources, - * archetypes, index handles, and the initiating `userId`. A store *is* the - * transaction context — there is no separate transaction type. - */ - export type Store = Database.Plugin.ToStore; - /** Index-declaration type bound to this database's components. */ - export type Index = Database.Index; -} diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/core-database/resources.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/core-database/resources.ts deleted file mode 100644 index 61a075482..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/core-database/resources.ts +++ /dev/null @@ -1,39 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { Schema } from "@adobe/data/schema"; -import { GameStatus } from "../../../data/game-status/game-status.js"; -import { Direction } from "../../../data/direction/direction.js"; -import { Lane } from "../../../data/lane/lane.js"; - -// Board dimensions and the run's scalar state. Each carries the default the -// resource facet requires. -const width = { type: "integer", minimum: 1, default: 9 } as const satisfies Schema; -const height = { type: "integer", minimum: 1, default: 9 } as const satisfies Schema; -const lives = { type: "integer", minimum: 0, default: 3 } as const satisfies Schema; -const score = { type: "integer", minimum: 0, default: 0 } as const satisfies Schema; - -// The static terrain layout: one Lane per row, referenced by identity so it -// stays the single source of truth for the row type. -const lanes = { type: "array", items: Lane.schema, default: [] } as const satisfies Schema; - -// Loop plumbing with no `data/` `State` analogue (the projection ignores both). -// `frameDelta` is the per-frame dt the render loop feeds the step systems; -// `pendingDirection` is the queued hop the input system consumes and clears. -const frameDelta = { type: "number", default: 1 / 60 } as const satisfies Schema; -const pendingDirection = { - oneOf: [Direction.schema, { type: "null" }], - default: null, -} as const satisfies Schema; - -export const resources = Database.resources({ - session: { - width, - height, - lives, - score, - status: GameStatus.schema, - lanes, - frameDelta, - pendingDirection, - }, -}); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/main-service.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/main-service.ts deleted file mode 100644 index 607092998..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/main-service.ts +++ /dev/null @@ -1,5 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// The whole assembled feature database. Consumers (ui/, the app entry) reference -// `MainService` — never the topmost layer by name — so adding or dropping a -// behaviour layer changes only this one line. -export { SystemDatabase as MainService } from "./system-database/system-database.js"; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/outcome-selection.test.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/outcome-selection.test.ts deleted file mode 100644 index fb9ed71c9..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/outcome-selection.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// Outcome SELECTION/DETECTION tests — a system concern the whole-tick conformance -// can't isolate, and where subtle bugs hide (which lane the frog is on, the exact -// coverage boundary, adjacent-lane hazards). Each case seeds an edge-case -// geometry, drives ONE frame with dt = 0 (so hazards don't move and the frog -// isn't carried — motion is an identity), and reads back the resulting `State`. -// This exercises the collision system's lane lookup + coverage narrow-phase in -// isolation from movement. -import { describe, it, expect } from "vitest"; -import type { State } from "../../../data/state/state.js"; -import type { Lane } from "../../../data/lane/lane.js"; -import { createSystemDatabase } from "../conformance/create-system-database.js"; -import { projection } from "../conformance/projection.js"; -import { driveFrame } from "../conformance/drive-frame.js"; - -const roadLanes: readonly Lane[] = [ - { row: 0, kind: "grass" }, - { row: 1, kind: "road" }, - { row: 2, kind: "goal" }, -]; -const riverLanes: readonly Lane[] = [ - { row: 0, kind: "grass" }, - { row: 1, kind: "river" }, - { row: 2, kind: "goal" }, -]; - -const base = (overrides: Partial): State => ({ - width: 5, - height: 3, - lanes: roadLanes, - entities: new Map(), - frog: { x: 2, y: 0 }, - lives: 3, - score: 0, - status: "playing", - ...overrides, -}); - -// Seed the geometry, run exactly one selection-only frame (dt 0), project back. -const detect = (state: State): State => { - const db = createSystemDatabase(); - projection.fromState(db.store, state); - db.store.resources.frameDelta = 0; - driveFrame(db); - return projection.toState(db.store); -}; - -describe("outcome selection — road", () => { - it("a car under the frog costs a life and respawns it", () => { - const after = detect( - base({ - frog: { x: 2, y: 1 }, - entities: new Map([[1, { kind: "car", lane: 1, x: 2, width: 1, velocity: 0 }]]), - }), - ); - expect(after.lives).toBe(2); - expect(after.frog).toEqual({ x: 2, y: 0 }); - expect(after.status).toBe("playing"); - }); - - it("a car on the frog's lane but not under it is safe", () => { - const after = detect( - base({ - frog: { x: 2, y: 1 }, - entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 0 }]]), - }), - ); - expect(after.lives).toBe(3); - expect(after.frog).toEqual({ x: 2, y: 1 }); - }); - - it("the final life turns a hit into game over (no respawn)", () => { - const after = detect( - base({ - frog: { x: 2, y: 1 }, - entities: new Map([[1, { kind: "car", lane: 1, x: 2, width: 1, velocity: 0 }]]), - lives: 1, - }), - ); - expect(after.lives).toBe(0); - expect(after.status).toBe("gameOver"); - expect(after.frog).toEqual({ x: 2, y: 1 }); - }); -}); - -describe("outcome selection — river", () => { - it("a log under the frog is safe (ride)", () => { - const after = detect( - base({ - lanes: riverLanes, - frog: { x: 2, y: 1 }, - entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 3, velocity: 0 }]]), - }), - ); - expect(after.lives).toBe(3); - expect(after.frog).toEqual({ x: 2, y: 1 }); - }); - - it("open water with no log drowns the frog", () => { - const after = detect(base({ lanes: riverLanes, frog: { x: 2, y: 1 }, entities: new Map() })); - expect(after.lives).toBe(2); - expect(after.frog).toEqual({ x: 2, y: 0 }); - }); - - it("the log's exclusive right edge is open water (drown at the boundary)", () => { - const after = detect( - base({ - lanes: riverLanes, - frog: { x: 3, y: 1 }, // log covers [0, 3); x = 3 is NOT covered - entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 3, velocity: 0 }]]), - }), - ); - expect(after.lives).toBe(2); - }); -}); - -describe("outcome selection — terrain", () => { - it("reaching the goal scores and wins", () => { - const after = detect(base({ frog: { x: 2, y: 2 } })); - expect(after.score).toBe(1); - expect(after.status).toBe("won"); - }); - - it("grass is always safe", () => { - const after = detect(base({ frog: { x: 2, y: 0 } })); - expect(after.lives).toBe(3); - expect(after.score).toBe(0); - expect(after.status).toBe("playing"); - }); -}); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/system-database.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/system-database.ts deleted file mode 100644 index 610022b70..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/system-database.ts +++ /dev/null @@ -1,177 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database, scheduler } from "@adobe/data/ecs"; -import type { Entity } from "@adobe/data/ecs"; -import { TransactionDatabase } from "../transaction-database/transaction-database.js"; -import { GameStatus } from "../../../data/game-status/game-status.js"; -import { LaneKind } from "../../../data/lane-kind/lane-kind.js"; -import { Hazard } from "../../../data/hazard/hazard.js"; -import { Outcome } from "../../../data/outcome/outcome.js"; -import { State } from "../../../data/state/state.js"; - -// The real-time tick loop. Extends the transaction database (schema + -// transactions — hopper has no index/computed layer) combined with the built-in -// `scheduler`, and declares the `systems` facet INLINE so each `create`'s `db` is -// strongly typed (the assembled database with a *writable* store) and the -// scheduler can infer the system-name union from the map's keys. -// -// The three systems mirror the internal sequence of `data/` `State.step`: -// -// hopInput → consume the queued hop and dispatch the `hop` transaction. NOT -// part of step (a keypress transform); seeded empty in conformance. -// Named `hopInput` (not `input`) so it never collides with core's -// `input` phase anchor when the ui render plugin is combined on top. -// movement → advance every hazard (wrap) and carry the frog on the log it -// rides — the "advance hazards → carry frog" half of step. -// collision → classify the frog's fate with the `frogOutcome` oracle, then -// dispatch the discrete outcome (winGoal / loseLife) as a -// transaction so observers (the HUD) are notified — the -// "outcome → apply" half of step. -// -// Every system replicates step's top-level guard (`if (!isPlaying) return`) so a -// finished game is a truly frozen frame. The scheduler drives the systems on -// requestAnimationFrame; its `schedulerState` resource gates start/pause/resume. -// A headless host (tests, server sim) omits rAF and drives frames itself by -// invoking `db.system.functions[name]()` for each name in `db.system.order`. -const systemDatabasePlugin = Database.Plugin.create({ - extends: Database.Plugin.combine(TransactionDatabase.plugin, scheduler), - systems: { - // Consume the queued hop, if any, and dispatch the conformant `hop` - // transaction (reusing its data/-verified clamp+snap and notifying observers), - // then clear the queue so one keypress is one hop. Runs before movement so the - // player acts, then the world advances. Frozen once the game is over. - hopInput: { - create: (db) => () => { - const { resources } = db.store; - if (!GameStatus.isPlaying(resources.status)) return; - const direction = resources.pendingDirection; - if (direction === null) return; - db.transactions.hop({ direction }); - resources.pendingDirection = null; - }, - }, - - // Advance every hazard and carry a log-riding frog — the hot per-row half of - // step. The carrier (the log the frog stands on) is read from the PRE-advance - // positions, matching step; hazards then advance in place via `Hazard.nextX` - // (column writes, no per-row allocation, no archetype migration); finally the - // frog is moved with its log. Frozen once the game is over. - movement: { - schedule: { after: ["hopInput"] }, - create: (db) => () => { - const { resources } = db.store; - if (!GameStatus.isPlaying(resources.status)) return; - const dt = resources.frameDelta; - const width = resources.width; - - let frogId: Entity | undefined; - let frogX = 0; - let frogY = 0; - for (const arch of db.store.queryArchetypes(["x", "y"])) { - for (let i = 0; i < arch.rowCount; i++) { - frogId = arch.columns.id.get(i); - frogX = arch.columns.x.get(i); - frogY = arch.columns.y.get(i); - } - } - - // Only a carrying lane (river) rides; find the log under the frog first, - // before its position is advanced below. - const lane = State.laneAt({ lanes: resources.lanes }, frogY); - const riding = lane !== undefined && LaneKind.coveredOutcome[lane.kind] === "ride"; - let carried = false; - let carrierVelocity = 0; - if (riding) { - for (const arch of db.store.queryArchetypes(["lane", "x", "width", "velocity"])) { - const laneCol = arch.columns.lane; - const xCol = arch.columns.x; - const widthCol = arch.columns.width; - const velocityCol = arch.columns.velocity; - for (let i = 0; i < arch.rowCount; i++) { - if (laneCol.get(i) === frogY && Hazard.coversAt(xCol.get(i), widthCol.get(i), frogX)) { - carried = true; - carrierVelocity = velocityCol.get(i); - break; - } - } - } - } - - for (const arch of db.store.queryArchetypes(["x", "velocity"])) { - const xCol = arch.columns.x; - const velocityCol = arch.columns.velocity; - for (let i = 0; i < arch.rowCount; i++) { - xCol.set(i, Hazard.nextX(xCol.get(i), velocityCol.get(i), dt, width)); - } - } - - if (frogId !== undefined && carried) { - db.store.update(frogId, { x: frogX + carrierVelocity * dt }); - } - }, - }, - - // Resolve the frog's fate the way step does: read it (post-movement) and the - // hazards (post-advance), classify with the `frogOutcome` oracle, then apply — - // a win scores and ends the game, a fatal outcome spends a life and respawns - // (or ends the game on the last life). Building the small hazard slice once - // per frame (not per row) keeps the oracle the single source of the collision - // math. Frozen once the game is over. - collision: { - schedule: { after: ["movement"] }, - create: (db) => () => { - const { resources } = db.store; - if (!GameStatus.isPlaying(resources.status)) return; - - let hasFrog = false; - let frogX = 0; - let frogY = 0; - for (const arch of db.store.queryArchetypes(["x", "y"])) { - for (let i = 0; i < arch.rowCount; i++) { - hasFrog = true; - frogX = arch.columns.x.get(i); - frogY = arch.columns.y.get(i); - } - } - if (!hasFrog) return; - - const entities = new Map(); - for (const arch of db.store.queryArchetypes(["kind", "lane", "x", "width", "velocity"])) { - for (let i = 0; i < arch.rowCount; i++) { - entities.set(arch.columns.id.get(i), { - kind: arch.columns.kind.get(i), - lane: arch.columns.lane.get(i), - x: arch.columns.x.get(i), - width: arch.columns.width.get(i), - velocity: arch.columns.velocity.get(i), - }); - } - } - - const outcome = State.frogOutcome({ - lanes: resources.lanes, - entities, - frog: { x: frogX, y: frogY }, - width: resources.width, - }); - - // Discrete atomic events go through transactions so observers (the HUD) - // are notified. Dispatched AFTER the archetype reads above, so no live - // cursor is held across them. - if (outcome === "win") { - db.transactions.winGoal(); - return; - } - if (Outcome.isFatal[outcome]) { - db.transactions.loseLife(); - } - }, - }, - }, -}); - -export type SystemDatabase = Database.Plugin.ToDatabase; - -export namespace SystemDatabase { - export const plugin = systemDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/tick-loop.test.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/tick-loop.test.ts deleted file mode 100644 index a0532499f..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/tick-loop.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// Whole-tick conformance: one frame of the ECS system loop must produce the same -// logical `State` as one `data/` `State.step`. Reuses the feature projection over -// the SHARED `step` cases — the same `{ before, args, after }` array the pure -// `State.step` spec test runs — so this is "substitute the implementation, reuse -// the truth". -// -// Systems run through `db.system.functions`, so unlike a transaction they need the -// assembled database, obtained cast-free via `createSystemDatabase` (the -// writable-store lens). Per case: seed `fromState(db.store, before)`, seed the -// frame `frameDelta` (a resource with no `data/` analogue, written straight to the -// store as the oracle is fed `dt`), and — crucially — seed NO pending input, so -// the per-frame systems' combined effect equals `State.step(before, dt)` exactly -// (hop is covered by the transaction conformance). Then drive one headless frame -// and assert `toState ≡ after`. Each case also asserts `State.step ≡ after` first, -// keeping the shared case honest. The hazards live in the identity-keyed -// `entities` `ReadonlyMap`, so `assertState` matches them order-independently and up -// to an id-bijection (the ecs, and the pure spawn, mint their own ids — the case's -// plain spec-ids need only correspond). -import { describe, it } from "vitest"; -import { Conformance } from "@adobe/data-testing"; -import { State } from "../../../data/state/state.js"; -import { cases } from "../../../data/state/step.cases.js"; -import { createSystemDatabase } from "../conformance/create-system-database.js"; -import { projection } from "../conformance/projection.js"; -import { driveFrame } from "../conformance/drive-frame.js"; - -describe("ECS system tick loop conforms to State.step (one frame = one step)", () => { - for (const testCase of cases.cases) { - it(testCase.name, () => { - const { dt } = testCase.args; - // A case `before` is a delta over the feature default and `after` a writes - // patch (`SpecCase.before`/`after` are `Partial`), so materialise the - // full seed and the full expected state the same way the runners do. `db.store` - // supplies the schemas `assertState` reads to compare up to an id-bijection. - const before = { ...State.create(), ...testCase.before }; - const expected = { ...before, ...testCase.after }; - const db = createSystemDatabase(); - Conformance.assertState({ ...before, ...State.step(before, { dt }) }, expected, db.store); - - projection.fromState(db.store, before); - db.store.resources.frameDelta = dt; - driveFrame(db); - Conformance.assertState(projection.toState(db.store), expected, db.store); - }); - } -}); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transaction-database.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transaction-database.ts deleted file mode 100644 index 094f29018..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transaction-database.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { CoreDatabase } from "../core-database/core-database.js"; -import * as transactions from "./transactions/index.js"; - -// Hopper has no index layer (a dozen hazards brute-force fine), so the -// transaction layer extends the core schema directly. -const transactionDatabasePlugin = Database.Plugin.create({ - extends: CoreDatabase.plugin, - transactions, -}); - -export type TransactionDatabase = Database.Plugin.ToDatabase< - typeof transactionDatabasePlugin ->; - -export namespace TransactionDatabase { - export const plugin = transactionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/hop.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/hop.ts deleted file mode 100644 index 9d6491f0d..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/hop.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "../../../../data/state/state.js"; -import type { Direction } from "../../../../data/direction/direction.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; -import { readFrog } from "./read-frog.js"; - -// Hop the frog one cell (see State.hop). Board bounds and status come from -// resources; the decision is the pure `data/` transform, and this only writes -// the resulting position back to the frog entity. A no-op while not playing -// writes the same values, so it stays idempotent. -export const hop = (t: CoreDatabase.Store, { direction }: { readonly direction: Direction }) => { - const { id, frog } = readFrog(t); - const next = State.hop( - { frog, width: t.resources.width, height: t.resources.height, status: t.resources.status }, - { direction }, - ); - t.update(id, { x: next.frog.x, y: next.frog.y }); -}; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/index.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/index.ts deleted file mode 100644 index e0055b32b..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export * from "./hop.js"; -export * from "./new-game.js"; -export * from "./lose-life.js"; -export * from "./win-goal.js"; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/lose-life.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/lose-life.ts deleted file mode 100644 index c5ce0bcdf..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/lose-life.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "../../../../data/state/state.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; -import { readFrog } from "./read-frog.js"; - -// Spend a life (see State.loseLife): respawn the frog, or end the game on the -// last life. A discrete atomic event — the collision system dispatches this -// (rather than writing resources directly) so observers such as the HUD update. -export const loseLife = (t: CoreDatabase.Store) => { - const { id, frog } = readFrog(t); - const next = State.loseLife({ - lives: t.resources.lives, - status: t.resources.status, - frog, - width: t.resources.width, - }); - t.resources.lives = next.lives; - t.resources.status = next.status; - t.update(id, { x: next.frog.x, y: next.frog.y }); -}; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/new-game.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/new-game.ts deleted file mode 100644 index 75d808198..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/new-game.ts +++ /dev/null @@ -1,29 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "../../../../data/state/state.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// Reset the whole store to the initial game (see State.create): clear the frog -// and every hazard, restore the scalar resources and terrain, then insert the -// starting frog and hazards. -export const newGame = (t: CoreDatabase.Store) => { - const initial = State.create(); - - for (const arch of t.queryArchetypes(t.archetypes.Frog.components)) { - for (let row = arch.rowCount - 1; row >= 0; row--) t.delete(arch.columns.id.get(row)); - } - for (const arch of t.queryArchetypes(t.archetypes.Hazard.components)) { - for (let row = arch.rowCount - 1; row >= 0; row--) t.delete(arch.columns.id.get(row)); - } - - t.resources.width = initial.width; - t.resources.height = initial.height; - t.resources.lives = initial.lives; - t.resources.score = initial.score; - t.resources.status = initial.status; - t.resources.lanes = initial.lanes; - - t.archetypes.Frog.insert({ x: initial.frog.x, y: initial.frog.y }); - for (const hazard of initial.entities.values()) { - t.archetypes.Hazard.insert(hazard); - } -}; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/read-frog.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/read-frog.ts deleted file mode 100644 index e74d0ba8a..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/read-frog.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// The single frog entity — its id (to update) and current position. A read -// helper shared by the transactions here, so it stays OUT of the barrel (only -// dispatchable mutations belong there). Throws if the store has no frog, which -// is an invariant violation, not bad input. -export const readFrog = (t: CoreDatabase.Store) => { - for (const arch of t.queryArchetypes(t.archetypes.Frog.components)) { - for (let row = 0; row < arch.rowCount; row++) { - return { - id: arch.columns.id.get(row), - frog: { x: arch.columns.x.get(row), y: arch.columns.y.get(row) }, - }; - } - } - throw new Error("frog entity missing from store"); -}; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/win-goal.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/win-goal.ts deleted file mode 100644 index 22646f0ef..000000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/win-goal.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "../../../../data/state/state.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// Score the goal and win (see State.winGoal). A discrete atomic event — the -// collision system dispatches this (rather than writing resources directly) so -// observers such as the HUD update. -export const winGoal = (t: CoreDatabase.Store) => { - const next = State.winGoal({ score: t.resources.score, status: t.resources.status }); - t.resources.score = next.score; - t.resources.status = next.status; -}; diff --git a/packages/data-gpu-hopper/src/features/main/spec/case-boards.ts b/packages/data-gpu-hopper/src/features/main/spec/case-boards.ts new file mode 100644 index 000000000..5612bff5d --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/case-boards.ts @@ -0,0 +1,29 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Lane } from "../data/values/lane/lane.js"; +import type { State } from "./state.js"; + +// Two 5-wide, 3-tall boards differing only in the middle lane's terrain, shared by the +// system and frame cases. +export const roadLanes: readonly Lane[] = [ + { row: 0, kind: "grass" }, + { row: 1, kind: "road" }, + { row: 2, kind: "goal" }, +]; +export const riverLanes: readonly Lane[] = [ + { row: 0, kind: "grass" }, + { row: 1, kind: "river" }, + { row: 2, kind: "goal" }, +]; + +// A full in-play state on the road board, frog resting on grass, no hazards. +export const board = (overrides: Partial): State => ({ + width: 5, + height: 3, + lanes: roadLanes, + entities: new Map(), + frog: { x: 2, y: 0 }, + lives: 3, + score: 0, + status: "playing", + ...overrides, +}); diff --git a/packages/data-gpu-hopper/src/features/main/spec/collision.cases.ts b/packages/data-gpu-hopper/src/features/main/spec/collision.cases.ts new file mode 100644 index 000000000..b5ecff5a6 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/collision.cases.ts @@ -0,0 +1,44 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { Hazard } from "../data/entities/hazard.js"; +import type { collision } from "./collision.js"; +import { board, riverLanes } from "./case-boards.js"; + +const car: Hazard = { kind: "car", lane: 1, x: 2, width: 1, velocity: 0 }; +const log: Hazard = { kind: "log", lane: 1, x: 0, width: 3, velocity: 0 }; + +// Outcome selection where the frog stands (collision runs alone, nothing moves). +// `after` is the writes patch. +export const cases: Conformance.SpecCases = { + cases: [ + { name: "a car under the frog costs a life and respawns it", + before: board({ frog: { x: 2, y: 1 }, entities: new Map([[1, car]]) }), + after: { lives: 2, frog: { x: 2, y: 0 }, status: "playing" } }, + { name: "a car on the frog's lane but not under it is safe", + before: board({ frog: { x: 2, y: 1 }, entities: new Map([[1, { ...car, x: 0 }]]) }), + after: { lives: 3, frog: { x: 2, y: 1 } } }, + { name: "the final life turns a hit into game over (no respawn)", + before: board({ frog: { x: 2, y: 1 }, entities: new Map([[1, car]]), lives: 1 }), + after: { lives: 0, status: "gameOver", frog: { x: 2, y: 1 } } }, + { name: "a log under the frog is safe (ride)", + before: board({ lanes: riverLanes, frog: { x: 2, y: 1 }, entities: new Map([[1, log]]) }), + after: { lives: 3, frog: { x: 2, y: 1 } } }, + { name: "open water with no log drowns the frog", + before: board({ lanes: riverLanes, frog: { x: 2, y: 1 } }), + after: { lives: 2, frog: { x: 2, y: 0 } } }, + // The log covers [0, 3); x = 3 is not covered. + { name: "the log's exclusive right edge is open water (drown at the boundary)", + before: board({ lanes: riverLanes, frog: { x: 3, y: 1 }, entities: new Map([[1, log]]) }), + after: { lives: 2, frog: { x: 2, y: 0 } } }, + { name: "a frog off the board edge drowns even while over a log", + before: board({ lanes: riverLanes, frog: { x: 5.2, y: 1 }, entities: new Map([[1, { ...log, x: 4 }]]) }), + after: { lives: 2, frog: { x: 2, y: 0 } } }, + { name: "reaching the goal scores and wins", + before: board({ frog: { x: 2, y: 2 } }), + after: { score: 1, status: "won" } }, + { name: "grass is always safe", + before: board({ frog: { x: 2, y: 0 } }), + after: { lives: 3, score: 0, status: "playing" } }, + ], +}; diff --git a/packages/data-gpu-hopper/src/features/main/spec/collision.ts b/packages/data-gpu-hopper/src/features/main/spec/collision.ts new file mode 100644 index 000000000..08ee83fab --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/collision.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Outcome } from "../data/values/outcome/outcome.js"; +import type { State } from "./state.js"; +import { frogOutcome } from "./frog-outcome.js"; +import { winGoal } from "./win-goal.js"; +import { loseLife } from "./lose-life.js"; +import { whilePlaying } from "./while-playing.js"; + +type CollisionPatch = Pick; + +// The `collision` system: resolve the frog's fate where it stands — score a win, or on +// a fatal outcome spend a life (respawn, or game over on the last life). +export const collision = ( + state: Pick, +): CollisionPatch => { + const held: CollisionPatch = { frog: state.frog, lives: state.lives, score: state.score, status: state.status }; + return whilePlaying(state.status, held, () => { + const outcome = frogOutcome(state); + if (outcome === "win") return { ...held, ...winGoal(state) }; + if (Outcome.isFatal[outcome]) return { ...held, ...loseLife(state) }; + return held; + }); +}; diff --git a/packages/data-gpu-hopper/src/features/main/spec/create.test.ts b/packages/data-gpu-hopper/src/features/main/spec/create.test.ts new file mode 100644 index 000000000..05e3f5840 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/create.test.ts @@ -0,0 +1,50 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { State } from "./state.js"; +import { LaneKind } from "../data/values/lane-kind/lane-kind.js"; +import { Lane } from "../data/values/lane/lane.js"; +import { Frog } from "../data/values/frog/frog.js"; + +describe("State.create", () => { + const state = State.create(); + + it("starts in play with three lives and no score", () => { + expect(state.status).toBe("playing"); + expect(state.lives).toBe(3); + expect(state.score).toBe(0); + }); + + it("spawns the frog centred on the bottom row", () => { + expect(state.frog).toEqual(Frog.start(state.width)); + expect(state.frog.y).toBe(0); + }); + + it("has a lane for every row of the board", () => { + for (let row = 0; row < state.height; row++) { + expect(Lane.at(state.lanes, row)).toBeDefined(); + } + }); + + it("places cars only on roads and logs only on rivers", () => { + for (const hazard of state.entities.values()) { + const kind = Lane.at(state.lanes, hazard.lane)?.kind; + const expected = hazard.kind === "car" ? "road" : "river"; + expect(kind).toBe(expected); + } + }); + + it("gives every moving lane at least one hazard", () => { + const carrying = new Set(["road", "river"]); + for (const lane of state.lanes) { + if (!carrying.has(lane.kind)) continue; + const populated = [...state.entities.values()].some((hazard) => hazard.lane === lane.row); + expect(populated, `lane ${lane.row} (${lane.kind}) has no hazard`).toBe(true); + } + }); + + it("colours every terrain kind used by the board", () => { + for (const lane of state.lanes) { + expect(LaneKind.laneColor[lane.kind]).toHaveLength(4); + } + }); +}); diff --git a/packages/data-gpu-hopper/src/features/main/spec/create.ts b/packages/data-gpu-hopper/src/features/main/spec/create.ts new file mode 100644 index 000000000..829fcaa83 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/create.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import { Level } from "../data/values/level/level.js"; +import { Frog } from "../data/values/frog/frog.js"; + +// The initial game: `Level.initial`'s board with the frog at the start, nothing scored. +// Each hazard is keyed by its own spec-id. +export const create = (): State => { + const { width, height, lanes, hazards, lives } = Level.initial; + return { + width, + height, + lanes, + entities: new Map(hazards.map((hazard, index) => [index + 1, hazard])), + frog: Frog.start(width), + lives, + score: 0, + status: "playing", + }; +}; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/frog-outcome.test.ts b/packages/data-gpu-hopper/src/features/main/spec/frog-outcome.test.ts similarity index 93% rename from packages/data-gpu-hopper/src/features/main/data/state/frog-outcome.test.ts rename to packages/data-gpu-hopper/src/features/main/spec/frog-outcome.test.ts index 9f7f14c9f..9023d2865 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/frog-outcome.test.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/frog-outcome.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest"; import { State } from "./state.js"; import type { State as StateType } from "./state.js"; -import type { Lane } from "../lane/lane.js"; -import type { Hazard } from "../hazard/hazard.js"; +import type { Lane } from "../data/values/lane/lane.js"; +import type { Hazard } from "../data/entities/hazard.js"; const lanes: readonly Lane[] = [ { row: 0, kind: "grass" }, diff --git a/packages/data-gpu-hopper/src/features/main/spec/frog-outcome.ts b/packages/data-gpu-hopper/src/features/main/spec/frog-outcome.ts new file mode 100644 index 000000000..6cf188591 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/frog-outcome.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Frog } from "../data/values/frog/frog.js"; +import { Lane } from "../data/values/lane/lane.js"; +import { LaneKind } from "../data/values/lane-kind/lane-kind.js"; +import type { Outcome } from "../data/values/outcome/outcome.js"; +import type { State } from "./state.js"; + +// The frog's fate on the current board: its lane's terrain, and whether a hazard on +// that lane covers it. A frog carried off the board edge is covered by nothing. +export const frogOutcome = (state: Pick): Outcome => { + const lane = Lane.at(state.lanes, state.frog.y); + if (!lane) return "safe"; + const covered = + Frog.onBoard(state.frog, state.width) && + [...state.entities.values()].some( + (hazard) => hazard.lane === state.frog.y && Lane.coversAt(hazard.x, hazard.width, state.frog.x), + ); + return LaneKind.outcome(lane.kind, covered); +}; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/hop.cases.ts b/packages/data-gpu-hopper/src/features/main/spec/hop.cases.ts similarity index 90% rename from packages/data-gpu-hopper/src/features/main/data/state/hop.cases.ts rename to packages/data-gpu-hopper/src/features/main/spec/hop.cases.ts index eb15bbfaa..ce07efcf1 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/hop.cases.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/hop.cases.ts @@ -15,9 +15,8 @@ const base: Omit = { status: "playing", }; -// Inert, spec-owned cases, shared with the ecs `hop` transaction. `before` -// overrides the default with a small test board; `after` is the writes patch -// (`frog` only). `direction` is plain data — no service double, no entity refs. +// `before` overrides the default with a small test board; `after` is the writes patch +// (`frog` only). export const cases: Conformance.SpecCases = { cases: [ { name: "hops up toward the goal", diff --git a/packages/data-gpu-hopper/src/features/main/spec/hop.ts b/packages/data-gpu-hopper/src/features/main/spec/hop.ts new file mode 100644 index 000000000..0a5222141 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/hop.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Frog } from "../data/values/frog/frog.js"; +import type { Direction } from "../data/values/direction/direction.js"; +import { GameStatus } from "../data/values/game-status/game-status.js"; +import type { State } from "./state.js"; + +// Hop the frog one cell in `direction` (see `Frog.hop`). A no-op unless the game is in +// play. Writes only `frog`. +export const hop = ( + state: Pick, + { direction }: { readonly direction: Direction }, +): Pick => + GameStatus.isPlaying(state.status) + ? { frog: Frog.hop(state.frog, direction, state.width, state.height) } + : { frog: state.frog }; diff --git a/packages/data-gpu-hopper/src/features/main/spec/lose-life.ts b/packages/data-gpu-hopper/src/features/main/spec/lose-life.ts new file mode 100644 index 000000000..f09395d2a --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/lose-life.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Frog } from "../data/values/frog/frog.js"; +import { GameStatus } from "../data/values/game-status/game-status.js"; +import type { State } from "./state.js"; + +// Spend one life: respawn the frog at the start, or end the game on the last life. A +// no-op once the game has finished. A `collision` sub-transition, not an operation of +// its own. +export const loseLife = ( + state: Pick, +): Pick => { + if (!GameStatus.isPlaying(state.status)) + return { lives: state.lives, status: state.status, frog: state.frog }; + const lives = state.lives - 1; + return lives <= 0 + ? { lives: 0, status: "gameOver", frog: state.frog } + : { lives, status: state.status, frog: Frog.start(state.width) }; +}; diff --git a/packages/data-gpu-hopper/src/features/main/spec/movement.cases.ts b/packages/data-gpu-hopper/src/features/main/spec/movement.cases.ts new file mode 100644 index 000000000..2092f6d7e --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/movement.cases.ts @@ -0,0 +1,37 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { movement } from "./movement.js"; +import { board, riverLanes } from "./case-boards.js"; + +// dt = 1 so displacements are exact. `after` is the writes patch (`entities`, `frog`). +export const cases: Conformance.SpecCases = { + cases: [ + { name: "scrolls hazards and leaves a frog on grass in place", + before: board({ entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]) }), + args: { dt: 1 }, + after: { entities: new Map([[1, { kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }]]), frog: { x: 2, y: 0 } } }, + { name: "a car does not carry the frog", + before: board({ frog: { x: 0, y: 1 }, + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]) }), + args: { dt: 1 }, + after: { entities: new Map([[1, { kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }]]), frog: { x: 0, y: 1 } } }, + { name: "a log carries the frog by its velocity", + before: board({ lanes: riverLanes, frog: { x: 1, y: 1 }, + entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 3, velocity: 1 }]]) }), + args: { dt: 1 }, + after: { entities: new Map([[1, { kind: "log", lane: 1, x: 1, width: 3, velocity: 1 }]]), frog: { x: 2, y: 1 } } }, + // Pre-scroll the log [3, 5) covers x = 4; post-scroll it wraps to [0, 2), which + // does not. The carrier comes from the pre-scroll positions. + { name: "finds the carrier from the pre-scroll positions", + before: board({ lanes: riverLanes, frog: { x: 4, y: 1 }, + entities: new Map([[1, { kind: "log", lane: 1, x: 3, width: 2, velocity: 2 }]]) }), + args: { dt: 1 }, + after: { entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 2, velocity: 2 }]]), frog: { x: 6, y: 1 } } }, + { name: "a frog in the water beside a log is not carried", + before: board({ lanes: riverLanes, frog: { x: 4, y: 1 }, + entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 3, velocity: 1 }]]) }), + args: { dt: 1 }, + after: { entities: new Map([[1, { kind: "log", lane: 1, x: 1, width: 3, velocity: 1 }]]), frog: { x: 4, y: 1 } } }, + ], +}; diff --git a/packages/data-gpu-hopper/src/features/main/spec/movement.ts b/packages/data-gpu-hopper/src/features/main/spec/movement.ts new file mode 100644 index 000000000..e696ebd5c --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/movement.ts @@ -0,0 +1,28 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Lane } from "../data/values/lane/lane.js"; +import { LaneKind } from "../data/values/lane-kind/lane-kind.js"; +import type { State } from "./state.js"; +import { whilePlaying } from "./while-playing.js"; + +// The `movement` system: scroll every hazard by `dt` and carry the frog on the log it +// rides, found from the pre-scroll positions. +export const movement = ( + state: Pick, + { dt }: { readonly dt: number }, +): Pick => + whilePlaying(state.status, { entities: state.entities, frog: state.frog }, () => { + const lane = Lane.at(state.lanes, state.frog.y); + const carrier = + lane && LaneKind.coveredOutcome[lane.kind] === "ride" + ? [...state.entities.values()].find( + (hazard) => hazard.lane === state.frog.y && Lane.coversAt(hazard.x, hazard.width, state.frog.x), + ) + : undefined; + const entities = new Map( + [...state.entities].map( + ([id, hazard]) => [id, { ...hazard, x: Lane.nextX(hazard.x, hazard.velocity, dt, state.width) }] as const, + ), + ); + const frog = carrier ? { x: state.frog.x + carrier.velocity * dt, y: state.frog.y } : state.frog; + return { entities, frog }; + }); diff --git a/packages/data-gpu-hopper/src/features/main/spec/new-game.cases.ts b/packages/data-gpu-hopper/src/features/main/spec/new-game.cases.ts new file mode 100644 index 000000000..ef4f8a267 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/new-game.cases.ts @@ -0,0 +1,31 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { newGame } from "./new-game.js"; +import { create } from "./create.js"; + +// `before` is a fully divergent mid-run state (dimensions, terrain, hazards, frog, +// lives, score, status all differ) so the reset is proven total. `after` is the +// initial game; its hazard keys compare up to an id-bijection. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "resets a mid-game store to the initial game", + before: { + width: 3, + height: 3, + lanes: [ + { row: 0, kind: "grass" }, + { row: 1, kind: "river" }, + { row: 2, kind: "goal" }, + ], + entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 2, velocity: 1 }]]), + frog: { x: 1, y: 2 }, + lives: 0, + score: 7, + status: "gameOver", + }, + after: create(), + }, + ], +}; diff --git a/packages/data-gpu-hopper/src/features/main/spec/new-game.ts b/packages/data-gpu-hopper/src/features/main/spec/new-game.ts new file mode 100644 index 000000000..56f895014 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/new-game.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import { create } from "./create.js"; + +// Start over: ignores the prior state and produces the initial game. +export const newGame = (_state: State): State => create(); diff --git a/packages/data-gpu-hopper/src/features/main/spec/public.ts b/packages/data-gpu-hopper/src/features/main/spec/public.ts new file mode 100644 index 000000000..e32307735 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/public.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { create } from "./create.js"; +export { samples } from "./samples.js"; +export { frogOutcome } from "./frog-outcome.js"; diff --git a/packages/data-gpu-hopper/src/features/main/spec/samples.ts b/packages/data-gpu-hopper/src/features/main/spec/samples.ts new file mode 100644 index 000000000..152ebe1d5 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/samples.ts @@ -0,0 +1,41 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import { create } from "./create.js"; + +// Representative full states for the projection round-trip (toState ∘ fromState ≡ +// identity; hazard keys compare up to an id-bijection): the initial game, a mid-run +// state with a fractional log-carried frog and depleted lives, and a minimal empty +// board. +export const samples: readonly State[] = [ + create(), + { + width: 5, + height: 3, + lanes: [ + { row: 0, kind: "grass" }, + { row: 1, kind: "river" }, + { row: 2, kind: "goal" }, + ], + entities: new Map([ + [1, { kind: "log", lane: 1, x: 1.5, width: 3, velocity: 1 }], + [2, { kind: "log", lane: 1, x: 4, width: 2, velocity: 1 }], + ]), + frog: { x: 2.5, y: 1 }, + lives: 1, + score: 4, + status: "playing", + }, + { + width: 4, + height: 2, + lanes: [ + { row: 0, kind: "grass" }, + { row: 1, kind: "goal" }, + ], + entities: new Map(), + frog: { x: 1, y: 0 }, + lives: 3, + score: 0, + status: "playing", + }, +]; diff --git a/packages/data-gpu-hopper/src/features/main/spec/spec.test.ts b/packages/data-gpu-hopper/src/features/main/spec/spec.test.ts new file mode 100644 index 000000000..bdab1adf7 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/spec.test.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "./spec.js"; + +// Runs every case against the pure spec functions — no ECS involved. +Conformance.checkSpec(spec); diff --git a/packages/data-gpu-hopper/src/features/main/spec/spec.ts b/packages/data-gpu-hopper/src/features/main/spec/spec.ts new file mode 100644 index 000000000..69c4e77e6 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/spec.ts @@ -0,0 +1,30 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import * as systems from "./systems.js"; + +import { cases as hop } from "./hop.cases.js"; +import { cases as newGame } from "./new-game.cases.js"; +import { cases as movement } from "./movement.cases.js"; +import { cases as collision } from "./collision.cases.js"; +import { board } from "./case-boards.js"; + +// The feature's spec: the `hop` / `newGame` actions and the `movement` / `collision` +// systems, each with its cases, plus the game-over state both systems leave unchanged. +// No entity references and no injected services, so no `schemas` or `services`. +export const spec = Conformance.spec({ + state: State, + fns: transforms, + cases: { hop, newGame }, + systems: { + fns: systems, + cases: { movement, collision }, + noOp: [ + { name: "frozen once the game is over", + before: board({ frog: { x: 2, y: 1 }, lives: 0, status: "gameOver", + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]) }), + args: { dt: 1 } }, + ], + }, +}); diff --git a/packages/data-gpu-hopper/src/features/main/spec/state.ts b/packages/data-gpu-hopper/src/features/main/spec/state.ts new file mode 100644 index 000000000..82238ef26 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/state.ts @@ -0,0 +1,28 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert } from "@adobe/data/types"; +import type { Conformance } from "@adobe/data-testing"; +import type { Lane } from "../data/values/lane/lane.js"; +import type { Frog } from "../data/values/frog/frog.js"; +import type { GameStatus } from "../data/values/game-status/game-status.js"; +import type { Hazard } from "../data/entities/hazard.js"; +import type { components } from "../data/components/index.js"; +import type { resources } from "../data/resources/index.js"; + +// The whole game as one immutable value: the specification the ECS implementation is +// verified against. Every field but `entities` is a singleton (a resource); +// `entities` holds the moving hazards keyed by a plain numeric id, the values id-less. +export type State = { + readonly width: number; + readonly height: number; + readonly lanes: readonly Lane[]; + readonly entities: ReadonlyMap; + readonly frog: Frog; + readonly lives: number; + readonly score: number; + readonly status: GameStatus; +}; + +// Singleton keys name resources; entity value keys name components. +type _Pin = Assert>; + +export * as State from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/spec/systems.ts b/packages/data-gpu-hopper/src/features/main/spec/systems.ts new file mode 100644 index 000000000..131c3513b --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/systems.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The spec of each ECS system, named exactly like it. The frame order is not authored +// here; it comes from the systems' `schedule` declarations. +export { movement } from "./movement.js"; +export { collision } from "./collision.js"; diff --git a/packages/data-gpu-hopper/src/features/main/spec/transforms.ts b/packages/data-gpu-hopper/src/features/main/spec/transforms.ts new file mode 100644 index 000000000..ba1505796 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/transforms.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The conformed operations: the `hop` and `newGame` actions. Coverage is checked at +// compile time against one `*.cases.ts` module each. The per-frame systems are in +// `systems.ts`. +export { hop } from "./hop.js"; +export { newGame } from "./new-game.js"; diff --git a/packages/data-gpu-hopper/src/features/main/spec/while-playing.ts b/packages/data-gpu-hopper/src/features/main/spec/while-playing.ts new file mode 100644 index 000000000..05ef6a369 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/while-playing.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { GameStatus } from "../data/values/game-status/game-status.js"; +import type { GameStatus as GameStatusType } from "../data/values/game-status/game-status.js"; + +// A system's game-over guard: `run()` while the game is in play, else `held` (the +// system's writes left as they are). +export const whilePlaying = (status: GameStatusType, held: W, run: () => W): W => + GameStatus.isPlaying(status) ? run() : held; diff --git a/packages/data-gpu-hopper/src/features/main/spec/win-goal.ts b/packages/data-gpu-hopper/src/features/main/spec/win-goal.ts new file mode 100644 index 000000000..08ce27f88 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/win-goal.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { GameStatus } from "../data/values/game-status/game-status.js"; +import type { State } from "./state.js"; + +// Score the reached goal and end the game as won. A no-op once the game has finished. +// A `collision` sub-transition, not an operation of its own. +export const winGoal = (state: Pick): Pick => + GameStatus.isPlaying(state.status) + ? { score: state.score + 1, status: "won" } + : { score: state.score, status: state.status }; diff --git a/packages/data-gpu-hopper/src/features/main/ui/hopper-app-plugin.ts b/packages/data-gpu-hopper/src/features/main/ui/hopper-app-plugin.ts index 8e71721be..ab0e22722 100644 --- a/packages/data-gpu-hopper/src/features/main/ui/hopper-app-plugin.ts +++ b/packages/data-gpu-hopper/src/features/main/ui/hopper-app-plugin.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { MainService } from "../services/main-service/main-service.js"; +import { MainService } from "../ecs/main-service.js"; import { HopperRenderDatabase } from "./render/hopper-render-plugin.js"; // The browser application: the headless simulation (`MainService`) plus the diff --git a/packages/data-gpu-hopper/src/features/main/ui/hopper-app/hopper-app-element.ts b/packages/data-gpu-hopper/src/features/main/ui/hopper-app/hopper-app-element.ts index 21f4b5034..5d611dd63 100644 --- a/packages/data-gpu-hopper/src/features/main/ui/hopper-app/hopper-app-element.ts +++ b/packages/data-gpu-hopper/src/features/main/ui/hopper-app/hopper-app-element.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { customElement } from "lit/decorators.js"; import { DatabaseElement, useEffect, useElement, useObservableValues } from "@adobe/data-lit"; -import type { Direction } from "../../data/direction/direction.js"; +import type { Direction } from "../../data/values/direction/direction.js"; import { HopperApp } from "../hopper-app-plugin.js"; import * as presentation from "./hopper-app-presentation.js"; import { styles } from "./hopper-app.css.js"; @@ -51,7 +51,7 @@ export class HopperAppElement extends DatabaseElement { const onKeyDown = (event: KeyboardEvent) => { const direction = keyToDirection[event.key]; if (direction === undefined) return; - service.transactions.queueHop(direction); + service.actions.hop({ direction }); event.preventDefault(); }; diff --git a/packages/data-gpu-hopper/src/features/main/ui/hopper-app/hopper-app-presentation.ts b/packages/data-gpu-hopper/src/features/main/ui/hopper-app/hopper-app-presentation.ts index 0bedbc875..81790dd35 100644 --- a/packages/data-gpu-hopper/src/features/main/ui/hopper-app/hopper-app-presentation.ts +++ b/packages/data-gpu-hopper/src/features/main/ui/hopper-app/hopper-app-presentation.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { html, type TemplateResult } from "lit"; -import type { GameStatus } from "../../data/game-status/game-status.js"; +import type { GameStatus } from "../../data/values/game-status/game-status.js"; // Pure presentation: the HUD (lives / score / status + new-game) around the // WebGPU canvas the renderer draws into. The canvas is imperative — the scheduler diff --git a/packages/data-gpu-hopper/src/features/main/ui/render/hopper-render-plugin.ts b/packages/data-gpu-hopper/src/features/main/ui/render/hopper-render-plugin.ts index e4b6a5b8e..93e4c450e 100644 --- a/packages/data-gpu-hopper/src/features/main/ui/render/hopper-render-plugin.ts +++ b/packages/data-gpu-hopper/src/features/main/ui/render/hopper-render-plugin.ts @@ -5,12 +5,12 @@ import { Mat4x4, Quat, Vec3 } from "@adobe/data/math"; import type { Aabb } from "@adobe/data/math"; import { pbrIblRender, shapeGeometry, VisibleMaterial } from "@adobe/data-gpu/graphics"; import type { Camera } from "@adobe/data-gpu/graphics"; -import { CoreDatabase } from "../../services/main-service/core-database/core-database.js"; -import { newGame } from "../../services/main-service/transaction-database/transactions/new-game.js"; -import { Frog } from "../../data/frog/frog.js"; -import { HazardKind } from "../../data/hazard-kind/hazard-kind.js"; -import { LaneKind } from "../../data/lane-kind/lane-kind.js"; -import type { Direction } from "../../data/direction/direction.js"; +import { CoreDatabase } from "../../ecs/core/core-database.js"; +import { newGame } from "../../ecs/transactions/new-game.js"; +import { Frog } from "../../data/values/frog/frog.js"; +import { HazardKind } from "../../data/values/hazard-kind/hazard-kind.js"; +import { LaneKind } from "../../data/values/lane-kind/lane-kind.js"; +import { Lane } from "../../data/values/lane/lane.js"; // Board → world mapping. The board lies on the XZ plane (Y up): game column `x` // maps to world X, game row `y` maps to world −Z (row 0 nearest the camera). The @@ -44,9 +44,11 @@ const hopperRenderPlugin = Database.Plugin.create({ resources: { // color key → the cube mesh-asset entity baked for that color. _cubeMeshByColor: { default: null as Map | null, nonPersistent: true }, + // The frog's render cube (the frog is a resource, not an entity). + _frogCube: { default: null as Entity | null, nonPersistent: true }, }, archetypes: { - // A moving cube bound to a game entity (frog / hazard). `_worldMatrix` is + // A moving cube bound to a hazard entity. `_worldMatrix` is // added by the transform system, so this renders like any `Model`. _RenderCube: ["mesh", "position", "rotation", "scale", "visible", "parent", "_gameEntity"], }, @@ -79,22 +81,16 @@ const hopperRenderPlugin = Database.Plugin.create({ environmentUrl: null, }; }, - // Reset to a fresh game. `newGame` deletes then re-inserts the frog and - // hazards, which recycles their entity ids; the render cubes are keyed by - // those ids, so a surviving cube would silently re-bind to a different-kind - // entity (a log landing on the frog's start, the frog cube drifting off to a - // hazard). Drop the render cubes here so `_renderSync` rebuilds them from the - // fresh entities next frame. + // Reset to a fresh game. `newGame` deletes then re-inserts the hazards, which + // recycles their entity ids; the render cubes are keyed by those ids, so a + // surviving cube would silently re-bind to a different hazard. Drop the render + // cubes here so `_renderSync` rebuilds them from the fresh entities next frame. startGame(t) { newGame(t); for (const arch of t.queryArchetypes(t.archetypes._RenderCube.components)) { for (let row = arch.rowCount - 1; row >= 0; row--) t.delete(arch.columns.id.get(row)); } }, - // Queue a hop; the `hopInput` system consumes it next frame. - queueHop(t, direction: Direction) { - t.resources.pendingDirection = direction; - }, }, systems: { // Keep the simulation's `frameDelta` fed from the real frame clock (the @@ -108,7 +104,7 @@ const hopperRenderPlugin = Database.Plugin.create({ // One-time scene build once the GPU device and the shared cube are ready: // bake one color material per palette entry (all reusing the shared cube - // geometry) and lay down the static lane tiles. + // geometry), lay down the static lane tiles, and add the frog's cube. _buildScene: { schedule: { during: ["update"] }, create: (db) => { @@ -164,7 +160,6 @@ const hopperRenderPlugin = Database.Plugin.create({ return meshAsset; }; - ensure(Frog.frogColor); ensure(HazardKind.hazardColor.car); ensure(HazardKind.hazardColor.log); @@ -182,16 +177,24 @@ const hopperRenderPlugin = Database.Plugin.create({ }); } + db.store.resources._frogCube = db.store.archetypes.Model.insert({ + mesh: ensure(Frog.frogColor), + position: [0, ENTITY_Y, 0], + rotation: Quat.identity, + scale: FROG_SCALE, + visible: true, + parent: 0, + }); db.store.resources._cubeMeshByColor = byColor; built = true; }; }, }, - // Every frame, reconcile the moving render cubes to the game entities and - // reposition them: create a cube for a new frog/hazard, drop a cube whose - // game entity is gone (a new game recreates them), and copy each live - // entity's board position into its cube's world transform. + // Every frame, move the frog's cube, then reconcile the hazard cubes to the + // hazard entities: create a cube for a new hazard, drop a cube whose hazard is + // gone (a new game recreates them), and copy each live hazard's board position + // into its cube's world transform. _renderSync: { schedule: { during: ["preRender"] }, create: (db) => () => { @@ -205,35 +208,16 @@ const hopperRenderPlugin = Database.Plugin.create({ for (let i = 0; i < arch.rowCount; i++) cubeByGame.set(gameCol.get(i), idCol.get(i)); } - const alive = new Set(); - const frogMesh = byColor.get(colorKey(Frog.frogColor)); - const lanes = db.store.resources.lanes; - for (const arch of db.store.queryArchetypes(["x", "y"])) { - const idCol = arch.columns.id; - const xCol = arch.columns.x; - const yCol = arch.columns.y; - for (let i = 0; i < arch.rowCount; i++) { - const gameId = idCol.get(i); - alive.add(gameId); - const y = yCol.get(i); - // On a river lane an alive frog is riding a log — lift it onto the - // log's top face instead of leaving it at ground level. - const lane = lanes.find((l) => l.row === y); - const onLog = lane !== undefined && LaneKind.coveredOutcome[lane.kind] === "ride"; - const position: Vec3 = [xCol.get(i), onLog ? FROG_Y_ON_LOG : ENTITY_Y, -y]; - const cube = cubeByGame.get(gameId); - if (cube !== undefined) { - db.store.update(cube, { position }); - } else if (frogMesh !== undefined) { - db.store.archetypes._RenderCube.insert({ - mesh: frogMesh, position, rotation: Quat.identity, scale: FROG_SCALE, - visible: true, parent: 0, _gameEntity: gameId, - }); - } - } + // On a river lane the frog is riding a log: lift it onto the log's top face. + const { frog, lanes, _frogCube } = db.store.resources; + if (_frogCube !== null) { + const lane = Lane.at(lanes, frog.y); + const onLog = lane !== undefined && LaneKind.coveredOutcome[lane.kind] === "ride"; + db.store.update(_frogCube, { position: [frog.x, onLog ? FROG_Y_ON_LOG : ENTITY_Y, -frog.y] }); } - for (const arch of db.store.queryArchetypes(["kind", "lane", "x", "width", "velocity"])) { + const alive = new Set(); + for (const arch of db.store.queryArchetypes(db.store.archetypes.Hazard.components)) { const idCol = arch.columns.id; const kindCol = arch.columns.kind; const laneCol = arch.columns.lane; diff --git a/packages/data-gpu-hopper/src/main.ts b/packages/data-gpu-hopper/src/main.ts deleted file mode 100644 index f1bf9c356..000000000 --- a/packages/data-gpu-hopper/src/main.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { render } from "lit"; -import { Hopper } from "./index.js"; - -// Mount the top-level element through its lazy wrapper (no side-effect import). -const app = document.getElementById("app"); -if (app) render(Hopper(), app); diff --git a/packages/data-gpu-hopper/tsconfig.build.json b/packages/data-gpu-hopper/tsconfig.build.json deleted file mode 100644 index 1395b61d5..000000000 --- a/packages/data-gpu-hopper/tsconfig.build.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - // The RUNTIME wall (dependency isolation). It compiles the shippable data + services - // tiers as a `composite` project, so a data/ or services/ runtime file that imports a - // test-tier file (`*.cases.ts`, `*.fake.ts`, `spec.ts`, anything under `conformance/`) - // is a COMPILE ERROR (TS6307: not in this project's file list). `ui/` and the app-shell - // (`src/*.ts`) live in the test project instead — they import test-tier only in their own - // `*.test.ts`, and keeping their (often very large) inferred element/plugin types out of - // this project avoids forcing them into serialized declarations. Emits declarations only, - // to a throwaway dir, purely to satisfy the composite contract `tsconfig.test.json` refs. - "extends": "./tsconfig.json", - "compilerOptions": { - "composite": true, - "noEmit": false, - "declaration": true, - "emitDeclarationOnly": true, - "outDir": "./.tsbuild", - "rootDir": "src" - }, - "include": ["src/features/**/*"], - "exclude": [ - "**/ui/**", - "**/*.test.ts", - "**/*.type-test.ts", - "**/*.cases.ts", - "**/*.fake.ts", - "**/spec.ts", - "**/conformance/**" - ] -} diff --git a/packages/data-gpu-hopper/tsconfig.data.json b/packages/data-gpu-hopper/tsconfig.data.json new file mode 100644 index 000000000..ed8fdef71 --- /dev/null +++ b/packages/data-gpu-hopper/tsconfig.data.json @@ -0,0 +1,16 @@ +{ + // Layer 1+2 runtime: pure data declarations (data/) and service namespaces (services/). + // Composite so higher layers reference it; a data/services file importing spec/, ecs/ + // or any test file is a compile error (TS6307: not in this project). + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/data", + "rootDir": "src" + }, + "include": ["src/features/*/data/**/*", "src/features/*/services/**/*"], + "exclude": ["**/*.test.ts", "**/*.type-test.ts"] +} diff --git a/packages/data-gpu-hopper/tsconfig.ecs.json b/packages/data-gpu-hopper/tsconfig.ecs.json new file mode 100644 index 000000000..78227f085 --- /dev/null +++ b/packages/data-gpu-hopper/tsconfig.ecs.json @@ -0,0 +1,16 @@ +{ + // Runtime: the ECS implementation (ecs/, minus conformance). References only data + + // services, so an ecs file importing spec/ is a compile error. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/ecs", + "rootDir": "src" + }, + "include": ["src/features/*/ecs/**/*"], + "exclude": ["**/conformance/**", "**/*.test.ts", "**/*.type-test.ts"], + "references": [{ "path": "./tsconfig.data.json" }] +} diff --git a/packages/data-gpu-hopper/tsconfig.spec.json b/packages/data-gpu-hopper/tsconfig.spec.json new file mode 100644 index 000000000..e4dc0a8ff --- /dev/null +++ b/packages/data-gpu-hopper/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + // Test tier: the pure spec (spec/). References only data + services, so a spec file + // importing ecs/ is a compile error. May use @adobe/data-testing. No runtime file + // imports it (runtime projects never reference it). + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/spec", + "rootDir": "src" + }, + "include": ["src/features/*/spec/**/*"], + "exclude": ["**/*.test.ts", "**/*.type-test.ts"], + "references": [{ "path": "./tsconfig.data.json" }] +} diff --git a/packages/data-gpu-hopper/tsconfig.test.json b/packages/data-gpu-hopper/tsconfig.test.json index f328ac324..0f21c5b66 100644 --- a/packages/data-gpu-hopper/tsconfig.test.json +++ b/packages/data-gpu-hopper/tsconfig.test.json @@ -1,11 +1,7 @@ { - // The NON-EMITTING tier: everything the runtime wall (`tsconfig.build.json`) doesn't own — - // the test-tier (conformance manifest + inert cases, service fakes, `*.test.ts`, - // `conformance/`), plus `ui/` and the app-shell (`src/*.ts`). It `references` the runtime - // project, so its imports of data/services resolve through that project's declarations, - // and it alone may see `@adobe/data-testing`. Nothing here emits, so large element/plugin - // types are type-checked without being serialized. `tsc -b tsconfig.test.json` builds the - // wall first, then type-checks this tier — together covering all of `src`. + // Everything else, non-emitting: ui/, app/, ecs/conformance/, and every *.test.ts. + // References the ecs and spec projects, so it sees both. `tsc -b tsconfig.test.json` + // builds the layer projects first, then type-checks this tier — together covering src. "extends": "./tsconfig.json", "compilerOptions": { "composite": false, @@ -13,14 +9,12 @@ }, "include": [ "src/*.ts", - "src/**/ui/**/*", + "src/app/**/*", + "src/features/*/ui/**/*", + "src/features/*/ecs/conformance/**/*", "src/**/*.test.ts", - "src/**/*.type-test.ts", - "src/**/*.cases.ts", - "src/**/*.fake.ts", - "src/**/spec.ts", - "src/**/conformance/**/*" + "src/**/*.type-test.ts" ], "exclude": [], - "references": [{ "path": "./tsconfig.build.json" }] + "references": [{ "path": "./tsconfig.ecs.json" }, { "path": "./tsconfig.spec.json" }] } diff --git a/packages/data-gpu/package.json b/packages/data-gpu/package.json index 5e8127b66..55527c8c5 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.10.21", + "version": "0.10.22", "description": "Adobe data WebGPU plugins and types for graphics and compute", "type": "module", "private": false, diff --git a/packages/data-lit-space-rock-game/index.html b/packages/data-lit-space-rock-game/index.html index d89d88668..444fc1952 100644 --- a/packages/data-lit-space-rock-game/index.html +++ b/packages/data-lit-space-rock-game/index.html @@ -13,6 +13,6 @@

Space Rock Game

A real-time ECS game with Lit and @adobe/data.

- + diff --git a/packages/data-lit-space-rock-game/src/app/main.ts b/packages/data-lit-space-rock-game/src/app/main.ts new file mode 100644 index 000000000..d456c9573 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/app/main.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { render } from "lit"; +import { Database } from "@adobe/data/ecs"; +import { SpaceRockGame } from "../features/main/ui/space-rock-game/space-rock-game.js"; +import { MainService } from "../features/main/ecs/main-service.js"; + +const app = document.getElementById("app"); +if (app) { + // The live Space Rock Game database (schema + indexes + transactions + computed, + // combined with the built-in rAF scheduler). Built once here and passed to + // the lazy wrapper via its `.service` seam so the upgraded element receives + // the ticking database. + const service = Database.create(MainService.plugin); + render(SpaceRockGame({ service }), app); +} diff --git a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/asteroid.ts b/packages/data-lit-space-rock-game/src/features/main/data/asteroid/asteroid.ts deleted file mode 100644 index 9e10a43b2..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/asteroid.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type Asteroid = Schema.ToType; -export * as Asteroid from "./public.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/asteroid/schema.ts deleted file mode 100644 index c7abd7d69..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/schema.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { Vec2 } from "@adobe/data/math"; -import { Size } from "../size/size.js"; - -// A drifting asteroid: position, constant velocity, and its size tier (which -// determines radius, score, and how it splits). -export const schema = Schema.fromObjectProperties( - { position: Vec2.schema, velocity: Vec2.schema, size: Size.schema }, - ["position", "velocity", "size"], -); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/bullet/bullet.ts b/packages/data-lit-space-rock-game/src/features/main/data/bullet/bullet.ts deleted file mode 100644 index 33768f44a..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/bullet/bullet.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type Bullet = Schema.ToType; -export * as Bullet from "./public.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/bullet/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/bullet/public.ts deleted file mode 100644 index 93612f42b..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/bullet/public.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -export { schema } from "./schema.js"; -export { is } from "./is.js"; -export { speed } from "./speed.js"; -export { radius } from "./radius.js"; -export { lifetime } from "./lifetime.js"; -export { isExpired } from "./is-expired.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/bullet/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/bullet/schema.ts deleted file mode 100644 index 9b6716be6..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/bullet/schema.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { Vec2, F32 } from "@adobe/data/math"; - -// A fired bullet: position, constant-speed velocity, and how many seconds it -// has been alive (`age`, used to expire it). Every field is a 32-bit number or -// numeric sub-struct, so it uses `fromStructProperties` — a guaranteed-valid -// struct schema stored in linear memory. -export const schema = Schema.fromStructProperties({ - position: Vec2.schema, - velocity: Vec2.schema, - age: F32.schema, -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/components/age.ts b/packages/data-lit-space-rock-game/src/features/main/data/components/age.ts new file mode 100644 index 000000000..8e439f635 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/components/age.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { Schema } from "@adobe/data/schema"; +import { F32 } from "@adobe/data/math"; + +// Seconds a bullet has been alive. Session scope: the game is client-local and ephemeral. +export const age = { ...F32.schema, ...Scope.session } satisfies Schema; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/components/index.ts b/packages/data-lit-space-rock-game/src/features/main/data/components/index.ts new file mode 100644 index 000000000..98971dbcb --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/components/index.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { position } from "./position.js"; +import { velocity } from "./velocity.js"; +import { size } from "./size.js"; +import { age } from "./age.js"; + +export const components = { position, velocity, size, age }; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/components/position.ts b/packages/data-lit-space-rock-game/src/features/main/data/components/position.ts new file mode 100644 index 000000000..c7783e10e --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/components/position.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { Schema } from "@adobe/data/schema"; +import { Vec2 } from "@adobe/data/math"; + +// Where a bullet or asteroid is. Session scope: the game is client-local and ephemeral. +export const position = { ...Vec2.schema, ...Scope.session } satisfies Schema; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/components/size.ts b/packages/data-lit-space-rock-game/src/features/main/data/components/size.ts new file mode 100644 index 000000000..8f2da7ef8 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/components/size.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { Schema } from "@adobe/data/schema"; +import { Size } from "../values/size/size.js"; + +// An asteroid's size tier. Session scope: the game is client-local and ephemeral. +export const size = { ...Size.schema, ...Scope.session } satisfies Schema; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/components/velocity.ts b/packages/data-lit-space-rock-game/src/features/main/data/components/velocity.ts new file mode 100644 index 000000000..5c2e17102 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/components/velocity.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { Schema } from "@adobe/data/schema"; +import { Vec2 } from "@adobe/data/math"; + +// Constant drift of a bullet or asteroid, pixels per second. Session scope: the game is client-local and ephemeral. +export const velocity = { ...Vec2.schema, ...Scope.session } satisfies Schema; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/entities/asteroid.ts b/packages/data-lit-space-rock-game/src/features/main/data/entities/asteroid.ts new file mode 100644 index 000000000..27fc43345 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/entities/asteroid.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Database } from "@adobe/data/ecs"; +import type { Assert, Equal } from "@adobe/data/types"; +import type { components } from "../components/index.js"; +import type { Asteroid as AsteroidValue } from "../values/asteroid/asteroid.js"; + +// An asteroid: its row is exactly the `Asteroid` value. +export const Asteroid = ["position", "velocity", "size"] as const satisfies Database.EntityComponents; +export type Asteroid = Database.EntityType; + +type _Pin = Assert>; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/entities/bullet.ts b/packages/data-lit-space-rock-game/src/features/main/data/entities/bullet.ts new file mode 100644 index 000000000..81edbbcf2 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/entities/bullet.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Database } from "@adobe/data/ecs"; +import type { Assert, Equal } from "@adobe/data/types"; +import type { components } from "../components/index.js"; +import type { Bullet as BulletValue } from "../values/bullet/bullet.js"; + +// A bullet: its row is exactly the `Bullet` value. +export const Bullet = ["position", "velocity", "age"] as const satisfies Database.EntityComponents; +export type Bullet = Database.EntityType; + +type _Pin = Assert>; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/entities/index.ts b/packages/data-lit-space-rock-game/src/features/main/data/entities/index.ts new file mode 100644 index 000000000..6a4f685cd --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/entities/index.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Bullet } from "./bullet.js"; +import { Asteroid } from "./asteroid.js"; + +export const entities = { Bullet, Asteroid }; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/input/input.ts b/packages/data-lit-space-rock-game/src/features/main/data/input/input.ts deleted file mode 100644 index 066ea59c3..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/input/input.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type Input = Schema.ToType; -export * as Input from "./public.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/input/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/input/schema.ts deleted file mode 100644 index 3df090a04..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/input/schema.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema, Boolean } from "@adobe/data/schema"; -import { F32 } from "@adobe/data/math"; - -// The player's intent for one tick: which way to turn (-1 left, 0 none, -// +1 right), whether the thruster is on, and whether the trigger is pulled. -export const schema = Schema.fromObjectProperties( - { turn: F32.schema, thrust: Boolean.schema, fire: Boolean.schema }, - ["turn", "thrust", "fire"], -); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/bounds.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/bounds.ts new file mode 100644 index 000000000..2d2f5419a --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/bounds.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Vec2 } from "@adobe/data/math"; + +// Play-field size [width, height]; entities wrap within it. Session scope. +export const bounds = { ...Vec2.schema, ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/frame-delta.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/frame-delta.ts new file mode 100644 index 000000000..0637fe87b --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/frame-delta.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { F32 } from "@adobe/data/math"; + +// Fixed simulation timestep, seconds per tick. Session scope. +export const frameDelta = { ...F32.schema, default: 1 / 60, ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/index.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/index.ts new file mode 100644 index 000000000..fd549eb37 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/index.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { bounds } from "./bounds.js"; +import { ship } from "./ship.js"; +import { score } from "./score.js"; +import { lives } from "./lives.js"; +import { wave } from "./wave.js"; +import { input } from "./input.js"; +import { frameDelta } from "./frame-delta.js"; + +export const resources = { bounds, ship, score, lives, wave, input, frameDelta }; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/input.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/input.ts new file mode 100644 index 000000000..1da38cf6f --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/input.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Input } from "../values/input/input.js"; + +// The player's intent, read by the systems each tick. Session scope. +export const input = { ...Input.schema, default: Input.none, ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/lives.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/lives.ts new file mode 100644 index 000000000..f17a09f23 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/lives.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Lives } from "../values/lives/lives.js"; + +// Lives remaining. Session scope. +export const lives = { ...Lives.schema, default: Lives.initial, ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/score.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/score.ts new file mode 100644 index 000000000..314e158fb --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/score.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { F32 } from "@adobe/data/math"; + +// Points scored this game. Session scope. +export const score = { ...F32.schema, ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/ship.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/ship.ts new file mode 100644 index 000000000..663afe9b8 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/ship.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Ship } from "../values/ship/ship.js"; + +// The one player ship. Session scope. +export const ship = { ...Ship.schema, default: Ship.spawn([0, 0]), ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/wave.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/wave.ts new file mode 100644 index 000000000..5ae126786 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/wave.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Wave } from "../values/wave/wave.js"; + +// The current wave number. Session scope. +export const wave = { ...Wave.schema, ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/ship/schema.ts deleted file mode 100644 index 4aecd913b..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/ship/schema.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { Vec2, F32 } from "@adobe/data/math"; - -// The player ship: where it is, how fast it drifts, and which way it points -// (`rotation` in radians; velocity persists as momentum between ticks). Every -// field is a 32-bit number or numeric sub-struct, so it uses -// `fromStructProperties` — a guaranteed-valid struct schema stored in linear -// memory. -export const schema = Schema.fromStructProperties({ - position: Vec2.schema, - velocity: Vec2.schema, - rotation: F32.schema, -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/ship.ts b/packages/data-lit-space-rock-game/src/features/main/data/ship/ship.ts deleted file mode 100644 index 5f4c7f2a5..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/ship/ship.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type Ship = Schema.ToType; -export * as Ship from "./public.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/size/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/size/schema.ts deleted file mode 100644 index b5530628b..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/size/schema.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -// The asteroid size tiers, largest first. This enum is the ONLY place the -// members are named; every consumer goes through the descriptors below. -export const schema = { - type: "string", - enum: ["large", "medium", "small"], - description: "Asteroid size tier", - default: "large", -} as const satisfies Schema; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/size/size.ts b/packages/data-lit-space-rock-game/src/features/main/data/size/size.ts deleted file mode 100644 index bba918b6f..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/size/size.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type Size = Schema.ToType; -export * as Size from "./public.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.ts deleted file mode 100644 index 06e696085..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.ts +++ /dev/null @@ -1,28 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Vec2 } from "@adobe/data/math"; -import type { State } from "./state.js"; -import { Ship } from "../ship/ship.js"; -import { spawnWave } from "./spawn-wave.js"; - -// A fresh game for a `bounds`-sized field: ship centred, no bullets, three lives, -// zero score, and the first wave of asteroids spawned in. This is a "new game" -// TRANSITION — it produces a fresh game from the `bounds` alone and deliberately -// **ignores** the prior `state` (a reset), which is exactly what the ecs `newGame` -// transaction it maps to does (it clears whatever was there). The prior state is -// still the transition's first parameter so it fits the `(state, args) => state` -// shape the co-located conformance cases derive their `args` type from. -export const createInitial = ( - _state: State, - { bounds }: { readonly bounds: Vec2 }, -): State => { - const fresh: State = { - bounds, - ship: Ship.spawn(Vec2.scale(bounds, 0.5)), - entities: new Map(), - score: 0, - lives: 3, - wave: 0, - }; - // spawnWave returns only { entities, wave }; layer it over the fresh game. - return { ...fresh, ...spawnWave(fresh) }; -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/create.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/create.test.ts deleted file mode 100644 index 661be7851..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/create.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { State } from "./state.js"; - -describe("State.create", () => { - it("is a blank neutral state: no field, idle ship, empty, full lives, wave 0", () => { - const state = State.create(); - expect(state.bounds).toEqual([0, 0]); - expect(state.ship.velocity).toEqual([0, 0]); - expect(state.entities).toEqual(new Map()); - expect(state.score).toBe(0); - expect(state.lives).toBe(3); - expect(state.wave).toBe(0); - }); - - it("is not game over", () => { - expect(State.isGameOver(State.create())).toBe(false); - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/create.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/create.ts deleted file mode 100644 index c76653adc..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/create.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; -import { Ship } from "../ship/ship.js"; - -// A blank neutral `State`: no play-field, a centred-at-origin idle ship, no -// entities, full lives, zero score, wave 0. Unlike `createInitial` it spawns no -// wave — it is the base every transform test builds a `before` from -// (`{ ...State.create(), …overrides }`), so each case names only the fields it -// exercises. -export const create = (): State => ({ - bounds: [0, 0], - ship: Ship.spawn([0, 0]), - entities: new Map(), - score: 0, - lives: 3, - wave: 0, -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts deleted file mode 100644 index 8f55fb6a9..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; -import { Bullet } from "../bullet/bullet.js"; -import { Ship } from "../ship/ship.js"; - -// Fire one bullet from the ship's nose, inheriting its momentum. Composes the -// ship's muzzle kinematics with the bullet's own speed constant, minting a fresh -// entity id (the map key) for it — the bullet value itself carries no id. -export const fireBullet = ( - state: Pick, -): Pick => { - const { position, velocity } = Ship.muzzle(state.ship, Bullet.speed); - const bullet: Bullet = { position, velocity, age: 0 }; - const id = Math.max(0, ...state.entities.keys()) + 1; - return { entities: new Map(state.entities).set(id, bullet) }; -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.test.ts deleted file mode 100644 index 23e827662..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { State } from "./state.js"; - -describe("State.isGameOver", () => { - it("is over once lives reach zero", () => { - expect(State.isGameOver({ lives: 0 })).toBe(true); - }); - - it("is not over while a life remains", () => { - expect(State.isGameOver({ lives: 1 })).toBe(false); - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.ts deleted file mode 100644 index 84665f913..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; - -// The game is over once every life is spent. -export const isGameOver = (state: Pick): boolean => - state.lives <= 0; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/public.ts deleted file mode 100644 index 86f77fe1e..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/public.ts +++ /dev/null @@ -1,15 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -export { create } from "./create.js"; -export { samples } from "./samples.js"; -export { createInitial } from "./create-initial.js"; -export { stepShip } from "./step-ship.js"; -export { fireBullet } from "./fire-bullet.js"; -export { stepBullets } from "./step-bullets.js"; -export { stepAsteroids } from "./step-asteroids.js"; -export { resolveBulletHits } from "./resolve-bullet-hits.js"; -export { resolveShipHits } from "./resolve-ship-hits.js"; -export { spawnWave } from "./spawn-wave.js"; -export { spawnRandomWave } from "./spawn-random-wave.js"; -export { isGameOver } from "./is-game-over.js"; -export { step } from "./step.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.cases.ts deleted file mode 100644 index 064e0ac23..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.cases.ts +++ /dev/null @@ -1,201 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { resolveBulletHits } from "./resolve-bullet-hits.js"; -import { create } from "./create.js"; - -// Inert, spec-owned cases (args is `{ dt }`). `resolveBulletHits` has no same-named -// ecs op — the collision system dispatches the `hitAsteroid` transaction per hit and -// is conformed by the detection/tick-loop tests — so `checkFeature` skips it; the -// pure `checkSpec` verifies it here. Detection is SWEPT: each bullet's path this -// frame is the segment [position - velocity*dt, position], and it destroys the first -// asteroid that segment passes through, scores it (large 20 / medium 50 / small 100), -// and replaces it with its split children (large→2 medium, medium→2 small, -// small→none). Stationary parents give children velocity [0,0]. Bullet.radius 2, -// asteroid radii 40/20/10. Every case keeps each bullet overlapping at most one -// asteroid, so the outcome is order-independent (collections compare as multisets). -const field = { ...create(), bounds: [800, 600] as [number, number] }; - -export const cases: Conformance.SpecCases = { - cases: [ - { - name: "destroys bullet + asteroid, scores, and spawns split children (large → 2 medium)", - before: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [50, 50], velocity: [0, 0], size: "large" }], - ]), - score: 0, - }, - args: { dt: 1 / 60 }, - after: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], size: "medium" }], - [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], - ]), - score: 20, - }, - }, - { - name: "medium splits into two small", - before: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], - ]), - score: 5, - }, - args: { dt: 1 / 60 }, - after: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], size: "small" }], - [2, { position: [50, 50], velocity: [0, 0], size: "small" }], - ]), - score: 55, - }, - }, - { - name: "the smallest tier is destroyed outright — no children", - before: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [50, 50], velocity: [0, 0], size: "small" }], - ]), - score: 0, - }, - args: { dt: 1 / 60 }, - after: { ...field, entities: new Map(), score: 100 }, - }, - { - name: "a bullet that hits nothing is left untouched", - before: { - ...field, - entities: new Map([ - [1, { position: [10, 10], velocity: [0, 0], age: 0 }], - [2, { position: [500, 500], velocity: [0, 0], size: "large" }], - ]), - score: 7, - }, - args: { dt: 1 / 60 }, - after: { - ...field, - entities: new Map([ - [1, { position: [10, 10], velocity: [0, 0], age: 0 }], - [2, { position: [500, 500], velocity: [0, 0], size: "large" }], - ]), - score: 7, - }, - }, - { - name: "only the overlapping asteroid is hit; distant ones remain", - before: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [50, 50], velocity: [0, 0], size: "large" }], - [3, { position: [500, 500], velocity: [0, 0], size: "small" }], - ]), - score: 0, - }, - args: { dt: 1 / 60 }, - after: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], size: "medium" }], - [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], - [3, { position: [500, 500], velocity: [0, 0], size: "small" }], - ]), - score: 20, - }, - }, - { - name: "two bullets each destroy their own asteroid", - before: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [500, 500], velocity: [0, 0], age: 0 }], - [3, { position: [50, 50], velocity: [0, 0], size: "small" }], - [4, { position: [500, 500], velocity: [0, 0], size: "small" }], - ]), - score: 0, - }, - args: { dt: 1 / 60 }, - after: { ...field, entities: new Map(), score: 200 }, - }, - { - name: "split children are not hittable by another bullet in the same pass", - before: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [50, 50], velocity: [0, 0], age: 0 }], - [3, { position: [50, 50], velocity: [0, 0], size: "large" }], - ]), - score: 0, - }, - args: { dt: 1 / 60 }, - after: { - ...field, - // One bullet destroys the large (→ 2 medium). The second finds no original - // target — the large is gone and its children, spawned this same pass, are - // not yet hittable — so it survives. - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], - [3, { position: [50, 50], velocity: [0, 0], size: "medium" }], - ]), - score: 20, - }, - }, - { - name: "boundary: distance exactly equal to the radius sum still overlaps", - before: { - ...field, - entities: new Map([ - [1, { position: [0, 0], velocity: [0, 0], age: 0 }], - [2, { position: [42, 0], velocity: [0, 0], size: "large" }], - ]), - score: 0, - }, - args: { dt: 1 / 60 }, - after: { - ...field, - entities: new Map([ - [1, { position: [42, 0], velocity: [0, 0], size: "medium" }], - [2, { position: [42, 0], velocity: [0, 0], size: "medium" }], - ]), - score: 20, - }, - }, - { - name: "a fast bullet whose path sweeps through a medium destroys it (no tunnelling)", - before: { - ...field, - // Over dt=1/60 the bullet travels 50px: prev = [0,0] - [-3000,0]/60 = [50,0]. - // Both endpoints are 25px from the medium at [25,0] — outside the 22px radius - // sum, so a point test misses. The travelled segment crosses [25,0], so a - // swept test hits. - entities: new Map([ - [1, { position: [0, 0], velocity: [-3000, 0], age: 0 }], - [2, { position: [25, 0], velocity: [0, 0], size: "medium" }], - ]), - score: 0, - }, - args: { dt: 1 / 60 }, - after: { - ...field, - entities: new Map([ - [1, { position: [25, 0], velocity: [0, 0], size: "small" }], - [2, { position: [25, 0], velocity: [0, 0], size: "small" }], - ]), - score: 50, - }, - }, - ], -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.cases.ts deleted file mode 100644 index 2608d0cff..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.cases.ts +++ /dev/null @@ -1,97 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { resolveShipHits } from "./resolve-ship-hits.js"; -import { create } from "./create.js"; -import { Ship } from "../ship/ship.js"; - -// Inert, spec-owned cases (no args). `resolveShipHits` has no same-named ecs op — -// the collision system dispatches the `loseLife` transaction when the ship is struck -// and is conformed by the detection/tick-loop tests — so `checkFeature` skips it; the -// pure `checkSpec` verifies it here. A touching asteroid costs one life (floored at -// 0) and respawns the ship at the field centre; otherwise the state is untouched. -// Field 200×200 → centre [100,100]; Ship.radius 12, large asteroid radius 40. -// Respawn = Ship.spawn(centre) = { [100,100], [0,0], −π/2 }; the asteroids are left -// in place. -const field = { ...create(), bounds: [200, 200] as [number, number] }; -const respawned = Ship.spawn([100, 100]); - -export const cases: Conformance.SpecCases = { - cases: [ - { - name: "an asteroid on the ship costs a life and respawns it at centre", - before: { - ...field, - ship: { position: [10, 10], velocity: [5, 5], rotation: 1 }, - entities: new Map([[1, { position: [10, 10], velocity: [0, 0], size: "large" }]]), - lives: 3, - }, - after: { - ...field, - ship: respawned, - entities: new Map([[1, { position: [10, 10], velocity: [0, 0], size: "large" }]]), - lives: 2, - }, - }, - { - name: "no asteroid touching the ship is a no-op", - before: { - ...field, - ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - entities: new Map([[1, { position: [500, 500], velocity: [0, 0], size: "large" }]]), - lives: 3, - }, - after: { - ...field, - ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - entities: new Map([[1, { position: [500, 500], velocity: [0, 0], size: "large" }]]), - lives: 3, - }, - }, - { - name: "lives never drop below zero, and the ship still respawns", - before: { - ...field, - ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - entities: new Map([[1, { position: [10, 10], velocity: [0, 0], size: "large" }]]), - lives: 0, - }, - after: { - ...field, - ship: respawned, - entities: new Map([[1, { position: [10, 10], velocity: [0, 0], size: "large" }]]), - lives: 0, - }, - }, - { - name: "boundary: distance exactly equal to the radius sum still counts as a hit", - before: { - ...field, - ship: { position: [0, 0], velocity: [0, 0], rotation: 0 }, - entities: new Map([[1, { position: [52, 0], velocity: [0, 0], size: "large" }]]), - lives: 3, - }, - after: { - ...field, - ship: respawned, - entities: new Map([[1, { position: [52, 0], velocity: [0, 0], size: "large" }]]), - lives: 2, - }, - }, - { - name: "an empty field is a no-op", - before: { - ...field, - ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - entities: new Map(), - lives: 3, - }, - after: { - ...field, - ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - entities: new Map(), - lives: 3, - }, - }, - ], -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.cases.ts deleted file mode 100644 index 13de73a5a..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.cases.ts +++ /dev/null @@ -1,56 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { spawnRandomWave } from "./spawn-random-wave.js"; -import { create } from "./create.js"; - -// Inert, spec-owned cases for the value-returning, service-injected -// `spawnRandomWave`. The injected `random` port is DATA-only here: its draws are -// scheduled in `responses` (the case owns the sequence), and the runner synthesizes -// the recording double from the manifest's `services` template. `next` is a -// value-returning read, not a fire-and-forget effect, so it is NOT declared in -// `effects`. Shared with the ecs `spawnRandomWave` action (the transaction is -// system-dispatched and skipped by name). -// -// A spawn draws exactly 4 values (one per asteroid at `asteroidsFor(1) = 4`). Field -// 200×200 → centre [100,100], ring radius 80; positions match `spawnWave`, only -// drift SPEED is jittered: `speed(i) = 60·(0.5 + sequence[i])` → [30, 60, 45, 75] in -// ring order. -const field = { ...create(), bounds: [200, 200] as [number, number] }; - -export const cases: Conformance.SpecCases = { - cases: [ - { - name: "spawns a randomized wave (jittered drift speeds) when the field is clear", - before: { ...field, entities: new Map(), wave: 0 }, - responses: { random: { next: [0, 0.5, 0.25, 0.75] } }, - after: { - ...field, - wave: 1, - entities: new Map([ - [1, { position: [180, 100], velocity: [0, 30], size: "large" }], - [2, { position: [100, 180], velocity: [-60, 0], size: "large" }], - [3, { position: [20, 100], velocity: [0, -45], size: "large" }], - [4, { position: [100, 20], velocity: [75, 0], size: "large" }], - ]), - }, - }, - { - name: "does nothing while asteroids still remain", - before: { - ...field, - wave: 1, - entities: new Map([ - [1, { position: [10, 10], velocity: [0, 0], size: "large" }], - ]), - }, - after: { - ...field, - wave: 1, - entities: new Map([ - [1, { position: [10, 10], velocity: [0, 0], size: "large" }], - ]), - }, - }, - ], -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts deleted file mode 100644 index 4af88abbb..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts +++ /dev/null @@ -1,53 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Vec2 } from "@adobe/data/math"; -import type { State } from "./state.js"; -import { Asteroid } from "../asteroid/asteroid.js"; -import { Size } from "../size/size.js"; -import { Motion } from "../motion/motion.js"; -import type { Services } from "../../services/services.js"; - -// Base drift speed; each rock's actual speed is jittered around it. -const waveSpeed = 60; -// Each cleared wave spawns this many more rocks than the last. -const asteroidsFor = (wave: number): number => 3 + wave; - -/** - * The randomized sibling of {@link spawnWave} (the todo `createRandomTodo` ↔ - * `createTodo` split): it takes an **injected `random` service** — keyed by the - * service name minus its `-service` suffix (`state.md`) — so the per-asteroid - * variation cannot be computed from `state` alone. Still **deterministic given - * the service**: inject a fixed sequence and the wave is fixed, which is how it - * is unit-tested and how the ECS `spawnRandomWave` transaction conforms to it. - * - * The ring layout (angles/positions) matches `spawnWave` exactly; only each - * rock's drift SPEED is scaled — `waveSpeed · (0.5 + random.next())`, i.e. - * `[0.5×, 1.5×)` — drawing one value per asteroid in ring order. A no-op while - * asteroids remain (draws nothing, returns the same reference). - */ -export const spawnRandomWave = ( - state: Pick, - { random }: Pick, -): Pick => { - const hasAsteroid = [...state.entities.values()].some((v) => Asteroid.is(v)); - if (hasAsteroid) { - return { entities: state.entities, wave: state.wave }; - } - const wave = state.wave + 1; - const count = asteroidsFor(wave); - const center = Vec2.scale(state.bounds, 0.5); - const ring = Math.min(state.bounds[0], state.bounds[1]) * 0.4; - const entities = new Map(state.entities); - let nextId = Math.max(0, ...state.entities.keys()) + 1; - for (let i = 0; i < count; i++) { - const angle = (Math.PI * 2 * i) / count; - const outward = Motion.rotate([1, 0], angle); - const tangent = Motion.rotate([1, 0], angle + Math.PI / 2); - const speed = waveSpeed * (0.5 + random.next()); - entities.set(nextId++, { - position: Vec2.add(center, Vec2.scale(outward, ring)), - velocity: Vec2.scale(tangent, speed), - size: Size.largest, - }); - } - return { wave, entities }; -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.cases.ts deleted file mode 100644 index 9100699d4..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.cases.ts +++ /dev/null @@ -1,51 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { spawnWave } from "./spawn-wave.js"; -import { create } from "./create.js"; - -// Inert, spec-owned cases for the deterministic `spawnWave` (no args) — the fixed -// FIRST wave `createInitial` seeds (its randomized refill sibling is -// `spawnRandomWave`). When the field is clear it bumps the wave and spawns a ring of -// large asteroids around the centre, each drifting tangentially at 60px/s; while -// asteroids remain it is a no-op. Field 200×200 → centre [100,100], ring radius 80. -// From wave 0 the count is asteroidsFor(1)=4, so the ring lands on the four clean -// quadrant angles. `spawnWave` has no ecs op — `checkFeature` skips it; the pure -// `checkSpec` verifies it here. -const field = { ...create(), bounds: [200, 200] as [number, number] }; - -export const cases: Conformance.SpecCases = { - cases: [ - { - name: "spawns the next wave of large asteroids when the field is clear", - before: { ...field, entities: new Map(), wave: 0 }, - after: { - ...field, - wave: 1, - entities: new Map([ - [1, { position: [180, 100], velocity: [0, 60], size: "large" }], - [2, { position: [100, 180], velocity: [-60, 0], size: "large" }], - [3, { position: [20, 100], velocity: [0, -60], size: "large" }], - [4, { position: [100, 20], velocity: [60, 0], size: "large" }], - ]), - }, - }, - { - name: "does nothing while asteroids still remain", - before: { - ...field, - wave: 1, - entities: new Map([ - [1, { position: [10, 10], velocity: [0, 0], size: "large" }], - ]), - }, - after: { - ...field, - wave: 1, - entities: new Map([ - [1, { position: [10, 10], velocity: [0, 0], size: "large" }], - ]), - }, - }, - ], -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts deleted file mode 100644 index c81a4bf41..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts +++ /dev/null @@ -1,43 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Vec2 } from "@adobe/data/math"; -import type { State } from "./state.js"; -import { Asteroid } from "../asteroid/asteroid.js"; -import { Size } from "../size/size.js"; -import { Motion } from "../motion/motion.js"; - -// Drift speed of freshly-spawned asteroids. -const waveSpeed = 60; -// Each cleared wave spawns this many more rocks than the last. -const asteroidsFor = (wave: number): number => 3 + wave; - -// When the field is clear, advance to the next wave: spawn a ring of large -// asteroids around the centre (clear of the ship's spawn), each drifting -// tangentially at a fixed speed. Deterministic — this seeds the FIRST wave -// (`createInitial`, so a fresh game always starts from the same fair layout). -// The randomized sibling `spawnRandomWave` injects a `random` service for the -// varied refill waves the tick loop spawns. -export const spawnWave = ( - state: Pick, -): Pick => { - const hasAsteroid = [...state.entities.values()].some((v) => Asteroid.is(v)); - if (hasAsteroid) { - return { entities: state.entities, wave: state.wave }; - } - const wave = state.wave + 1; - const count = asteroidsFor(wave); - const center = Vec2.scale(state.bounds, 0.5); - const ring = Math.min(state.bounds[0], state.bounds[1]) * 0.4; - const entities = new Map(state.entities); - let nextId = Math.max(0, ...state.entities.keys()) + 1; - for (let i = 0; i < count; i++) { - const angle = (Math.PI * 2 * i) / count; - const outward = Motion.rotate([1, 0], angle); - const tangent = Motion.rotate([1, 0], angle + Math.PI / 2); - entities.set(nextId++, { - position: Vec2.add(center, Vec2.scale(outward, ring)), - velocity: Vec2.scale(tangent, waveSpeed), - size: Size.largest, - }); - } - return { wave, entities }; -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts deleted file mode 100644 index 727baba0d..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "./spec.js"; - -// The single pure-spec test for every transform in this folder. It runs the -// manifest's inert cases against the pure functions, synthesizing each injected -// service's recording double from the `services` templates + the case's `responses`, -// and asserting `after` (up to an id-bijection) and declared `effects`. -Conformance.checkSpec(spec); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.ts deleted file mode 100644 index bba4d5b43..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import * as transforms from "./transforms.js"; -import { MainService } from "../../services/main-service/main-service.js"; -import { projection } from "../../services/main-service/conformance/projection.js"; -import { createFake as random } from "../../services/random-service/random.fake.js"; - -import { cases as createInitial } from "./create-initial.cases.js"; -import { cases as fireBullet } from "./fire-bullet.cases.js"; -import { cases as spawnWave } from "./spawn-wave.cases.js"; -import { cases as spawnRandomWave } from "./spawn-random-wave.cases.js"; -import { cases as stepShip } from "./step-ship.cases.js"; -import { cases as stepBullets } from "./step-bullets.cases.js"; -import { cases as stepAsteroids } from "./step-asteroids.cases.js"; -import { cases as resolveBulletHits } from "./resolve-bullet-hits.cases.js"; -import { cases as resolveShipHits } from "./resolve-ship-hits.cases.js"; -import { cases as step } from "./step.cases.js"; - -// The feature's conformance manifest — the single object `spec.test.ts` (pure) and -// `conformance.test.ts` (ecs) both import. It wires each conformed fn to its inert cases module explicitly: `cases` names one module per conformed fn, -// checked at COMPILE TIME against `transforms` (a fn without cases, or cases without -// a fn, won't type). `spawnRandomWave` and `step` inject the `random` port, so -// `services` supplies its shape-only recording template; each case owns the draws via -// its `responses`. Space-rock has no `state/` derivations (so no `computedPlugin`, no -// `hydrate`) and no entity-reference args (so no case declares an `args` schema). -// -// `checkFeature` pairs each ecs op to its same-named transition: `createInitial` -// (transaction), `fireBullet` (transaction + action), and `spawnRandomWave` (action — -// its transaction is renamed `refillWave`, the system-dispatched form, so it is not -// paired and never receives the stripped transaction args). Every per-frame transition -// has no same-named op and is skipped there; the system tick-loop test conforms them. -// -// This is a test-tier module (excluded from the runtime program) — the ONE place the -// feature touches `@adobe/data-testing`. No transform or `*.cases.ts` file does. -export const spec = Conformance.feature({ - state: State, - fns: transforms, - plugin: MainService.plugin, - projection, - services: { random }, - cases: { - createInitial, - fireBullet, - spawnWave, - spawnRandomWave, - stepShip, - stepBullets, - stepAsteroids, - resolveBulletHits, - resolveShipHits, - step, - }, -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/state.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/state.ts deleted file mode 100644 index f33eef638..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/state.ts +++ /dev/null @@ -1,21 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Vec2 } from "@adobe/data/math"; -import type { Ship } from "../ship/ship.js"; -import type { Bullet } from "../bullet/bullet.js"; -import type { Asteroid } from "../asteroid/asteroid.js"; - -// The whole game modelled as one immutable value — the pure specification the -// ECS implementation is verified against. Everything advances by `dt` each -// tick via `State.step`. -export type State = { - readonly bounds: Vec2; // play-field size [width, height]; entities wrap within it - readonly ship: Ship; - // Every bullet and asteroid, in ONE identity-keyed map — the value carries no - // id (identity is the key), and each entry is discriminated by its own shape - // (`Bullet.is` / `Asteroid.is`). Membership queries scan + guard. - readonly entities: ReadonlyMap; - readonly score: number; - readonly lives: number; - readonly wave: number; -}; -export * as State from "./public.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.cases.ts deleted file mode 100644 index 373cd2ca9..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.cases.ts +++ /dev/null @@ -1,59 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { stepAsteroids } from "./step-asteroids.js"; -import { create } from "./create.js"; -import { Size } from "../size/size.js"; - -// Inert, spec-owned cases (args is `{ dt }`). `stepAsteroids` has no ecs op — the -// asteroid half of the per-frame `movement` system reproduces it — so `checkFeature` -// skips it; the pure `checkSpec` verifies it here. The 100×100 field forces wrap. -// Asteroids drift by constant velocity, so `after` is exact. -const field = { ...create(), bounds: [100, 100] as [number, number] }; - -export const cases: Conformance.SpecCases = { - cases: [ - { - name: "drifts an asteroid by its velocity", - before: { ...field, entities: new Map([[1, { position: [10, 10], velocity: [30, 0], size: Size.largest }]]) }, - args: { dt: 1 }, - after: { ...field, entities: new Map([[1, { position: [40, 10], velocity: [30, 0], size: Size.largest }]]) }, - }, - { - name: "wraps an asteroid around the toroidal field", - before: { ...field, entities: new Map([[1, { position: [80, 80], velocity: [50, 50], size: Size.largest }]]) }, - args: { dt: 1 }, - after: { ...field, entities: new Map([[1, { position: [30, 30], velocity: [50, 50], size: Size.largest }]]) }, - }, - { - name: "wraps negatively across the left edge", - before: { ...field, entities: new Map([[1, { position: [10, 10], velocity: [-50, 0], size: "medium" }]]) }, - args: { dt: 1 }, - after: { ...field, entities: new Map([[1, { position: [60, 10], velocity: [-50, 0], size: "medium" }]]) }, - }, - { - name: "advances several asteroids of different sizes independently", - before: { - ...field, - entities: new Map([ - [1, { position: [10, 10], velocity: [10, 0], size: "large" }], - [2, { position: [20, 20], velocity: [0, 10], size: "small" }], - ]), - }, - args: { dt: 1 }, - after: { - ...field, - entities: new Map([ - [1, { position: [20, 10], velocity: [10, 0], size: "large" }], - [2, { position: [20, 30], velocity: [0, 10], size: "small" }], - ]), - }, - }, - { - name: "an empty field stays empty", - before: { ...field, entities: new Map() }, - args: { dt: 1 }, - after: { ...field, entities: new Map() }, - }, - ], -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.cases.ts deleted file mode 100644 index 5fd769afc..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.cases.ts +++ /dev/null @@ -1,63 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { stepBullets } from "./step-bullets.js"; -import { create } from "./create.js"; -import { Bullet } from "../bullet/bullet.js"; - -// Inert, spec-owned cases (args is `{ dt }`). `stepBullets` has no ecs op — the -// per-frame `lifetime` system reproduces its advance/age/expire path — so -// `checkFeature` skips it; the pure `checkSpec` verifies it here. `Bullet.lifetime` -// is 1.2; the 100×100 field forces wrap. Covers move+age, wrap, expiry on the -// boundary, survival just under it, mixed drop, and the empty list. -const field = { ...create(), bounds: [100, 100] as [number, number] }; - -export const cases: Conformance.SpecCases = { - cases: [ - { - name: "moves and ages a live bullet", - before: { ...field, entities: new Map([[1, { position: [10, 50], velocity: [100, 0], age: 0 }]]) }, - args: { dt: 0.1 }, - after: { ...field, entities: new Map([[1, { position: [20, 50], velocity: [100, 0], age: 0.1 }]]) }, - }, - { - name: "wraps a bullet across the right edge", - before: { ...field, entities: new Map([[1, { position: [95, 50], velocity: [100, 0], age: 0 }]]) }, - args: { dt: 0.1 }, - after: { ...field, entities: new Map([[1, { position: [5, 50], velocity: [100, 0], age: 0.1 }]]) }, - }, - { - name: "drops a bullet that expires this tick (age + dt ≥ lifetime)", - before: { - ...field, - entities: new Map([[1, { position: [10, 50], velocity: [100, 0], age: Bullet.lifetime }]]), - }, - args: { dt: 0.1 }, - after: { ...field, entities: new Map() }, - }, - { - name: "keeps and ages a bullet still under its lifetime", - before: { ...field, entities: new Map([[1, { position: [10, 50], velocity: [0, 0], age: 1.0 }]]) }, - args: { dt: 0.1 }, - after: { ...field, entities: new Map([[1, { position: [10, 50], velocity: [0, 0], age: 1.1 }]]) }, - }, - { - name: "advances survivors and drops only the expired bullet", - before: { - ...field, - entities: new Map([ - [1, { position: [10, 50], velocity: [100, 0], age: 0 }], - [2, { position: [10, 60], velocity: [100, 0], age: Bullet.lifetime }], - ]), - }, - args: { dt: 0.1 }, - after: { ...field, entities: new Map([[1, { position: [20, 50], velocity: [100, 0], age: 0.1 }]]) }, - }, - { - name: "an empty list stays empty", - before: { ...field, entities: new Map() }, - args: { dt: 0.1 }, - after: { ...field, entities: new Map() }, - }, - ], -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.cases.ts deleted file mode 100644 index ce381e423..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.cases.ts +++ /dev/null @@ -1,63 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { stepShip } from "./step-ship.js"; -import type { Input } from "../input/input.js"; -import { create } from "./create.js"; - -// Inert, spec-owned cases (args is `{ dt, input }`). `stepShip` has no ecs op — the -// per-frame system loop (`control` + the ship half of `movement`) reproduces it — -// so `checkFeature` skips it; the pure `checkSpec` verifies it here. Geometry chosen -// so every `after` is exact (turnRate 3, thrustAccel 200, field 100×100 to force -// wrap). -const field = { ...create(), bounds: [100, 100] as [number, number] }; -const idle: Input = { turn: 0, thrust: false, fire: false }; - -export const cases: Conformance.SpecCases = { - cases: [ - { - name: "turns right by a positive turn input", - before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, - args: { dt: 1, input: { turn: 1, thrust: false, fire: false } }, - after: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 3 } }, - }, - { - name: "turns left by a negative turn input", - before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, - args: { dt: 1, input: { turn: -1, thrust: false, fire: false } }, - after: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: -3 } }, - }, - { - name: "no turn holds rotation and coasts by velocity", - before: { ...field, ship: { position: [50, 50], velocity: [10, 0], rotation: 0.7 } }, - args: { dt: 1, input: idle }, - after: { ...field, ship: { position: [60, 50], velocity: [10, 0], rotation: 0.7 } }, - }, - { - name: "thrusts along the facing, then coasts by the new velocity", - before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, - args: { dt: 0.1, input: { turn: 0, thrust: true, fire: false } }, - after: { ...field, ship: { position: [52, 50], velocity: [20, 0], rotation: 0 } }, - }, - { - name: "wraps across the right edge", - before: { ...field, ship: { position: [95, 50], velocity: [100, 0], rotation: 0 } }, - args: { dt: 0.1, input: idle }, - after: { ...field, ship: { position: [5, 50], velocity: [100, 0], rotation: 0 } }, - }, - { - name: "wraps across the top edge (negative wrap)", - before: { ...field, ship: { position: [5, 5], velocity: [0, -100], rotation: 0 } }, - args: { dt: 0.1, input: idle }, - after: { ...field, ship: { position: [5, 95], velocity: [0, -100], rotation: 0 } }, - }, - { - // Turn then thrust: −3 turns to 0, and thrust must use the NEW rotation 0 - // (facing +x → velocity [200,0]); using the old −3 would point elsewhere. - name: "turn composes before thrust — thrust uses the post-turn rotation", - before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: -3 } }, - args: { dt: 1, input: { turn: 1, thrust: true, fire: false } }, - after: { ...field, ship: { position: [50, 50], velocity: [200, 0], rotation: 0 } }, - }, - ], -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts deleted file mode 100644 index 90d14181d..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts +++ /dev/null @@ -1,25 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; -import { Ship } from "../ship/ship.js"; -import type { Input } from "../input/input.js"; -import { Motion } from "../motion/motion.js"; - -// Advance the ship one tick: turn, optionally thrust, then coast by its velocity -// and wrap at the screen edges. `dt` and `input` are bundled into one args object -// (second parameter) so the co-located conformance cases derive their `args` type -// straight from this signature (`Conformance`). -export const stepShip = ( - state: Pick, - { dt, input }: { readonly dt: number; readonly input: Input }, -): Pick => { - const { ship } = state; - const rotation = Ship.turn(ship.rotation, input.turn, dt); - const velocity = input.thrust - ? Ship.thrust(ship.velocity, rotation, dt) - : ship.velocity; - const position = Motion.wrap( - Motion.advance(ship.position, velocity, dt), - state.bounds, - ); - return { ship: { position, velocity, rotation } }; -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step.cases.ts deleted file mode 100644 index 92bd79170..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step.cases.ts +++ /dev/null @@ -1,138 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { step } from "./step.js"; -import { Input } from "../input/input.js"; -import { Ship } from "../ship/ship.js"; - -// Inert, spec-owned cases for the whole-tick `step` (args is `{ dt, input }`; the -// injected `random` port is stripped from the case args and synthesized by the -// runner from the manifest's `services` template). `step` has no ecs transaction — -// the per-frame system loop conforms it in `system-database/tick-loop.test.ts` -// (see systems.md), which also reads these cases — so `checkFeature` skips it while -// the pure `checkSpec` verifies it here. -// -// Each case exercises one branch of the step pipeline — advance/wrap, fire, -// bullet↔asteroid resolution, ship↔asteroid resolution, and the game-over freeze — -// with geometry chosen so every `after` is exact. NONE clears the field, so `random` -// is never drawn; the randomized refill is conformed out-of-band via the -// `spawnRandomWave` cases. -export const cases: Conformance.SpecCases = { - cases: [ - { - name: "advances and wraps every body (movement)", - before: { - bounds: [200, 200], - ship: { position: [190, 100], velocity: [30, 0], rotation: 0 }, - entities: new Map([[1, { position: [190, 180], velocity: [30, 30], size: "large" }]]), - score: 0, - lives: 3, - wave: 1, - }, - args: { dt: 1, input: Input.none }, - after: { - bounds: [200, 200], - ship: { position: [20, 100], velocity: [30, 0], rotation: 0 }, - entities: new Map([[1, { position: [20, 10], velocity: [30, 30], size: "large" }]]), - score: 0, - lives: 3, - wave: 1, - }, - }, - { - name: "fires from the post-move muzzle and advances the new bullet (lifetime)", - before: { - bounds: [400, 400], - ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - entities: new Map([[1, { position: [350, 350], velocity: [0, 0], size: "large" }]]), - score: 0, - lives: 3, - wave: 1, - }, - args: { dt: 0.1, input: { turn: 0, thrust: false, fire: true } }, - after: { - bounds: [400, 400], - ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - entities: new Map([ - [1, { position: [152, 100], velocity: [400, 0], age: 0.1 }], - [2, { position: [350, 350], velocity: [0, 0], size: "large" }], - ]), - score: 0, - lives: 3, - wave: 1, - }, - }, - { - name: "resolves a bullet↔asteroid hit (split + score)", - before: { - bounds: [800, 600], - ship: { position: [700, 500], velocity: [0, 0], rotation: 0 }, - entities: new Map([ - [1, { position: [100, 100], velocity: [0, 0], age: 0 }], - [2, { position: [100, 100], velocity: [0, 0], size: "large" }], - ]), - score: 0, - lives: 3, - wave: 1, - }, - args: { dt: 0.1, input: Input.none }, - after: { - bounds: [800, 600], - ship: { position: [700, 500], velocity: [0, 0], rotation: 0 }, - entities: new Map([ - [1, { position: [100, 100], velocity: [0, 0], size: "medium" }], - [2, { position: [100, 100], velocity: [0, 0], size: "medium" }], - ]), - score: 20, - lives: 3, - wave: 1, - }, - }, - { - name: "resolves a ship↔asteroid hit (lose a life, respawn at centre)", - before: { - bounds: [200, 200], - ship: Ship.spawn([100, 100]), - entities: new Map([[1, { position: [100, 100], velocity: [0, 0], size: "large" }]]), - score: 0, - lives: 3, - wave: 1, - }, - args: { dt: 0.1, input: Input.none }, - after: { - bounds: [200, 200], - ship: Ship.spawn([100, 100]), - entities: new Map([[1, { position: [100, 100], velocity: [0, 0], size: "large" }]]), - score: 0, - lives: 2, - wave: 1, - }, - }, - { - name: "freezes the whole tick once the game is over", - before: { - bounds: [200, 200], - ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, - entities: new Map([ - [1, { position: [60, 60], velocity: [0, 0], age: 0.5 }], - [2, { position: [100, 100], velocity: [0, 0], size: "large" }], - ]), - score: 40, - lives: 0, - wave: 2, - }, - args: { dt: 0.1, input: { turn: 1, thrust: true, fire: true } }, - after: { - bounds: [200, 200], - ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, - entities: new Map([ - [1, { position: [60, 60], velocity: [0, 0], age: 0.5 }], - [2, { position: [100, 100], velocity: [0, 0], size: "large" }], - ]), - score: 40, - lives: 0, - wave: 2, - }, - }, - ], -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts deleted file mode 100644 index dcce1c371..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts +++ /dev/null @@ -1,54 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; -import type { Input } from "../input/input.js"; -import { stepShip } from "./step-ship.js"; -import { fireBullet } from "./fire-bullet.js"; -import { stepBullets } from "./step-bullets.js"; -import { stepAsteroids } from "./step-asteroids.js"; -import { resolveBulletHits } from "./resolve-bullet-hits.js"; -import { resolveShipHits } from "./resolve-ship-hits.js"; -import { spawnRandomWave } from "./spawn-random-wave.js"; -import { isGameOver } from "./is-game-over.js"; -import type { Services } from "../../services/services.js"; - -// Advance the whole game one tick. This is the authoritative spec the ECS systems -// are verified against: move the ship, fire, advance bullets and asteroids, -// resolve collisions, then refill the wave if the field is clear. A game that is -// over is frozen (idempotent). `dt`, `input`, and the injected `random` service -// are bundled into one args object (second parameter) so the co-located -// conformance cases derive their `args` type from this signature. -// -// The refill draws randomness, so `step` threads the injected `random` service -// down to `spawnRandomWave` — keeping the whole tick deterministic GIVEN the -// service. The runtime ECS `waves` system supplies a real `Math.random`-backed -// source; because that source cannot be shared with the pure oracle -// frame-for-frame, the shared tick-loop conformance cases never clear the field -// (the randomized refill is exercised directly in `spawn-random-wave.ts` cases and -// conformed via the `spawnRandomWave` transaction). -export const step = ( - state: State, - { - dt, - input, - random, - }: { - readonly dt: number; - readonly input: Input; - } & Pick, -): State => { - if (isGameOver(state)) { - return state; - } - // Each sub-transition returns only the fields it writes; layer each patch over - // the running full state so the whole tick composes into one State. - let next: State = { ...state, ...stepShip(state, { dt, input }) }; - if (input.fire) { - next = { ...next, ...fireBullet(next) }; - } - next = { ...next, ...stepBullets(next, { dt }) }; - next = { ...next, ...stepAsteroids(next, { dt }) }; - next = { ...next, ...resolveBulletHits(next, { dt }) }; - next = { ...next, ...resolveShipHits(next) }; - next = { ...next, ...spawnRandomWave(next, { random }) }; - return next; -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/transforms.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/transforms.ts deleted file mode 100644 index 7a490b628..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/transforms.ts +++ /dev/null @@ -1,26 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -// The conformed functions of this feature — every pure transform the spec verifies, -// and nothing else (helpers like `create`, `samples`, `createInitial`'s siblings, -// and `isGameOver` are not conformance transforms and keep their own `*.test.ts`). -// Its keys ARE the conformance surface: the manifest's coverage guard -// (`Conformance.feature`) requires exactly one `*.cases.ts` module per key here, so -// adding a transform without cases — or cases without a transform — fails to compile. -// -// The discrete transitions (`createInitial`, `fireBullet`, `spawnRandomWave`) pair -// with same-named ecs transactions/actions and are conformed by both `checkSpec` and -// `checkFeature`. The per-frame transitions (`spawnWave`, `stepShip`, `stepBullets`, -// `stepAsteroids`, `resolveBulletHits`, `resolveShipHits`, `step`) have no same-named -// ecs op — the system tick loop realizes them (conformed in -// `system-database/tick-loop.test.ts`) — so `checkFeature` skips them while -// `checkSpec` still verifies each against its pure function here. -export { createInitial } from "./create-initial.js"; -export { fireBullet } from "./fire-bullet.js"; -export { spawnWave } from "./spawn-wave.js"; -export { spawnRandomWave } from "./spawn-random-wave.js"; -export { stepShip } from "./step-ship.js"; -export { stepBullets } from "./step-bullets.js"; -export { stepAsteroids } from "./step-asteroids.js"; -export { resolveBulletHits } from "./resolve-bullet-hits.js"; -export { resolveShipHits } from "./resolve-ship-hits.js"; -export { step } from "./step.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/asteroid.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/asteroid.ts new file mode 100644 index 000000000..e7c16607e --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/asteroid.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Vec2 } from "@adobe/data/math"; +import type { Size } from "../size/size.js"; + +// A drifting asteroid: position, constant velocity, and its size tier (which +// determines radius, score, and how it splits). +export type Asteroid = { + readonly position: Vec2; + readonly velocity: Vec2; + readonly size: Size; +}; +export * as Asteroid from "./public.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/is.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/is.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/asteroid/is.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/is.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/public.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/asteroid/public.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/public.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/radius.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/radius.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/asteroid/radius.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/radius.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/radius.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/radius.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/asteroid/radius.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/radius.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/schema.ts new file mode 100644 index 000000000..b5c0f1ccc --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/schema.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Asteroid } from "./asteroid.js"; +import { Schema } from "@adobe/data/schema"; +import { Vec2 } from "@adobe/data/math"; +import { Size } from "../size/size.js"; + +// A drifting asteroid: position, constant velocity, and its size tier (which +// determines radius, score, and how it splits). +export const schema = Schema.fromObjectProperties( + { position: Vec2.schema, velocity: Vec2.schema, size: Size.schema }, + ["position", "velocity", "size"], +); + +type _Pin = Assert, Asteroid>>; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/score.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/score.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/asteroid/score.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/score.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/score.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/score.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/asteroid/score.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/score.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/split.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/split.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/asteroid/split.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/split.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/split.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/split.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/asteroid/split.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/split.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/bullet.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/bullet.ts new file mode 100644 index 000000000..143b64176 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/bullet.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Vec2 } from "@adobe/data/math"; + +// A fired bullet: position, constant-speed velocity, and how many seconds it has +// been alive (`age`, used to expire it). +export type Bullet = { + readonly position: Vec2; + readonly velocity: Vec2; + readonly age: number; +}; +export * as Bullet from "./public.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/fire.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/fire.ts new file mode 100644 index 000000000..34322bc3d --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/fire.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Bullet } from "./bullet.js"; +import { Ship } from "../ship/ship.js"; +import { speed } from "./speed.js"; + +// A bullet fired now from `ship`'s nose, inheriting its momentum. +export const fire = (ship: Ship): Bullet => ({ ...Ship.muzzle(ship, speed), age: 0 }); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/bullet/is-expired.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/is-expired.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/bullet/is-expired.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/bullet/is-expired.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/bullet/is-expired.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/is-expired.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/bullet/is-expired.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/bullet/is-expired.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/bullet/is.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/is.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/bullet/is.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/bullet/is.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/bullet/lifetime.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/lifetime.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/bullet/lifetime.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/bullet/lifetime.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/public.ts new file mode 100644 index 000000000..c53871c4a --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/public.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export { schema } from "./schema.js"; +export { is } from "./is.js"; +export { speed } from "./speed.js"; +export { radius } from "./radius.js"; +export { lifetime } from "./lifetime.js"; +export { isExpired } from "./is-expired.js"; +export { fire } from "./fire.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/bullet/radius.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/radius.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/bullet/radius.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/bullet/radius.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/schema.ts new file mode 100644 index 000000000..63c5d5e82 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/schema.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Bullet } from "./bullet.js"; +import { Schema } from "@adobe/data/schema"; +import { Vec2, F32 } from "@adobe/data/math"; + +// A fired bullet: position, constant-speed velocity, and how many seconds it +// has been alive (`age`, used to expire it). Every field is a 32-bit number or +// numeric sub-struct, so it uses `fromStructProperties` — a guaranteed-valid +// struct schema stored in linear memory. +export const schema = Schema.fromStructProperties({ + position: Vec2.schema, + velocity: Vec2.schema, + age: F32.schema, +}); + +type _Pin = Assert, Bullet>>; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/bullet/speed.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/speed.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/bullet/speed.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/bullet/speed.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/collision/circles-overlap.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/collision/circles-overlap.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/collision/circles-overlap.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/collision/circles-overlap.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/collision/circles-overlap.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/collision/circles-overlap.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/collision/circles-overlap.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/collision/circles-overlap.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/collision/collision.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/collision/collision.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/collision/collision.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/collision/collision.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/collision/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/collision/public.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/collision/public.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/collision/public.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/collision/segment-circle-overlap.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/collision/segment-circle-overlap.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/collision/segment-circle-overlap.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/collision/segment-circle-overlap.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/collision/segment-circle-overlap.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/collision/segment-circle-overlap.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/collision/segment-circle-overlap.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/collision/segment-circle-overlap.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/input/input.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/input/input.ts new file mode 100644 index 000000000..adea90aca --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/input/input.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The player's intent for one tick: which way to turn (-1 left, 0 none, +1 right), +// whether the thruster is on, and whether the trigger is pulled. +export type Input = { + readonly turn: number; + readonly thrust: boolean; + readonly fire: boolean; +}; +export * as Input from "./public.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/input/none.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/input/none.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/input/none.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/input/none.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/input/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/input/public.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/input/public.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/input/public.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/input/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/input/schema.ts new file mode 100644 index 000000000..7d535d13c --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/input/schema.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Input } from "./input.js"; +import { Schema, Boolean } from "@adobe/data/schema"; +import { F32 } from "@adobe/data/math"; + +// The player's intent for one tick: which way to turn (-1 left, 0 none, +// +1 right), whether the thruster is on, and whether the trigger is pulled. +export const schema = Schema.fromObjectProperties( + { turn: F32.schema, thrust: Boolean.schema, fire: Boolean.schema }, + ["turn", "thrust", "fire"], +); + +type _Pin = Assert, Input>>; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/lives/initial.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/lives/initial.ts new file mode 100644 index 000000000..6d2518b3d --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/lives/initial.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Lives } from "./lives.js"; + +// Lives a fresh game starts with. +export const initial: Lives = 3; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/lives/is-game-over.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/lives/is-game-over.test.ts new file mode 100644 index 000000000..2c2f6f1fc --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/lives/is-game-over.test.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { Lives } from "./lives.js"; + +describe("Lives.isGameOver", () => { + it("is over once lives reach zero", () => { + expect(Lives.isGameOver(0)).toBe(true); + }); + + it("is not over while a life remains", () => { + expect(Lives.isGameOver(1)).toBe(false); + }); +}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/lives/is-game-over.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/lives/is-game-over.ts new file mode 100644 index 000000000..abdfa2673 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/lives/is-game-over.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Lives } from "./lives.js"; + +// The game is over once every life is spent. +export const isGameOver = (lives: Lives): boolean => lives <= 0; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/lives/lives.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/lives/lives.ts new file mode 100644 index 000000000..8e4bfd45c --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/lives/lives.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Lives remaining; the game is over once they are spent. +export type Lives = number; +export * as Lives from "./public.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/lives/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/lives/public.ts new file mode 100644 index 000000000..73655a095 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/lives/public.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { schema } from "./schema.js"; +export { initial } from "./initial.js"; +export { isGameOver } from "./is-game-over.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/lives/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/lives/schema.ts new file mode 100644 index 000000000..bfea281ba --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/lives/schema.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Lives } from "./lives.js"; +import { Schema } from "@adobe/data/schema"; +import { F32 } from "@adobe/data/math"; + +export const schema = F32.schema; + +type _Pin = Assert, Lives>>; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/motion/advance.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/motion/advance.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/motion/advance.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/motion/advance.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/motion/advance.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/motion/advance.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/motion/advance.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/motion/advance.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/motion/motion.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/motion/motion.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/motion/motion.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/motion/motion.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/motion/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/motion/public.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/motion/public.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/motion/public.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/motion/rotate.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/motion/rotate.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/motion/rotate.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/motion/rotate.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/motion/rotate.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/motion/rotate.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/motion/rotate.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/motion/rotate.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/motion/wrap.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/motion/wrap.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/motion/wrap.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/motion/wrap.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/motion/wrap.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/motion/wrap.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/motion/wrap.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/motion/wrap.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/facing.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/facing.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/facing.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/facing.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/facing.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/facing.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/facing.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/facing.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/muzzle.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/muzzle.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/muzzle.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/muzzle.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/muzzle.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/muzzle.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/muzzle.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/muzzle.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/public.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/public.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/public.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/radius.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/radius.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/radius.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/radius.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/ship/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/schema.ts new file mode 100644 index 000000000..e198f12a1 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/schema.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Ship } from "./ship.js"; +import { Schema } from "@adobe/data/schema"; +import { Vec2, F32 } from "@adobe/data/math"; + +// The player ship: where it is, how fast it drifts, and which way it points +// (`rotation` in radians; velocity persists as momentum between ticks). Every +// field is a 32-bit number or numeric sub-struct, so it uses +// `fromStructProperties` — a guaranteed-valid struct schema stored in linear +// memory. +export const schema = Schema.fromStructProperties({ + position: Vec2.schema, + velocity: Vec2.schema, + rotation: F32.schema, +}); + +type _Pin = Assert, Ship>>; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/ship/ship.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/ship.ts new file mode 100644 index 000000000..c5c25b4a1 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/ship.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Vec2 } from "@adobe/data/math"; + +// The player ship: where it is, how fast it drifts, and which way it points +// (`rotation` in radians; velocity persists as momentum between ticks). +export type Ship = { + readonly position: Vec2; + readonly velocity: Vec2; + readonly rotation: number; +}; +export * as Ship from "./public.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/spawn.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/spawn.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/spawn.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/spawn.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/spawn.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/spawn.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/spawn.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/spawn.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/thrust-accel.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/thrust-accel.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/thrust-accel.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/thrust-accel.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/thrust.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/thrust.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/thrust.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/thrust.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/thrust.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/thrust.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/thrust.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/thrust.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/turn-rate.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/turn-rate.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/turn-rate.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/turn-rate.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/turn.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/turn.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/turn.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/turn.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/ship/turn.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/turn.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/turn.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/turn.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/size/color.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/size/color.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/size/color.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/size/color.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/size/is.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/size/is.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/size/is.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/size/is.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/size/largest.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/size/largest.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/size/largest.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/size/largest.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/size/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/size/public.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/size/public.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/size/public.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/size/radius.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/size/radius.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/size/radius.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/size/radius.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/size/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/size/schema.ts new file mode 100644 index 000000000..3e56e8613 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/size/schema.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Size } from "./size.js"; +import { Schema } from "@adobe/data/schema"; + +// The asteroid size tiers, largest first. This enum is the ONLY place the +// members are named; every consumer goes through the descriptors below. +export const schema = { + type: "string", + enum: ["large", "medium", "small"], + description: "Asteroid size tier", + default: "large", +} as const satisfies Schema; + +type _Pin = Assert, Size>>; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/size/score.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/size/score.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/size/score.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/size/score.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/size/size.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/size/size.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/size/size.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/size/size.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/size/size.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/size/size.ts new file mode 100644 index 000000000..8ce8c0d8a --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/size/size.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The asteroid size tiers, largest first. +export type Size = "large" | "medium" | "small"; +export * as Size from "./public.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/size/smaller.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/size/smaller.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/size/smaller.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/size/smaller.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/size/split-count.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/size/split-count.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/size/split-count.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/size/split-count.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/size/values.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/size/values.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/size/values.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/size/values.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/spatial/cell-key.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/spatial/cell-key.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/spatial/cell-key.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/spatial/cell-key.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/spatial/cell-key.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/spatial/cell-key.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/spatial/cell-key.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/spatial/cell-key.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/spatial/cell-size.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/spatial/cell-size.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/spatial/cell-size.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/spatial/cell-size.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/spatial/neighbor-keys.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/spatial/neighbor-keys.test.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/spatial/neighbor-keys.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/spatial/neighbor-keys.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/spatial/neighbor-keys.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/spatial/neighbor-keys.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/spatial/neighbor-keys.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/spatial/neighbor-keys.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/spatial/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/spatial/public.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/spatial/public.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/spatial/public.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/spatial/spatial.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/spatial/spatial.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/spatial/spatial.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/spatial/spatial.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/wave/asteroid-count.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/wave/asteroid-count.ts new file mode 100644 index 000000000..17121a9e5 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/wave/asteroid-count.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Wave } from "./wave.js"; + +// Each wave spawns one more rock than the last. +export const asteroidCount = (wave: Wave): number => 3 + wave; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/wave/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/wave/public.ts new file mode 100644 index 000000000..733d866c8 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/wave/public.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { schema } from "./schema.js"; +export { speed } from "./speed.js"; +export { asteroidCount } from "./asteroid-count.js"; +export { ring } from "./ring.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/wave/ring.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/wave/ring.test.ts new file mode 100644 index 000000000..89cdf2fba --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/wave/ring.test.ts @@ -0,0 +1,22 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { Wave } from "./wave.js"; + +describe("Wave.ring", () => { + it("places asteroidCount(wave) large rocks on a ring, drifting tangentially", () => { + const speeds = [10, 20, 30, 40]; + const rocks = Wave.ring([200, 200], 1, () => speeds.shift() ?? 0); + expect(rocks.map((r) => r.size)).toEqual(["large", "large", "large", "large"]); + const rounded = rocks.map((r) => [...r.position, ...r.velocity].map((v) => Math.round(v) + 0)); + expect(rounded).toEqual([ + [180, 100, 0, 10], + [100, 180, -20, 0], + [20, 100, 0, -30], + [100, 20, 40, 0], + ]); + }); + + it("grows by one rock per wave", () => { + expect(Wave.ring([200, 200], 2, () => Wave.speed)).toHaveLength(Wave.asteroidCount(2)); + }); +}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/wave/ring.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/wave/ring.ts new file mode 100644 index 000000000..25d8e28aa --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/wave/ring.ts @@ -0,0 +1,28 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Vec2 } from "@adobe/data/math"; +import type { Wave } from "./wave.js"; +import type { Asteroid } from "../asteroid/asteroid.js"; +import { Size } from "../size/size.js"; +import { Motion } from "../motion/motion.js"; +import { asteroidCount } from "./asteroid-count.js"; + +// The asteroids of `wave`: a ring of large rocks around the centre of `bounds` +// (radius 0.4 · the shorter side, clear of the ship's spawn), each drifting +// tangentially. `speedOf` is called once per rock, in ring order, for its speed. +export const ring = (bounds: Vec2, wave: Wave, speedOf: () => number): Asteroid[] => { + const count = asteroidCount(wave); + const center = Vec2.scale(bounds, 0.5); + const radius = Math.min(bounds[0], bounds[1]) * 0.4; + const asteroids: Asteroid[] = []; + for (let i = 0; i < count; i++) { + const angle = (Math.PI * 2 * i) / count; + const outward = Motion.rotate([1, 0], angle); + const tangent = Motion.rotate([1, 0], angle + Math.PI / 2); + asteroids.push({ + position: Vec2.add(center, Vec2.scale(outward, radius)), + velocity: Vec2.scale(tangent, speedOf()), + size: Size.largest, + }); + } + return asteroids; +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/wave/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/wave/schema.ts new file mode 100644 index 000000000..35bb10f1d --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/wave/schema.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Wave } from "./wave.js"; +import { Schema } from "@adobe/data/schema"; +import { F32 } from "@adobe/data/math"; + +export const schema = F32.schema; + +type _Pin = Assert, Wave>>; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/wave/speed.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/wave/speed.ts new file mode 100644 index 000000000..e071cca50 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/wave/speed.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Base drift speed (pixels per second) of a freshly spawned asteroid. +export const speed = 60; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/values/wave/wave.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/wave/wave.ts new file mode 100644 index 000000000..fff80a5fd --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/values/wave/wave.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The current wave number (0 before the first wave spawns). +export type Wave = number; +export * as Wave from "./public.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/actions/action-database.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/actions/action-database.ts new file mode 100644 index 000000000..e5333825f --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/actions/action-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { ComputedDatabase } from "../computed/computed-database.js"; +import * as actions from "./index.js"; + +// Extends the service database with the `actions` facet: the app-facing +// operations the UI calls. +const actionDatabasePlugin = Database.Plugin.create({ + extends: ComputedDatabase.plugin, + actions, +}); + +export type ActionDatabase = Database.Plugin.ToDatabase; + +export namespace ActionDatabase { + export const plugin = actionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/actions/create-initial.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/actions/create-initial.ts new file mode 100644 index 000000000..f2e263a38 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/actions/create-initial.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Vec2 } from "@adobe/data/math"; +import type { ServiceDatabase } from "../services/service-database.js"; + +// Start a fresh game on a `bounds`-sized field. +export const createInitial = (db: ServiceDatabase, { bounds }: { readonly bounds: Vec2 }) => { + db.transactions.createInitial({ bounds }); +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/actions/fire-bullet.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/actions/fire-bullet.ts new file mode 100644 index 000000000..0fc5e5580 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/actions/fire-bullet.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +// Fire one bullet from the ship's muzzle. +export const fireBullet = (db: ServiceDatabase) => { + db.transactions.fireBullet(); +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/actions/index.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/actions/index.ts new file mode 100644 index 000000000..cb89a9568 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/actions/index.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export * from "./create-initial.js"; +export * from "./fire-bullet.js"; +export * from "./spawn-random-wave.js"; +export * from "./set-input.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/actions/set-input.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/actions/set-input.ts new file mode 100644 index 000000000..8768166cb --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/actions/set-input.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Input } from "../../data/values/input/input.js"; +import type { ServiceDatabase } from "../services/service-database.js"; + +// Record the player's intent; the systems read it each frame. +export const setInput = (db: ServiceDatabase, input: Input) => { + db.transactions.setInput(input); +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/actions/spawn-random-wave.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/actions/spawn-random-wave.ts new file mode 100644 index 000000000..ea174177c --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/actions/spawn-random-wave.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +// Refill a cleared field with a randomized wave drawn from the `random` service +// (a no-op while asteroids remain). +export const spawnRandomWave = (db: ServiceDatabase) => { + db.transactions.refillWave({ random: db.services.random }); +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/computed/computed-database.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/computed/computed-database.ts new file mode 100644 index 000000000..4d3c62650 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/computed/computed-database.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { ServiceDatabase } from "../services/service-database.js"; +import * as computed from "./index.js"; + +// Extends the transactional database with the `computed` facet: derived +// observable values wired to pure data/ derivations (gameOver ← Lives.isGameOver). +const computedDatabasePlugin = Database.Plugin.create({ + extends: ServiceDatabase.plugin, + computed, +}); + +export type ComputedDatabase = Database.Plugin.ToDatabase< + typeof computedDatabasePlugin +>; + +export namespace ComputedDatabase { + export const plugin = computedDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/computed/game-over.test.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/computed/game-over.test.ts new file mode 100644 index 000000000..6770e455d --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/computed/game-over.test.ts @@ -0,0 +1,30 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// gameOver is identity wiring to the tested `Lives.isGameOver` rule, so this only +// asserts the observable tracks it for the `lives` resource across transactions. +import { describe, it, expect } from "vitest"; +import { Database } from "@adobe/data/ecs"; +import { ComputedDatabase } from "./computed-database.js"; + +describe("gameOver computed reflects Lives.isGameOver over lives", () => { + it("tracks the lives resource across transactions", () => { + const db = Database.create(ComputedDatabase.plugin); + + let value: boolean | undefined; + const unsubscribe = db.computed.gameOver((v) => { + value = v; + }); + + db.transactions.createInitial({ bounds: [800, 600] }); + expect(value).toBe(false); + + db.transactions.loseLife(); + db.transactions.loseLife(); + expect(value).toBe(false); + db.transactions.loseLife(); + expect(db.resources.lives).toBe(0); + expect(value).toBe(true); + + unsubscribe(); + }); +}); diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/computed/game-over.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/computed/game-over.ts new file mode 100644 index 000000000..442d1f81c --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/computed/game-over.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { cached } from "@adobe/data/cache"; +import { Observe } from "@adobe/data/observe"; +import { Lives } from "../../data/values/lives/lives.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Whether the game is over, projected from the `lives` resource through the pure +// `Lives.isGameOver` rule, so the UI never inlines `lives <= 0`. +export const gameOver = cached((db: CoreDatabase) => + Observe.withFilter(db.observe.resources.lives, Lives.isGameOver), +); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/computed-database/computed/index.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/computed/index.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/computed-database/computed/index.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/computed/index.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/conformance.test.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/conformance.test.ts new file mode 100644 index 000000000..95f9415be --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/conformance.test.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { implementation } from "./implementation.js"; + +// Replays every spec case against the same-named action or system, every frame case +// against one real frame, and round-trips `State.samples` through the projection. +Conformance.checkFeature(implementation); diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/frame.cases.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/frame.cases.ts new file mode 100644 index 000000000..087f7c93b --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/frame.cases.ts @@ -0,0 +1,133 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "../../spec/state.js"; +import type * as systems from "../../spec/systems.js"; +import { Input } from "../../data/values/input/input.js"; +import { Ship } from "../../data/values/ship/ship.js"; + +// Whole-frame cases: every system once, in the order their `schedule`s declare. Each +// exercises one hand-off between systems with geometry chosen so every `after` is +// exact. Three only hold in that order: thrust (`control`) before the coast +// (`movement`), the ship's move before it fires (`lifetime`), and the last hit +// (`collision`) before the refill (`waves`). +export const cases: Conformance.FrameCases = { + cases: [ + { + // Thrust first: velocity [20,0], then the coast moves 2px (coasting first gives 0). + name: "thrusts, then coasts by the new velocity", + before: { + bounds: [200, 200], + ship: { position: [50, 50], velocity: [0, 0], rotation: 0 }, + entities: new Map([[1, { position: [150, 150], velocity: [0, 0], size: "large" }]]), + wave: 1, + }, + args: { dt: 0.1, input: { turn: 0, thrust: true, fire: false } }, + after: { ship: { position: [52, 50], velocity: [20, 0], rotation: 0 } }, + }, + { + name: "advances and wraps every body", + before: { + bounds: [200, 200], + ship: { position: [190, 100], velocity: [30, 0], rotation: 0 }, + entities: new Map([[1, { position: [190, 180], velocity: [30, 30], size: "large" }]]), + wave: 1, + }, + args: { dt: 1, input: Input.none }, + after: { + ship: { position: [20, 100], velocity: [30, 0], rotation: 0 }, + entities: new Map([[1, { position: [20, 10], velocity: [30, 30], size: "large" }]]), + }, + }, + { + // The ship moves to [110,100] first, so the bullet leaves [122,100] at + // 100 + 400 px/s and advances to [172,100] (firing before the move gives [162,100]). + name: "fires from the post-move muzzle and advances the new bullet", + before: { + bounds: [400, 400], + ship: { position: [100, 100], velocity: [100, 0], rotation: 0 }, + entities: new Map([[1, { position: [350, 350], velocity: [0, 0], size: "large" }]]), + wave: 1, + }, + args: { dt: 0.1, input: { turn: 0, thrust: false, fire: true } }, + after: { + ship: { position: [110, 100], velocity: [100, 0], rotation: 0 }, + entities: new Map([ + [1, { position: [172, 100], velocity: [500, 0], age: 0.1 }], + [2, { position: [350, 350], velocity: [0, 0], size: "large" }], + ]), + }, + }, + { + name: "resolves a bullet↔asteroid hit (split + score)", + before: { + bounds: [800, 600], + ship: { position: [700, 500], velocity: [0, 0], rotation: 0 }, + entities: new Map([ + [1, { position: [100, 100], velocity: [0, 0], age: 0 }], + [2, { position: [100, 100], velocity: [0, 0], size: "large" }], + ]), + wave: 1, + }, + args: { dt: 0.1, input: Input.none }, + after: { + entities: new Map([ + [1, { position: [100, 100], velocity: [0, 0], size: "medium" }], + [2, { position: [100, 100], velocity: [0, 0], size: "medium" }], + ]), + score: 20, + }, + }, + { + name: "resolves a ship↔asteroid hit (lose a life, respawn at centre)", + before: { + bounds: [200, 200], + ship: Ship.spawn([100, 100]), + entities: new Map([[1, { position: [100, 100], velocity: [0, 0], size: "large" }]]), + wave: 1, + }, + args: { dt: 0.1, input: Input.none }, + after: { lives: 2 }, + }, + { + name: "freezes the whole frame once the game is over", + before: { + bounds: [200, 200], + ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, + entities: new Map([ + [1, { position: [60, 60], velocity: [0, 0], age: 0.5 }], + [2, { position: [100, 100], velocity: [0, 0], size: "large" }], + ]), + score: 40, + lives: 0, + wave: 2, + }, + args: { dt: 0.1, input: { turn: 1, thrust: true, fire: true } }, + after: {}, + }, + { + // `collision` clears the field before `waves` looks, so it refills this frame. + name: "shooting the last asteroid refills the field the same frame", + before: { + bounds: [200, 200], + ship: Ship.spawn([100, 100]), + entities: new Map([ + [1, { position: [30, 30], velocity: [0, 0], age: 0 }], + [2, { position: [30, 30], velocity: [0, 0], size: "small" }], + ]), + wave: 0, + }, + args: { dt: 0.1, input: Input.none }, + responses: { random: { next: [0, 0.5, 0.25, 0.75] } }, + after: { + score: 100, + wave: 1, + entities: new Map([ + [1, { position: [180, 100], velocity: [0, 30], size: "large" }], + [2, { position: [100, 180], velocity: [-60, 0], size: "large" }], + [3, { position: [20, 100], velocity: [0, -45], size: "large" }], + [4, { position: [100, 20], velocity: [75, 0], size: "large" }], + ]), + }, + }, + ], +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/implementation.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/implementation.ts new file mode 100644 index 000000000..aa9e6526c --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/implementation.ts @@ -0,0 +1,27 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "../../spec/spec.js"; +import { MainService } from "../main-service.js"; +import { projection } from "./projection.js"; +import { cases as frame } from "./frame.cases.js"; + +// The spec paired with its ECS build. `createInitial`, `fireBullet` and +// `spawnRandomWave` conform against same-named actions; each spec system against its +// same-named ECS system, with a case's `dt` and `input` written into the resources +// the systems read. Every system is modelled; `frame` replays whole frames in +// schedule order. +export const implementation = Conformance.implementation(spec, { + plugin: MainService.plugin, + projection, + frame: { + args: { + dt: (db, dt) => { + db.store.resources.frameDelta = dt; + }, + input: (db, input) => { + db.store.resources.input = input; + }, + }, + cases: frame, + }, +}); diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/projection.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/projection.ts new file mode 100644 index 000000000..28e0856eb --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/projection.ts @@ -0,0 +1,59 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { State } from "../../spec/state.js"; +import { Bullet } from "../../data/values/bullet/bullet.js"; +import type { Asteroid } from "../../data/values/asteroid/asteroid.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Read one entity back into its `data/` value: a bullet or an asteroid, probed by +// archetype (only bullets carry `age`, only asteroids `size`). +const toData = (store: CoreDatabase.Store, entity: Entity): Bullet | Asteroid => { + const bullet = store.read(entity, store.archetypes.Bullet); + if (bullet !== null) return { position: bullet.position, velocity: bullet.velocity, age: bullet.age }; + const asteroid = store.read(entity, store.archetypes.Asteroid); + if (asteroid !== null) return { position: asteroid.position, velocity: asteroid.velocity, size: asteroid.size }; + throw new Error("conformance projection: entity is not a bullet or asteroid"); +}; + +// The test-only ecs↔`State` projection. `fromState` clears every entity (tail→head, +// so each delete is from the tail), sets the resources, and inserts each entity into +// its archetype by its structural guard, returning the `spec id → entity` map. +// `toState` reads it back keyed by entity (the map compares up to an id-bijection). +export const projection = { + fromState: (store: CoreDatabase.Store, state: State): ReadonlyMap => { + for (const arch of store.queryArchetypes(["position"])) { + for (let row = arch.rowCount - 1; row >= 0; row--) { + store.delete(arch.columns.id.get(row)); + } + } + store.resources.bounds = state.bounds; + store.resources.ship = state.ship; + store.resources.score = state.score; + store.resources.lives = state.lives; + store.resources.wave = state.wave; + return new Map( + [...state.entities].map(([specId, value]) => [ + specId, + Bullet.is(value) ? store.archetypes.Bullet.insert(value) : store.archetypes.Asteroid.insert(value), + ]), + ); + }, + toState: (store: CoreDatabase.Store): State => { + const entities = new Map(); + for (const arch of store.queryArchetypes(["position"])) { + for (let row = 0; row < arch.rowCount; row++) { + const entity = arch.columns.id.get(row); + entities.set(entity, toData(store, entity)); + } + } + return { + bounds: store.resources.bounds, + ship: store.resources.ship, + entities, + score: store.resources.score, + lives: store.resources.lives, + wave: store.resources.wave, + }; + }, + toData, +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/core/archetypes.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/core/archetypes.ts new file mode 100644 index 000000000..17e17a3ae --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/core/archetypes.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { components } from "../../data/components/index.js"; +import { entities } from "../../data/entities/index.js"; + +// One archetype per spec entity, packed exactly as declared (the ship is a resource). +export const archetypes = Database.archetypes(components, { + Bullet: entities.Bullet, + Asteroid: entities.Asteroid, +}); diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/core/core-database.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/core/core-database.ts new file mode 100644 index 000000000..50ff03d49 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/core/core-database.ts @@ -0,0 +1,26 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import type { Store } from "@adobe/data/ecs"; +import { components } from "../../data/components/index.js"; +import { resources } from "../../data/resources/index.js"; +import { archetypes } from "./archetypes.js"; + +// The core schema: the feature's data/ components and resources, packed into archetypes. +const coreDatabasePlugin = Database.Plugin.create({ + components, + resources, + archetypes, +}); + +export type CoreDatabase = Database.Plugin.ToDatabase; + +type CoreComponents = Store.Components< + Database.Plugin.ToStore +>; + +export namespace CoreDatabase { + export const plugin = coreDatabasePlugin; + export type Store = Database.Plugin.ToStore; + /** Index-declaration type bound to this database's components. */ + export type Index = Database.Index; +} diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/indexes/by-cell.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/indexes/by-cell.ts new file mode 100644 index 000000000..a6e617f78 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/indexes/by-cell.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Spatial } from "../../data/values/spatial/spatial.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Broad-phase spatial index: cell id → the entities currently in that cell. +// A COMPUTED-KEY index — the `cell` slot is derived from each entity's +// `position` on the fly (`Spatial.cellKey`), so it stays correct with zero +// maintenance: there is no stored `cell` column to keep in sync at every +// insert/update, and nothing can silently drift. Coverage is by columns, so +// every archetype carrying `position` (Asteroid, Bullet) is indexed. +// Multi-value (not unique) — many entities share a cell. The collision system +// unions `find({ cell })` over `Spatial.neighborKeys(...)` to gather overlap +// candidates. +export const byCell = { + components: ["position"], + key: { cell: (c) => Spatial.cellKey(c.position!, Spatial.cellSize) }, +} as const satisfies CoreDatabase.Index; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/indexes/index-database.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/indexes/index-database.ts new file mode 100644 index 000000000..b9cbfc559 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/indexes/index-database.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { CoreDatabase } from "../core/core-database.js"; +import * as indexes from "./index.js"; + +// Extends the core schema with the `indexes` facet. `create` validates every +// index descriptor against the real component/archetype facets (an +// archetype-scoped index would need a field the `CoreDatabase.Index` helper +// can't express; `byCell` is column-scoped so it validates with `satisfies`). +const indexDatabasePlugin = Database.Plugin.create({ + extends: CoreDatabase.plugin, + indexes, +}); + +export type IndexDatabase = Database.Plugin.ToDatabase; + +export namespace IndexDatabase { + export const plugin = indexDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/index-database/indexes/index.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/indexes/index.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/index-database/indexes/index.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/indexes/index.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/main-service.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/main-service.ts new file mode 100644 index 000000000..866607061 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/main-service.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// The feature's assembled database, aliased once so consumers (ui/, the app +// entry, ecs/conformance/) never name the topmost layer. Add or drop a layer → +// change only this line. +export { SystemDatabase as MainService } from "./systems/system-database.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/services/service-database.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/services/service-database.ts new file mode 100644 index 000000000..b5ec2666f --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/services/service-database.ts @@ -0,0 +1,30 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import type { Assert, Equal } from "@adobe/data/types"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; +import { RandomService } from "../../services/random-service/random-service.js"; +import type { Services } from "../../services/services.js"; + +// Extends the computed database with the `services` facet. `random` is a +// capability port with no ECS state to bind, so it is registered directly from +// its `services/` contract (like data-lit-todo's `nameGenerator`): production +// uses the real `Math.random`-backed source; tests inject +// `RandomService.createFake` through the `Database.create` service override. +// Consumers reach it as `db.services.random`. +const serviceDatabasePlugin = Database.Plugin.create({ + extends: TransactionDatabase.plugin, + services: { + random: RandomService.create, + }, +}); + +export type ServiceDatabase = Database.Plugin.ToDatabase; + +// Drift-guard: the services the ECS resolves onto `db.services` must exactly match +// the injectable `Services` map the data transitions `Pick` from. +type _ServicesPin = Assert>; + +export namespace ServiceDatabase { + export const plugin = serviceDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/systems/system-database.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/systems/system-database.ts new file mode 100644 index 000000000..67799f323 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/systems/system-database.ts @@ -0,0 +1,177 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database, scheduler } from "@adobe/data/ecs"; +import type { Entity } from "@adobe/data/ecs"; +import { Vec2 } from "@adobe/data/math"; +import { ActionDatabase } from "../actions/action-database.js"; +import { Motion } from "../../data/values/motion/motion.js"; +import { Spatial } from "../../data/values/spatial/spatial.js"; +import { Collision } from "../../data/values/collision/collision.js"; +import { Asteroid } from "../../data/values/asteroid/asteroid.js"; +import { Bullet } from "../../data/values/bullet/bullet.js"; +import { Ship } from "../../data/values/ship/ship.js"; +import { Lives } from "../../data/values/lives/lives.js"; +import { Size } from "../../data/values/size/size.js"; + +// The real-time tick loop: the action database combined with the built-in +// `scheduler`. Each frame runs these systems in their `schedule` order — +// advance → fire → age → collide → refill: +// +// control → turn + thrust the ship (rotation + velocity) +// movement → advance + wrap the ship and every asteroid +// lifetime → fire on a `fire` edge (from the post-move muzzle), then advance + +// age + retire every bullet, the new one included +// collision → hitAsteroid per bullet hit, then loseLife if the ship is struck +// waves → refillWave once the field is clear (injected `random`) +// +// Continuous motion is in-place column (and resource) writes — no per-frame +// transaction; the discrete outcomes (fire, hit, lose a life, refill) dispatch +// transactions. Every system is frozen once the game is over. The scheduler drives +// them on requestAnimationFrame; a headless host (tests, server sim) instead invokes +// `db.system.functions[name]()` for each name of each tier in `db.system.order` +// (skipping `schedulerSystem`). +const systemDatabasePlugin = Database.Plugin.create({ + extends: Database.Plugin.combine(ActionDatabase.plugin, scheduler), + systems: { + control: { + create: (db) => () => { + const { resources } = db.store; + if (Lives.isGameOver(resources.lives)) return; + const { input, frameDelta: dt, ship } = resources; + const rotation = Ship.turn(ship.rotation, input.turn, dt); + const velocity = input.thrust ? Ship.thrust(ship.velocity, rotation, dt) : ship.velocity; + resources.ship = { position: ship.position, velocity, rotation }; + }, + }, + + // The hot per-row path for the non-bullet bodies. Bullets (the only rows with + // `age`) advance in `lifetime`, after firing, so the fired bullet moves this frame. + movement: { + schedule: { after: ["control"] }, + create: (db) => () => { + const { resources } = db.store; + if (Lives.isGameOver(resources.lives)) return; + const dt = resources.frameDelta; + const bounds = resources.bounds; + const ship = resources.ship; + resources.ship = { ...ship, position: Motion.wrap(Motion.advance(ship.position, ship.velocity, dt), bounds) }; + for (const arch of db.store.queryArchetypes(["position", "velocity"], { exclude: ["age"] })) { + const position = arch.columns.position; + const velocity = arch.columns.velocity; + for (let i = 0; i < arch.rowCount; i++) { + position.set(i, Motion.wrap(Motion.advance(position.get(i), velocity.get(i), dt), bounds)); + } + } + }, + }, + + // Fire on a `fire` edge (clearing it, so one keypress is one shot), then advance + + // age + wrap every bullet, retiring the ones that expire this tick + // (`Bullet.isExpired` reads the age before the increment). Deletes hole-fill from + // the tail, so iterate tail→head: rows ahead of the cursor are already done. + lifetime: { + schedule: { after: ["movement"] }, + create: (db) => () => { + const { resources } = db.store; + if (Lives.isGameOver(resources.lives)) return; + const dt = resources.frameDelta; + const bounds = resources.bounds; + if (resources.input.fire) { + db.transactions.fireBullet(); + resources.input = { ...resources.input, fire: false }; + } + for (const arch of db.store.queryArchetypes(["position", "velocity", "age"])) { + const { id, position, velocity, age } = arch.columns; + for (let i = arch.rowCount - 1; i >= 0; i--) { + const current = age.get(i); + if (Bullet.isExpired(current, dt)) { + db.store.delete(id.get(i)); + } else { + position.set(i, Motion.wrap(Motion.advance(position.get(i), velocity.get(i), dt), bounds)); + age.set(i, current + dt); + } + } + } + }, + }, + + // Bullet↔asteroid first, then ship↔asteroid. A bullet's path this frame + // (position - velocity·dt → position) can span many cells, so bullet detection + // scans every asteroid with a segment test; the point-like ship uses the `byCell` + // broad phase (its cell + the eight neighbours). All bullet hits are detected + // against the untouched store before any is applied, so a child spawned this frame + // is never a target; each asteroid is claimed by at most one bullet. + collision: { + schedule: { after: ["movement", "lifetime"] }, + create: (db) => { + const findSweptHitAsteroid = ( + prev: Vec2, + position: Vec2, + exclude: ReadonlySet, + ): Entity | undefined => { + for (const arch of db.store.queryArchetypes(db.store.archetypes.Asteroid.components)) { + const { id, position: center, size } = arch.columns; + for (let i = 0; i < arch.rowCount; i++) { + const entity = id.get(i); + if (exclude.has(entity)) continue; + const reach = Bullet.radius + Size.radius[size.get(i)]; + if (Collision.segmentCircleOverlap(prev, position, center.get(i), reach)) return entity; + } + } + return undefined; + }; + const shipIsStruck = (center: Vec2): boolean => { + for (const cell of Spatial.neighborKeys(center, Spatial.cellSize)) { + for (const entity of db.indexes.byCell.find({ cell })) { + const asteroid = db.store.read(entity, db.store.archetypes.Asteroid); + if (asteroid === null) continue; + if (Collision.circlesOverlap(center, Ship.radius, asteroid.position, Asteroid.radius(asteroid))) return true; + } + } + return false; + }; + return () => { + const { resources } = db.store; + if (Lives.isGameOver(resources.lives)) return; + const dt = resources.frameDelta; + const claimed = new Set(); + const hits: { readonly bullet: Entity; readonly asteroid: Entity }[] = []; + for (const arch of db.store.queryArchetypes(db.store.archetypes.Bullet.components)) { + const { id, position, velocity } = arch.columns; + for (let i = 0; i < arch.rowCount; i++) { + const end = position.get(i); + const asteroid = findSweptHitAsteroid(Vec2.subtract(end, Vec2.scale(velocity.get(i), dt)), end, claimed); + if (asteroid !== undefined) { + claimed.add(asteroid); + hits.push({ bullet: id.get(i), asteroid }); + } + } + } + for (const hit of hits) db.transactions.hitAsteroid(hit); + if (shipIsStruck(resources.ship.position)) db.transactions.loseLife(); + }; + }, + }, + + // Refill a cleared field (only asteroids carry `size`) with a randomized wave. + waves: { + schedule: { after: ["collision"] }, + create: (db) => { + const random = db.services.random; + return () => { + if (Lives.isGameOver(db.store.resources.lives)) return; + for (const arch of db.store.queryArchetypes(["size"])) { + if (arch.rowCount > 0) return; + } + db.transactions.refillWave({ random }); + }; + }, + }, + }, +}); + +export type SystemDatabase = Database.Plugin.ToDatabase; + +export namespace SystemDatabase { + export const plugin = systemDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/clear-entities.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/clear-entities.ts new file mode 100644 index 000000000..f771a0f33 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/clear-entities.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +// Remove every bullet and asteroid (both carry `position`, so one query covers +// them). Iterate tail→head so each delete is from the tail: no hole-fill shift, and +// indices ahead of the cursor stay valid. +export const clearEntities = (t: CoreDatabase.Store): void => { + for (const arch of t.queryArchetypes(["position"])) { + for (let row = arch.rowCount - 1; row >= 0; row--) { + t.delete(arch.columns.id.get(row)); + } + } +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/create-initial.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/create-initial.ts new file mode 100644 index 000000000..937523009 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/create-initial.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Vec2 } from "@adobe/data/math"; +import { Ship } from "../../data/values/ship/ship.js"; +import { Lives } from "../../data/values/lives/lives.js"; +import { Wave } from "../../data/values/wave/wave.js"; +import type { CoreDatabase } from "../core/core-database.js"; +import { clearEntities } from "./clear-entities.js"; +import { nextWave } from "./next-wave.js"; + +// Start a fresh game on a `bounds`-sized field: clear every entity, centre the +// ship, reset the counters, and spawn the first wave (every rock at the base speed). +export const createInitial = ( + t: CoreDatabase.Store, + { bounds }: { readonly bounds: Vec2 }, +): void => { + clearEntities(t); + t.resources.bounds = bounds; + t.resources.ship = Ship.spawn(Vec2.scale(bounds, 0.5)); + t.resources.score = 0; + t.resources.lives = Lives.initial; + t.resources.wave = 0; + nextWave(t, () => Wave.speed); +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/fire-bullet.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/fire-bullet.ts new file mode 100644 index 000000000..21e2c7fcb --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/fire-bullet.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Bullet } from "../../data/values/bullet/bullet.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Fire one bullet from the ship's muzzle. +export const fireBullet = (t: CoreDatabase.Store): void => { + t.archetypes.Bullet.insert(Bullet.fire(t.resources.ship)); +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/hit-asteroid.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/hit-asteroid.ts new file mode 100644 index 000000000..efc059cfa --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/hit-asteroid.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import { Asteroid } from "../../data/values/asteroid/asteroid.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// A bullet struck an asteroid (dispatched by the collision system): remove both, +// award the asteroid's score, and insert its split children. A missing asteroid +// (already resolved this frame) is a no-op. +export const hitAsteroid = ( + t: CoreDatabase.Store, + { bullet, asteroid }: { readonly bullet: Entity; readonly asteroid: Entity }, +): void => { + const row = t.read(asteroid, t.archetypes.Asteroid); + if (row === null) return; + const target: Asteroid = { position: row.position, velocity: row.velocity, size: row.size }; + t.delete(bullet); + t.delete(asteroid); + t.resources.score = t.resources.score + Asteroid.score(target); + for (const child of Asteroid.split(target)) t.archetypes.Asteroid.insert(child); +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/index.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/index.ts new file mode 100644 index 000000000..f8f4c3c2c --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/index.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export * from "./set-input.js"; +export * from "./create-initial.js"; +export * from "./refill-wave.js"; +export * from "./fire-bullet.js"; +export * from "./hit-asteroid.js"; +export * from "./lose-life.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/lose-life.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/lose-life.ts new file mode 100644 index 000000000..ceacde3da --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/lose-life.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Vec2 } from "@adobe/data/math"; +import { Ship } from "../../data/values/ship/ship.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// An asteroid struck the ship (dispatched by the collision system): spend a life +// (floored at zero) and respawn the ship at the centre. Bullets stay in play. +export const loseLife = (t: CoreDatabase.Store): void => { + t.resources.lives = Math.max(0, t.resources.lives - 1); + t.resources.ship = Ship.spawn(Vec2.scale(t.resources.bounds, 0.5)); +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/next-wave.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/next-wave.ts new file mode 100644 index 000000000..f4df31c47 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/next-wave.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Wave } from "../../data/values/wave/wave.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// When the field is clear, bump `wave` and insert its ring of asteroids, each +// drifting at `speedOf()`. A no-op while any asteroid remains. +export const nextWave = (t: CoreDatabase.Store, speedOf: () => number): void => { + for (const arch of t.queryArchetypes(t.archetypes.Asteroid.components)) { + if (arch.rowCount > 0) return; + } + const wave = t.resources.wave + 1; + t.resources.wave = wave; + for (const asteroid of Wave.ring(t.resources.bounds, wave, speedOf)) { + t.archetypes.Asteroid.insert(asteroid); + } +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/refill-wave.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/refill-wave.ts new file mode 100644 index 000000000..a8d85aaa1 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/refill-wave.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Wave } from "../../data/values/wave/wave.js"; +import type { CoreDatabase } from "../core/core-database.js"; +import type { RandomService } from "../../services/random-service/random-service.js"; +import { nextWave } from "./next-wave.js"; + +// The randomized refill the `waves` system and the `spawnRandomWave` action +// dispatch: each rock's speed is `Wave.speed · (0.5 + random.next())`. It takes the +// injected service as an argument, so it is conformed through the action. +export const refillWave = ( + t: CoreDatabase.Store, + { random }: { readonly random: RandomService }, +): void => { + nextWave(t, () => Wave.speed * (0.5 + random.next())); +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/set-input.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/set-input.ts new file mode 100644 index 000000000..54260bb76 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/set-input.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Input } from "../../data/values/input/input.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Record the player's intent for upcoming ticks; the systems read it each frame. +export const setInput = (t: CoreDatabase.Store, input: Input): void => { + t.resources.input = input; +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/transaction-database.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/transaction-database.ts new file mode 100644 index 000000000..87cd2e69a --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/transaction-database.ts @@ -0,0 +1,22 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { IndexDatabase } from "../indexes/index-database.js"; +import * as transactions from "./index.js"; + +// Extends the indexed schema with the `transactions` facet: the discrete, atomic +// state changes the UI, actions and systems dispatch (set input, start a game, fire, +// resolve a hit, lose a life, refill a wave). Per-frame continuous motion is the +// systems layer's in-place column writes, not a transaction. +const transactionDatabasePlugin = Database.Plugin.create({ + extends: IndexDatabase.plugin, + transactions, +}); + +export type TransactionDatabase = Database.Plugin.ToDatabase< + typeof transactionDatabasePlugin +>; + +export namespace TransactionDatabase { + export const plugin = transactionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/action-database.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/action-database.ts deleted file mode 100644 index f23aede57..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/action-database.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { ServiceDatabase } from "../service-database/service-database.js"; -import * as actions from "./actions/index.js"; - -// Extends the service database with the `actions` facet: the async, app-facing -// realizations that orchestrate a `services/` port and commit through a single -// transaction. Per-frame step transitions are realized by the `systems` layer -// (conformed via the tick-loop), not by actions. -const actionDatabasePlugin = Database.Plugin.create({ - extends: ServiceDatabase.plugin, - actions, -}); - -export type ActionDatabase = Database.Plugin.ToDatabase; - -export namespace ActionDatabase { - export const plugin = actionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/fire-bullet.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/fire-bullet.ts deleted file mode 100644 index 540be73df..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/fire-bullet.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// The app-facing realization of `State.fireBullet`: a local shot needs no outside -// capability, so it commits through the single `fireBullet` transaction (which -// reads the current ship from the store and inserts the muzzle bullet). The UI -// never awaits this — state flows back through observables. -export const fireBullet = (db: ServiceDatabase) => { - db.transactions.fireBullet(); -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/index.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/index.ts deleted file mode 100644 index d40e650a9..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export * from "./fire-bullet.js"; -export * from "./spawn-random-wave.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/spawn-random-wave.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/spawn-random-wave.ts deleted file mode 100644 index c634acd06..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/spawn-random-wave.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// The app-facing realization of `State.spawnRandomWave` (which injects the same -// `random` port): read the `random` service from `db.services` and commit the next -// wave through the `refillWave` transaction (which reads the current field/wave/bounds -// from the store — never a cached computed — and is a no-op while asteroids remain). -// The action keeps the transition's name so `checkFeature` pairs it here (the seam -// where the injected `random` double is supplied); the transaction is the -// system-dispatched `refillWave`. `random.next()` is a value-returning read, not a -// fire-and-forget effect, so it is not surfaced to the caller. -export const spawnRandomWave = (db: ServiceDatabase) => { - db.transactions.refillWave({ random: db.services.random }); -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/computed-database/computed-database.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/computed-database/computed-database.ts deleted file mode 100644 index caba9ef35..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/computed-database/computed-database.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { TransactionDatabase } from "../transaction-database/transaction-database.js"; -import * as computed from "./computed/index.js"; - -// Extends the transactional database with the `computed` facet: derived -// observable values wired to pure data/ derivations (gameOver ← State.isGameOver). -const computedDatabasePlugin = Database.Plugin.create({ - extends: TransactionDatabase.plugin, - computed, -}); - -export type ComputedDatabase = Database.Plugin.ToDatabase< - typeof computedDatabasePlugin ->; - -export namespace ComputedDatabase { - export const plugin = computedDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/computed-database/computed/game-over.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/computed-database/computed/game-over.test.ts deleted file mode 100644 index 2d97b7c05..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/computed-database/computed/game-over.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// gameOver is identity wiring to the already-tested data/ rule (State.isGameOver), -// so this only asserts the observable tracks the data/ verdict for the `lives` -// resource before and after transactions — it does not re-test the rule itself. -import { describe, it, expect } from "vitest"; -import { Database } from "@adobe/data/ecs"; -import type { Vec2 } from "@adobe/data/math"; -import { State } from "../../../../data/state/state.js"; -import { ComputedDatabase } from "../computed-database.js"; - -const bounds: Vec2 = [800, 600]; - -describe("gameOver computed reflects data/ State.isGameOver over lives", () => { - it("tracks the lives resource across transactions", () => { - const db = Database.create(ComputedDatabase.plugin); - db.transactions.setBounds(bounds); - - let value: boolean | undefined; - const unsubscribe = db.computed.gameOver((v) => { - value = v; - }); - - // Fresh game: three lives → not over, matching the data/ verdict. - db.transactions.newGame(); - expect(value).toBe(State.isGameOver({ lives: db.resources.lives })); - expect(value).toBe(false); - - // Spend every life → over. - db.transactions.loseLife(); - db.transactions.loseLife(); - db.transactions.loseLife(); - expect(db.resources.lives).toBe(0); - expect(value).toBe(State.isGameOver({ lives: db.resources.lives })); - expect(value).toBe(true); - - unsubscribe(); - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/computed-database/computed/game-over.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/computed-database/computed/game-over.ts deleted file mode 100644 index fcf52ba40..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/computed-database/computed/game-over.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { cached } from "@adobe/data/cache"; -import { Observe } from "@adobe/data/observe"; -import { State } from "../../../../data/state/state.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// Whether the game is over, projected from the `lives` resource through the -// pure data/ rule (State.isGameOver → lives spent). The "game over" test lives -// in data/, so the UI never inlines `lives <= 0`. Reads only resources.lives, -// so it types on the lowest layer that exposes it: CoreDatabase. -export const gameOver = cached((db: CoreDatabase) => - Observe.withFilter(db.observe.resources.lives, (lives) => State.isGameOver({ lives })), -); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/conformance.test.ts deleted file mode 100644 index 9b98e4e9c..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/conformance.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "../../../data/state/spec.js"; - -// The whole ecs conformance for this feature in one call: `checkFeature` pairs each -// transaction/action on `MainService.plugin` with its same-named `data/state` -// transition (`createInitial` transaction, `fireBullet` transaction + action, -// `spawnRandomWave` action), seeds each case's `before` over `State.create()`, and -// round-trips `State.samples` through the `projection`. The bullets and asteroids live -// in one identity-keyed `entities` `ReadonlyMap`, so the comparator matches them -// order-independently (the ecs materialises them in nondeterministic row order and -// mints its own ids the `after`/`samples` refs leave open). The per-frame transitions -// (`step*`, `resolve*`, `spawnWave`) have no same-named ecs op — the system tick loop -// conforms them in `system-database/tick-loop.test.ts` (see systems.md) — so they are -// simply skipped here. Space-rock has no derivations, so no `computedPlugin`. -Conformance.checkFeature(spec); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-system-database.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-system-database.ts deleted file mode 100644 index 746f19d64..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-system-database.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { MainService } from "../main-service.js"; - -// The assembled feature database viewed as its writable *system surface* — the -// same `Database & { store }` a system's `create(db)` receives — obtained -// cast-free through the library lens `Database.toSystemDatabase` (the widening -// twin of `UIService.restrict`). ONLY the system tick-loop / detection tests -// need it: they drive the systems through `db.system.functions` and reach the -// store via `db.store`. Transaction and projection conformance use `createStore` -// instead (a transaction needs no `Database`). Test-only. -export const createSystemDatabase = (): Database.Plugin.ToSystemDatabase< - typeof MainService.plugin -> => Database.toSystemDatabase(Database.create(MainService.plugin)); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/drive-frame.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/drive-frame.ts deleted file mode 100644 index 1d03634b4..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/drive-frame.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { SystemDatabase } from "../system-database/system-database.js"; - -// Drive exactly one frame headlessly — no rAF, no rendering — the way the -// scheduler would: every system in dependency order (`db.system.order`), -// skipping the scheduler's own bookkeeping system. This is the documented -// headless-host pattern (see systems.md), used by the tick-loop conformance -// test and the collision-detection tests. Test-only. -export const driveFrame = (db: SystemDatabase): void => { - for (const tier of db.system.order) { - for (const name of tier) { - if (name === "schedulerSystem") continue; - // `name` comes from `db.system.order`, so it is always one of this - // database's declared system keys — an invariant the `string` element - // type of `order` cannot carry. - const fn = - db.system.functions[ - name as keyof SystemDatabase["system"]["functions"] - ]; - if (typeof fn === "function") fn(); - } - } -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.ts deleted file mode 100644 index 6aad496a4..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.ts +++ /dev/null @@ -1,104 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { State } from "../../../data/state/state.js"; -import type { Ship } from "../../../data/ship/ship.js"; -import { Bullet } from "../../../data/bullet/bullet.js"; -import { Asteroid } from "../../../data/asteroid/asteroid.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Read one entity back into its `data/` value — the per-entity projection -// `toState` is built on, and the single place the ecs↔data mapping for the three -// entity kinds lives. Unlike a single-archetype feature (todo), an entity here is -// one of Ship / Bullet / Asteroid, so `toData` probes each named archetype (their -// component sets are distinct — only Ship has `rotation`, only Bullet `age`, only -// Asteroid `size`) and projects the first that matches. Test-only. -const toData = ( - store: CoreDatabase.Store, - entity: Entity, -): Ship | Bullet | Asteroid => { - const ship = store.read(entity, store.archetypes.Ship); - if (ship !== null) - return { - position: ship.position, - velocity: ship.velocity, - rotation: ship.rotation, - }; - const bullet = store.read(entity, store.archetypes.Bullet); - if (bullet !== null) - return { - position: bullet.position, - velocity: bullet.velocity, - age: bullet.age, - }; - const asteroid = store.read(entity, store.archetypes.Asteroid); - if (asteroid !== null) - return { - position: asteroid.position, - velocity: asteroid.velocity, - size: asteroid.size, - }; - throw new Error( - "conformance projection: entity is not a ship, bullet, or asteroid", - ); -}; - -// The test-only ecs↔`State` projection, carried on the manifest and used by `Conformance.checkFeature`. -// `fromState` seeds a store to a `State` (space-rock is not id-addressed, so it -// returns no id map); `toState` reads it back through `toData`; `toData` reads one -// entity. These are STRICTLY for conformance tests and MUST NEVER run in -// production code. -export const projection = { - // Seed a store to exactly match a `data/` `State`: clear every entity, set the - // scalar resources, then insert the ship and every `entities` value into its own - // archetype by its structural guard. The inverse of `toState`. Clearing iterates - // tail→head so each delete is from the tail (no hole-fill shift). Every entity - // carries `position`, so one query covers all three archetypes. Row shapes equal - // their `data/` types (no stored broad-phase column), so each value inserts - // directly. Space-rock is not id-addressed (no case declares an `args` schema), so - // `fromState` returns `void`. - fromState: (store: CoreDatabase.Store, state: State): void => { - for (const arch of store.queryArchetypes(["position"])) { - for (let row = arch.rowCount - 1; row >= 0; row--) { - store.delete(arch.columns.id.get(row)); - } - } - store.resources.bounds = state.bounds; - store.resources.score = state.score; - store.resources.lives = state.lives; - store.resources.wave = state.wave; - store.archetypes.Ship.insert(state.ship); - for (const value of state.entities.values()) { - if (Bullet.is(value)) store.archetypes.Bullet.insert(value); - else store.archetypes.Asteroid.insert(value); - } - }, - // Read a store back into a `data/` `State` — the inverse of `fromState`, built on - // the per-entity `toData` projection. Every entity carries `position`, so one - // query covers all three archetypes; each entity is projected through `toData`. - // The ship (its `rotation` distinguishes it) seeds the singleton `ship` field; - // every bullet and asteroid goes into the ONE identity-keyed `entities` map under - // a fresh key. Row/key order is arbitrary — the map compares order-independently. - toState: (store: CoreDatabase.Store): State => { - let ship: Ship | undefined; - const entities = new Map(); - let nextKey = 1; - for (const arch of store.queryArchetypes(["position"])) { - for (let row = 0; row < arch.rowCount; row++) { - const value = toData(store, arch.columns.id.get(row)); - if ("rotation" in value) ship = value; - else entities.set(nextKey++, value); - } - } - if (ship === undefined) - throw new Error("conformance projection: expected a ship entity"); - return { - bounds: store.resources.bounds, - ship, - entities, - score: store.resources.score, - lives: store.resources.lives, - wave: store.resources.wave, - }; - }, - toData, -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/archetypes.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/archetypes.ts deleted file mode 100644 index 7e023e640..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/archetypes.ts +++ /dev/null @@ -1,31 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { Schema } from "@adobe/data/schema"; -import type { Assert, Equal } from "@adobe/data/types"; -import type { Ship } from "../../../data/ship/ship.js"; -import type { Asteroid } from "../../../data/asteroid/asteroid.js"; -import type { Bullet } from "../../../data/bullet/bullet.js"; -import { components } from "./components.js"; - -// The three entity shapes. Each is a genuinely distinct component set; the -// shared columns (`position`, `velocity`) are referenced by identity from the -// single declaration in components.ts. There is no stored broad-phase column — -// the `byCell` index derives the cell from `position` (see -// index-database/indexes/by-cell.ts) — so each archetype's shape equals exactly -// the data/ row type it materialises. -export const archetypes = Database.archetypes(components, { - Ship: ["position", "velocity", "rotation"], - Asteroid: ["position", "velocity", "size"], - Bullet: ["position", "velocity", "age"], -}); - -// Drift guards: each archetype's row shape must equal the data/ row type it -// mirrors — the guard fails to compile if any column and its data type diverge. -const shipSchema = Schema.fromArchetype(components, archetypes.Ship); -type _ShipMatches = Assert, Ship>>; - -const asteroidSchema = Schema.fromArchetype(components, archetypes.Asteroid); -type _AsteroidMatches = Assert, Asteroid>>; - -const bulletSchema = Schema.fromArchetype(components, archetypes.Bullet); -type _BulletMatches = Assert, Bullet>>; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/components.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/components.ts deleted file mode 100644 index f47054b21..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/components.ts +++ /dev/null @@ -1,22 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { Vec2, F32 } from "@adobe/data/math"; -import { Size } from "../../../data/size/size.js"; - -// The shared entity component columns, bound by identity to their data/ schemas. -// `position` and `velocity` are declared once and referenced by the Ship, -// Asteroid, and Bullet archetypes alike. Radius is derived (from `size` for -// asteroids, from constants for ship/bullet) so it is not stored as a column. -// The broad-phase spatial cell is NOT stored: `byCell` is a computed-key index -// that derives it from `position` on demand (see index-database/indexes/by-cell), -// so there is no `cell` column to keep in sync. All state is `session` scope: -// the game is client-local and ephemeral. -export const components = Database.components({ - session: { - position: Vec2.schema, - velocity: Vec2.schema, - rotation: F32.schema, - size: Size.schema, - age: F32.schema, - }, -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/core-database.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/core-database.ts deleted file mode 100644 index 7d8018b63..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/core-database.ts +++ /dev/null @@ -1,40 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import type { Store } from "@adobe/data/ecs"; -import { components } from "./components.js"; -import { resources } from "./resources.js"; -import { archetypes } from "./archetypes.js"; - -// The core schema: every component, resource, and archetype for the feature, -// grouped by scope inside components.ts / resources.ts. Asteroids is -// session-only (local + ephemeral) — no document/settings/presence state. -const coreDatabasePlugin = Database.Plugin.create({ - components, - resources, - archetypes, -}); - -export type CoreDatabase = Database.Plugin.ToDatabase; - -// Resolved component map, declared at module scope so the imported `Store` -// namespace isn't shadowed by `CoreDatabase.Store` below. -type CoreComponents = Store.Components< - Database.Plugin.ToStore ->; - -export namespace CoreDatabase { - export const plugin = coreDatabasePlugin; - /** - * The store this database's transactions operate on: entities, resources, - * archetypes, index handles, and the initiating `userId`. A store *is* the - * transaction context — there is no separate transaction type. - */ - export type Store = Database.Plugin.ToStore; - /** Index-declaration type bound to this database's components. */ - export type Index = Database.Index; - /** A component name, and a component-key tuple (an archetype's shape / a `count`/`select` - * query-key list). Derived from `components` — NOT `CoreComponents` (plugin-derived, so it - * would cycle when used to `satisfies`-check the archetypes it is derived from). */ - export type Component = Extract; - export type Archetype = readonly Component[]; -} diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/resources.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/resources.ts deleted file mode 100644 index 073090bcd..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/resources.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { Vec2, F32 } from "@adobe/data/math"; -import { Input } from "../../../data/input/input.js"; - -// The singleton simulation state. Each entry is a Schema carrying a `default`; -// where the data/ schema's own default fits it is bound by identity, otherwise -// the schema is spread and the default overridden. All `session` scope: this -// game state is client-local and ephemeral. -export const resources = Database.resources({ - session: { - bounds: Vec2.schema, // play-field size [width, height]; schema default [0, 0] - score: F32.schema, // schema default 0 - lives: { ...F32.schema, default: 3 }, - wave: F32.schema, // schema default 0 - input: { ...Input.schema, default: Input.none }, - frameDelta: { ...F32.schema, default: 1 / 60 }, // fixed timestep, seconds/tick - }, -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/index-database/index-database.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/index-database/index-database.ts deleted file mode 100644 index c319f829b..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/index-database/index-database.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { CoreDatabase } from "../core-database/core-database.js"; -import * as indexes from "./indexes/index.js"; - -// Extends the core schema with the `indexes` facet. `create` validates every -// index descriptor against the real component/archetype facets (an -// archetype-scoped index would need a field the `CoreDatabase.Index` helper -// can't express; `byCell` is column-scoped so it validates with `satisfies`). -const indexDatabasePlugin = Database.Plugin.create({ - extends: CoreDatabase.plugin, - indexes, -}); - -export type IndexDatabase = Database.Plugin.ToDatabase; - -export namespace IndexDatabase { - export const plugin = indexDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/index-database/indexes/by-cell.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/index-database/indexes/by-cell.ts deleted file mode 100644 index 54ef5f5a2..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/index-database/indexes/by-cell.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Spatial } from "../../../../data/spatial/spatial.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// Broad-phase spatial index: cell id → the entities currently in that cell. -// A COMPUTED-KEY index — the `cell` slot is derived from each entity's -// `position` on the fly (`Spatial.cellKey`), so it stays correct with zero -// maintenance: there is no stored `cell` column to keep in sync at every -// insert/update, and nothing can silently drift. Coverage is by columns, so -// every archetype carrying `position` (Ship, Asteroid, Bullet) is indexed. -// Multi-value (not unique) — many entities share a cell. The collision system -// unions `find({ cell })` over `Spatial.neighborKeys(...)` to gather overlap -// candidates. -export const byCell = { - components: ["position"], - key: { cell: (c) => Spatial.cellKey(c.position!, Spatial.cellSize) }, -} as const satisfies CoreDatabase.Index; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/main-service.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/main-service.ts deleted file mode 100644 index dfdf7c39f..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/main-service.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// The feature's assembled database, aliased once so consumers (ui/, the app -// entry, ecs/conformance/) never name the topmost layer. Add or drop a layer → -// change only this line. -export { SystemDatabase as MainService } from "./system-database/system-database.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/service-database/service-database.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/service-database/service-database.ts deleted file mode 100644 index ca899dd94..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/service-database/service-database.ts +++ /dev/null @@ -1,30 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import type { Assert, Equal } from "@adobe/data/types"; -import { ComputedDatabase } from "../computed-database/computed-database.js"; -import { RandomService } from "../../random-service/random-service.js"; -import type { Services } from "../../services.js"; - -// Extends the computed database with the `services` facet. `random` is a -// capability port with no ECS state to bind, so it is registered directly from -// its `services/` contract (like data-lit-todo's `nameGenerator`): production -// uses the real `Math.random`-backed source; tests inject -// `RandomService.createFake` through the `Database.create` service override. -// Consumers reach it as `db.services.random`. -const serviceDatabasePlugin = Database.Plugin.create({ - extends: ComputedDatabase.plugin, - services: { - random: RandomService.create, - }, -}); - -export type ServiceDatabase = Database.Plugin.ToDatabase; - -// Drift-guard: the services the ECS resolves onto `db.services` must exactly match -// the injectable `Services` map the data transitions `Pick` from. -type _ServicesPin = Assert>; - -export namespace ServiceDatabase { - export const plugin = serviceDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/collision-detection.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/collision-detection.test.ts deleted file mode 100644 index e562e22ba..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/collision-detection.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// Collision SELECTION/DETECTION tests — a system concern the whole-tick -// conformance can't isolate, and where subtle broad-phase bugs hide. Each case -// seeds an edge-case geometry, drives ONE frame with dt = 0 (so nothing moves -// or ages — advance/wrap are identities, no bullet expires, no fire), and reads -// back which entities actually interacted. This exercises the `byCell` -// computed-key broad phase + the circle narrow-phase in isolation from motion. -// -// The broad phase must union a 3×3 block of cells (cellSize = 80), so the key -// cases put an overlapping pair in DIFFERENT cells, and a non-overlapping pair -// where a naive same-cell test might false-positive / false-negative. -import { describe, it, expect } from "vitest"; -import type { State } from "../../../data/state/state.js"; -import { Ship } from "../../../data/ship/ship.js"; -import { Bullet } from "../../../data/bullet/bullet.js"; -import { Asteroid } from "../../../data/asteroid/asteroid.js"; -import { Input } from "../../../data/input/input.js"; -import { Size } from "../../../data/size/size.js"; -import { createSystemDatabase } from "../conformance/create-system-database.js"; -import { projection } from "../conformance/projection.js"; -import { driveFrame } from "../conformance/drive-frame.js"; - -const base = (overrides: Partial): State => ({ - bounds: [800, 600], - ship: Ship.spawn([750, 550]), // far corner — no ship strike unless overridden - entities: new Map(), - score: 0, - lives: 3, - wave: 1, - ...overrides, -}); - -// The bullets / asteroids projected out of the unified `entities` map by their -// structural guards — the collision selection assertions read these back. -const bulletsOf = (state: State): readonly Bullet[] => - [...state.entities.values()].filter(Bullet.is); -const asteroidsOf = (state: State): readonly Asteroid[] => - [...state.entities.values()].filter(Asteroid.is); - -// Seed the geometry, run exactly one detection-only frame (dt 0), project back. -const detect = (state: State): State => { - const db = createSystemDatabase(); - projection.fromState(db.store, state); - db.store.resources.frameDelta = 0; - db.transactions.setInput(Input.none); - driveFrame(db); - return projection.toState(db.store); -}; - -describe("collision detection — bullet ↔ asteroid selection", () => { - it("destroys only the asteroid the bullet overlaps, scoring it", () => { - const after = detect( - base({ - entities: new Map([ - [1, { position: [100, 100], velocity: [0, 0], age: 0 }], - [2, { position: [100, 100], velocity: [0, 0], size: "large" }], // overlapped - [3, { position: [400, 300], velocity: [0, 0], size: "large" }], // far away - ]), - }), - ); - expect(after.score).toBe(Size.score.large); - expect(bulletsOf(after)).toHaveLength(0); - // The struck large became two mediums; the distant large is untouched. - const asteroids = asteroidsOf(after); - expect(asteroids.filter((a) => a.size === "medium")).toHaveLength(2); - expect(asteroids.filter((a) => a.size === "large")).toHaveLength(1); - }); - - it("registers a hit across a cell boundary (broad phase unions neighbours)", () => { - // Bullet in cell x=0 (79/80), asteroid centre in cell x=1 (81/80); they are - // 2px apart, well within 2+40, so a correct 3×3 neighbour union finds it. - const after = detect( - base({ - entities: new Map([ - [1, { position: [79, 100], velocity: [0, 0], age: 0 }], - [2, { position: [81, 100], velocity: [0, 0], size: "large" }], - ]), - }), - ); - expect(after.score).toBe(Size.score.large); - expect(bulletsOf(after)).toHaveLength(0); - }); - - it("registers a hit exactly at the radius-sum boundary (distance == r₁+r₂)", () => { - const after = detect( - base({ - entities: new Map([ - [1, { position: [0, 0], velocity: [0, 0], age: 0 }], - [2, { position: [42, 0], velocity: [0, 0], size: "large" }], // 42 == 2+40 - ]), - }), - ); - expect(after.score).toBe(Size.score.large); - expect(bulletsOf(after)).toHaveLength(0); - }); - - it("does NOT register just beyond the radius sum (no false positive)", () => { - const after = detect( - base({ - entities: new Map([ - [1, { position: [0, 0], velocity: [0, 0], age: 0 }], - [2, { position: [43, 0], velocity: [0, 0], size: "large" }], // 43 > 42 - ]), - }), - ); - expect(after.score).toBe(0); - expect(bulletsOf(after)).toHaveLength(1); - const asteroids = asteroidsOf(after); - expect(asteroids).toHaveLength(1); - expect(asteroids[0].size).toBe("large"); - }); - - it("leaves a bullet that overlaps nothing untouched", () => { - const after = detect( - base({ - entities: new Map([ - [1, { position: [10, 10], velocity: [0, 0], age: 0 }], - [2, { position: [400, 300], velocity: [0, 0], size: "large" }], - ]), - }), - ); - expect(after.score).toBe(0); - expect(bulletsOf(after)).toHaveLength(1); - expect(asteroidsOf(after)).toHaveLength(1); - }); - - it("does not let a second bullet hit a child the first spawned this same frame", () => { - // Two bullets on one large: one destroys it (→ 2 medium); the second must - // find no ORIGINAL target and survive — never chain onto a fresh medium. - const after = detect( - base({ - entities: new Map([ - [1, { position: [100, 100], velocity: [0, 0], age: 0 }], - [2, { position: [100, 100], velocity: [0, 0], age: 0 }], - [3, { position: [100, 100], velocity: [0, 0], size: "large" }], - ]), - }), - ); - expect(after.score).toBe(Size.score.large); - const asteroids = asteroidsOf(after); - expect(asteroids.filter((a) => a.size === "medium")).toHaveLength(2); - expect(asteroids.filter((a) => a.size === "small")).toHaveLength(0); - expect(bulletsOf(after)).toHaveLength(1); - }); -}); - -describe("collision detection — ship ↔ asteroid selection", () => { - it("costs a life when an asteroid overlaps the ship", () => { - const after = detect( - base({ - ship: Ship.spawn([400, 300]), - entities: new Map([ - [1, { position: [400, 300], velocity: [0, 0], size: "large" }], - ]), - lives: 3, - }), - ); - expect(after.lives).toBe(2); - expect(after.ship.position).toEqual([400, 300]); // respawned at the centre - }); - - it("spends exactly one life even when several asteroids touch the ship", () => { - const after = detect( - base({ - ship: Ship.spawn([400, 300]), - entities: new Map([ - [1, { position: [400, 300], velocity: [0, 0], size: "large" }], - [2, { position: [410, 300], velocity: [0, 0], size: "large" }], - ]), - lives: 3, - }), - ); - expect(after.lives).toBe(2); - }); - - it("does not cost a life when the nearest asteroid is out of range", () => { - const after = detect( - base({ - ship: Ship.spawn([400, 300]), - entities: new Map([ - [1, { position: [460, 300], velocity: [0, 0], size: "large" }], // 60 > 52 - ]), - lives: 3, - }), - ); - expect(after.lives).toBe(3); - expect(after.ship.position).toEqual([400, 300]); - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/system-database.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/system-database.ts deleted file mode 100644 index 7fecac2d0..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/system-database.ts +++ /dev/null @@ -1,283 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database, scheduler } from "@adobe/data/ecs"; -import type { Entity } from "@adobe/data/ecs"; -import { Vec2 } from "@adobe/data/math"; -import { ActionDatabase } from "../action-database/action-database.js"; -import { Motion } from "../../../data/motion/motion.js"; -import { Spatial } from "../../../data/spatial/spatial.js"; -import { Collision } from "../../../data/collision/collision.js"; -import { Asteroid } from "../../../data/asteroid/asteroid.js"; -import { Bullet } from "../../../data/bullet/bullet.js"; -import { Ship } from "../../../data/ship/ship.js"; -import { State } from "../../../data/state/state.js"; - -// The real-time tick loop. Extends the action database (schema + indexes + -// transactions + computed + services + actions) combined with the built-in -// `scheduler` — systems come last in the pipeline, so they sit atop the service -// and action layers the feature builds — and declares -// the `systems` facet INLINE so each `create`'s `db` is strongly typed (the -// assembled database with a *writable* store) and the scheduler can infer the -// system-name union from the map's keys. -// -// The five systems mirror the internal sequence of the `data/` `State.step` -// oracle — advance → fire → age → collide → refill — so the tick cannot diverge -// from the spec even though the work is split across ordered systems: -// -// control → the turn/thrust half of State.stepShip (rotation + velocity) -// movement → the advance+wrap half of stepShip and stepAsteroids (ship + rocks) -// lifetime → State.fireBullet THEN State.stepBullets (fire from the *post-move* -// muzzle, then advance + age + retire every bullet, new one included) -// collision→ resolveBulletHits then resolveShipHits (discrete transactions) -// waves → State.spawnRandomWave once the field is clear (injected random) -// -// Firing lives in `lifetime` (after `movement`) precisely so the bullet leaves -// the ship's post-move muzzle and is advanced+aged in the same frame — matching -// step's stepShip→fireBullet→stepBullets order. The scheduler drives the systems -// on requestAnimationFrame; its `schedulerState` resource ("running" | "paused" | -// "disposed") gates start/pause/resume. A headless host (tests, server sim) omits -// rAF and drives frames itself by invoking `db.system.functions[name]()` for each -// name in `db.system.order`. -const systemDatabasePlugin = Database.Plugin.create({ - extends: Database.Plugin.combine(ActionDatabase.plugin, scheduler), - systems: { - // Apply the player's intent to the ship: turn, then thrust along the new - // facing — the rotation/velocity half of State.stepShip (movement advances - // the ship's position afterwards). Frozen once the game is over. - control: { - create: (db) => () => { - const { resources } = db.store; - if (State.isGameOver({ lives: resources.lives })) return; - const input = resources.input; - const dt = resources.frameDelta; - for (const arch of db.store.queryArchetypes(["position", "velocity", "rotation"])) { - const rotation = arch.columns.rotation; - const velocity = arch.columns.velocity; - for (let i = 0; i < arch.rowCount; i++) { - const turned = Ship.turn(rotation.get(i), input.turn, dt); - rotation.set(i, turned); - if (input.thrust) { - velocity.set(i, Ship.thrust(velocity.get(i), turned, dt)); - } - } - } - }, - }, - - // The hot per-row path for the non-bullet bodies (ship + asteroids): advance - // by velocity and wrap at the screen edges — the Motion half of - // State.stepShip / stepAsteroids. Bullets are excluded (only they carry - // `age`); they advance in `lifetime`, paired with firing, so the step's - // fireBullet→stepBullets adjacency is preserved. The broad-phase cell is - // derived from `position` by the `byCell` computed-key index, so a moved - // body needs no cell bookkeeping here. Every body stays in its own - // archetype, so these are in-place column writes with no migration and no - // transaction overhead. Frozen once the game is over. - movement: { - schedule: { after: ["control"] }, - create: (db) => () => { - const { resources } = db.store; - if (State.isGameOver({ lives: resources.lives })) return; - const dt = resources.frameDelta; - const bounds = resources.bounds; - for (const arch of db.store.queryArchetypes( - ["position", "velocity"], - { exclude: ["age"] }, - )) { - const position = arch.columns.position; - const velocity = arch.columns.velocity; - for (let i = 0; i < arch.rowCount; i++) { - position.set(i, Motion.wrap(Motion.advance(position.get(i), velocity.get(i), dt), bounds)); - } - } - }, - }, - - // Bullet lifecycle, running after movement so the ship is already at its - // post-move position — the ecs wiring for State.fireBullet followed by - // State.stepBullets. First, on a `fire` edge, dispatch fireBullet (reusing - // the data/-verified muzzle kinematics off the *post-move* ship and notifying - // the HUD) and clear the edge so one keypress is one shot. Then advance + age - // + wrap every bullet — the freshly-fired one included, exactly as stepBullets - // maps over the bullet it was just handed — dropping any that outlive their - // lifetime. `Bullet.isExpired` reads the age *before* the increment (as the - // spec does), so an expiring bullet is retired rather than advanced. Deletion - // hole-fills from the tail, so iterate tail→head: indices ahead of the cursor - // stay valid and no snapshot is needed. Runs before collision so a bullet that - // expires this frame can no longer score a hit. Frozen once the game is over. - lifetime: { - schedule: { after: ["movement"] }, - create: (db) => () => { - const { resources } = db.store; - if (State.isGameOver({ lives: resources.lives })) return; - const dt = resources.frameDelta; - const bounds = resources.bounds; - if (resources.input.fire) { - db.transactions.fireBullet(); - resources.input = { ...resources.input, fire: false }; - } - for (const arch of db.store.queryArchetypes(["position", "velocity", "age"])) { - const id = arch.columns.id; - const position = arch.columns.position; - const velocity = arch.columns.velocity; - const age = arch.columns.age; - for (let i = arch.rowCount - 1; i >= 0; i--) { - const current = age.get(i); - if (Bullet.isExpired(current, dt)) { - db.store.delete(id.get(i)); - } else { - position.set(i, Motion.wrap(Motion.advance(position.get(i), velocity.get(i), dt), bounds)); - age.set(i, current + dt); - } - } - } - }, - }, - - // Resolve collisions the way State.step does: bullet↔asteroid first (each - // bullet destroys the first asteroid its path this frame sweeps through), - // then ship↔asteroid. Both discrete outcomes are dispatched as transactions — - // hitAsteroid (score + split) and loseLife (spend a life, respawn) — so the - // data/-verified logic is reused and the reactive HUD is notified. - // - // Two different broad phases: the SHIP is point-like and moves slowly, so it - // uses the `byCell` index (this cell + its 8 neighbours) then a circle test. - // A BULLET is swept — its per-frame travel can span many cells, which the - // 3×3 byCell window cannot bound — so bullet detection brute-force scans the - // whole Asteroid archetype (rock counts are small; the byCell index was - // already over-engineering) with a segment-vs-circle test. Set/scan order - // need not match the spec's array order (the conformance test compares as a - // multiset). Bullet ids are snapshotted first: a dispatch migrates archetype - // rows, so we must not hold a live cursor across one; a bullet is only removed - // by its own hit, so reading each surviving bullet's position/velocity by id - // stays valid. Frozen once the game is over. - collision: { - schedule: { after: ["movement", "lifetime"] }, - create: (db) => { - const findHitAsteroid = ( - center: Vec2, - radius: number, - exclude?: ReadonlySet, - ): Entity | undefined => { - const candidates = new Set(); - for (const cell of Spatial.neighborKeys(center, Spatial.cellSize)) { - for (const entity of db.indexes.byCell.find({ cell })) candidates.add(entity); - } - for (const candidate of candidates) { - if (exclude !== undefined && exclude.has(candidate)) continue; - const asteroid = db.store.read(candidate, db.store.archetypes.Asteroid); - if (asteroid === null) continue; - if (Collision.circlesOverlap(center, radius, asteroid.position, Asteroid.radius(asteroid))) { - return candidate; - } - } - return undefined; - }; - // Swept bullet detection: test the segment the bullet travelled this - // frame (prev → position) against every asteroid. A long sweep spans - // many cells, so the byCell 3×3 window can't bound it — scan the whole - // Asteroid archetype (counts are small). - const findSweptHitAsteroid = ( - prev: Vec2, - position: Vec2, - radius: number, - exclude: ReadonlySet, - ): Entity | undefined => { - for (const arch of db.store.queryArchetypes(["size"])) { - const id = arch.columns.id; - for (let i = 0; i < arch.rowCount; i++) { - const entity = id.get(i); - if (exclude.has(entity)) continue; - const asteroid = db.store.read(entity, db.store.archetypes.Asteroid); - if (asteroid === null) continue; - if ( - Collision.segmentCircleOverlap( - prev, - position, - asteroid.position, - radius + Asteroid.radius(asteroid), - ) - ) { - return entity; - } - } - } - return undefined; - }; - return () => { - if (State.isGameOver({ lives: db.store.resources.lives })) return; - - // Detect every (bullet, asteroid) pair FIRST, against the untouched - // store — so no child a split spawns this frame is ever a target (and - // no reused entity id can alias one). Each asteroid is claimed by at - // most one bullet, matching resolveBulletHits. Then apply. - const dt = db.store.resources.frameDelta; - const bulletIds: Entity[] = []; - for (const arch of db.store.queryArchetypes(["position", "age"])) { - const id = arch.columns.id; - for (let i = 0; i < arch.rowCount; i++) bulletIds.push(id.get(i)); - } - const claimed = new Set(); - const hits: { readonly bullet: Entity; readonly asteroid: Entity }[] = []; - for (const bullet of bulletIds) { - const position = db.store.get(bullet, "position"); - const velocity = db.store.get(bullet, "velocity"); - if (position === undefined || velocity === undefined) continue; - // Reconstruct the bullet's path this frame. NOTE: this is off on a - // frame where the bullet screen-wrapped (position jumped across an - // edge), a rare edge case — acceptable for now. - const prev = Vec2.subtract(position, Vec2.scale(velocity, dt)); - const asteroid = findSweptHitAsteroid(prev, position, Bullet.radius, claimed); - if (asteroid !== undefined) { - claimed.add(asteroid); - hits.push({ bullet, asteroid }); - } - } - for (const hit of hits) db.transactions.hitAsteroid(hit); - - // resolveShipHits spends at most one life regardless of how many - // asteroids touch the ship, so stop at the first overlap. - for (const arch of db.store.queryArchetypes(["position", "rotation"])) { - const position = arch.columns.position; - for (let i = 0; i < arch.rowCount; i++) { - if (findHitAsteroid(position.get(i), Ship.radius) !== undefined) { - db.transactions.loseLife(); - return; - } - } - } - }; - }, - }, - - // Refill the field once it is clear — the ecs wiring for State.spawnRandomWave. - // Only the Asteroid archetype carries `size`, so a non-empty match means rocks - // remain and there is nothing to do; otherwise dispatch spawnRandomWave, which - // bumps `wave` and inserts the next ring through the data/-verified layout — - // its per-rock drift speeds jittered by the injected `random` service, so live - // waves vary run to run. The random source is the feature's `random` service - // (`db.services.random`, the real Math.random-backed source in production); - // it is read once here (in `create`, before the per-frame loop) and injected on - // every spawn. Frozen once the game is over — step never reaches the refill - // after game over, so a dead game does not respawn a wave. - waves: { - schedule: { after: ["collision"] }, - create: (db) => { - const random = db.services.random; - return () => { - if (State.isGameOver({ lives: db.store.resources.lives })) return; - for (const arch of db.store.queryArchetypes(["size"])) { - if (arch.rowCount > 0) return; - } - db.transactions.refillWave({ random }); - }; - }, - }, - }, -}); - -export type SystemDatabase = Database.Plugin.ToDatabase; - -export namespace SystemDatabase { - export const plugin = systemDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts deleted file mode 100644 index db6075daf..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// Whole-tick conformance: one frame of the ECS system loop must produce the -// same logical `State` as one `data/` `State.step`. Reuses the feature -// projection (`ecs/conformance/`) over the SHARED `step` cases — the same -// `{ before, args, after }` array the pure `State.step` spec test runs — so -// this is "substitute the implementation, reuse the truth". -// -// Systems run through `db.system.functions`, so unlike a transaction they need -// the assembled database, obtained cast-free via `createSystemDatabase` (the -// writable-store lens). Per case: seed `fromState(db.store, before)`, seed the -// fixed-timestep `frameDelta` (a resource with no `data/` analogue, written -// straight to the store as the oracle is fed `args.dt`) and the player `input`, -// drive one headless frame, then assert `toState(db.store) ≡ after`. Each case -// also asserts `State.step ≡ after` first, keeping the shared case honest. -// -// None of the shared cases clears the field, so neither the oracle nor the -// `waves` system draws randomness — the injected `random` double is inert here -// and both sides stay exact. The randomized refill (whose real `Math.random` -// source can't be shared with the pure oracle frame-for-frame) is covered -// out-of-band: `spawn-random-wave.test.ts` (transition), the `spawnRandomWave` -// transaction conformance (ecs mutation), and the detection test below (the -// `waves` system actually refilling a cleared field). -import { describe, it, expect } from "vitest"; -import { State } from "../../../data/state/state.js"; -import { Ship } from "../../../data/ship/ship.js"; -import { Asteroid } from "../../../data/asteroid/asteroid.js"; -import { Input } from "../../../data/input/input.js"; -import { cases } from "../../../data/state/step.cases.js"; -import { Conformance } from "@adobe/data-testing"; -import { createFake as createRandom } from "../../random-service/random.fake.js"; -import { createSystemDatabase } from "../conformance/create-system-database.js"; -import { projection } from "../conformance/projection.js"; -import { driveFrame } from "../conformance/drive-frame.js"; - -describe("ECS system tick loop conforms to State.step (one frame = one step)", () => { - for (const testCase of cases.cases) { - it(testCase.name, () => { - const { dt, input } = testCase.args; - // A case `before` is a delta over the feature default (`SpecCase.before` is - // `Partial`), so materialise the full seed the same way the runners do. - // `db.store` supplies the schemas `assertState` reads to compare up to an - // id-bijection (a tick spawns split children, so keys differ on both sides). - const before = { ...State.create(), ...testCase.before }; - const db = createSystemDatabase(); - // The case args omit the injected `random` port (data-only), so supply an inert - // double here — no step case clears the field, so it is never drawn — to satisfy - // the oracle's signature while keeping both sides exact. - Conformance.assertState( - State.step(before, { dt, input, random: createRandom() }), - testCase.after, - db.store, - ); - - projection.fromState(db.store, before); - db.store.resources.frameDelta = dt; - db.transactions.setInput(input); - driveFrame(db); - Conformance.assertState(projection.toState(db.store), testCase.after, db.store); - }); - } - - // The `waves` system uses the REAL random source, so its drift speeds vary and - // can't be asserted against the pure oracle. Detection-style check instead: a - // driven frame over a cleared field must refill it with the fixed ring of four - // large asteroids and bump the wave — positions are deterministic (only speed - // is random), so assert those and the count, not the velocities. - it("waves system refills a cleared field with the large-asteroid ring", () => { - const db = createSystemDatabase(); - projection.fromState(db.store, { - ...State.create(), - bounds: [200, 200], - ship: Ship.spawn([100, 100]), - entities: new Map(), - wave: 0, - }); - db.store.resources.frameDelta = 0.1; - db.transactions.setInput(Input.none); - driveFrame(db); - - const after = projection.toState(db.store); - const asteroids = [...after.entities.values()].filter(Asteroid.is); - expect(after.wave).toBe(1); - expect(asteroids.length).toBe(4); - expect(asteroids.every((a) => a.size === "large")).toBe(true); - const positions = asteroids.map((a) => [ - Math.round(a.position[0]), - Math.round(a.position[1]), - ]); - expect(positions).toEqual( - expect.arrayContaining([ - [180, 100], - [100, 180], - [20, 100], - [100, 20], - ]), - ); - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transaction-database.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transaction-database.ts deleted file mode 100644 index 4755776fe..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transaction-database.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { IndexDatabase } from "../index-database/index-database.js"; -import * as transactions from "./transactions/index.js"; - -// Extends the indexed schema with the `transactions` facet: the discrete, -// atomic state changes the UI and systems dispatch (set input, start a game, -// spawn a wave, fire, resolve a hit, lose a life). Each is thin over the pure -// data/ transform it stands for; per-frame continuous motion is the systems -// layer, not a transaction. -const transactionDatabasePlugin = Database.Plugin.create({ - extends: IndexDatabase.plugin, - transactions, -}); - -export type TransactionDatabase = Database.Plugin.ToDatabase< - typeof transactionDatabasePlugin ->; - -export namespace TransactionDatabase { - export const plugin = transactionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/clear-entities.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/clear-entities.ts deleted file mode 100644 index f2afb1570..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/clear-entities.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// Remove every entity — ship, asteroids, and bullets all carry `position`, so -// one query covers them. Every row migrates out, so iterate tail→head: each -// delete is from the tail, so there is no hole-fill shift and indices ahead of -// the cursor stay valid. -export const clearEntities = (t: CoreDatabase.Store): void => { - for (const arch of t.queryArchetypes(["position"])) { - for (let row = arch.rowCount - 1; row >= 0; row--) { - t.delete(arch.columns.id.get(row)); - } - } -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/create-initial.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/create-initial.ts deleted file mode 100644 index f5c7e9ceb..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/create-initial.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Vec2 } from "@adobe/data/math"; -import type { CoreDatabase } from "../../core-database/core-database.js"; -import { setBounds } from "./set-bounds.js"; -import { newGame } from "./new-game.js"; - -// createInitial ⇄ State.createInitial: seed the play-field `bounds` the reset -// reads, then start a fresh game. This single transaction realizes the -// `createInitial` transition end-to-end so conformance can pair it by name; -// `newGame`/`setBounds` remain the infra pieces the UI drives directly. -export const createInitial = ( - t: CoreDatabase.Store, - { bounds }: { bounds: Vec2 }, -): void => { - setBounds(t, bounds); - newGame(t); -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/fire-bullet.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/fire-bullet.ts deleted file mode 100644 index 9f3fe6510..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/fire-bullet.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "../../../../data/state/state.js"; -import { Bullet } from "../../../../data/bullet/bullet.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; -import { readShip } from "./read-ship.js"; - -// Fire one bullet from the ship's muzzle. Thin wrapper over State.fireBullet: -// project the ship, let the data/ transform compute the bullet's spawn -// kinematics (nose position + inherited momentum), and insert it with its -// initial spatial cell. No ship yet (before newGame) → nothing to fire. -export const fireBullet = (t: CoreDatabase.Store): void => { - const found = readShip(t); - if (found === undefined) return; - const { entities } = State.fireBullet({ ship: found.ship, entities: new Map() }); - // The seed's entities were empty, so the patch holds exactly the fired bullet. - for (const value of entities.values()) { - if (Bullet.is(value)) t.archetypes.Bullet.insert(value); - } -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/hit-asteroid.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/hit-asteroid.ts deleted file mode 100644 index 62925bf93..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/hit-asteroid.ts +++ /dev/null @@ -1,28 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import { Asteroid } from "../../../../data/asteroid/asteroid.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// The outcome of a bullet striking an asteroid, dispatched by the collision -// system. Mirrors one iteration of State.resolveBulletHits: remove the bullet -// and the struck asteroid, award the asteroid's score, and insert its split -// children (the smallest tier yields none, so it just vanishes). A missing -// asteroid (already resolved earlier this frame) is a no-op. -export const hitAsteroid = ( - t: CoreDatabase.Store, - { bullet, asteroid }: { readonly bullet: Entity; readonly asteroid: Entity }, -): void => { - const row = t.read(asteroid, t.archetypes.Asteroid); - if (row === null) return; - const target: Asteroid = { - position: row.position, - velocity: row.velocity, - size: row.size, - }; - t.delete(bullet); - t.delete(asteroid); - t.resources.score = t.resources.score + Asteroid.score(target); - for (const child of Asteroid.split(target)) { - t.archetypes.Asteroid.insert(child); - } -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/index.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/index.ts deleted file mode 100644 index a9e480b8a..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export * from "./set-input.js"; -export * from "./set-bounds.js"; -export * from "./new-game.js"; -export * from "./create-initial.js"; -export * from "./refill-wave.js"; -export * from "./fire-bullet.js"; -export * from "./hit-asteroid.js"; -export * from "./lose-life.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/lose-life.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/lose-life.ts deleted file mode 100644 index b52032122..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/lose-life.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Vec2 } from "@adobe/data/math"; -import { Ship } from "../../../../data/ship/ship.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; -import { readShip } from "./read-ship.js"; - -// Spend a life and respawn the ship at centre — the discrete outcome the -// collision system dispatches when an asteroid strikes the ship. Reproduces the -// struck branch of State.resolveShipHits (lives floored at zero, ship recentred -// via Ship.spawn); bullets stay in play, as the spec leaves them. No ship yet -// (before newGame) → nothing to do. -export const loseLife = (t: CoreDatabase.Store): void => { - const found = readShip(t); - if (found === undefined) return; - t.resources.lives = Math.max(0, t.resources.lives - 1); - const respawned = Ship.spawn(Vec2.scale(t.resources.bounds, 0.5)); - t.update(found.entity, respawned); -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.ts deleted file mode 100644 index c503aeb40..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.ts +++ /dev/null @@ -1,24 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "../../../../data/state/state.js"; -import { Bullet } from "../../../../data/bullet/bullet.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; -import { clearEntities } from "./clear-entities.js"; - -// Start a fresh game on the current play-field. Thin wrapper over -// State.createInitial: clear every entity, reset the scalar resources, centre -// the ship, and spawn the first wave. `bounds` is preserved — it is set by the -// UI on canvas resize, not part of the reset. -export const newGame = (t: CoreDatabase.Store): void => { - const initial = State.createInitial(State.create(), { bounds: t.resources.bounds }); - clearEntities(t); - t.resources.score = initial.score; - t.resources.lives = initial.lives; - t.resources.wave = initial.wave; - t.archetypes.Ship.insert(initial.ship); - // Insert each entity into its archetype by its structural guard (createInitial - // spawns only asteroids, but this mirrors the projection's fromState). - for (const value of initial.entities.values()) { - if (Bullet.is(value)) t.archetypes.Bullet.insert(value); - else t.archetypes.Asteroid.insert(value); - } -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/read-asteroids.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/read-asteroids.ts deleted file mode 100644 index 92642a0a7..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/read-asteroids.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Asteroid } from "../../../../data/asteroid/asteroid.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// The current asteroids projected back to their data/ values — the slice -// State.spawnWave reads to decide whether the field is clear. Only the Asteroid -// archetype carries `size`, so the query matches it alone. -export const readAsteroids = (t: CoreDatabase.Store): Asteroid[] => { - const asteroids: Asteroid[] = []; - for (const arch of t.queryArchetypes(t.archetypes.Asteroid.components)) { - for (let row = 0; row < arch.rowCount; row++) { - asteroids.push({ - position: arch.columns.position.get(row), - velocity: arch.columns.velocity.get(row), - size: arch.columns.size.get(row), - }); - } - } - return asteroids; -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/read-ship.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/read-ship.ts deleted file mode 100644 index d5b4aacd0..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/read-ship.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { Ship } from "../../../../data/ship/ship.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// The single ship projected back to its data/ value, paired with its entity id -// (for in-place respawn). `undefined` before newGame has inserted one — every -// consumer guards and returns rather than assuming a ship exists. -export const readShip = ( - t: CoreDatabase.Store, -): { readonly entity: Entity; readonly ship: Ship } | undefined => { - const [entity] = t.select(t.archetypes.Ship.components); - if (entity === undefined) return undefined; - const row = t.read(entity, t.archetypes.Ship); - if (row === null) return undefined; - return { - entity, - ship: { position: row.position, velocity: row.velocity, rotation: row.rotation }, - }; -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/refill-wave.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/refill-wave.ts deleted file mode 100644 index 4e16fcde6..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/refill-wave.ts +++ /dev/null @@ -1,38 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "../../../../data/state/state.js"; -import { Asteroid } from "../../../../data/asteroid/asteroid.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; -import { RandomService } from "../../../random-service/random-service.js"; -import { readAsteroids } from "./read-asteroids.js"; - -// The system-dispatched refill: advance to the next wave with a randomized layout. -// Thin wrapper over State.spawnRandomWave: project the {asteroids, wave, bounds} -// slice, let the data/ transform decide the count and per-rock drift with the -// INJECTED `random` service, then bump `wave` and insert the spawned rocks. The -// transform is a no-op while the field still has asteroids (returns the same object), -// so dispatching mid-wave changes nothing. -// -// This transaction takes an injected service, so it is NOT a conformance-paired -// transaction (a transaction never receives services from the runner). It is named -// for its system-dispatched role — the `waves` system and the `spawnRandomWave` -// action call it, and the action is the seam `checkFeature` conforms the -// `spawnRandomWave` transition through. The `waves` system supplies the real random -// source; the action supplies `db.services.random` (a recording double under test). -export const refillWave = ( - t: CoreDatabase.Store, - { random }: { random: RandomService }, -): void => { - // refillWave is a no-op while the field still has asteroids; guard here so a - // mid-wave dispatch inserts nothing (the patch return is a fresh object, so it - // can no longer be reference-compared to detect the no-op). - if (readAsteroids(t).length > 0) return; - const after = State.spawnRandomWave( - { entities: new Map(), wave: t.resources.wave, bounds: t.resources.bounds }, - { random }, - ); - t.resources.wave = after.wave; - // The seed's entities were empty, so the patch holds exactly the spawned rocks. - for (const value of after.entities.values()) { - if (Asteroid.is(value)) t.archetypes.Asteroid.insert(value); - } -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-bounds.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-bounds.ts deleted file mode 100644 index 19476a8a7..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-bounds.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Vec2 } from "@adobe/data/math"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// Set the play-field size. The UI dispatches this when the canvas mounts or -// resizes; newGame/spawnWave read `bounds` to place the ship and asteroid ring. -export const setBounds = (t: CoreDatabase.Store, bounds: Vec2): void => { - t.resources.bounds = bounds; -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-input.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-input.ts deleted file mode 100644 index c02e158d4..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-input.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Input } from "../../../../data/input/input.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// Record the player's intent for upcoming ticks. The UI dispatches this on -// keydown/keyup; the step system reads the `input` resource each frame. -export const setInput = (t: CoreDatabase.Store, input: Input): void => { - t.resources.input = input; -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/random-service/create-fake.ts b/packages/data-lit-space-rock-game/src/features/main/services/random-service/create-fake.ts new file mode 100644 index 000000000..b555851c6 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/random-service/create-fake.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { RandomService } from "./random-service.js"; + +/** + * Fake {@link RandomService}, exported as `RandomService.createFake` (tree-shaken when + * unused). Conformance calls it only to enumerate the service's methods; each case's + * `responses` supply the draws. Standalone, `next` always returns 0. + */ +export const createFake = (): RandomService => ({ + serviceName: "random", + next: () => 0, +}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/random-service/create.ts b/packages/data-lit-space-rock-game/src/features/main/services/random-service/create.ts index 4231c8986..841e4a87f 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/random-service/create.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/random-service/create.ts @@ -1,12 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { RandomService } from "./random-service.js"; -/** - * The production random source: `next` delegates to `Math.random`. The ECS - * `waves` system constructs this once and injects it into the `spawnRandomWave` - * transaction, so live waves vary run to run. Tests inject - * {@link RandomService.createFake} instead. - */ +/** The production random source: `next` delegates to `Math.random`. */ export const create = (): RandomService => ({ serviceName: "random", next: () => Math.random(), diff --git a/packages/data-lit-space-rock-game/src/features/main/services/random-service/public.ts b/packages/data-lit-space-rock-game/src/features/main/services/random-service/public.ts index 6523855b6..56c941d3d 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/random-service/public.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/random-service/public.ts @@ -1,2 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; +export { createFake } from "./create-fake.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/random-service/random.fake.ts b/packages/data-lit-space-rock-game/src/features/main/services/random-service/random.fake.ts deleted file mode 100644 index 10cc19cd0..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/random-service/random.fake.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { RandomService } from "./random-service.js"; - -/** - * Shape-only recording template for {@link RandomService} — a TEST-TIER double, NOT - * re-exported through `public.ts`, so it can never be reached as - * `RandomService.createFake` from runtime code. Conformance calls it once purely to - * enumerate the service's methods; the RETURNS come from each case's `responses` (the - * per-asteroid draws a `spawnRandomWave` case owns), so this file hardcodes no value - * a case then has to guess at. `next` is a value method, so its placeholder is - * inert. Imported directly by the conformance manifest (`data/state/spec.ts`) and the - * system tick-loop test (as an inert `random` for a `State.step` that never draws). - */ -export const createFake = (): RandomService => ({ - serviceName: "random", - next: () => 0, -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/collision.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/collision.cases.ts new file mode 100644 index 000000000..a7361680f --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/collision.cases.ts @@ -0,0 +1,289 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { collision } from "./collision.js"; +import { Ship } from "../data/values/ship/ship.js"; + +// Cases for the `collision` system. Bullet detection is SWEPT: a bullet's path this +// frame is [position − velocity·dt, position], and it destroys the first asteroid +// that segment passes through, scores it (large 20 / medium 50 / small 100), and +// replaces it with its split children (stationary parents → stationary children). +// Then an asteroid touching the ship costs one life (floored at 0) and respawns it at +// the centre. Radii: bullet 2, ship 12, asteroids 40/20/10. The broad phase unions a +// 3×3 block of 80px cells, so some pairs straddle a cell boundary. +const dt = 1 / 60; +// Bullet cases keep the ship in the far corner, clear of every asteroid. +const field: Pick = { bounds: [800, 600], ship: Ship.spawn([750, 550]) }; +// Ship cases: 200×200 → respawn at [100,100]. +const small: Pick = { bounds: [200, 200] }; +const respawned = Ship.spawn([100, 100]); + +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "destroys bullet + asteroid, scores, and spawns split children (large → 2 medium)", + before: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "large" }], + ]), + }, + args: { dt }, + after: { + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], size: "medium" }], + [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], + ]), + score: 20, + }, + }, + { + name: "medium splits into two small", + before: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], + ]), + score: 5, + }, + args: { dt }, + after: { + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], size: "small" }], + [2, { position: [50, 50], velocity: [0, 0], size: "small" }], + ]), + score: 55, + }, + }, + { + name: "the smallest tier is destroyed outright — no children", + before: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "small" }], + ]), + }, + args: { dt }, + after: { entities: new Map(), score: 100 }, + }, + { + name: "a bullet that hits nothing is left untouched", + before: { + ...field, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], age: 0 }], + [2, { position: [500, 500], velocity: [0, 0], size: "large" }], + ]), + score: 7, + }, + args: { dt }, + after: {}, + }, + { + name: "only the overlapping asteroid is hit; distant ones remain", + before: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "large" }], + [3, { position: [500, 500], velocity: [0, 0], size: "small" }], + ]), + }, + args: { dt }, + after: { + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], size: "medium" }], + [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], + [3, { position: [500, 500], velocity: [0, 0], size: "small" }], + ]), + score: 20, + }, + }, + { + name: "two bullets each destroy their own asteroid", + before: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [500, 500], velocity: [0, 0], age: 0 }], + [3, { position: [50, 50], velocity: [0, 0], size: "small" }], + [4, { position: [500, 500], velocity: [0, 0], size: "small" }], + ]), + }, + args: { dt }, + after: { entities: new Map(), score: 200 }, + }, + { + // One bullet destroys the large (→ 2 medium). The second finds no original + // target — children spawned this same pass are not yet hittable — so it survives. + name: "split children are not hittable by another bullet in the same pass", + before: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], age: 0 }], + [3, { position: [50, 50], velocity: [0, 0], size: "large" }], + ]), + }, + args: { dt }, + after: { + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], + [3, { position: [50, 50], velocity: [0, 0], size: "medium" }], + ]), + score: 20, + }, + }, + { + name: "boundary: distance exactly equal to the radius sum still overlaps", + before: { + ...field, + entities: new Map([ + [1, { position: [0, 0], velocity: [0, 0], age: 0 }], + [2, { position: [42, 0], velocity: [0, 0], size: "large" }], + ]), + }, + args: { dt }, + after: { + entities: new Map([ + [1, { position: [42, 0], velocity: [0, 0], size: "medium" }], + [2, { position: [42, 0], velocity: [0, 0], size: "medium" }], + ]), + score: 20, + }, + }, + { + name: "just beyond the radius sum is no hit", + before: { + ...field, + entities: new Map([ + [1, { position: [0, 0], velocity: [0, 0], age: 0 }], + [2, { position: [43, 0], velocity: [0, 0], size: "large" }], + ]), + }, + args: { dt }, + after: {}, + }, + { + // Bullet in cell x=0 (79/80), asteroid in cell x=1 (81/80), 2px apart. + name: "registers a hit across a cell boundary", + before: { + ...field, + entities: new Map([ + [1, { position: [79, 100], velocity: [0, 0], age: 0 }], + [2, { position: [81, 100], velocity: [0, 0], size: "large" }], + ]), + }, + args: { dt }, + after: { + entities: new Map([ + [1, { position: [81, 100], velocity: [0, 0], size: "medium" }], + [2, { position: [81, 100], velocity: [0, 0], size: "medium" }], + ]), + score: 20, + }, + }, + { + // Over dt=1/60 the bullet travels 50px: prev = [50,0]. Both endpoints are 25px + // from the medium at [25,0] — outside the 22px radius sum, so a point test + // misses; the travelled segment crosses it, so a swept test hits. + name: "a fast bullet whose path sweeps through a medium destroys it (no tunnelling)", + before: { + ...field, + entities: new Map([ + [1, { position: [0, 0], velocity: [-3000, 0], age: 0 }], + [2, { position: [25, 0], velocity: [0, 0], size: "medium" }], + ]), + }, + args: { dt }, + after: { + entities: new Map([ + [1, { position: [25, 0], velocity: [0, 0], size: "small" }], + [2, { position: [25, 0], velocity: [0, 0], size: "small" }], + ]), + score: 50, + }, + }, + { + name: "an asteroid on the ship costs a life and respawns it at centre", + before: { + ...small, + ship: { position: [10, 10], velocity: [5, 5], rotation: 1 }, + entities: new Map([[1, { position: [10, 10], velocity: [0, 0], size: "large" }]]), + }, + args: { dt }, + after: { ship: respawned, lives: 2 }, + }, + { + name: "several asteroids on the ship cost exactly one life", + before: { + ...small, + ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], size: "large" }], + [2, { position: [20, 10], velocity: [0, 0], size: "large" }], + ]), + }, + args: { dt }, + after: { ship: respawned, lives: 2 }, + }, + { + name: "no asteroid touching the ship is a no-op", + before: { + ...small, + ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, + entities: new Map([[1, { position: [500, 500], velocity: [0, 0], size: "large" }]]), + }, + args: { dt }, + after: {}, + }, + { + name: "an asteroid just out of the ship's reach is a no-op", + before: { + ...small, + ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, + entities: new Map([[1, { position: [160, 100], velocity: [0, 0], size: "large" }]]), // 60 > 52 + }, + args: { dt }, + after: {}, + }, + { + name: "the last life drops lives to zero, and the ship still respawns", + before: { + ...small, + ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, + entities: new Map([[1, { position: [10, 10], velocity: [0, 0], size: "large" }]]), + lives: 1, + }, + args: { dt }, + after: { ship: respawned, lives: 0 }, + }, + { + name: "boundary: distance exactly equal to the radius sum still counts as a hit", + before: { + ...small, + ship: { position: [0, 0], velocity: [0, 0], rotation: 0 }, + entities: new Map([[1, { position: [52, 0], velocity: [0, 0], size: "large" }]]), + }, + args: { dt }, + after: { ship: respawned, lives: 2 }, + }, + { + name: "bullet hits resolve first: a destroyed asteroid can't strike the ship", + before: { + ...small, + ship: respawned, + entities: new Map([ + [1, { position: [100, 100], velocity: [0, 0], age: 0 }], + [2, { position: [100, 100], velocity: [0, 0], size: "small" }], + ]), + }, + args: { dt }, + after: { entities: new Map(), score: 100 }, + }, + ], +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/collision.ts b/packages/data-lit-space-rock-game/src/features/main/spec/collision.ts new file mode 100644 index 000000000..d24d0c787 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/collision.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import { resolveBulletHits } from "./resolve-bullet-hits.js"; +import { resolveShipHits } from "./resolve-ship-hits.js"; +import { whilePlaying } from "./while-playing.js"; + +// The `collision` system: bullet↔asteroid hits first, then ship↔asteroid against +// what is left. +export const collision = ( + state: Pick, + { dt }: { readonly dt: number }, +): Pick => + whilePlaying(state, (playing) => { + const hits = resolveBulletHits(playing, { dt }); + return { ...hits, ...resolveShipHits({ ...playing, ...hits }) }; + }); diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/control.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/control.cases.ts new file mode 100644 index 000000000..451e8e25c --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/control.cases.ts @@ -0,0 +1,43 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { control } from "./control.js"; +import { Input } from "../data/values/input/input.js"; + +// Cases for the `control` system. turnRate 3, thrustAccel 200, so every `after` is +// exact. Control never moves the ship; `movement` does. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "turns right by a positive turn input", + before: { ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, + args: { dt: 1, input: { turn: 1, thrust: false, fire: false } }, + after: { ship: { position: [50, 50], velocity: [0, 0], rotation: 3 } }, + }, + { + name: "turns left by a negative turn input", + before: { ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, + args: { dt: 1, input: { turn: -1, thrust: false, fire: false } }, + after: { ship: { position: [50, 50], velocity: [0, 0], rotation: -3 } }, + }, + { + name: "idle input holds rotation and velocity", + before: { ship: { position: [50, 50], velocity: [10, 0], rotation: 0.7 } }, + args: { dt: 1, input: Input.none }, + after: { ship: { position: [50, 50], velocity: [10, 0], rotation: 0.7 } }, + }, + { + name: "thrusts along the facing without moving the ship", + before: { ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, + args: { dt: 0.1, input: { turn: 0, thrust: true, fire: false } }, + after: { ship: { position: [50, 50], velocity: [20, 0], rotation: 0 } }, + }, + { + // −3 turns to 0, so thrust points +x; the old rotation would point elsewhere. + name: "turn composes before thrust — thrust uses the post-turn rotation", + before: { ship: { position: [50, 50], velocity: [0, 0], rotation: -3 } }, + args: { dt: 1, input: { turn: 1, thrust: true, fire: false } }, + after: { ship: { position: [50, 50], velocity: [200, 0], rotation: 0 } }, + }, + ], +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/control.ts b/packages/data-lit-space-rock-game/src/features/main/spec/control.ts new file mode 100644 index 000000000..bd684ba33 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/control.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import type { Input } from "../data/values/input/input.js"; +import { Ship } from "../data/values/ship/ship.js"; +import { whilePlaying } from "./while-playing.js"; + +// The `control` system: turn the ship, then thrust along the post-turn facing. +export const control = ( + state: Pick, + { dt, input }: { readonly dt: number; readonly input: Input }, +): Pick => + whilePlaying(state, ({ ship }) => { + const rotation = Ship.turn(ship.rotation, input.turn, dt); + const velocity = input.thrust ? Ship.thrust(ship.velocity, rotation, dt) : ship.velocity; + return { ship: { position: ship.position, velocity, rotation } }; + }); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/create-initial.cases.ts similarity index 78% rename from packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.cases.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/create-initial.cases.ts index 7b3e12e5e..891f56eac 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.cases.ts +++ b/packages/data-lit-space-rock-game/src/features/main/spec/create-initial.cases.ts @@ -3,13 +3,11 @@ import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { createInitial } from "./create-initial.js"; -// Inert, spec-owned cases, shared with the ecs `createInitial` transaction. -// `createInitial` ignores `before` entirely — it produces a fresh game from the -// bounds alone — so `before` here is a deliberately dirty state, which also proves -// the transaction clears whatever was there. A fresh game centres the ship, resets -// score/lives/wave, and spawns wave 1 (asteroidsFor(1)=4 large in a clean quadrant -// ring at radius min(bounds)·0.4), so every `after` is exact. `bounds` is plain -// data — no service double, no entity refs. +// Cases for `createInitial`, shared with the ecs `createInitial` action. It ignores +// `before` — a fresh game comes from the bounds alone — so `before` is deliberately +// dirty, which also proves the action clears whatever was there. A fresh game centres +// the ship, resets score/lives/wave, and spawns wave 1 (4 large rocks in a quadrant +// ring at radius min(bounds)·0.4), so every `after` is exact. const dirty: State = { bounds: [1, 1], ship: { position: [10, 10], velocity: [5, 5], rotation: 1 }, diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/create-initial.ts b/packages/data-lit-space-rock-game/src/features/main/spec/create-initial.ts new file mode 100644 index 000000000..1081ab277 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/create-initial.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Vec2 } from "@adobe/data/math"; +import type { State } from "./state.js"; +import { Ship } from "../data/values/ship/ship.js"; +import { Lives } from "../data/values/lives/lives.js"; +import { spawnWave } from "./spawn-wave.js"; + +// A fresh game for a `bounds`-sized field: ship centred, no bullets, full lives, +// zero score, and the first wave spawned. A reset: the prior `state` is ignored. +export const createInitial = ( + _state: State, + { bounds }: { readonly bounds: Vec2 }, +): State => { + const fresh: State = { + bounds, + ship: Ship.spawn(Vec2.scale(bounds, 0.5)), + entities: new Map(), + score: 0, + lives: Lives.initial, + wave: 0, + }; + return { ...fresh, ...spawnWave(fresh) }; +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/create.test.ts b/packages/data-lit-space-rock-game/src/features/main/spec/create.test.ts new file mode 100644 index 000000000..57a03fa29 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/create.test.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { State } from "./state.js"; +import { Lives } from "../data/values/lives/lives.js"; + +describe("State.create", () => { + it("is a blank neutral state: no field, idle ship, empty, full lives, wave 0", () => { + const state = State.create(); + expect(state.bounds).toEqual([0, 0]); + expect(state.ship.velocity).toEqual([0, 0]); + expect(state.entities).toEqual(new Map()); + expect(state.score).toBe(0); + expect(state.lives).toBe(3); + expect(state.wave).toBe(0); + }); + + it("is not game over", () => { + expect(Lives.isGameOver(State.create().lives)).toBe(false); + }); +}); diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/create.ts b/packages/data-lit-space-rock-game/src/features/main/spec/create.ts new file mode 100644 index 000000000..a743d8338 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/create.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import { Ship } from "../data/values/ship/ship.js"; +import { Lives } from "../data/values/lives/lives.js"; + +// A blank neutral `State`: no play-field, an idle ship at the origin, no entities, +// full lives, zero score, wave 0. Cases author their `before` as deltas over it. +export const create = (): State => ({ + bounds: [0, 0], + ship: Ship.spawn([0, 0]), + entities: new Map(), + score: 0, + lives: Lives.initial, + wave: 0, +}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/fire-bullet.cases.ts similarity index 83% rename from packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.cases.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/fire-bullet.cases.ts index 31a3628d8..146a2b24b 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.cases.ts +++ b/packages/data-lit-space-rock-game/src/features/main/spec/fire-bullet.cases.ts @@ -4,12 +4,10 @@ import type { State } from "./state.js"; import type { fireBullet } from "./fire-bullet.js"; import { create } from "./create.js"; -// Inert, spec-owned cases, shared with the ecs `fireBullet` transaction/action. A -// bullet leaves the ship's nose (position + facing·Ship.radius=12) inheriting the -// ship's momentum plus Bullet.speed=400 along the facing. Rotation 0 → facing [1,0]; -// rotation −π/2 → facing [0,−1] (up). The existing bullets pass through untouched. -// `fireBullet` takes no args, so no `args` field. `before`/`after` are full states -// over a fixed 800×600 field. +// Cases for `fireBullet`, shared with the ecs `fireBullet` action. A bullet leaves +// the ship's nose (position + facing·Ship.radius=12) inheriting the ship's momentum +// plus Bullet.speed=400 along the facing. Rotation 0 → facing [1,0]; rotation −π/2 → +// facing [0,−1] (up). Existing bullets pass through untouched. const field = { ...create(), bounds: [800, 600] as [number, number] }; export const cases: Conformance.SpecCases = { diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/fire-bullet.ts b/packages/data-lit-space-rock-game/src/features/main/spec/fire-bullet.ts new file mode 100644 index 000000000..fb2f9bd1f --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/fire-bullet.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import { Bullet } from "../data/values/bullet/bullet.js"; + +// Fire one bullet from the ship's nose, inheriting its momentum, under a fresh +// entity id (the map key). +export const fireBullet = ( + state: Pick, +): Pick => { + const id = Math.max(0, ...state.entities.keys()) + 1; + return { entities: new Map(state.entities).set(id, Bullet.fire(state.ship)) }; +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/lifetime.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/lifetime.cases.ts new file mode 100644 index 000000000..dd740e7e4 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/lifetime.cases.ts @@ -0,0 +1,65 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { lifetime } from "./lifetime.js"; +import { Bullet } from "../data/values/bullet/bullet.js"; +import { Input } from "../data/values/input/input.js"; + +// Cases for the `lifetime` system. `Bullet.lifetime` is 1.2; the 100×100 field forces +// wrap. A fired bullet leaves the nose (position + facing·12) at 400px/s and is +// advanced the same tick. +const field: Pick = { bounds: [100, 100] }; +const trigger: Input = { turn: 0, thrust: false, fire: true }; + +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "moves and ages a live bullet", + before: { ...field, entities: new Map([[1, { position: [10, 50], velocity: [100, 0], age: 0 }]]) }, + args: { dt: 0.1, input: Input.none }, + after: { entities: new Map([[1, { position: [20, 50], velocity: [100, 0], age: 0.1 }]]) }, + }, + { + name: "wraps a bullet across the right edge", + before: { ...field, entities: new Map([[1, { position: [95, 50], velocity: [100, 0], age: 0 }]]) }, + args: { dt: 0.1, input: Input.none }, + after: { entities: new Map([[1, { position: [5, 50], velocity: [100, 0], age: 0.1 }]]) }, + }, + { + name: "drops a bullet that expires this tick (age + dt ≥ lifetime)", + before: { ...field, entities: new Map([[1, { position: [10, 50], velocity: [100, 0], age: Bullet.lifetime }]]) }, + args: { dt: 0.1, input: Input.none }, + after: { entities: new Map() }, + }, + { + name: "keeps and ages a bullet still under its lifetime", + before: { ...field, entities: new Map([[1, { position: [10, 50], velocity: [0, 0], age: 1.0 }]]) }, + args: { dt: 0.1, input: Input.none }, + after: { entities: new Map([[1, { position: [10, 50], velocity: [0, 0], age: 1.1 }]]) }, + }, + { + name: "advances survivors and drops only the expired bullet", + before: { + ...field, + entities: new Map([ + [1, { position: [10, 50], velocity: [100, 0], age: 0 }], + [2, { position: [10, 60], velocity: [100, 0], age: Bullet.lifetime }], + ]), + }, + args: { dt: 0.1, input: Input.none }, + after: { entities: new Map([[1, { position: [20, 50], velocity: [100, 0], age: 0.1 }]]) }, + }, + { + name: "leaves asteroids to `movement`", + before: { ...field, entities: new Map([[1, { position: [10, 10], velocity: [30, 0], size: "large" }]]) }, + args: { dt: 0.1, input: Input.none }, + after: { entities: new Map([[1, { position: [10, 10], velocity: [30, 0], size: "large" }]]) }, + }, + { + name: "fires from the nose and advances the new bullet this tick", + before: { bounds: [400, 400], ship: { position: [100, 100], velocity: [0, 0], rotation: 0 } }, + args: { dt: 0.1, input: trigger }, + after: { entities: new Map([[1, { position: [152, 100], velocity: [400, 0], age: 0.1 }]]) }, + }, + ], +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/lifetime.ts b/packages/data-lit-space-rock-game/src/features/main/spec/lifetime.ts new file mode 100644 index 000000000..31a860b19 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/lifetime.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import type { Input } from "../data/values/input/input.js"; +import { fireBullet } from "./fire-bullet.js"; +import { stepBullets } from "./step-bullets.js"; +import { whilePlaying } from "./while-playing.js"; + +// The `lifetime` system: fire if the trigger is pulled, then advance, age and retire +// every bullet, the new one included. +export const lifetime = ( + state: Pick, + { dt, input }: { readonly dt: number; readonly input: Input }, +): Pick => + whilePlaying(state, (playing) => + stepBullets(input.fire ? { ...playing, ...fireBullet(playing) } : playing, { dt }), + ); diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/movement.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/movement.cases.ts new file mode 100644 index 000000000..88aa60f89 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/movement.cases.ts @@ -0,0 +1,73 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { movement } from "./movement.js"; +import { Size } from "../data/values/size/size.js"; + +// Cases for the `movement` system. The 100×100 field forces wrap; bodies drift by +// constant velocity, so every `after` is exact. The default ship idles at the origin. +const field: Pick = { bounds: [100, 100] }; + +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "coasts the ship by its velocity", + before: { ...field, ship: { position: [50, 50], velocity: [10, 0], rotation: 0.7 } }, + args: { dt: 1 }, + after: { ship: { position: [60, 50], velocity: [10, 0], rotation: 0.7 } }, + }, + { + name: "wraps the ship across the right edge", + before: { ...field, ship: { position: [95, 50], velocity: [100, 0], rotation: 0 } }, + args: { dt: 0.1 }, + after: { ship: { position: [5, 50], velocity: [100, 0], rotation: 0 } }, + }, + { + name: "wraps the ship across the top edge (negative wrap)", + before: { ...field, ship: { position: [5, 5], velocity: [0, -100], rotation: 0 } }, + args: { dt: 0.1 }, + after: { ship: { position: [5, 95], velocity: [0, -100], rotation: 0 } }, + }, + { + name: "drifts an asteroid by its velocity", + before: { ...field, entities: new Map([[1, { position: [10, 10], velocity: [30, 0], size: Size.largest }]]) }, + args: { dt: 1 }, + after: { entities: new Map([[1, { position: [40, 10], velocity: [30, 0], size: Size.largest }]]) }, + }, + { + name: "wraps an asteroid around the toroidal field", + before: { ...field, entities: new Map([[1, { position: [80, 80], velocity: [50, 50], size: Size.largest }]]) }, + args: { dt: 1 }, + after: { entities: new Map([[1, { position: [30, 30], velocity: [50, 50], size: Size.largest }]]) }, + }, + { + name: "wraps an asteroid negatively across the left edge", + before: { ...field, entities: new Map([[1, { position: [10, 10], velocity: [-50, 0], size: "medium" }]]) }, + args: { dt: 1 }, + after: { entities: new Map([[1, { position: [60, 10], velocity: [-50, 0], size: "medium" }]]) }, + }, + { + name: "advances several asteroids of different sizes independently", + before: { + ...field, + entities: new Map([ + [1, { position: [10, 10], velocity: [10, 0], size: "large" }], + [2, { position: [20, 20], velocity: [0, 10], size: "small" }], + ]), + }, + args: { dt: 1 }, + after: { + entities: new Map([ + [1, { position: [20, 10], velocity: [10, 0], size: "large" }], + [2, { position: [20, 30], velocity: [0, 10], size: "small" }], + ]), + }, + }, + { + name: "leaves bullets to `lifetime`", + before: { ...field, entities: new Map([[1, { position: [10, 10], velocity: [10, 0], age: 0 }]]) }, + args: { dt: 1 }, + after: { entities: new Map([[1, { position: [10, 10], velocity: [10, 0], age: 0 }]]) }, + }, + ], +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/movement.ts b/packages/data-lit-space-rock-game/src/features/main/spec/movement.ts new file mode 100644 index 000000000..ca9dbf0e2 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/movement.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import { Motion } from "../data/values/motion/motion.js"; +import { stepAsteroids } from "./step-asteroids.js"; +import { whilePlaying } from "./while-playing.js"; + +// The `movement` system: advance + wrap the ship and every asteroid. Bullets move +// in `lifetime`. +export const movement = ( + state: Pick, + { dt }: { readonly dt: number }, +): Pick => + whilePlaying(state, (playing) => { + const { ship, bounds } = playing; + return { + ship: { ...ship, position: Motion.wrap(Motion.advance(ship.position, ship.velocity, dt), bounds) }, + ...stepAsteroids(playing, { dt }), + }; + }); diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/public.ts b/packages/data-lit-space-rock-game/src/features/main/spec/public.ts new file mode 100644 index 000000000..69dfc9c89 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/public.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { create } from "./create.js"; +export { samples } from "./samples.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts b/packages/data-lit-space-rock-game/src/features/main/spec/resolve-bullet-hits.ts similarity index 93% rename from packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/resolve-bullet-hits.ts index 6fae12907..c0194c0ee 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts +++ b/packages/data-lit-space-rock-game/src/features/main/spec/resolve-bullet-hits.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; import type { State } from "./state.js"; -import { Bullet } from "../bullet/bullet.js"; -import { Asteroid } from "../asteroid/asteroid.js"; -import { Collision } from "../collision/collision.js"; +import { Bullet } from "../data/values/bullet/bullet.js"; +import { Asteroid } from "../data/values/asteroid/asteroid.js"; +import { Collision } from "../data/values/collision/collision.js"; // Resolve bullet↔asteroid collisions: each bullet destroys the first asteroid // its path this frame passes through, scoring it and replacing it with its diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts b/packages/data-lit-space-rock-game/src/features/main/spec/resolve-ship-hits.ts similarity index 82% rename from packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/resolve-ship-hits.ts index 4655bebfd..24fa34718 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts +++ b/packages/data-lit-space-rock-game/src/features/main/spec/resolve-ship-hits.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; import type { State } from "./state.js"; -import { Ship } from "../ship/ship.js"; -import { Asteroid } from "../asteroid/asteroid.js"; -import { Collision } from "../collision/collision.js"; +import { Ship } from "../data/values/ship/ship.js"; +import { Asteroid } from "../data/values/asteroid/asteroid.js"; +import { Collision } from "../data/values/collision/collision.js"; // If any asteroid is touching the ship, it costs a life and the ship respawns // at the centre. No collision leaves the state untouched (idempotent). diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/samples.ts b/packages/data-lit-space-rock-game/src/features/main/spec/samples.ts similarity index 100% rename from packages/data-lit-space-rock-game/src/features/main/data/state/samples.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/samples.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/spawn-random-wave.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/spawn-random-wave.cases.ts new file mode 100644 index 000000000..81a4952cf --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/spawn-random-wave.cases.ts @@ -0,0 +1,50 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { spawnRandomWave } from "./spawn-random-wave.js"; +import { create } from "./create.js"; + +// Cases for the service-injected `spawnRandomWave`, shared with the ecs +// `spawnRandomWave` action. The `random` draws are scheduled in `responses`; `next` +// is a value-returning read, not an effect, so it is not declared in `effects`. A +// spawn draws 4 values (one per rock of wave 1). Field 200×200 → centre [100,100], +// ring radius 80; positions match `spawnWave`, only drift speed is jittered: +// `60·(0.5 + draw)` → [30, 60, 45, 75] in ring order. +const field = { ...create(), bounds: [200, 200] as [number, number] }; + +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "spawns a randomized wave (jittered drift speeds) when the field is clear", + before: { ...field, entities: new Map(), wave: 0 }, + responses: { random: { next: [0, 0.5, 0.25, 0.75] } }, + after: { + ...field, + wave: 1, + entities: new Map([ + [1, { position: [180, 100], velocity: [0, 30], size: "large" }], + [2, { position: [100, 180], velocity: [-60, 0], size: "large" }], + [3, { position: [20, 100], velocity: [0, -45], size: "large" }], + [4, { position: [100, 20], velocity: [75, 0], size: "large" }], + ]), + }, + }, + { + name: "does nothing while asteroids still remain", + before: { + ...field, + wave: 1, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], size: "large" }], + ]), + }, + after: { + ...field, + wave: 1, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], size: "large" }], + ]), + }, + }, + ], +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/spawn-random-wave.ts b/packages/data-lit-space-rock-game/src/features/main/spec/spawn-random-wave.ts new file mode 100644 index 000000000..07595b7a0 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/spawn-random-wave.ts @@ -0,0 +1,28 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import { Asteroid } from "../data/values/asteroid/asteroid.js"; +import { Wave } from "../data/values/wave/wave.js"; +import type { Services } from "../services/services.js"; + +/** + * The randomized sibling of {@link spawnWave}: same ring layout, but each rock's + * drift speed is `Wave.speed · (0.5 + random.next())`, i.e. `[0.5×, 1.5×)`, drawing + * one value per asteroid in ring order. Deterministic given the injected `random` + * service. A no-op while asteroids remain (draws nothing). + */ +export const spawnRandomWave = ( + state: Pick, + { random }: Pick, +): Pick => { + const hasAsteroid = [...state.entities.values()].some((v) => Asteroid.is(v)); + if (hasAsteroid) { + return { entities: state.entities, wave: state.wave }; + } + const wave = state.wave + 1; + const entities = new Map(state.entities); + let nextId = Math.max(0, ...state.entities.keys()) + 1; + for (const asteroid of Wave.ring(state.bounds, wave, () => Wave.speed * (0.5 + random.next()))) { + entities.set(nextId++, asteroid); + } + return { wave, entities }; +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/spawn-wave.test.ts b/packages/data-lit-space-rock-game/src/features/main/spec/spawn-wave.test.ts new file mode 100644 index 000000000..367bda42c --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/spawn-wave.test.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { State } from "./state.js"; +import type { Asteroid } from "../data/values/asteroid/asteroid.js"; +import { spawnWave } from "./spawn-wave.js"; + +// `spawnWave` seeds a fresh game's first wave; the `createInitial` cases pin its +// layout. These cover the wave bump and the no-op branch `createInitial` never reaches. +describe("spawnWave", () => { + const field: State = { ...State.create(), bounds: [200, 200] }; + + it("spawns the next wave of large asteroids when the field is clear", () => { + const { wave, entities } = spawnWave({ ...field, wave: 1 }); + expect(wave).toBe(2); + expect([...entities.values()].map((a) => "size" in a && a.size)).toEqual(Array(5).fill("large")); + }); + + it("does nothing while asteroids still remain", () => { + const rock: Asteroid = { position: [10, 10], velocity: [0, 0], size: "large" }; + const entities = new Map([[1, rock]]); + expect(spawnWave({ ...field, wave: 1, entities })).toEqual({ wave: 1, entities }); + }); +}); diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/spawn-wave.ts b/packages/data-lit-space-rock-game/src/features/main/spec/spawn-wave.ts new file mode 100644 index 000000000..ebb0eb908 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/spawn-wave.ts @@ -0,0 +1,24 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import { Asteroid } from "../data/values/asteroid/asteroid.js"; +import { Wave } from "../data/values/wave/wave.js"; + +// When the field is clear, advance to the next wave: a ring of large asteroids +// drifting at the fixed base speed. Deterministic — this seeds the FIRST wave +// (`createInitial`), so a fresh game always starts from the same fair layout. +// A no-op while asteroids remain. +export const spawnWave = ( + state: Pick, +): Pick => { + const hasAsteroid = [...state.entities.values()].some((v) => Asteroid.is(v)); + if (hasAsteroid) { + return { entities: state.entities, wave: state.wave }; + } + const wave = state.wave + 1; + const entities = new Map(state.entities); + let nextId = Math.max(0, ...state.entities.keys()) + 1; + for (const asteroid of Wave.ring(state.bounds, wave, () => Wave.speed)) { + entities.set(nextId++, asteroid); + } + return { wave, entities }; +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/spec.test.ts b/packages/data-lit-space-rock-game/src/features/main/spec/spec.test.ts new file mode 100644 index 000000000..bdab1adf7 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/spec.test.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "./spec.js"; + +// Runs every case against the pure spec functions — no ECS involved. +Conformance.checkSpec(spec); diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/spec.ts b/packages/data-lit-space-rock-game/src/features/main/spec/spec.ts new file mode 100644 index 000000000..329dae7cf --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/spec.ts @@ -0,0 +1,68 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import * as systems from "./systems.js"; +import { components } from "../data/components/index.js"; +import { resources } from "../data/resources/index.js"; +import { RandomService } from "../services/random-service/random-service.js"; +import { Ship } from "../data/values/ship/ship.js"; +import type { Bullet } from "../data/values/bullet/bullet.js"; +import type { Asteroid } from "../data/values/asteroid/asteroid.js"; + +import { cases as createInitial } from "./create-initial.cases.js"; +import { cases as fireBullet } from "./fire-bullet.cases.js"; +import { cases as spawnRandomWave } from "./spawn-random-wave.cases.js"; +import { cases as control } from "./control.cases.js"; +import { cases as movement } from "./movement.cases.js"; +import { cases as lifetime } from "./lifetime.cases.js"; +import { cases as collision } from "./collision.cases.js"; +import { cases as waves } from "./waves.cases.js"; + +// The feature's spec: every transition and every ECS system, one inert cases module +// each (coverage checked at compile time). `noOp` is a state every system must leave +// unchanged; the runner checks it against each one. `schemas` supplies the column +// precision the comparator reads; `services` is the shape template the runner builds +// `random` recording doubles from. `ecs/conformance/` pairs this with the ECS build +// and owns the whole-frame cases. +export const spec = Conformance.spec({ + state: State, + fns: transforms, + schemas: { ...components, ...resources }, + services: { random: RandomService.createFake }, + cases: { + createInitial, + fireBullet, + spawnRandomWave, + }, + systems: { + fns: systems, + cases: { control, movement, lifetime, collision, waves }, + noOp: [ + { + // Everything is live: input held, ship and asteroid moving, a bullet on a + // small asteroid, a large one on the ship. + name: "frozen once the game is over", + before: { + bounds: [200, 200], + ship: Ship.spawn([100, 100]), + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0.5 }], + [2, { position: [50, 50], velocity: [10, 0], size: "small" }], + [3, { position: [100, 100], velocity: [0, 10], size: "large" }], + ]), + score: 40, + lives: 0, + wave: 2, + }, + args: { dt: 0.1, input: { turn: 1, thrust: true, fire: true } }, + }, + { + // A cleared field, which would make `waves` refill. + name: "frozen once the game is over, even on a cleared field", + before: { bounds: [200, 200], ship: Ship.spawn([100, 100]), entities: new Map(), score: 40, lives: 0, wave: 3 }, + args: { dt: 0.1, input: { turn: 1, thrust: true, fire: true } }, + }, + ], + }, +}); diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/state.ts b/packages/data-lit-space-rock-game/src/features/main/spec/state.ts new file mode 100644 index 000000000..a8ee608c7 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/state.ts @@ -0,0 +1,27 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Vec2 } from "@adobe/data/math"; +import type { Assert } from "@adobe/data/types"; +import type { Conformance } from "@adobe/data-testing"; +import type { Ship } from "../data/values/ship/ship.js"; +import type { Bullet } from "../data/values/bullet/bullet.js"; +import type { Asteroid } from "../data/values/asteroid/asteroid.js"; +import type { components } from "../data/components/index.js"; +import type { resources } from "../data/resources/index.js"; + +// The whole game as one immutable value — the specification the ECS implementation +// is verified against. `entities` holds every bullet and asteroid in one +// identity-keyed map (the value carries no id); each entry is discriminated by its +// shape (`Bullet.is` / `Asteroid.is`). +export type State = { + readonly bounds: Vec2; + readonly ship: Ship; + readonly entities: ReadonlyMap; + readonly score: number; + readonly lives: number; + readonly wave: number; +}; + +// Singleton keys name resources; entity value keys name components. +type _Pin = Assert>; + +export * as State from "./public.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts b/packages/data-lit-space-rock-game/src/features/main/spec/step-asteroids.ts similarity index 76% rename from packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/step-asteroids.ts index 79eccea65..e43aff11e 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts +++ b/packages/data-lit-space-rock-game/src/features/main/spec/step-asteroids.ts @@ -1,12 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; -import { Asteroid } from "../asteroid/asteroid.js"; -import { Motion } from "../motion/motion.js"; +import { Asteroid } from "../data/values/asteroid/asteroid.js"; +import { Motion } from "../data/values/motion/motion.js"; // Drift every asteroid one tick by its constant velocity, wrapping at edges -// (keeping each asteroid's entity id). Non-asteroid entities pass through. `dt` is -// bundled into one args object (second parameter) as the conformance case model -// requires. +// (keeping each asteroid's entity id). Non-asteroid entities pass through. export const stepAsteroids = ( state: Pick, { dt }: { readonly dt: number }, diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts b/packages/data-lit-space-rock-game/src/features/main/spec/step-bullets.ts similarity index 78% rename from packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/step-bullets.ts index 99d01bcd5..167ae6aa4 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts +++ b/packages/data-lit-space-rock-game/src/features/main/spec/step-bullets.ts @@ -1,12 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; -import { Bullet } from "../bullet/bullet.js"; -import { Motion } from "../motion/motion.js"; +import { Bullet } from "../data/values/bullet/bullet.js"; +import { Motion } from "../data/values/motion/motion.js"; // Advance every bullet one tick: drop the ones that expire this tick, and move // + age + wrap the survivors (keeping each survivor's entity id). Non-bullet -// entities pass through untouched. `dt` is bundled into one args object (second -// parameter) as the conformance case model requires. +// entities pass through untouched. export const stepBullets = ( state: Pick, { dt }: { readonly dt: number }, diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/systems.ts b/packages/data-lit-space-rock-game/src/features/main/spec/systems.ts new file mode 100644 index 000000000..df23a0c9f --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/systems.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The spec of each ECS system, named exactly like it; one `*.cases.ts` module each. +// Their frame order is not authored here: conformance folds them in the order the +// systems' `schedule`s declare. +export { control } from "./control.js"; +export { movement } from "./movement.js"; +export { lifetime } from "./lifetime.js"; +export { collision } from "./collision.js"; +export { waves } from "./waves.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/transforms.ts b/packages/data-lit-space-rock-game/src/features/main/spec/transforms.ts new file mode 100644 index 000000000..00e3b8831 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/transforms.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The conformed transitions of this feature; its keys are the manifest's coverage +// surface (one `*.cases.ts` module per key), each paired with a same-named action. +// The per-frame systems are specified in `systems.ts`. +export { createInitial } from "./create-initial.js"; +export { fireBullet } from "./fire-bullet.js"; +export { spawnRandomWave } from "./spawn-random-wave.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/waves.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/waves.cases.ts new file mode 100644 index 000000000..a31965c28 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/waves.cases.ts @@ -0,0 +1,46 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { waves } from "./waves.js"; +import type { Asteroid } from "../data/values/asteroid/asteroid.js"; +import type { Bullet } from "../data/values/bullet/bullet.js"; + +// Cases for the `waves` system. The `random` draws are scheduled in `responses`, one +// per rock of the new wave (wave 1 has 4). Field 200×200 → centre [100,100], ring +// radius 80, rocks on the quadrant angles; drift speed is `60·(0.5 + draw)` → +// [30, 60, 45, 75] in ring order. +const field: Pick = { bounds: [200, 200] }; +const ring: readonly Asteroid[] = [ + { position: [180, 100], velocity: [0, 30], size: "large" }, + { position: [100, 180], velocity: [-60, 0], size: "large" }, + { position: [20, 100], velocity: [0, -45], size: "large" }, + { position: [100, 20], velocity: [75, 0], size: "large" }, +]; + +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "refills a cleared field with a randomized ring and bumps the wave", + before: { ...field, entities: new Map(), wave: 0 }, + responses: { random: { next: [0, 0.5, 0.25, 0.75] } }, + after: { wave: 1, entities: new Map(ring.map((rock, i): [number, Asteroid] => [i + 1, rock])) }, + }, + { + name: "bullets don't hold back the refill", + before: { ...field, entities: new Map([[1, { position: [10, 10], velocity: [0, 0], age: 0.5 }]]), wave: 0 }, + responses: { random: { next: [0, 0.5, 0.25, 0.75] } }, + after: { + wave: 1, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], age: 0.5 }], + ...ring.map((rock, i): [number, Asteroid] => [i + 2, rock]), + ]), + }, + }, + { + name: "does nothing while asteroids remain", + before: { ...field, wave: 1, entities: new Map([[1, { position: [10, 10], velocity: [0, 0], size: "large" }]]) }, + after: {}, + }, + ], +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/waves.ts b/packages/data-lit-space-rock-game/src/features/main/spec/waves.ts new file mode 100644 index 000000000..28f7680e3 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/waves.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import type { Services } from "../services/services.js"; +import { spawnRandomWave } from "./spawn-random-wave.js"; +import { whilePlaying } from "./while-playing.js"; + +// The `waves` system: refill a cleared field with a randomized wave. +export const waves = ( + state: Pick, + { random }: Pick, +): Pick => whilePlaying(state, (playing) => spawnRandomWave(playing, { random })); diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/while-playing.ts b/packages/data-lit-space-rock-game/src/features/main/spec/while-playing.ts new file mode 100644 index 000000000..c6c242c60 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/while-playing.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import { Lives } from "../data/values/lives/lives.js"; + +// A system's game-over guard, as in every ECS system: while the game is on, `play`; +// once it is over, hand back the state read, so the system writes nothing new. +export const whilePlaying = , R>(state: S, play: (state: S) => R): S | R => + Lives.isGameOver(state.lives) ? state : play(state); diff --git a/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-draw.test.ts b/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-draw.test.ts index 2dc475a7c..46961c4f5 100644 --- a/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-draw.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-draw.test.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, it, expect } from "vitest"; -import { Size } from "../../data/size/size.js"; -import { Bullet } from "../../data/bullet/bullet.js"; +import { Size } from "../../data/values/size/size.js"; +import { Bullet } from "../../data/values/bullet/bullet.js"; import { draw } from "./space-rock-game-draw.js"; type Arc = { x: number; y: number; r: number }; diff --git a/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-draw.ts b/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-draw.ts index 2a80c760f..8a2675857 100644 --- a/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-draw.ts +++ b/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-draw.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Vec2 } from "@adobe/data/math"; -import { Ship } from "../../data/ship/ship.js"; -import { Bullet } from "../../data/bullet/bullet.js"; -import { Size } from "../../data/size/size.js"; +import { Ship } from "../../data/values/ship/ship.js"; +import { Bullet } from "../../data/values/bullet/bullet.js"; +import { Size } from "../../data/values/size/size.js"; // Pure canvas renderer. Given a 2D context and a plain snapshot of what to draw, // it paints one frame: asteroids as circles (radius + colour looked up from the diff --git a/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-element.ts b/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-element.ts index 6cfa6973b..2cb5f769f 100644 --- a/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-element.ts +++ b/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-element.ts @@ -11,9 +11,9 @@ import { useRef, useWindowEvent, } from "@adobe/data-lit"; -import { MainService } from "../../services/main-service/main-service.js"; -import type { Input } from "../../data/input/input.js"; -import type { Size } from "../../data/size/size.js"; +import { MainService } from "../../ecs/main-service.js"; +import type { Input } from "../../data/values/input/input.js"; +import type { Size } from "../../data/values/size/size.js"; import { styles } from "./space-rock-game.css.js"; import * as presentation from "./space-rock-game-presentation.js"; import { draw } from "./space-rock-game-draw.js"; @@ -42,20 +42,12 @@ const toInput = (held: Held, fire: boolean): Input => ({ }); // Read the live store into the plain scene the pure `draw` paints. This is the -// render bridge, not game logic: the scheduler advances the sim on its own rAF, -// mostly via in-place column writes that never fire observers, so the canvas -// must read the current columns synchronously each frame rather than subscribe. +// render bridge, not game logic: the scheduler advances the sim on its own rAF, so +// the canvas reads the current state synchronously each frame. const buildScene = (game: MainService) => { - const ships: { position: Vec2; rotation: number }[] = []; - for (const arch of game.queryArchetypes(["position", "rotation"])) { - const position = arch.columns.position; - const rotation = arch.columns.rotation; - for (let i = 0; i < arch.rowCount; i++) { - ships.push({ position: position.get(i), rotation: rotation.get(i) }); - } - } + const { position, rotation } = game.resources.ship; const asteroids: { position: Vec2; size: Size }[] = []; - for (const arch of game.queryArchetypes(["position", "size"])) { + for (const arch of game.queryArchetypes(game.archetypes.Asteroid.components)) { const position = arch.columns.position; const size = arch.columns.size; for (let i = 0; i < arch.rowCount; i++) { @@ -63,13 +55,13 @@ const buildScene = (game: MainService) => { } } const bullets: { position: Vec2 }[] = []; - for (const arch of game.queryArchetypes(["position", "age"])) { + for (const arch of game.queryArchetypes(game.archetypes.Bullet.components)) { const position = arch.columns.position; for (let i = 0; i < arch.rowCount; i++) { bullets.push({ position: position.get(i) }); } } - return { ships, asteroids, bullets }; + return { ships: [{ position, rotation }], asteroids, bullets }; }; @customElement(tagName) @@ -85,10 +77,11 @@ export class SpaceRockGameElement extends DatabaseElement({ left: false, right: false, up: false }); - // Bootstrap once: size the play-field to the canvas, then start a game. + const newGame = () => service.actions.createInitial({ bounds: [canvasWidth, canvasHeight] }); + + // Bootstrap once: start a game sized to the canvas. useEffect(() => { - service.transactions.setBounds([canvasWidth, canvasHeight]); - service.transactions.newGame(); + newGame(); }, [service]); // Draw loop — render only. The scheduler ticks the sim on its own rAF; this @@ -97,10 +90,8 @@ export class SpaceRockGameElement extends DatabaseElementdata-lit-tictactoe

Tic-Tac-Toe with Lit and @adobe/data-lit.

- + diff --git a/packages/data-lit-tictactoe/src/app/main.ts b/packages/data-lit-tictactoe/src/app/main.ts new file mode 100644 index 000000000..d5dd481af --- /dev/null +++ b/packages/data-lit-tictactoe/src/app/main.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { render } from "lit"; +import { Database } from "@adobe/data/ecs"; +import { Tictactoe } from "../features/main/ui/tictactoe-app/tictactoe-app.js"; +import { MainService } from "../features/main/ecs/main-service.js"; + +const app = document.getElementById("app"); +if (app) { + // The assembled feature database extends the base game with AI services. + // Built once here and passed to the lazy wrapper so the upgraded element + // receives the agent-extended database. + const service = Database.create(MainService.plugin); + render(Tictactoe({ service }), app); +} diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/board-state.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/board-state.ts deleted file mode 100644 index 1eaae2e0e..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/board-state/board-state.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type BoardState = Schema.ToType; -export * as BoardState from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/create-initial-board.test.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/create-initial-board.test.ts deleted file mode 100644 index 3144596b9..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/board-state/create-initial-board.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { createInitialBoard } from "./create-initial-board.js"; - -describe("createInitialBoard", () => { - it("is nine blank cells", () => { - expect(createInitialBoard()).toBe(" "); - expect(createInitialBoard()).toHaveLength(9); - }); -}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/create-initial-board.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/create-initial-board.ts deleted file mode 100644 index 8999af5fd..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/board-state/create-initial-board.ts +++ /dev/null @@ -1,5 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { BoardState } from "./board-state.js"; - -// The empty board: nine blank cells. -export const createInitialBoard = (): BoardState => " "; diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/from-marks.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/from-marks.ts deleted file mode 100644 index 26b47ac9a..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/board-state/from-marks.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { BoardState } from "./board-state.js"; -import type { PlacedMark } from "../placed-mark/placed-mark.js"; - -// Project the placed marks (the entity source of truth) into the compact -// index-addressed board snapshot the pure helpers operate on. -export const fromMarks = (marks: readonly PlacedMark[]): BoardState => { - const cells: string[] = new Array(9).fill(" "); - for (const { mark, index } of marks) cells[index] = mark; - return cells.join(""); -}; diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/get-winner.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/get-winner.ts deleted file mode 100644 index 1d5cf3971..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/board-state/get-winner.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -import type { BoardState } from "./board-state.js"; -import type { PlayerMark } from "../player-mark/player-mark.js"; -import { getWinningLine } from "./get-winning-line.js"; -import { isBoardFull } from "./is-board-full.js"; - -export const getWinner = ( - board: BoardState, -): PlayerMark | "cat" | null => { - const line = getWinningLine(board); - if (line) return board[line[0]] as PlayerMark; - return isBoardFull(board) ? "cat" : null; -}; diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/public.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/public.ts deleted file mode 100644 index efd52236b..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/board-state/public.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -export { schema } from "./schema.js"; -export { fromMarks } from "./from-marks.js"; -export { createInitialBoard } from "./create-initial-board.js"; -export { setBoardCell } from "./set-board-cell.js"; -export { currentPlayer } from "./current-player.js"; -export { deriveStatus } from "./derive-status.js"; -export { getWinningLine } from "./get-winning-line.js"; -export { getWinner } from "./get-winner.js"; -export { getMoveCount } from "./get-move-count.js"; -export { isBoardFull } from "./is-board-full.js"; -export { isGameOver } from "./is-game-over.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/schema.ts deleted file mode 100644 index a5ef21ec2..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/board-state/schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -// The board: 9 cells, top-left to bottom-right, each a space, "X", or "O". -// The empty board is the natural default. -export const schema = { - type: "string", - description: "Tic-Tac-Toe board top left to bottom right", - default: " ", - minLength: 9, - maxLength: 9, -} as const satisfies Schema; diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/set-board-cell.test.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/set-board-cell.test.ts deleted file mode 100644 index b4ade5a49..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/board-state/set-board-cell.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { setBoardCell } from "./set-board-cell.js"; - -describe("setBoardCell", () => { - it("places a mark at the first cell", () => { - expect(setBoardCell({ board: " ", index: 0, mark: "X" })).toBe( - "X ", - ); - }); - - it("places a mark at the last cell", () => { - expect(setBoardCell({ board: " ", index: 8, mark: "O" })).toBe( - " O", - ); - }); - - it("leaves the other cells untouched", () => { - expect(setBoardCell({ board: "X O ", index: 4, mark: "X" })).toBe( - "X X ", - ); - }); -}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/set-board-cell.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/set-board-cell.ts deleted file mode 100644 index fc248e617..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/board-state/set-board-cell.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { BoardState } from "./board-state.js"; -import type { PlayerMark } from "../player-mark/player-mark.js"; - -// Return the board with `mark` placed at `index` (pure — the spec's write). -export const setBoardCell = (args: { - board: BoardState; - index: number; - mark: PlayerMark; -}): BoardState => { - const { board, index, mark } = args; - return board.slice(0, index) + mark + board.slice(index + 1); -}; diff --git a/packages/data-lit-tictactoe/src/features/main/data/cell-index/cell-index.ts b/packages/data-lit-tictactoe/src/features/main/data/cell-index/cell-index.ts deleted file mode 100644 index e931a47a2..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/cell-index/cell-index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type CellIndex = Schema.ToType; -export * as CellIndex from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/cell-index/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/cell-index/schema.ts deleted file mode 100644 index ecbdc13fe..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/cell-index/schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -// Which of the nine board cells (0 top-left to 8 bottom-right) a mark occupies. -export const schema = { - type: "integer", - minimum: 0, - maximum: 8, -} as const satisfies Schema; diff --git a/packages/data-lit-tictactoe/src/features/main/data/components/cell-index.ts b/packages/data-lit-tictactoe/src/features/main/data/components/cell-index.ts new file mode 100644 index 000000000..1d4512279 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/components/cell-index.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { CellIndex } from "../values/cell-index/cell-index.js"; + +export const cellIndex = CellIndex.schema; diff --git a/packages/data-lit-tictactoe/src/features/main/data/components/index.ts b/packages/data-lit-tictactoe/src/features/main/data/components/index.ts new file mode 100644 index 000000000..bf2166a20 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/components/index.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { mark } from "./mark.js"; +import { cellIndex } from "./cell-index.js"; + +export const components = { mark, cellIndex }; diff --git a/packages/data-lit-tictactoe/src/features/main/data/components/mark.ts b/packages/data-lit-tictactoe/src/features/main/data/components/mark.ts new file mode 100644 index 000000000..a169c226c --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/components/mark.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { PlayerMark } from "../values/player-mark/player-mark.js"; + +export const mark = PlayerMark.schema; diff --git a/packages/data-lit-tictactoe/src/features/main/data/entities/index.ts b/packages/data-lit-tictactoe/src/features/main/data/entities/index.ts new file mode 100644 index 000000000..15de7550a --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/entities/index.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { PlacedMark } from "./placed-mark.js"; + +export const entities = { PlacedMark }; diff --git a/packages/data-lit-tictactoe/src/features/main/data/entities/placed-mark.ts b/packages/data-lit-tictactoe/src/features/main/data/entities/placed-mark.ts new file mode 100644 index 000000000..75a81188c --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/entities/placed-mark.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Database } from "@adobe/data/ecs"; +import type { components } from "../components/index.js"; + +// One mark placed on the board: which mark, and which cell it occupies. +export const PlacedMark = ["mark", "cellIndex"] as const satisfies Database.EntityComponents; +export type PlacedMark = Database.EntityType; diff --git a/packages/data-lit-tictactoe/src/features/main/data/game-status/game-status.ts b/packages/data-lit-tictactoe/src/features/main/data/game-status/game-status.ts deleted file mode 100644 index c25982593..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/game-status/game-status.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type GameStatus = Schema.ToType; -export * as GameStatus from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/game-status/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/game-status/schema.ts deleted file mode 100644 index c0f6fa3f8..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/game-status/schema.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -export const schema = { - type: "string", - enum: ["idle", "in_progress", "won", "draw"], -} as const satisfies Schema; diff --git a/packages/data-lit-tictactoe/src/features/main/data/move-reject-reason/move-reject-reason.ts b/packages/data-lit-tictactoe/src/features/main/data/move-reject-reason/move-reject-reason.ts deleted file mode 100644 index 7366a3780..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/move-reject-reason/move-reject-reason.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type MoveRejectReason = Schema.ToType; -export * as MoveRejectReason from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/move-reject-reason/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/move-reject-reason/schema.ts deleted file mode 100644 index 38bda7ca9..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/move-reject-reason/schema.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -export const schema = { - type: "string", - enum: ["index_out_of_bounds", "game_not_active", "cell_occupied", "game_over"], -} as const satisfies Schema; diff --git a/packages/data-lit-tictactoe/src/features/main/data/placed-mark/placed-mark.ts b/packages/data-lit-tictactoe/src/features/main/data/placed-mark/placed-mark.ts deleted file mode 100644 index fea8887dd..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/placed-mark/placed-mark.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type PlacedMark = Schema.ToType; -export * as PlacedMark from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/placed-mark/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/placed-mark/schema.ts deleted file mode 100644 index 841f7b8c7..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/placed-mark/schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { PlayerMark } from "../player-mark/player-mark.js"; -import { CellIndex } from "../cell-index/cell-index.js"; - -// One mark placed on the board: which mark, and which cell it occupies. This is -// the shape of a PlacedMark entity's row — the archetype is checked against it -// (see ecs/archetypes). -export const schema = Schema.fromObjectProperties( - { mark: PlayerMark.schema, index: CellIndex.schema }, - ["mark", "index"], -); diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/player-mark.ts b/packages/data-lit-tictactoe/src/features/main/data/player-mark/player-mark.ts deleted file mode 100644 index 00c93620f..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/player-mark/player-mark.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type PlayerMark = Schema.ToType; -export * as PlayerMark from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/player-mark/schema.ts deleted file mode 100644 index b27a63335..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/player-mark/schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -export const schema = { - type: "string", - enum: ["X", "O"], - description: "Player mark", - default: "X", -} as const satisfies Schema; diff --git a/packages/data-lit-tictactoe/src/features/main/data/resources/draws.ts b/packages/data-lit-tictactoe/src/features/main/data/resources/draws.ts new file mode 100644 index 000000000..807246a45 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/resources/draws.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Score } from "../values/score/score.js"; + +export const draws = Score.schema satisfies ResourceSchema; diff --git a/packages/data-lit-tictactoe/src/features/main/data/resources/first-player.ts b/packages/data-lit-tictactoe/src/features/main/data/resources/first-player.ts new file mode 100644 index 000000000..71bf49718 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/resources/first-player.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ResourceSchema } from "@adobe/data/ecs"; +import { PlayerMark } from "../values/player-mark/player-mark.js"; + +// Who moves first in the current game; alternates on every restart. +export const firstPlayer = PlayerMark.schema satisfies ResourceSchema; diff --git a/packages/data-lit-tictactoe/src/features/main/data/resources/index.ts b/packages/data-lit-tictactoe/src/features/main/data/resources/index.ts new file mode 100644 index 000000000..20b2f9ee6 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/resources/index.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { firstPlayer } from "./first-player.js"; +import { xWins } from "./x-wins.js"; +import { oWins } from "./o-wins.js"; +import { draws } from "./draws.js"; + +export const resources = { firstPlayer, xWins, oWins, draws }; diff --git a/packages/data-lit-tictactoe/src/features/main/data/resources/o-wins.ts b/packages/data-lit-tictactoe/src/features/main/data/resources/o-wins.ts new file mode 100644 index 000000000..b46536251 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/resources/o-wins.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Score } from "../values/score/score.js"; + +export const oWins = Score.schema satisfies ResourceSchema; diff --git a/packages/data-lit-tictactoe/src/features/main/data/resources/x-wins.ts b/packages/data-lit-tictactoe/src/features/main/data/resources/x-wins.ts new file mode 100644 index 000000000..27132108b --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/resources/x-wins.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Score } from "../values/score/score.js"; + +export const xWins = Score.schema satisfies ResourceSchema; diff --git a/packages/data-lit-tictactoe/src/features/main/data/score/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/score/schema.ts deleted file mode 100644 index ae015c673..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/score/schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -// A non-negative scoreboard tally (wins, draws). Starts at zero. -export const schema = { - type: "number", - minimum: 0, - default: 0, -} as const satisfies Schema; diff --git a/packages/data-lit-tictactoe/src/features/main/data/score/score.ts b/packages/data-lit-tictactoe/src/features/main/data/score/score.ts deleted file mode 100644 index a59627000..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/score/score.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type Score = Schema.ToType; -export * as Score from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/create.ts b/packages/data-lit-tictactoe/src/features/main/data/state/create.ts deleted file mode 100644 index 5f3f275ff..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/create.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { BoardState } from "../board-state/board-state.js"; -import type { State } from "./state.js"; - -// The default game state: an empty board, X to move first, a zeroed scoreboard. -// It is the baseline the conformance cases author their `before`/`input` as deltas -// over (passed to the runners as `initial`), and the state a fresh game starts in. -export const create = (): State => ({ - board: BoardState.createInitialBoard(), - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, -}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/current-player.cases.ts b/packages/data-lit-tictactoe/src/features/main/data/state/current-player.cases.ts deleted file mode 100644 index d7c1d0934..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/current-player.cases.ts +++ /dev/null @@ -1,43 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { currentPlayer } from "./current-player.js"; - -// Inert derivation cases for `currentPlayer` — `{ input, value }` pairs, the -// `Conformance` binding is `import type`. Shared with the ecs `currentPlayer` -// computed. `input` is a full `State` delta; `value` is the mark to move. -export const cases: Conformance.SpecDerivations = { - cases: [ - { - name: "the first player moves on an empty board", - input: { - board: " ", - firstPlayer: "X", - }, - value: "X", - }, - { - name: "honors a first player of O on an empty board", - input: { - board: " ", - firstPlayer: "O", - }, - value: "O", - }, - { - name: "alternates to the opponent after the first move", - input: { - board: " X ", - firstPlayer: "X", - }, - value: "O", - }, - { - name: "returns to the first player after both have moved", - input: { - board: "XO ", - firstPlayer: "X", - }, - value: "X", - }, - ], -}; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts b/packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts deleted file mode 100644 index 065ca5f27..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { BoardState } from "../board-state/board-state.js"; -import type { PlayerMark } from "../player-mark/player-mark.js"; -import type { State } from "./state.js"; - -// Whose turn it is: composes TWO `State` fields — the board (move count) and the -// `firstPlayer` — so it is a `state/` derivation, not single-field board math -// (see `data/state.md`). The mark-counting itself is the board's own helper -// (`BoardState.currentPlayer`), which the ecs implementation reuses directly; this -// derivation is the spec the ecs `currentPlayer` computed is conformed against. -export const currentPlayer = ( - state: Pick, -): PlayerMark => BoardState.currentPlayer(state.board, state.firstPlayer); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/play-move.cases.ts b/packages/data-lit-tictactoe/src/features/main/data/state/play-move.cases.ts deleted file mode 100644 index b8c866345..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/play-move.cases.ts +++ /dev/null @@ -1,52 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { playMove } from "./play-move.js"; - -// Inert, spec-owned cases for `playMove` — data only, no test-framework runtime -// import (the `Conformance` binding is `import type`). `before` is a delta over -// `State.create()` (empty board, X first, zeroed scores); `after` lists only what -// the move writes — the board. `index` is a plain cell index, not an entity -// reference, so no `args` schema is authored. Covers a legal placement, turn -// alternation by move count, a winning placement, and the three rejections -// (occupied, out of bounds, already won) that leave the board as-is. -export const cases: Conformance.SpecCases = { - cases: [ - { - name: "places the first player's mark into an empty cell", - before: {}, - args: { index: 4 }, - after: { board: " X " }, - }, - { - name: "alternates to the opponent by move count", - before: { board: " X " }, - args: { index: 0 }, - after: { board: "O X " }, - }, - { - name: "completes a three-in-a-row (winning placement is still just a placement)", - before: { board: "XX OO ", xWins: 1, oWins: 2 }, - args: { index: 2 }, - after: { board: "XXX OO " }, - }, - { - name: "ignores an occupied cell (no-op)", - before: { board: " X " }, - args: { index: 4 }, - after: { board: " X " }, - }, - { - name: "ignores an out-of-bounds index (no-op)", - before: { firstPlayer: "O" }, - args: { index: 9 }, - after: { board: " " }, - }, - { - name: "ignores a move once the game is already won (no-op)", - before: { board: "XXX " }, - args: { index: 4 }, - after: { board: "XXX " }, - }, - ], -}; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts b/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts deleted file mode 100644 index 8e980d298..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts +++ /dev/null @@ -1,26 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { BoardState } from "../board-state/board-state.js"; -import { PlayMoveArgs } from "../play-move-args/play-move-args.js"; -import type { State } from "./state.js"; - -// Place the current player's mark into `index`. Reads the board + first player, -// writes the board — a `{ board }` patch. Illegal moves (out of bounds, occupied, -// game over) leave the board unchanged, keeping the transform idempotent. -export const playMove = ( - state: Pick, - input: PlayMoveArgs, -): Pick => { - if ( - !PlayMoveArgs.canPlayMove({ board: state.board, index: input.index }).ok - ) { - return { board: state.board }; - } - const mark = BoardState.currentPlayer(state.board, state.firstPlayer); - return { - board: BoardState.setBoardCell({ - board: state.board, - index: input.index, - mark, - }), - }; -}; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/public.ts b/packages/data-lit-tictactoe/src/features/main/data/state/public.ts deleted file mode 100644 index 74cd08856..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/public.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { create } from "./create.js"; -export { samples } from "./samples.js"; -export { playMove } from "./play-move.js"; -export { restartGame } from "./restart-game.js"; -export { currentPlayer } from "./current-player.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.cases.ts b/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.cases.ts deleted file mode 100644 index c0f287939..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.cases.ts +++ /dev/null @@ -1,58 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { restartGame } from "./restart-game.js"; - -// Inert, spec-owned cases for `restartGame` (no args) — data only, the `Conformance` -// binding is `import type`. `before` is a delta over `State.create()`. Every restart -// clears the board and hands the first move to the other player; the scoreboard is -// bumped only for the finished game's outcome — X win, O win, draw (cat), or, when -// the game wasn't finished, no counter at all. -export const cases: Conformance.SpecCases = { - cases: [ - { - name: "tallies an X win, alternates first player, clears the board", - before: { board: "XXX " }, - after: { - board: " ", - firstPlayer: "O", - xWins: 1, - oWins: 0, - draws: 0, - }, - }, - { - name: "tallies an O win", - before: { board: "OOOXX ", firstPlayer: "O", xWins: 1, oWins: 2 }, - after: { - board: " ", - firstPlayer: "X", - xWins: 1, - oWins: 3, - draws: 0, - }, - }, - { - name: "tallies a draw (full board, no line)", - before: { board: "XOXXOOOXX", firstPlayer: "O", xWins: 2, oWins: 1 }, - after: { - board: " ", - firstPlayer: "X", - xWins: 2, - oWins: 1, - draws: 1, - }, - }, - { - name: "restarts an unfinished game without touching any counter", - before: { board: "X O ", xWins: 1, oWins: 1, draws: 1 }, - after: { - board: " ", - firstPlayer: "O", - xWins: 1, - oWins: 1, - draws: 1, - }, - }, - ], -}; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts b/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts deleted file mode 100644 index 4ab570d2d..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { BoardState } from "../board-state/board-state.js"; -import { PlayerMark } from "../player-mark/player-mark.js"; -import type { State } from "./state.js"; - -// Tally the finished game into the scoreboard, hand the first move to the other -// player, and clear the board. -export const restartGame = (state: State): State => { - const winner = BoardState.getWinner(state.board); - const status = BoardState.deriveStatus(state.board); - return { - board: BoardState.createInitialBoard(), - firstPlayer: PlayerMark.opponent[state.firstPlayer], - xWins: state.xWins + (winner === "X" ? 1 : 0), - oWins: state.oWins + (winner === "O" ? 1 : 0), - draws: state.draws + (status === "draw" ? 1 : 0), - }; -}; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/samples.ts b/packages/data-lit-tictactoe/src/features/main/data/state/samples.ts deleted file mode 100644 index 497177afb..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/samples.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; - -// Representative full states for the projection round-trip (toState ∘ fromState ≡ -// identity). Varied boards + non-default counters exercise the whole ecs↔State map. -export const samples: readonly State[] = [ - { board: "XOXXOOOXX", firstPlayer: "O", xWins: 3, oWins: 2, draws: 1 }, - { board: "X O X ", firstPlayer: "O", xWins: 1, oWins: 0, draws: 0 }, -]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts b/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts deleted file mode 100644 index ebd7cc8f6..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "./spec.js"; - -// The single pure-spec test for every transform AND derivation in this folder. It -// runs the manifest's inert cases against the pure functions and asserts `after` -// (up to an id-bijection) and declared `effects`. -Conformance.checkSpec(spec); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/spec.ts b/packages/data-lit-tictactoe/src/features/main/data/state/spec.ts deleted file mode 100644 index 26d5d8dcc..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import * as transforms from "./transforms.js"; -import { MainService } from "../../services/main-service/main-service.js"; -import { ComputedDatabase } from "../../services/main-service/computed-database/computed-database.js"; -import { projection } from "../../services/main-service/conformance/projection.js"; - -import { cases as playMove } from "./play-move.cases.js"; -import { cases as restartGame } from "./restart-game.cases.js"; -import { cases as currentPlayer } from "./current-player.cases.js"; - -// The feature's conformance manifest — the single object `spec.test.ts` (pure) and -// `conformance.test.ts` (ecs) both import. It wires each conformed fn to its inert cases module explicitly: `cases` names one module per conformed fn, -// checked at COMPILE TIME against `transforms` (a fn without cases, or cases without -// a fn, won't type). `computedPlugin` is required because `currentPlayer` is a -// derivation. This feature injects no capability services, so `services` is omitted; -// tictactoe is index-addressed (no entity-id-list derivation), so no `hydrate`. -// -// This is a test-tier module (excluded from the runtime program) — the ONE place the -// feature touches `@adobe/data-testing`. No transform or `*.cases.ts` file does. -export const spec = Conformance.feature({ - state: State, - fns: transforms, - plugin: MainService.plugin, - computedPlugin: ComputedDatabase.plugin, - projection, - cases: { - playMove, - restartGame, - currentPlayer, - }, -}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/state.ts b/packages/data-lit-tictactoe/src/features/main/data/state/state.ts deleted file mode 100644 index 597275cf5..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/state.ts +++ /dev/null @@ -1,15 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { BoardState } from "../board-state/board-state.js"; -import type { PlayerMark } from "../player-mark/player-mark.js"; -import type { Score } from "../score/score.js"; - -// The full persistent game state as one immutable object — the specification -// the ECS implementation is verified against. -export type State = { - readonly board: BoardState; - readonly firstPlayer: PlayerMark; - readonly xWins: Score; - readonly oWins: Score; - readonly draws: Score; -}; -export * as State from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/transforms.ts b/packages/data-lit-tictactoe/src/features/main/data/state/transforms.ts deleted file mode 100644 index 5543e3760..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/transforms.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -// The conformed functions of this feature — every pure transform and derivation the -// spec verifies, and nothing else (helpers like `create`, `samples` are deliberately -// absent). Its keys ARE the conformance surface: the manifest's coverage guard -// (`Conformance.feature`) requires exactly one `*.cases.ts` module per key here, so -// adding a transform without cases — or cases without a transform — fails to compile. -// This is a per-feature, local barrel (not a cross-feature registry), authored where -// the transforms live. -export { playMove } from "./play-move.js"; -export { restartGame } from "./restart-game.js"; -export { currentPlayer } from "./current-player.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-cell/blank.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-cell/blank.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-cell/blank.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-cell/blank.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-cell/blank.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-cell/blank.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-cell/blank.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-cell/blank.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-cell/board-cell.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-cell/board-cell.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-cell/board-cell.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-cell/board-cell.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-cell/public.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-cell/public.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-cell/public.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-cell/public.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/board-state/board-state.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/board-state.ts new file mode 100644 index 000000000..86fd20f5e --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/board-state.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type BoardState = string; +export * as BoardState from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/current-player.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/current-player.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/current-player.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/current-player.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/current-player.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/current-player.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/current-player.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/current-player.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/derive-status.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/derive-status.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/derive-status.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/derive-status.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/derive-status.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/derive-status.ts similarity index 79% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/derive-status.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/derive-status.ts index db9f8c1c5..4a4bbbcff 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/board-state/derive-status.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/derive-status.ts @@ -2,11 +2,12 @@ import type { BoardState } from "./board-state.js"; import type { GameStatus } from "../game-status/game-status.js"; +import { getMoveCount } from "./get-move-count.js"; import { getWinningLine } from "./get-winning-line.js"; import { isBoardFull } from "./is-board-full.js"; export const deriveStatus = (board: BoardState): GameStatus => { if (getWinningLine(board)) return "won"; if (isBoardFull(board)) return "draw"; - return (board.match(/[XO]/g) ?? []).length > 0 ? "in_progress" : "idle"; + return getMoveCount(board) > 0 ? "in_progress" : "idle"; }; diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/from-marks.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/from-marks.test.ts similarity index 78% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/from-marks.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/from-marks.test.ts index 27cae2013..95d71dda8 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/board-state/from-marks.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/from-marks.test.ts @@ -10,9 +10,9 @@ describe("fromMarks", () => { it("projects marks into their index-addressed cells", () => { expect( fromMarks([ - { mark: "X", index: 0 }, - { mark: "O", index: 4 }, - { mark: "X", index: 8 }, + { mark: "X", cellIndex: 0 }, + { mark: "O", cellIndex: 4 }, + { mark: "X", cellIndex: 8 }, ]), ).toBe("X O X"); }); diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/board-state/from-marks.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/from-marks.ts new file mode 100644 index 000000000..c1d62ed20 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/from-marks.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { BoardState } from "./board-state.js"; +import type { CellIndex } from "../cell-index/cell-index.js"; +import type { PlayerMark } from "../player-mark/player-mark.js"; + +// Fold placed marks into the compact index-addressed board the other helpers +// operate on. Takes the mark fields structurally, so values never import entities. +export const fromMarks = ( + marks: Iterable<{ readonly mark: PlayerMark; readonly cellIndex: CellIndex }>, +): BoardState => { + const cells: string[] = new Array(9).fill(" "); + for (const { mark, cellIndex } of marks) cells[cellIndex] = mark; + return cells.join(""); +}; diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/get-move-count.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-move-count.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/get-move-count.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-move-count.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/get-move-count.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-move-count.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/get-move-count.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-move-count.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/get-winner.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-winner.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/get-winner.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-winner.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-winner.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-winner.ts new file mode 100644 index 000000000..f9469132a --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-winner.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import type { BoardState } from "./board-state.js"; +import { PlayerMark } from "../player-mark/player-mark.js"; +import { getWinningLine } from "./get-winning-line.js"; +import { isBoardFull } from "./is-board-full.js"; + +export const getWinner = ( + board: BoardState, +): PlayerMark | "cat" | null => { + const line = getWinningLine(board); + const mark = line ? board[line[0]] : undefined; + if (PlayerMark.is(mark)) return mark; + return isBoardFull(board) ? "cat" : null; +}; diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/get-winning-line.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-winning-line.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/get-winning-line.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-winning-line.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/get-winning-line.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-winning-line.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/get-winning-line.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-winning-line.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/is-board-full.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/is-board-full.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/is-board-full.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/is-board-full.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/is-board-full.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/is-board-full.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/is-board-full.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/is-board-full.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/is-game-over.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/is-game-over.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/is-game-over.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/is-game-over.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/is-game-over.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/is-game-over.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/is-game-over.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/is-game-over.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/board-state/public.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/public.ts new file mode 100644 index 000000000..239d943ed --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/public.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export { schema } from "./schema.js"; +export { fromMarks } from "./from-marks.js"; +export { currentPlayer } from "./current-player.js"; +export { deriveStatus } from "./derive-status.js"; +export { getWinningLine } from "./get-winning-line.js"; +export { getWinner } from "./get-winner.js"; +export { getMoveCount } from "./get-move-count.js"; +export { isBoardFull } from "./is-board-full.js"; +export { isGameOver } from "./is-game-over.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/board-state/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/schema.ts new file mode 100644 index 000000000..c00073e89 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/schema.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { BoardState } from "./board-state.js"; +import { Schema } from "@adobe/data/schema"; + +// The board: 9 cells, top-left to bottom-right, each a space, "X", or "O". +// The empty board is the natural default. +export const schema = { + type: "string", + description: "Tic-Tac-Toe board top left to bottom right", + default: " ", + minLength: 9, + maxLength: 9, +} as const satisfies Schema; + +type _Pin = Assert, BoardState>>; diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/cell-index/cell-index.ts b/packages/data-lit-tictactoe/src/features/main/data/values/cell-index/cell-index.ts new file mode 100644 index 000000000..8fc072902 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/cell-index/cell-index.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type CellIndex = number; +export * as CellIndex from "./public.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/lane/public.ts b/packages/data-lit-tictactoe/src/features/main/data/values/cell-index/public.ts similarity index 100% rename from packages/data-gpu-hopper/src/features/main/data/lane/public.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/cell-index/public.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/cell-index/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/values/cell-index/schema.ts new file mode 100644 index 000000000..581c5dd13 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/cell-index/schema.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { CellIndex } from "./cell-index.js"; +import { Schema } from "@adobe/data/schema"; + +// Which of the nine board cells (0 top-left to 8 bottom-right) a mark occupies. +export const schema = { + type: "integer", + minimum: 0, + maximum: 8, +} as const satisfies Schema; + +type _Pin = Assert, CellIndex>>; diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/game-status/game-status.ts b/packages/data-lit-tictactoe/src/features/main/data/values/game-status/game-status.ts new file mode 100644 index 000000000..396ef21bf --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/game-status/game-status.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type GameStatus = "idle" | "in_progress" | "won" | "draw"; +export * as GameStatus from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/game-status/is-active.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/game-status/is-active.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/game-status/is-active.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/game-status/is-active.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/game-status/is-active.ts b/packages/data-lit-tictactoe/src/features/main/data/values/game-status/is-active.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/game-status/is-active.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/game-status/is-active.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/game-status/public.ts b/packages/data-lit-tictactoe/src/features/main/data/values/game-status/public.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/game-status/public.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/game-status/public.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/game-status/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/values/game-status/schema.ts new file mode 100644 index 000000000..75fa746e8 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/game-status/schema.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { GameStatus } from "./game-status.js"; +import { Schema } from "@adobe/data/schema"; + +export const schema = { + type: "string", + enum: ["idle", "in_progress", "won", "draw"], +} as const satisfies Schema; + +type _Pin = Assert, GameStatus>>; diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/move-reject-reason/move-reject-reason.ts b/packages/data-lit-tictactoe/src/features/main/data/values/move-reject-reason/move-reject-reason.ts new file mode 100644 index 000000000..f6ca311cc --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/move-reject-reason/move-reject-reason.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type MoveRejectReason = "index_out_of_bounds" | "cell_occupied" | "game_over"; +export * as MoveRejectReason from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/cell-index/public.ts b/packages/data-lit-tictactoe/src/features/main/data/values/move-reject-reason/public.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/cell-index/public.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/move-reject-reason/public.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/move-reject-reason/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/values/move-reject-reason/schema.ts new file mode 100644 index 000000000..8555f7d04 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/move-reject-reason/schema.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { MoveRejectReason } from "./move-reject-reason.js"; +import { Schema } from "@adobe/data/schema"; + +export const schema = { + type: "string", + enum: ["index_out_of_bounds", "cell_occupied", "game_over"], +} as const satisfies Schema; + +type _Pin = Assert, MoveRejectReason>>; diff --git a/packages/data-lit-tictactoe/src/features/main/data/play-move-args/can-play-move.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/play-move-args/can-play-move.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/play-move-args/can-play-move.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/play-move-args/can-play-move.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/play-move-args/can-play-move.ts b/packages/data-lit-tictactoe/src/features/main/data/values/play-move-args/can-play-move.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/play-move-args/can-play-move.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/play-move-args/can-play-move.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/play-move-args/play-move-args.ts b/packages/data-lit-tictactoe/src/features/main/data/values/play-move-args/play-move-args.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/play-move-args/play-move-args.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/play-move-args/play-move-args.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/play-move-args/public.ts b/packages/data-lit-tictactoe/src/features/main/data/values/play-move-args/public.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/play-move-args/public.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/play-move-args/public.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/is.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/is.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/player-mark/is.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/player-mark/is.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/is.ts b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/is.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/player-mark/is.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/player-mark/is.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/mark-color.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/mark-color.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/player-mark/mark-color.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/player-mark/mark-color.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/mark-color.ts b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/mark-color.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/player-mark/mark-color.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/player-mark/mark-color.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/opponent.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/opponent.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/player-mark/opponent.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/player-mark/opponent.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/opponent.ts b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/opponent.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/player-mark/opponent.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/player-mark/opponent.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/player-mark.ts b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/player-mark.ts new file mode 100644 index 000000000..552449e58 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/player-mark.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type PlayerMark = "X" | "O"; +export * as PlayerMark from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/public.ts b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/public.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/player-mark/public.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/player-mark/public.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/schema.ts new file mode 100644 index 000000000..0089c6da6 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/schema.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { PlayerMark } from "./player-mark.js"; +import { Schema } from "@adobe/data/schema"; + +export const schema = { + type: "string", + enum: ["X", "O"], + description: "Player mark", + default: "X", +} as const satisfies Schema; + +type _Pin = Assert, PlayerMark>>; diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/values.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/values.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/player-mark/values.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/player-mark/values.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/values.ts b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/values.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/player-mark/values.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/player-mark/values.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/move-reject-reason/public.ts b/packages/data-lit-tictactoe/src/features/main/data/values/score/public.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/move-reject-reason/public.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/score/public.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/score/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/values/score/schema.ts new file mode 100644 index 000000000..16f1072f8 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/score/schema.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Score } from "./score.js"; +import { Schema } from "@adobe/data/schema"; + +// A non-negative scoreboard tally (wins, draws). Starts at zero. +export const schema = { + type: "number", + minimum: 0, + default: 0, +} as const satisfies Schema; + +type _Pin = Assert, Score>>; diff --git a/packages/data-lit-tictactoe/src/features/main/data/values/score/score.ts b/packages/data-lit-tictactoe/src/features/main/data/values/score/score.ts new file mode 100644 index 000000000..999d841cb --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/score/score.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type Score = number; +export * as Score from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/winning-line/lines.test.ts b/packages/data-lit-tictactoe/src/features/main/data/values/winning-line/lines.test.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/winning-line/lines.test.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/winning-line/lines.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/winning-line/lines.ts b/packages/data-lit-tictactoe/src/features/main/data/values/winning-line/lines.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/winning-line/lines.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/winning-line/lines.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/winning-line/public.ts b/packages/data-lit-tictactoe/src/features/main/data/values/winning-line/public.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/winning-line/public.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/winning-line/public.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/winning-line/winning-line.ts b/packages/data-lit-tictactoe/src/features/main/data/values/winning-line/winning-line.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/winning-line/winning-line.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/winning-line/winning-line.ts diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/actions/action-database.ts b/packages/data-lit-tictactoe/src/features/main/ecs/actions/action-database.ts new file mode 100644 index 000000000..7a3100e82 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/actions/action-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { ComputedDatabase } from "../computed/computed-database.js"; +import * as actions from "./index.js"; + +const actionDatabasePlugin = Database.Plugin.create({ + extends: ComputedDatabase.plugin, + actions, +}); + +export type ActionDatabase = Database.Plugin.ToDatabase< + typeof actionDatabasePlugin +>; + +export namespace ActionDatabase { + export const plugin = actionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/index.ts b/packages/data-lit-tictactoe/src/features/main/ecs/actions/index.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/index.ts rename to packages/data-lit-tictactoe/src/features/main/ecs/actions/index.ts diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/actions/play-move.ts b/packages/data-lit-tictactoe/src/features/main/ecs/actions/play-move.ts new file mode 100644 index 000000000..86933039b --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/actions/play-move.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { PlayMoveArgs } from "../../data/values/play-move-args/play-move-args.js"; +import type { ServiceDatabase } from "../services/service-database.js"; + +// A local placement needs no outside capability: it commits one transaction. +export const playMove = (db: ServiceDatabase, args: PlayMoveArgs) => { + db.transactions.playMove(args); +}; diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/actions/restart-game.ts b/packages/data-lit-tictactoe/src/features/main/ecs/actions/restart-game.ts new file mode 100644 index 000000000..6b5233504 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/actions/restart-game.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +// Needs no outside capability: the tally and board reset commit as one transaction. +export const restartGame = (db: ServiceDatabase) => { + db.transactions.restartGame(); +}; diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/computed/board.ts b/packages/data-lit-tictactoe/src/features/main/ecs/computed/board.ts new file mode 100644 index 000000000..ad2fffb39 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/board.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { cached } from "@adobe/data/cache"; +import { Observe } from "@adobe/data/observe"; +import type { IndexDatabase } from "../indexes/index-database.js"; +import { readBoard } from "../transactions/read-board.js"; + +// The board, folded from the placed-mark entities; re-emits whenever a mark is added +// or removed. `withCache` shares one run across every subscriber (each cell observes it). +export const board = cached((db: IndexDatabase) => Observe.withCache(db.derive(readBoard))); diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/computed/computed-database.ts b/packages/data-lit-tictactoe/src/features/main/ecs/computed/computed-database.ts new file mode 100644 index 000000000..1668fa07f --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/computed-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { ServiceDatabase } from "../services/service-database.js"; +import * as computed from "./index.js"; + +const computedDatabasePlugin = Database.Plugin.create({ + extends: ServiceDatabase.plugin, + computed, +}); + +export type ComputedDatabase = Database.Plugin.ToDatabase< + typeof computedDatabasePlugin +>; + +export namespace ComputedDatabase { + export const plugin = computedDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/computed/current-player.test.ts b/packages/data-lit-tictactoe/src/features/main/ecs/computed/current-player.test.ts new file mode 100644 index 000000000..56e0dacac --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/current-player.test.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect, vi } from "vitest"; +import { Database } from "@adobe/data/ecs"; +import type { PlayerMark } from "../../data/values/player-mark/player-mark.js"; +import { ComputedDatabase } from "./computed-database.js"; + +describe("currentPlayer", () => { + it("re-emits when a restart flips firstPlayer on an empty board", async () => { + const db = Database.create(ComputedDatabase.plugin); + const seen: PlayerMark[] = []; + const unobserve = db.computed.currentPlayer((player) => seen.push(player)); + await vi.waitFor(() => expect(seen[seen.length - 1]).toBe("X")); + + db.transactions.restartGame(); + await vi.waitFor(() => expect(seen[seen.length - 1]).toBe("O")); + unobserve(); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/computed/current-player.ts b/packages/data-lit-tictactoe/src/features/main/ecs/computed/current-player.ts new file mode 100644 index 000000000..bc16ccf17 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/current-player.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { cached } from "@adobe/data/cache"; +import { Observe } from "@adobe/data/observe"; +import { BoardState } from "../../data/values/board-state/board-state.js"; +import type { IndexDatabase } from "../indexes/index-database.js"; +import { board } from "./board.js"; + +// Observes `firstPlayer` too: restarting an empty board flips it without touching a mark. +export const currentPlayer = cached((db: IndexDatabase) => + Observe.withMap( + Observe.fromProperties({ board: board(db), firstPlayer: db.observe.resources.firstPlayer }), + ({ board, firstPlayer }) => BoardState.currentPlayer(board, firstPlayer), + ), +); diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/computed/index.ts b/packages/data-lit-tictactoe/src/features/main/ecs/computed/index.ts new file mode 100644 index 000000000..82be88367 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/index.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export * from "./board.js"; +export * from "./current-player.js"; +export * from "./status.js"; +export * from "./winning-line.js"; +export * from "./winner.js"; +export * from "./is-game-over.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/computed/is-game-over.ts b/packages/data-lit-tictactoe/src/features/main/ecs/computed/is-game-over.ts new file mode 100644 index 000000000..940755bfd --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/is-game-over.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { cached } from "@adobe/data/cache"; +import { Observe } from "@adobe/data/observe"; +import { BoardState } from "../../data/values/board-state/board-state.js"; +import type { IndexDatabase } from "../indexes/index-database.js"; +import { board } from "./board.js"; + +export const isGameOver = cached((db: IndexDatabase) => + Observe.withFilter(board(db), BoardState.isGameOver), +); diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/computed/status.ts b/packages/data-lit-tictactoe/src/features/main/ecs/computed/status.ts new file mode 100644 index 000000000..326f2edcc --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/status.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { cached } from "@adobe/data/cache"; +import { Observe } from "@adobe/data/observe"; +import { BoardState } from "../../data/values/board-state/board-state.js"; +import type { IndexDatabase } from "../indexes/index-database.js"; +import { board } from "./board.js"; + +export const status = cached((db: IndexDatabase) => + Observe.withFilter(board(db), BoardState.deriveStatus), +); diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/computed/winner.ts b/packages/data-lit-tictactoe/src/features/main/ecs/computed/winner.ts new file mode 100644 index 000000000..05dd178cf --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/winner.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { cached } from "@adobe/data/cache"; +import { Observe } from "@adobe/data/observe"; +import { BoardState } from "../../data/values/board-state/board-state.js"; +import type { IndexDatabase } from "../indexes/index-database.js"; +import { board } from "./board.js"; + +export const winner = cached((db: IndexDatabase) => + Observe.withFilter(board(db), BoardState.getWinner), +); diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/computed/winning-line.ts b/packages/data-lit-tictactoe/src/features/main/ecs/computed/winning-line.ts new file mode 100644 index 000000000..31b2c6553 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/winning-line.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { cached } from "@adobe/data/cache"; +import { Observe } from "@adobe/data/observe"; +import { BoardState } from "../../data/values/board-state/board-state.js"; +import type { IndexDatabase } from "../indexes/index-database.js"; +import { board } from "./board.js"; + +export const winningLine = cached((db: IndexDatabase) => + Observe.withFilter(board(db), BoardState.getWinningLine), +); diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/conformance/conformance.test.ts b/packages/data-lit-tictactoe/src/features/main/ecs/conformance/conformance.test.ts new file mode 100644 index 000000000..8eb084a01 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/conformance/conformance.test.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { implementation } from "./implementation.js"; + +// Replays every spec case against the same-named action / computed, and round-trips +// `State.samples` through the projection. +Conformance.checkFeature(implementation); diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/conformance/implementation.ts b/packages/data-lit-tictactoe/src/features/main/ecs/conformance/implementation.ts new file mode 100644 index 000000000..334779eee --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/conformance/implementation.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "../../spec/spec.js"; +import { MainService } from "../main-service.js"; +import { ComputedDatabase } from "../computed/computed-database.js"; +import { projection } from "./projection.js"; + +// The spec paired with its ECS build: actions from `MainService`, computeds from the +// `ComputedDatabase` layer, and the projection between the store and `State`. +export const implementation = Conformance.implementation(spec, { + plugin: MainService.plugin, + computedPlugin: ComputedDatabase.plugin, + projection, +}); diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/conformance/projection.ts b/packages/data-lit-tictactoe/src/features/main/ecs/conformance/projection.ts new file mode 100644 index 000000000..034783012 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/conformance/projection.ts @@ -0,0 +1,42 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { State } from "../../spec/state.js"; +import type { PlacedMark } from "../../data/entities/placed-mark.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Read one placed-mark entity back into its id-less value. +const toData = (store: CoreDatabase.Store, entity: Entity): PlacedMark => { + const row = store.read(entity, store.archetypes.PlacedMark); + if (row === null) throw new Error("conformance projection: expected a placed-mark entity"); + return { mark: row.mark, cellIndex: row.cellIndex }; +}; + +// The ecs↔`State` projection. `fromState` clears every mark (tail first, so no delete +// shifts a row still to visit), sets the resources, and inserts each mark, returning +// the `spec id → entity` map; `toState` reads it back, keyed by entity. +export const projection = { + fromState: (store: CoreDatabase.Store, state: State): ReadonlyMap => { + for (const arch of store.queryArchetypes(store.archetypes.PlacedMark.components)) { + for (let row = arch.rowCount - 1; row >= 0; row--) store.delete(arch.columns.id.get(row)); + } + store.resources.firstPlayer = state.firstPlayer; + store.resources.xWins = state.xWins; + store.resources.oWins = state.oWins; + store.resources.draws = state.draws; + return new Map( + [...state.marks].map(([specId, mark]) => [specId, store.archetypes.PlacedMark.insert(mark)]), + ); + }, + toState: (store: CoreDatabase.Store): State => ({ + marks: new Map( + store + .select(store.archetypes.PlacedMark.components) + .map((entity) => [entity, toData(store, entity)]), + ), + firstPlayer: store.resources.firstPlayer, + xWins: store.resources.xWins, + oWins: store.resources.oWins, + draws: store.resources.draws, + }), + toData, +}; diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/core/archetypes.ts b/packages/data-lit-tictactoe/src/features/main/ecs/core/archetypes.ts new file mode 100644 index 000000000..10c458273 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/core/archetypes.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { components } from "../../data/components/index.js"; +import { PlacedMark } from "../../data/entities/placed-mark.js"; + +export const archetypes = Database.archetypes(components, { PlacedMark }); diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/core/core-database.ts b/packages/data-lit-tictactoe/src/features/main/ecs/core/core-database.ts new file mode 100644 index 000000000..1a37a8592 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/core/core-database.ts @@ -0,0 +1,28 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import type { Store } from "@adobe/data/ecs"; +import { components } from "../../data/components/index.js"; +import { resources } from "../../data/resources/index.js"; +import { archetypes } from "./archetypes.js"; + +// The core schema: the feature's data/ components and resources, packed into archetypes. +// Everything is document scope (shared + durable). +const coreDatabasePlugin = Database.Plugin.create({ + components, + resources, + archetypes, +}); + +export type CoreDatabase = Database.Plugin.ToDatabase; + +// Declared at module scope so the imported `Store` namespace isn't shadowed by +// `CoreDatabase.Store` below. +type CoreComponents = Store.Components>; + +export namespace CoreDatabase { + export const plugin = coreDatabasePlugin; + // The store transactions operate on (entities, resources, archetypes, indexes, `userId`). + export type Store = Database.Plugin.ToStore; + // Index-declaration type bound to this database's components. + export type Index = Database.Index; +} diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/indexes/by-cell.ts b/packages/data-lit-tictactoe/src/features/main/ecs/indexes/by-cell.ts new file mode 100644 index 000000000..7262f8dce --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/indexes/by-cell.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +// O(1) lookup of the mark occupying a cell. Unique: a cell holds at most one mark, +// so `db.indexes.byCell.get({ cellIndex }) → Entity | null`. +export const byCell = { + key: "cellIndex", + unique: true, +} as const satisfies CoreDatabase.Index; diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/indexes/index-database.ts b/packages/data-lit-tictactoe/src/features/main/ecs/indexes/index-database.ts new file mode 100644 index 000000000..3002e615c --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/indexes/index-database.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { CoreDatabase } from "../core/core-database.js"; +import * as indexes from "./index.js"; + +const indexDatabasePlugin = Database.Plugin.create({ + extends: CoreDatabase.plugin, + indexes, +}); + +export type IndexDatabase = Database.Plugin.ToDatabase; + +export namespace IndexDatabase { + export const plugin = indexDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/index-database/indexes/index.ts b/packages/data-lit-tictactoe/src/features/main/ecs/indexes/index.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/index-database/indexes/index.ts rename to packages/data-lit-tictactoe/src/features/main/ecs/indexes/index.ts diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/main-service.ts b/packages/data-lit-tictactoe/src/features/main/ecs/main-service.ts new file mode 100644 index 000000000..bbacf9c54 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/main-service.ts @@ -0,0 +1,2 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { ActionDatabase as MainService } from "./actions/action-database.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/services/create-agent-service.ts b/packages/data-lit-tictactoe/src/features/main/ecs/services/create-agent-service.ts new file mode 100644 index 000000000..1a0b7a8f0 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/services/create-agent-service.ts @@ -0,0 +1,73 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Observe } from "@adobe/data/observe"; +import { AgenticService } from "@adobe/data/service"; +import { BoardState } from "../../data/values/board-state/board-state.js"; +import type { PlayerMark } from "../../data/values/player-mark/player-mark.js"; +import type { TransactionDatabase } from "../transactions/transaction-database.js"; +import { readBoard } from "../transactions/read-board.js"; + +// An agent that plays one mark: it sees the board, whether it is its turn, and the +// winner, and may play a move on its turn or reset a finished game. Services sit +// below computed, so it derives what it observes from the store and `BoardState`. +export const createAgentService = (db: TransactionDatabase, agentMark: PlayerMark): AgenticService => { + const board = Observe.withCache(db.derive(readBoard)); + const gameOver = Observe.withFilter(board, BoardState.isGameOver); + const yourTurn = Observe.withMap( + Observe.fromProperties({ board, firstPlayer: db.observe.resources.firstPlayer }), + ({ board, firstPlayer }) => + !BoardState.isGameOver(board) && BoardState.currentPlayer(board, firstPlayer) === agentMark, + ); + + return AgenticService.create({ + interface: { + role: { + type: "state", + schema: { type: "string" }, + description: "Your role and objective", + }, + board: { + type: "state", + schema: BoardState.schema, + description: "Board state", + }, + yourTurn: { + type: "state", + schema: { type: "boolean" }, + description: "True when it is your turn to play", + }, + resetGame: { + type: "action", + description: "Reset the game after completion", + parameters: [], + }, + winner: { + type: "state", + schema: { type: "string", enum: ["X", "O", "cat", null] }, + description: "Winner mark when game over", + }, + playMove: { + type: "action", + description: `Play a ${agentMark} move on the board`, + parameters: [{ title: "index", type: "integer", minimum: 0, maximum: 8 }], + }, + }, + implementation: { + role: Observe.fromConstant( + `You are playing as ${agentMark} in tic-tac-toe. Play to the best of your ability.`, + ), + board, + yourTurn, + winner: Observe.withFilter(board, BoardState.getWinner), + resetGame: async () => { + db.transactions.restartGame(); + }, + playMove: async (index: number) => { + db.transactions.playMove({ index }); + }, + }, + conditional: { + resetGame: gameOver, + playMove: yourTurn, + }, + }); +}; diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/services/create-root-agent-service.ts b/packages/data-lit-tictactoe/src/features/main/ecs/services/create-root-agent-service.ts new file mode 100644 index 000000000..eb262d732 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/services/create-root-agent-service.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { AgenticService } from "@adobe/data/service"; +import type { TransactionDatabase } from "../transactions/transaction-database.js"; +import { createAgentService } from "./create-agent-service.js"; + +// Root agent linking to one agent per mark, so a supervising agent picks a side. +export const createRootAgentService = (db: TransactionDatabase): AgenticService => + AgenticService.create({ + interface: { + x: { type: "link", description: "Play as X" }, + o: { type: "link", description: "Play as O" }, + }, + implementation: { + x: createAgentService(db, "X"), + o: createAgentService(db, "O"), + }, + }); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/services/index.ts b/packages/data-lit-tictactoe/src/features/main/ecs/services/index.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/services/index.ts rename to packages/data-lit-tictactoe/src/features/main/ecs/services/index.ts diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/services/service-database.ts b/packages/data-lit-tictactoe/src/features/main/ecs/services/service-database.ts new file mode 100644 index 000000000..97fdb96d4 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/services/service-database.ts @@ -0,0 +1,22 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; +import { createAgentService, createRootAgentService } from "./index.js"; + +// AI agent services. Each observes the game and plays through its transactions, so +// it is built from the database rather than injected. +const serviceDatabasePlugin = Database.Plugin.create({ + extends: TransactionDatabase.plugin, + services: { + agent: createRootAgentService, + agentX: (db) => createAgentService(db, "X"), + agentO: (db) => createAgentService(db, "O"), + }, +}); + +export type ServiceDatabase = Database.Plugin.ToDatabase; + +export namespace ServiceDatabase { + export const plugin = serviceDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/index.ts b/packages/data-lit-tictactoe/src/features/main/ecs/transactions/index.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/index.ts rename to packages/data-lit-tictactoe/src/features/main/ecs/transactions/index.ts diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/transactions/play-move.ts b/packages/data-lit-tictactoe/src/features/main/ecs/transactions/play-move.ts new file mode 100644 index 000000000..f2840a28b --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/transactions/play-move.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { BoardState } from "../../data/values/board-state/board-state.js"; +import { PlayMoveArgs } from "../../data/values/play-move-args/play-move-args.js"; +import type { CoreDatabase } from "../core/core-database.js"; +import { readBoard } from "./read-board.js"; + +/** + * Play the current player's mark into `index` by creating a PlacedMark entity. + * In sync/P2P mode (`t.userId` set) a peer may only play their own mark; + * illegal or out-of-turn moves are silently ignored so the transaction stays + * idempotent under replay. + */ +export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => { + const board = readBoard(t); + const mark = BoardState.currentPlayer(board, t.resources.firstPlayer); + if (t.userId !== undefined && t.userId !== mark) return; + if (!PlayMoveArgs.canPlayMove({ board, index }).ok) return; + t.archetypes.PlacedMark.insert({ mark, cellIndex: index }); +}; diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/transactions/read-board.ts b/packages/data-lit-tictactoe/src/features/main/ecs/transactions/read-board.ts new file mode 100644 index 000000000..2f885e5e3 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/transactions/read-board.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { BoardState } from "../../data/values/board-state/board-state.js"; +import type { PlacedMark } from "../../data/entities/placed-mark.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// The current board, folded from the placed-mark entities. Takes only the read +// surface, so transactions (a store), the `board` computed and the agent service +// (a `derive` read) all share it. +type BoardReader = Pick & { + readonly archetypes: { readonly PlacedMark: Pick }; +}; + +export const readBoard = (db: BoardReader): BoardState => { + const marks: PlacedMark[] = []; + for (const id of db.select(db.archetypes.PlacedMark.components)) { + const { mark, cellIndex } = db.read(id) ?? {}; + if (mark !== undefined && cellIndex !== undefined) marks.push({ mark, cellIndex }); + } + return BoardState.fromMarks(marks); +}; diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/transactions/restart-game.ts b/packages/data-lit-tictactoe/src/features/main/ecs/transactions/restart-game.ts new file mode 100644 index 000000000..1480be8bc --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/transactions/restart-game.ts @@ -0,0 +1,22 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { BoardState } from "../../data/values/board-state/board-state.js"; +import { PlayerMark } from "../../data/values/player-mark/player-mark.js"; +import type { CoreDatabase } from "../core/core-database.js"; +import { readBoard } from "./read-board.js"; + +// Tally the finished game into the scoreboard, hand the first move to the other +// player, and clear the board by deleting every placed mark (tail first, so no +// delete shifts a row still to visit). +export const restartGame = (t: CoreDatabase.Store) => { + const winner = BoardState.getWinner(readBoard(t)); + if (winner === "X") t.resources.xWins++; + else if (winner === "O") t.resources.oWins++; + else if (winner === "cat") t.resources.draws++; + t.resources.firstPlayer = PlayerMark.opponent[t.resources.firstPlayer]; + + for (const arch of t.queryArchetypes(t.archetypes.PlacedMark.components)) { + for (let row = arch.rowCount - 1; row >= 0; row--) { + t.delete(arch.columns.id.get(row)); + } + } +}; diff --git a/packages/data-lit-tictactoe/src/features/main/ecs/transactions/transaction-database.ts b/packages/data-lit-tictactoe/src/features/main/ecs/transactions/transaction-database.ts new file mode 100644 index 000000000..b81bb52ac --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/transactions/transaction-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { IndexDatabase } from "../indexes/index-database.js"; +import * as transactions from "./index.js"; + +const transactionDatabasePlugin = Database.Plugin.create({ + extends: IndexDatabase.plugin, + transactions, +}); + +export type TransactionDatabase = Database.Plugin.ToDatabase< + typeof transactionDatabasePlugin +>; + +export namespace TransactionDatabase { + export const plugin = transactionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/action-database.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/action-database.ts deleted file mode 100644 index 84dc6b4ac..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/action-database.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { ServiceDatabase } from "../service-database/service-database.js"; -import * as actions from "./actions/index.js"; - -const actionDatabasePlugin = Database.Plugin.create({ - extends: ServiceDatabase.plugin, - actions, -}); - -export type ActionDatabase = Database.Plugin.ToDatabase< - typeof actionDatabasePlugin ->; - -export namespace ActionDatabase { - export const plugin = actionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-move.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-move.ts deleted file mode 100644 index 15dd3408a..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-move.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { PlayMoveArgs } from "../../../../data/play-move-args/play-move-args.js"; -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// The app-facing realization of `State.playMove`: a local placement needs no -// outside capability, so it commits the move through a single transaction. -export const playMove = (db: ServiceDatabase, args: PlayMoveArgs) => { - db.transactions.playMove(args); -}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/restart-game.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/restart-game.ts deleted file mode 100644 index de0bca258..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/restart-game.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// The app-facing realization of `State.restartGame`: no outside capability, so it -// commits the scoreboard tally + board reset through a single transaction. -export const restartGame = (db: ServiceDatabase) => { - db.transactions.restartGame(); -}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed-database.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed-database.ts deleted file mode 100644 index d66e1ad68..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed-database.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { TransactionDatabase } from "../transaction-database/transaction-database.js"; -import * as computed from "./computed/index.js"; - -const computedDatabasePlugin = Database.Plugin.create({ - extends: TransactionDatabase.plugin, - computed, -}); - -export type ComputedDatabase = Database.Plugin.ToDatabase< - typeof computedDatabasePlugin ->; - -export namespace ComputedDatabase { - export const plugin = computedDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/board.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/board.ts deleted file mode 100644 index 2e1c676db..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/board.ts +++ /dev/null @@ -1,22 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { cached } from "@adobe/data/cache"; -import { Observe } from "@adobe/data/observe"; -import { BoardState } from "../../../../data/board-state/board-state.js"; -import type { PlacedMark } from "../../../../data/placed-mark/placed-mark.js"; -import type { IndexDatabase } from "../../index-database/index-database.js"; - -// The board snapshot, derived reactively from the placed-mark entities. The -// derive re-emits whenever a transaction adds or removes a mark. `withCache` -// shares one run across all subscribers (each cell element observes the board). -export const board = cached((db: IndexDatabase) => - Observe.withCache(db.derive((read) => { - const marks: PlacedMark[] = []; - for (const id of read.select(db.archetypes.PlacedMark.components)) { - const m = read.read(id); - if (m && m.mark !== undefined && m.index !== undefined) { - marks.push({ mark: m.mark, index: m.index }); - } - } - return BoardState.fromMarks(marks); - })), -); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/current-player.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/current-player.ts deleted file mode 100644 index 27ae85502..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/current-player.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { cached } from "@adobe/data/cache"; -import { Observe } from "@adobe/data/observe"; -import { BoardState } from "../../../../data/board-state/board-state.js"; -import type { IndexDatabase } from "../../index-database/index-database.js"; -import { board } from "./board.js"; - -export const currentPlayer = cached((db: IndexDatabase) => - Observe.withFilter(board(db), (snapshot) => - BoardState.currentPlayer(snapshot, db.resources.firstPlayer), - ), -); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/index.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/index.ts deleted file mode 100644 index 654195b19..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export * from "./state.js"; -export * from "./board.js"; -export * from "./current-player.js"; -export * from "./move-count.js"; -export * from "./status.js"; -export * from "./winning-line.js"; -export * from "./winner.js"; -export * from "./is-game-over.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/is-game-over.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/is-game-over.ts deleted file mode 100644 index 8463cd41e..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/is-game-over.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { cached } from "@adobe/data/cache"; -import { Observe } from "@adobe/data/observe"; -import { BoardState } from "../../../../data/board-state/board-state.js"; -import type { IndexDatabase } from "../../index-database/index-database.js"; -import { board } from "./board.js"; - -export const isGameOver = cached((db: IndexDatabase) => - Observe.withFilter(board(db), BoardState.isGameOver), -); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/move-count.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/move-count.ts deleted file mode 100644 index edd7b45a0..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/move-count.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { cached } from "@adobe/data/cache"; -import { Observe } from "@adobe/data/observe"; -import { BoardState } from "../../../../data/board-state/board-state.js"; -import type { IndexDatabase } from "../../index-database/index-database.js"; -import { board } from "./board.js"; - -export const moveCount = cached((db: IndexDatabase) => - Observe.withFilter(board(db), BoardState.getMoveCount), -); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/state.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/state.ts deleted file mode 100644 index f197d418c..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/state.ts +++ /dev/null @@ -1,21 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { cached } from "@adobe/data/cache"; -import { Observe } from "@adobe/data/observe"; -import type { State } from "../../../../data/state/state.js"; -import type { IndexDatabase } from "../../index-database/index-database.js"; -import { board } from "./board.js"; - -// The full logical `State` projected from the ECS — the conformance anchor -// between the data-layer spec and this implementation. Reuses the `board` -// computed (which folds the placed-mark entities into the board string) and joins -// it with the scalar resource observables, so the mark-fold lives in exactly one -// place. Re-emits whenever the board or any counter changes. -export const state = cached((db: IndexDatabase): Observe => - Observe.fromProperties({ - board: board(db), - firstPlayer: db.observe.resources.firstPlayer, - xWins: db.observe.resources.xWins, - oWins: db.observe.resources.oWins, - draws: db.observe.resources.draws, - }), -); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/status.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/status.ts deleted file mode 100644 index d65d7e44a..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/status.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { cached } from "@adobe/data/cache"; -import { Observe } from "@adobe/data/observe"; -import { BoardState } from "../../../../data/board-state/board-state.js"; -import type { IndexDatabase } from "../../index-database/index-database.js"; -import { board } from "./board.js"; - -export const status = cached((db: IndexDatabase) => - Observe.withFilter(board(db), BoardState.deriveStatus), -); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/winner.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/winner.ts deleted file mode 100644 index 644e45835..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/winner.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { cached } from "@adobe/data/cache"; -import { Observe } from "@adobe/data/observe"; -import { BoardState } from "../../../../data/board-state/board-state.js"; -import type { IndexDatabase } from "../../index-database/index-database.js"; -import { board } from "./board.js"; - -export const winner = cached((db: IndexDatabase) => - Observe.withFilter(board(db), BoardState.getWinner), -); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/winning-line.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/winning-line.ts deleted file mode 100644 index 33dcc4d4c..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/winning-line.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { cached } from "@adobe/data/cache"; -import { Observe } from "@adobe/data/observe"; -import { BoardState } from "../../../../data/board-state/board-state.js"; -import type { IndexDatabase } from "../../index-database/index-database.js"; -import { board } from "./board.js"; - -export const winningLine = cached((db: IndexDatabase) => - Observe.withFilter(board(db), BoardState.getWinningLine), -); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/conformance.test.ts deleted file mode 100644 index c656214d7..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/conformance.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "../../../data/state/spec.js"; - -// The whole ecs conformance for this feature in one call: `checkFeature` replays the -// manifest's shared cases against the transactions/actions on `MainService.plugin` -// and the computeds on `ComputedDatabase`, seeds each `before` over `State.create()`, -// and round-trips `State.samples` through the `projection`. It shares `spec` with -// `spec.test.ts` — same cases, substituted implementation. -Conformance.checkFeature(spec); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.ts deleted file mode 100644 index 3c5531488..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.ts +++ /dev/null @@ -1,51 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import { BoardState } from "../../../data/board-state/board-state.js"; -import { PlayerMark } from "../../../data/player-mark/player-mark.js"; -import type { PlacedMark } from "../../../data/placed-mark/placed-mark.js"; -import type { State } from "../../../data/state/state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Read one placed-mark entity back into its `data/` value — the per-entity mapping -// `toState` folds over. -const toData = (store: CoreDatabase.Store, entity: Entity): PlacedMark => { - const row = store.read(entity, store.archetypes.PlacedMark); - if (row === null) - throw new Error("conformance projection: expected a placed-mark entity"); - return { mark: row.mark, index: row.index }; -}; - -// The test-only ecs↔`State` projection, carried on the manifest and used by `Conformance.checkFeature`. -// `fromState` seeds a store to a `State` (tictactoe is index-addressed, so it -// returns no id map); `toState` reads it back; `toData` reads one entity. -export const projection = { - fromState: (store: CoreDatabase.Store, state: State): void => { - for (const arch of store.queryArchetypes( - store.archetypes.PlacedMark.components, - )) { - for (let row = arch.rowCount - 1; row >= 0; row--) - store.delete(arch.columns.id.get(row)); - } - store.resources.firstPlayer = state.firstPlayer; - store.resources.xWins = state.xWins; - store.resources.oWins = state.oWins; - store.resources.draws = state.draws; - for (let index = 0; index < state.board.length; index++) { - const cell = state.board[index]; - if (PlayerMark.is(cell)) - store.archetypes.PlacedMark.insert({ mark: cell, index }); - } - }, - toState: (store: CoreDatabase.Store): State => ({ - board: BoardState.fromMarks( - [...store.select(store.archetypes.PlacedMark.components)].map((entity) => - toData(store, entity), - ), - ), - firstPlayer: store.resources.firstPlayer, - xWins: store.resources.xWins, - oWins: store.resources.oWins, - draws: store.resources.draws, - }), - toData, -}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/archetypes.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/archetypes.ts deleted file mode 100644 index 6c75b9be4..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/archetypes.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { Schema } from "@adobe/data/schema"; -import type { Assert, Equal } from "@adobe/data/types"; -import type { PlacedMark as PlacedMarkRow } from "../../../data/placed-mark/placed-mark.js"; -import { components } from "./components.js"; - -export const archetypes = Database.archetypes(components, { - PlacedMark: ["mark", "index"], -}); - -// Drift guard: the archetype's inferred row shape must equal the PlacedMark -// data type. Fails to compile if the component set and the type diverge. -const placedMarkSchema = Schema.fromArchetype(components, archetypes.PlacedMark); -type _PlacedMarkMatches = Assert< - Equal, PlacedMarkRow> ->; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/components.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/components.ts deleted file mode 100644 index d6506ded3..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/components.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { CellIndex } from "../../../data/cell-index/cell-index.js"; -import { PlayerMark } from "../../../data/player-mark/player-mark.js"; - -export const components = Database.components({ - document: { - mark: PlayerMark.schema, - index: CellIndex.schema, - }, -}); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/core-database.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/core-database.ts deleted file mode 100644 index 93d0f2c67..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/core-database.ts +++ /dev/null @@ -1,40 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import type { Store } from "@adobe/data/ecs"; -import { components } from "./components.js"; -import { resources } from "./resources.js"; -import { archetypes } from "./archetypes.js"; - -// The core schema: all components, resources, and archetypes for the feature, -// grouped by scope inside components.ts / resources.ts. tictactoe is -// document-only (shared + durable) — no settings/presence/session state. -const coreDatabasePlugin = Database.Plugin.create({ - components, - resources, - archetypes, -}); - -export type CoreDatabase = Database.Plugin.ToDatabase; - -// Resolved component map, declared at module scope so the imported `Store` -// namespace isn't shadowed by `CoreDatabase.Store` below. -type CoreComponents = Store.Components< - Database.Plugin.ToStore ->; - -export namespace CoreDatabase { - export const plugin = coreDatabasePlugin; - /** - * The store this database's transactions operate on: entities, resources, - * archetypes, index handles, and the initiating `userId`. A store *is* the - * transaction context — there is no separate transaction type. - */ - export type Store = Database.Plugin.ToStore; - /** Index-declaration type bound to this database's components. */ - export type Index = Database.Index; - /** A component name, and a component-key tuple (an archetype's shape / a `count`/`select` - * query-key list). Derived from `components` — NOT `CoreComponents` (plugin-derived, so it - * would cycle when used to `satisfies`-check the archetypes it is derived from). */ - export type Component = Extract; - export type Archetype = readonly Component[]; -} diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/resources.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/resources.ts deleted file mode 100644 index 17ca2f9bb..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/resources.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { PlayerMark } from "../../../data/player-mark/player-mark.js"; -import { Score } from "../../../data/score/score.js"; - -export const resources = Database.resources({ - document: { - firstPlayer: PlayerMark.schema, - xWins: Score.schema, - oWins: Score.schema, - draws: Score.schema, - }, -}); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/index-database/index-database.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/index-database/index-database.ts deleted file mode 100644 index 04b996c61..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/index-database/index-database.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { CoreDatabase } from "../core-database/core-database.js"; -import * as indexes from "./indexes/index.js"; - -const indexDatabasePlugin = Database.Plugin.create({ - extends: CoreDatabase.plugin, - indexes, -}); - -export type IndexDatabase = Database.Plugin.ToDatabase; - -export namespace IndexDatabase { - export const plugin = indexDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/index-database/indexes/by-cell.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/index-database/indexes/by-cell.ts deleted file mode 100644 index 25ff4e71b..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/index-database/indexes/by-cell.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// O(1) lookup of the mark occupying a given cell. Unique: a cell holds at most -// one mark, so `db.indexes.byCell.get({ index }) → Entity | null`. -export const byCell = { - key: "index", - unique: true, -} as const satisfies CoreDatabase.Index; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/main-service.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/main-service.ts deleted file mode 100644 index d8d6816d5..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/main-service.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// The assembled feature database. Its topmost layer is `action-database` -// (adds the async app-facing actions). Every consumer — `ui/`, the app entry, -// `services/main-service/conformance/` — references `MainService`, never the -// topmost layer, so adding or dropping a layer changes only this one line. -export { ActionDatabase as MainService } from "./action-database/action-database.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/service-database.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/service-database.ts deleted file mode 100644 index 1acf64737..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/service-database.ts +++ /dev/null @@ -1,26 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { AgenticService } from "@adobe/data/service"; -import { ComputedDatabase } from "../computed-database/computed-database.js"; -import { - createAgentService, - createRootAgentService, -} from "./services/index.js"; - -const serviceDatabasePlugin = Database.Plugin.create({ - extends: ComputedDatabase.plugin, - services: { - agent: (db): AgenticService => createRootAgentService(db), - agentX: (db): AgenticService => createAgentService(db, "X"), - agentO: (db): AgenticService => createAgentService(db, "O"), - }, -}); - -export type ServiceDatabase = Database.Plugin.ToDatabase< - typeof serviceDatabasePlugin ->; - -export namespace ServiceDatabase { - export const plugin = serviceDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/services/create-agent-service.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/services/create-agent-service.ts deleted file mode 100644 index 9c3fe1d3e..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/services/create-agent-service.ts +++ /dev/null @@ -1,91 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Observe } from "@adobe/data/observe"; -import { AgenticService } from "@adobe/data/service"; -import { BoardState } from "../../../../data/board-state/board-state.js"; -import type { PlayerMark } from "../../../../data/player-mark/player-mark.js"; -import { board as boardComputed } from "../../computed-database/computed/board.js"; -import type { ComputedDatabase } from "../../computed-database/computed-database.js"; - -const roleDescription = (mark: PlayerMark): string => - `You are playing as ${mark} in tic-tac-toe. Play to the best of your ability.`; - -/** - * Build an {@link AgenticService} that plays a single mark. The service - * exposes the board, whether it is the agent's turn, the winner, and the - * `playMove` / `resetGame` actions gated on those conditions. - */ -export const createAgentService = ( - db: ComputedDatabase, - agentMark: PlayerMark, -): AgenticService => { - const board = boardComputed(db); - const isGameOver = Observe.withFilter(board, BoardState.isGameOver); - const currentPlayer = Observe.withFilter(board, (nextBoard) => - BoardState.currentPlayer(nextBoard, db.resources.firstPlayer), - ); - const yourTurn = Observe.withMap( - Observe.fromProperties({ isGameOver, currentPlayer }), - ({ isGameOver: over, currentPlayer: cur }) => !over && cur === agentMark, - ); - - return AgenticService.create({ - interface: { - role: { - type: "state", - schema: { type: "string" as const }, - description: "Your role and objective", - }, - board: { - type: "state", - schema: BoardState.schema, - description: "Board state", - }, - yourTurn: { - type: "state", - schema: { type: "boolean" as const }, - description: "True when it is your turn to play", - }, - resetGame: { - type: "action", - description: "Reset the game after completion", - parameters: [], - }, - winner: { - type: "state", - schema: { - type: "string" as const, - enum: ["X", "O", "cat", null], - }, - description: "Winner mark when game over", - }, - playMove: { - type: "action", - description: `Play a ${agentMark} move on the board`, - parameters: [ - { - title: "index", - type: "integer" as const, - minimum: 0, - maximum: 8, - }, - ], - }, - }, - implementation: { - role: Observe.fromConstant(roleDescription(agentMark)), - board, - yourTurn, - winner: Observe.withFilter(board, BoardState.getWinner), - resetGame: async () => { - db.transactions.restartGame(); - }, - playMove: async (index: number) => { - db.transactions.playMove({ index }); - }, - }, - conditional: { - resetGame: isGameOver, - playMove: yourTurn, - }, - }); -}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/services/create-root-agent-service.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/services/create-root-agent-service.ts deleted file mode 100644 index 7258d9eaa..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/services/create-root-agent-service.ts +++ /dev/null @@ -1,26 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { AgenticService } from "@adobe/data/service"; -import type { ComputedDatabase } from "../../computed-database/computed-database.js"; -import { createAgentService } from "./create-agent-service.js"; - -const linkDescriptions = { x: "Play as X", o: "Play as O" } as const; - -/** - * Root agent exposing the two mark-specific agents as links, so a supervising - * agent can choose which side to drive. - */ -export const createRootAgentService = ( - db: ComputedDatabase, -): AgenticService => { - const root = AgenticService.create({ - interface: { - x: { type: "link", description: linkDescriptions.x }, - o: { type: "link", description: linkDescriptions.o }, - }, - implementation: { - x: createAgentService(db, "X"), - o: createAgentService(db, "O"), - }, - }); - return { ...root, linkDescriptions } as AgenticService; -}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transaction-database.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transaction-database.ts deleted file mode 100644 index ce4267576..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transaction-database.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { IndexDatabase } from "../index-database/index-database.js"; -import * as transactions from "./transactions/index.js"; - -const transactionDatabasePlugin = Database.Plugin.create({ - extends: IndexDatabase.plugin, - transactions, -}); - -export type TransactionDatabase = Database.Plugin.ToDatabase< - typeof transactionDatabasePlugin ->; - -export namespace TransactionDatabase { - export const plugin = transactionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/play-move.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/play-move.ts deleted file mode 100644 index da85c2bf3..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/play-move.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { BoardState } from "../../../../data/board-state/board-state.js"; -import { PlayMoveArgs } from "../../../../data/play-move-args/play-move-args.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; -import { readBoard } from "./read-board.js"; - -/** - * Play the current player's mark into `index` by creating a PlacedMark entity. - * In sync/P2P mode (`t.userId` set) a peer may only play their own mark; - * illegal or out-of-turn moves are silently ignored so the transaction stays - * idempotent under replay. - */ -export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => { - const board = readBoard(t); - const mark = BoardState.currentPlayer(board, t.resources.firstPlayer); - if (t.userId !== undefined && t.userId !== mark) return; - if (!PlayMoveArgs.canPlayMove({ board, index }).ok) return; - t.archetypes.PlacedMark.insert({ mark, index }); -}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/read-board.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/read-board.ts deleted file mode 100644 index ebf9166ab..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/read-board.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { BoardState } from "../../../../data/board-state/board-state.js"; -import type { PlacedMark } from "../../../../data/placed-mark/placed-mark.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// Synchronously project the placed-mark entities into a BoardState snapshot, -// for transactions that need the current board (playMove, restartGame). The -// board computed does the same projection reactively. -export const readBoard = (t: CoreDatabase.Store): BoardState => { - const marks: PlacedMark[] = []; - for (const arch of t.queryArchetypes(t.archetypes.PlacedMark.components)) { - for (let row = 0; row < arch.rowCount; row++) { - marks.push({ - mark: arch.columns.mark.get(row), - index: arch.columns.index.get(row), - }); - } - } - return BoardState.fromMarks(marks); -}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/restart-game.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/restart-game.ts deleted file mode 100644 index d2f0a219a..000000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/restart-game.ts +++ /dev/null @@ -1,25 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { BoardState } from "../../../../data/board-state/board-state.js"; -import { PlayerMark } from "../../../../data/player-mark/player-mark.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; -import { readBoard } from "./read-board.js"; - -/** - * Tally the finished game into the win/draw counters, hand the first move to - * the opposite player, and clear the board by deleting every PlacedMark entity. - */ -export const restartGame = (t: CoreDatabase.Store) => { - const board = readBoard(t); - const winner = BoardState.getWinner(board); - const status = BoardState.deriveStatus(board); - if (winner === "X") t.resources.xWins = t.resources.xWins + 1; - else if (winner === "O") t.resources.oWins = t.resources.oWins + 1; - else if (status === "draw") t.resources.draws = t.resources.draws + 1; - t.resources.firstPlayer = PlayerMark.opponent[t.resources.firstPlayer]; - - for (const arch of t.queryArchetypes(t.archetypes.PlacedMark.components)) { - for (let row = arch.rowCount - 1; row >= 0; row--) { - t.delete(arch.columns.id.get(row)); - } - } -}; diff --git a/packages/data-lit-tictactoe/src/features/main/spec/board.cases.ts b/packages/data-lit-tictactoe/src/features/main/spec/board.cases.ts new file mode 100644 index 000000000..e7888a20c --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/board.cases.ts @@ -0,0 +1,29 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { board } from "./board.js"; +import { marksOf } from "./marks-of.js"; + +export const cases: Conformance.SpecDerivations = { + cases: [ + { + name: "is blank with no marks", + input: { marks: new Map() }, + value: " ", + }, + { + name: "places each mark at its cell, whatever the entity order", + input: { + marks: new Map([ + [7, { mark: "O", cellIndex: 8 }], + [3, { mark: "X", cellIndex: 0 }], + ]), + }, + value: "X O", + }, + { + name: "draws a full board", + input: { marks: marksOf("XOXXOOOXX") }, + value: "XOXXOOOXX", + }, + ], +}; diff --git a/packages/data-lit-tictactoe/src/features/main/spec/board.ts b/packages/data-lit-tictactoe/src/features/main/spec/board.ts new file mode 100644 index 000000000..fb6264c0f --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/board.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { BoardState } from "../data/values/board-state/board-state.js"; +import type { State } from "./state.js"; + +// The board picture folded from the placed marks. +export const board = (state: Pick): BoardState => + BoardState.fromMarks(state.marks.values()); diff --git a/packages/data-lit-tictactoe/src/features/main/spec/create.ts b/packages/data-lit-tictactoe/src/features/main/spec/create.ts new file mode 100644 index 000000000..83f243237 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/create.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; + +// The default state: an empty board, X to move first, a zeroed scoreboard. Cases +// author their `before`/`input` as deltas over it. +export const create = (): State => ({ + marks: new Map(), + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, +}); diff --git a/packages/data-lit-tictactoe/src/features/main/spec/current-player.cases.ts b/packages/data-lit-tictactoe/src/features/main/spec/current-player.cases.ts new file mode 100644 index 000000000..a9f574b6b --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/current-player.cases.ts @@ -0,0 +1,29 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { currentPlayer } from "./current-player.js"; +import { marksOf } from "./marks-of.js"; + +export const cases: Conformance.SpecDerivations = { + cases: [ + { + name: "the first player moves on an empty board", + input: { marks: new Map(), firstPlayer: "X" }, + value: "X", + }, + { + name: "honors a first player of O on an empty board", + input: { marks: new Map(), firstPlayer: "O" }, + value: "O", + }, + { + name: "alternates to the opponent after the first move", + input: { marks: marksOf(" X "), firstPlayer: "X" }, + value: "O", + }, + { + name: "returns to the first player after both have moved", + input: { marks: marksOf("XO "), firstPlayer: "X" }, + value: "X", + }, + ], +}; diff --git a/packages/data-lit-tictactoe/src/features/main/spec/current-player.ts b/packages/data-lit-tictactoe/src/features/main/spec/current-player.ts new file mode 100644 index 000000000..4a111d42f --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/current-player.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { BoardState } from "../data/values/board-state/board-state.js"; +import type { PlayerMark } from "../data/values/player-mark/player-mark.js"; +import type { State } from "./state.js"; +import { board } from "./board.js"; + +// Whose turn it is: the first player when both have moved equally, else the opponent. +export const currentPlayer = (state: Pick): PlayerMark => + BoardState.currentPlayer(board(state), state.firstPlayer); diff --git a/packages/data-lit-tictactoe/src/features/main/spec/marks-of.ts b/packages/data-lit-tictactoe/src/features/main/spec/marks-of.ts new file mode 100644 index 000000000..644af7c14 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/marks-of.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { BoardState } from "../data/values/board-state/board-state.js"; +import type { PlacedMark } from "../data/entities/placed-mark.js"; +import { PlayerMark } from "../data/values/player-mark/player-mark.js"; + +// Fixture helper: the placed marks drawn in a nine-character board, keyed by spec-id +// in cell order, so cases and samples can author a board as its picture. Not a +// conformed fn (absent from `transforms`). +export const marksOf = (board: BoardState): ReadonlyMap => { + const marks = new Map(); + [...board].forEach((cell, cellIndex) => { + if (PlayerMark.is(cell)) marks.set(marks.size + 1, { mark: cell, cellIndex }); + }); + return marks; +}; diff --git a/packages/data-lit-tictactoe/src/features/main/spec/play-move.cases.ts b/packages/data-lit-tictactoe/src/features/main/spec/play-move.cases.ts new file mode 100644 index 000000000..c9a0930f0 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/play-move.cases.ts @@ -0,0 +1,55 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { playMove } from "./play-move.js"; +import { marksOf } from "./marks-of.js"; + +// `before` is a delta over `State.create()` (empty board, X first, zeroed scores); +// `after` is the marks the move leaves. `index` is a cell index, not an entity +// reference, so no `args` schema is authored. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "places the first player's mark into an empty cell", + before: {}, + args: { index: 4 }, + after: { marks: marksOf(" X ") }, + }, + { + name: "alternates to the opponent by move count", + before: { marks: marksOf(" X ") }, + args: { index: 0 }, + after: { marks: marksOf("O X ") }, + }, + { + name: "honors a first player of O", + before: { firstPlayer: "O" }, + args: { index: 8 }, + after: { marks: marksOf(" O") }, + }, + { + name: "completes a three-in-a-row (a winning placement is still just a placement)", + before: { marks: marksOf("XX OO "), xWins: 1, oWins: 2 }, + args: { index: 2 }, + after: { marks: marksOf("XXX OO ") }, + }, + { + name: "ignores an occupied cell", + before: { marks: marksOf(" X ") }, + args: { index: 4 }, + after: { marks: marksOf(" X ") }, + }, + { + name: "ignores an out-of-bounds index", + before: {}, + args: { index: 9 }, + after: { marks: new Map() }, + }, + { + name: "ignores a move once the game is won", + before: { marks: marksOf("XXXOO ") }, + args: { index: 5 }, + after: { marks: marksOf("XXXOO ") }, + }, + ], +}; diff --git a/packages/data-lit-tictactoe/src/features/main/spec/play-move.ts b/packages/data-lit-tictactoe/src/features/main/spec/play-move.ts new file mode 100644 index 000000000..cf44a3c35 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/play-move.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { PlayMoveArgs } from "../data/values/play-move-args/play-move-args.js"; +import type { State } from "./state.js"; +import { currentPlayer } from "./current-player.js"; +import { board } from "./board.js"; + +// Place the current player's mark into cell `index` — a `{ marks }` patch that mints +// the next id. An illegal move (out of bounds, occupied, game over) changes nothing. +export const playMove = ( + state: Pick, + { index }: PlayMoveArgs, +): Pick => { + if (!PlayMoveArgs.canPlayMove({ board: board(state), index }).ok) return { marks: state.marks }; + const id = Math.max(0, ...state.marks.keys()) + 1; + return { + marks: new Map(state.marks).set(id, { mark: currentPlayer(state), cellIndex: index }), + }; +}; diff --git a/packages/data-lit-tictactoe/src/features/main/spec/public.ts b/packages/data-lit-tictactoe/src/features/main/spec/public.ts new file mode 100644 index 000000000..69dfc9c89 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/public.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { create } from "./create.js"; +export { samples } from "./samples.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/spec/restart-game.cases.ts b/packages/data-lit-tictactoe/src/features/main/spec/restart-game.cases.ts new file mode 100644 index 000000000..d7799ad83 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/restart-game.cases.ts @@ -0,0 +1,32 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { restartGame } from "./restart-game.js"; +import { marksOf } from "./marks-of.js"; + +// Every restart clears the board and hands the first move to the other player; the +// scoreboard is bumped only for a finished game's outcome. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "tallies an X win", + before: { marks: marksOf("XXXOO ") }, + after: { marks: new Map(), firstPlayer: "O", xWins: 1, oWins: 0, draws: 0 }, + }, + { + name: "tallies an O win", + before: { marks: marksOf("OOOXX "), firstPlayer: "O", xWins: 1, oWins: 2 }, + after: { marks: new Map(), firstPlayer: "X", xWins: 1, oWins: 3, draws: 0 }, + }, + { + name: "tallies a draw (full board, no line)", + before: { marks: marksOf("XOXXOOOXX"), firstPlayer: "O", xWins: 2, oWins: 1 }, + after: { marks: new Map(), firstPlayer: "X", xWins: 2, oWins: 1, draws: 1 }, + }, + { + name: "restarts an unfinished game without touching any counter", + before: { marks: marksOf("X O "), xWins: 1, oWins: 1, draws: 1 }, + after: { marks: new Map(), firstPlayer: "O", xWins: 1, oWins: 1, draws: 1 }, + }, + ], +}; diff --git a/packages/data-lit-tictactoe/src/features/main/spec/restart-game.ts b/packages/data-lit-tictactoe/src/features/main/spec/restart-game.ts new file mode 100644 index 000000000..e297fc38e --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/restart-game.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { BoardState } from "../data/values/board-state/board-state.js"; +import { PlayerMark } from "../data/values/player-mark/player-mark.js"; +import type { State } from "./state.js"; +import { board } from "./board.js"; + +// Tally the finished game into the scoreboard, hand the first move to the other +// player, and clear the board. +export const restartGame = (state: State): State => { + const winner = BoardState.getWinner(board(state)); + return { + marks: new Map(), + firstPlayer: PlayerMark.opponent[state.firstPlayer], + xWins: state.xWins + (winner === "X" ? 1 : 0), + oWins: state.oWins + (winner === "O" ? 1 : 0), + draws: state.draws + (winner === "cat" ? 1 : 0), + }; +}; diff --git a/packages/data-lit-tictactoe/src/features/main/spec/samples.ts b/packages/data-lit-tictactoe/src/features/main/spec/samples.ts new file mode 100644 index 000000000..d2ccd28b5 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/samples.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import { marksOf } from "./marks-of.js"; + +// Representative full states for the projection round-trip (toState ∘ fromState ≡ +// identity): a full drawn board and a game in progress, with non-default counters. +export const samples: readonly State[] = [ + { marks: marksOf("XOXXOOOXX"), firstPlayer: "O", xWins: 3, oWins: 2, draws: 1 }, + { marks: marksOf("X O X "), firstPlayer: "O", xWins: 1, oWins: 0, draws: 0 }, +]; diff --git a/packages/data-lit-tictactoe/src/features/main/spec/spec.test.ts b/packages/data-lit-tictactoe/src/features/main/spec/spec.test.ts new file mode 100644 index 000000000..bdab1adf7 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/spec.test.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "./spec.js"; + +// Runs every case against the pure spec functions — no ECS involved. +Conformance.checkSpec(spec); diff --git a/packages/data-lit-tictactoe/src/features/main/spec/spec.ts b/packages/data-lit-tictactoe/src/features/main/spec/spec.ts new file mode 100644 index 000000000..1fb1c2358 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/spec.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; + +import { cases as playMove } from "./play-move.cases.js"; +import { cases as restartGame } from "./restart-game.cases.js"; +import { cases as board } from "./board.cases.js"; +import { cases as currentPlayer } from "./current-player.cases.js"; + +// The feature's spec: every action and derivation, one cases module each. No entity +// references and no injected services, so neither `schemas` nor `services` is needed. +// `ecs/conformance/` pairs it with the ECS build. +export const spec = Conformance.spec({ + state: State, + fns: transforms, + cases: { playMove, restartGame, board, currentPlayer }, +}); diff --git a/packages/data-lit-tictactoe/src/features/main/spec/state.ts b/packages/data-lit-tictactoe/src/features/main/spec/state.ts new file mode 100644 index 000000000..dab0dff9f --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/state.ts @@ -0,0 +1,24 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert } from "@adobe/data/types"; +import type { Conformance } from "@adobe/data-testing"; +import type { PlacedMark } from "../data/entities/placed-mark.js"; +import type { PlayerMark } from "../data/values/player-mark/player-mark.js"; +import type { Score } from "../data/values/score/score.js"; +import type { components } from "../data/components/index.js"; +import type { resources } from "../data/resources/index.js"; + +// The game state as one immutable object — the specification the ECS implementation +// is verified against. `marks` holds every placed mark keyed by a numeric id (identity +// is the key); the rest are singletons. +export type State = { + readonly marks: ReadonlyMap; + readonly firstPlayer: PlayerMark; + readonly xWins: Score; + readonly oWins: Score; + readonly draws: Score; +}; + +// Singleton keys name resources; entity value keys name components. +type _Pin = Assert>; + +export * as State from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/spec/transforms.ts b/packages/data-lit-tictactoe/src/features/main/spec/transforms.ts new file mode 100644 index 000000000..ea924058e --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/spec/transforms.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The conformed fns: every spec action and derivation, and nothing else (`create`, +// `samples`, `marksOf` are absent). `Conformance.spec` requires exactly one +// `*.cases.ts` module per key. +export { playMove } from "./play-move.js"; +export { restartGame } from "./restart-game.js"; +export { board } from "./board.js"; +export { currentPlayer } from "./current-player.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-app/tictactoe-app.ts b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-app/tictactoe-app.ts index 9401127e9..fc64da190 100644 --- a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-app/tictactoe-app.ts +++ b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-app/tictactoe-app.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { html, type TemplateResult } from "lit"; -import type { ComputedDatabase } from "../../services/main-service/computed-database/computed-database.js"; +import type { ComputedDatabase } from "../../ecs/computed/computed-database.js"; type TictactoeService = ComputedDatabase; diff --git a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-cell/observe-cell.ts b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-cell/observe-cell.ts index 499bc092f..b103cd2d9 100644 --- a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-cell/observe-cell.ts +++ b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-cell/observe-cell.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Observe } from "@adobe/data/observe"; import type { UIService } from "@adobe/data/service"; -import { BoardCell } from "../../data/board-cell/board-cell.js"; -import { GameStatus } from "../../data/game-status/game-status.js"; -import type { ComputedDatabase } from "../../services/main-service/computed-database/computed-database.js"; +import { BoardCell } from "../../data/values/board-cell/board-cell.js"; +import { GameStatus } from "../../data/values/game-status/game-status.js"; +import type { ComputedDatabase } from "../../ecs/computed/computed-database.js"; // Reactive per-cell view. The mark is looked up through the `byCell` index — // the cell binds to whichever entity occupies its board position — while @@ -15,7 +15,7 @@ export const observeCell = ( Observe.withMap( Observe.fromProperties({ occupants: Observe.fromKeys( - db.indexes.byCell.observe({ index }), + db.indexes.byCell.observe({ cellIndex: index }), (id) => db.observe.entity(id), ), winningLine: db.computed.winningLine, diff --git a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-cell/tictactoe-cell-presentation.ts b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-cell/tictactoe-cell-presentation.ts index 3e5471371..776e5bf7e 100644 --- a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-cell/tictactoe-cell-presentation.ts +++ b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-cell/tictactoe-cell-presentation.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { html } from "lit"; -import { PlayerMark } from "../../data/player-mark/player-mark.js"; +import { PlayerMark } from "../../data/values/player-mark/player-mark.js"; export function render(args: { cell: string; diff --git a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-element.ts b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-element.ts index d02adb036..428953216 100644 --- a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-element.ts +++ b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-element.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { DatabaseElement } from "@adobe/data-lit"; -import { ComputedDatabase } from "../services/main-service/computed-database/computed-database.js"; +import { ComputedDatabase } from "../ecs/computed/computed-database.js"; /** * Base class for all Tic-Tac-Toe elements. Deliberately typed on the minimal diff --git a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-hud/tictactoe-hud-element.ts b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-hud/tictactoe-hud-element.ts index f9ac40c1c..ce27f96bc 100644 --- a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-hud/tictactoe-hud-element.ts +++ b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-hud/tictactoe-hud-element.ts @@ -2,7 +2,7 @@ import { customElement } from "lit/decorators.js"; import { useObservableValues } from "@adobe/data-lit"; -import { PlayerMark } from "../../data/player-mark/player-mark.js"; +import { PlayerMark } from "../../data/values/player-mark/player-mark.js"; import { TictactoeElement } from "../tictactoe-element.js"; import { styles } from "./tictactoe-hud.css.js"; import * as presentation from "./tictactoe-hud-presentation.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-hud/tictactoe-hud-presentation.ts b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-hud/tictactoe-hud-presentation.ts index 37382408e..824a1ddee 100644 --- a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-hud/tictactoe-hud-presentation.ts +++ b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-hud/tictactoe-hud-presentation.ts @@ -1,8 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { html } from "lit"; -import type { GameStatus } from "../../data/game-status/game-status.js"; -import type { PlayerMark } from "../../data/player-mark/player-mark.js"; +import type { GameStatus } from "../../data/values/game-status/game-status.js"; +import type { PlayerMark } from "../../data/values/player-mark/player-mark.js"; export function render(args: { status: GameStatus; diff --git a/packages/data-lit-tictactoe/src/index.ts b/packages/data-lit-tictactoe/src/index.ts index 781a4f3bc..cf7855c90 100644 --- a/packages/data-lit-tictactoe/src/index.ts +++ b/packages/data-lit-tictactoe/src/index.ts @@ -7,16 +7,16 @@ // services. Feature-qualified per the cross-feature naming rule so a peer or // downstream package never collides with another feature's database. Carries // `.plugin` / `.Store` for consumers that build their own database. -export { MainService as TictactoeMainService } from "./features/main/services/main-service/main-service.js"; +export { MainService as TictactoeMainService } from "./features/main/ecs/main-service.js"; // The base game database — all game logic (resources, transactions, computed), // no AI. Combine its `.plugin` with P2P-specific plugins, or reach for // `TictactoeMainService` to get the agent-extended assembly. -export { ComputedDatabase as TictactoeGameDatabase } from "./features/main/services/main-service/computed-database/computed-database.js"; +export { ComputedDatabase as TictactoeGameDatabase } from "./features/main/ecs/computed/computed-database.js"; export { Tictactoe } from "./features/main/ui/tictactoe-app/tictactoe-app.js"; export { TictactoeElement } from "./features/main/ui/tictactoe-element.js"; -export { BoardState } from "./features/main/data/board-state/board-state.js"; -export { PlayerMark } from "./features/main/data/player-mark/player-mark.js"; -export { PlayMoveArgs } from "./features/main/data/play-move-args/play-move-args.js"; +export { BoardState } from "./features/main/data/values/board-state/board-state.js"; +export { PlayerMark } from "./features/main/data/values/player-mark/player-mark.js"; +export { PlayMoveArgs } from "./features/main/data/values/play-move-args/play-move-args.js"; diff --git a/packages/data-lit-tictactoe/src/main.ts b/packages/data-lit-tictactoe/src/main.ts deleted file mode 100644 index 60eeeb9c5..000000000 --- a/packages/data-lit-tictactoe/src/main.ts +++ /dev/null @@ -1,15 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -import { render } from "lit"; -import { Database } from "@adobe/data/ecs"; -import { Tictactoe } from "./features/main/ui/tictactoe-app/tictactoe-app.js"; -import { MainService } from "./features/main/services/main-service/main-service.js"; - -const app = document.getElementById("app"); -if (app) { - // The assembled feature database extends the base game with AI services. - // Built once here and passed to the lazy wrapper so the upgraded element - // receives the agent-extended database. - const service = Database.create(MainService.plugin); - render(Tictactoe({ service }), app); -} diff --git a/packages/data-lit-tictactoe/tsconfig.build.json b/packages/data-lit-tictactoe/tsconfig.build.json deleted file mode 100644 index 1395b61d5..000000000 --- a/packages/data-lit-tictactoe/tsconfig.build.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - // The RUNTIME wall (dependency isolation). It compiles the shippable data + services - // tiers as a `composite` project, so a data/ or services/ runtime file that imports a - // test-tier file (`*.cases.ts`, `*.fake.ts`, `spec.ts`, anything under `conformance/`) - // is a COMPILE ERROR (TS6307: not in this project's file list). `ui/` and the app-shell - // (`src/*.ts`) live in the test project instead — they import test-tier only in their own - // `*.test.ts`, and keeping their (often very large) inferred element/plugin types out of - // this project avoids forcing them into serialized declarations. Emits declarations only, - // to a throwaway dir, purely to satisfy the composite contract `tsconfig.test.json` refs. - "extends": "./tsconfig.json", - "compilerOptions": { - "composite": true, - "noEmit": false, - "declaration": true, - "emitDeclarationOnly": true, - "outDir": "./.tsbuild", - "rootDir": "src" - }, - "include": ["src/features/**/*"], - "exclude": [ - "**/ui/**", - "**/*.test.ts", - "**/*.type-test.ts", - "**/*.cases.ts", - "**/*.fake.ts", - "**/spec.ts", - "**/conformance/**" - ] -} diff --git a/packages/data-lit-tictactoe/tsconfig.data.json b/packages/data-lit-tictactoe/tsconfig.data.json new file mode 100644 index 000000000..ed8fdef71 --- /dev/null +++ b/packages/data-lit-tictactoe/tsconfig.data.json @@ -0,0 +1,16 @@ +{ + // Layer 1+2 runtime: pure data declarations (data/) and service namespaces (services/). + // Composite so higher layers reference it; a data/services file importing spec/, ecs/ + // or any test file is a compile error (TS6307: not in this project). + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/data", + "rootDir": "src" + }, + "include": ["src/features/*/data/**/*", "src/features/*/services/**/*"], + "exclude": ["**/*.test.ts", "**/*.type-test.ts"] +} diff --git a/packages/data-lit-tictactoe/tsconfig.ecs.json b/packages/data-lit-tictactoe/tsconfig.ecs.json new file mode 100644 index 000000000..78227f085 --- /dev/null +++ b/packages/data-lit-tictactoe/tsconfig.ecs.json @@ -0,0 +1,16 @@ +{ + // Runtime: the ECS implementation (ecs/, minus conformance). References only data + + // services, so an ecs file importing spec/ is a compile error. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/ecs", + "rootDir": "src" + }, + "include": ["src/features/*/ecs/**/*"], + "exclude": ["**/conformance/**", "**/*.test.ts", "**/*.type-test.ts"], + "references": [{ "path": "./tsconfig.data.json" }] +} diff --git a/packages/data-lit-tictactoe/tsconfig.spec.json b/packages/data-lit-tictactoe/tsconfig.spec.json new file mode 100644 index 000000000..e4dc0a8ff --- /dev/null +++ b/packages/data-lit-tictactoe/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + // Test tier: the pure spec (spec/). References only data + services, so a spec file + // importing ecs/ is a compile error. May use @adobe/data-testing. No runtime file + // imports it (runtime projects never reference it). + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/spec", + "rootDir": "src" + }, + "include": ["src/features/*/spec/**/*"], + "exclude": ["**/*.test.ts", "**/*.type-test.ts"], + "references": [{ "path": "./tsconfig.data.json" }] +} diff --git a/packages/data-lit-tictactoe/tsconfig.test.json b/packages/data-lit-tictactoe/tsconfig.test.json index f328ac324..0f21c5b66 100644 --- a/packages/data-lit-tictactoe/tsconfig.test.json +++ b/packages/data-lit-tictactoe/tsconfig.test.json @@ -1,11 +1,7 @@ { - // The NON-EMITTING tier: everything the runtime wall (`tsconfig.build.json`) doesn't own — - // the test-tier (conformance manifest + inert cases, service fakes, `*.test.ts`, - // `conformance/`), plus `ui/` and the app-shell (`src/*.ts`). It `references` the runtime - // project, so its imports of data/services resolve through that project's declarations, - // and it alone may see `@adobe/data-testing`. Nothing here emits, so large element/plugin - // types are type-checked without being serialized. `tsc -b tsconfig.test.json` builds the - // wall first, then type-checks this tier — together covering all of `src`. + // Everything else, non-emitting: ui/, app/, ecs/conformance/, and every *.test.ts. + // References the ecs and spec projects, so it sees both. `tsc -b tsconfig.test.json` + // builds the layer projects first, then type-checks this tier — together covering src. "extends": "./tsconfig.json", "compilerOptions": { "composite": false, @@ -13,14 +9,12 @@ }, "include": [ "src/*.ts", - "src/**/ui/**/*", + "src/app/**/*", + "src/features/*/ui/**/*", + "src/features/*/ecs/conformance/**/*", "src/**/*.test.ts", - "src/**/*.type-test.ts", - "src/**/*.cases.ts", - "src/**/*.fake.ts", - "src/**/spec.ts", - "src/**/conformance/**/*" + "src/**/*.type-test.ts" ], "exclude": [], - "references": [{ "path": "./tsconfig.build.json" }] + "references": [{ "path": "./tsconfig.ecs.json" }, { "path": "./tsconfig.spec.json" }] } diff --git a/packages/data-lit-todo/index.html b/packages/data-lit-todo/index.html index 924e78f7d..b8c079b2f 100644 --- a/packages/data-lit-todo/index.html +++ b/packages/data-lit-todo/index.html @@ -21,6 +21,6 @@

data-lit-todo

A todo application built with Lit and @adobe/data ECS.

- + diff --git a/packages/data-lit-todo/src/app/cross-feature.test.ts b/packages/data-lit-todo/src/app/cross-feature.test.ts new file mode 100644 index 000000000..1ec72141a --- /dev/null +++ b/packages/data-lit-todo/src/app/cross-feature.test.ts @@ -0,0 +1,30 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { Database } from "@adobe/data/ecs"; +import { AppSchema } from "./schema.js"; +import { MainService as AssignService } from "../features/assign/ecs/main-service.js"; + +// Every feature's entities share one store, so an id-addressed op must check the +// entity's kind: a user id passed to a todo op changes nothing. +describe("cross-feature entity ids", () => { + const setup = () => { + const db = Database.create(AppSchema.plugin).extend(AssignService.plugin); + db.actions.addUser({ name: "ada" }); + return { db, user: db.select(db.archetypes.User.components)[0]! }; + }; + + it("todo ops ignore a user id", () => { + const { db, user } = setup(); + db.actions.toggleComplete({ id: user }); + db.actions.selectTodo({ id: user }); + db.actions.deleteTodo({ id: user }); + expect(db.read(user)).toEqual({ user: true, name: "ada" }); + expect(db.resources.selectedTodo).not.toBe(user); + }); + + it("assign ops ignore a user id", () => { + const { db, user } = setup(); + db.actions.assignUser({ todo: user, name: "ada" }); + expect(db.read(user)?.assignees).toBeUndefined(); + }); +}); diff --git a/packages/data-lit-todo/src/app/main.ts b/packages/data-lit-todo/src/app/main.ts new file mode 100644 index 000000000..f2f6414f0 --- /dev/null +++ b/packages/data-lit-todo/src/app/main.ts @@ -0,0 +1,62 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { html, render } from "lit"; +import { Database, createVersionUpgrader, createStoragePersistenceService } from "@adobe/data/ecs"; +import { AppSchema } from "./schema.js"; +import { versions } from "./versioning/versions.js"; +import { TodoApp } from "../features/main/ui/todo-app/todo-app.js"; + +// Spectrum 2 theme registration (side-effect imports). +import "@spectrum-web-components/theme/sp-theme.js"; +import "@spectrum-web-components/theme/spectrum-two/theme-light.js"; +import "@spectrum-web-components/theme/spectrum-two/scale-medium.js"; + +const app = document.getElementById("app"); +if (app) { + // Configured with the version upgrader: each persisted quadrant is migrated from + // its saved `db.version` (carried in the blob metadata) up to the current schema. + const service = Database.create(AppSchema.plugin, { + versioning: createVersionUpgrader(versions), + }); + + // Split persistence: the DOCUMENT quadrant (the todos, shared) and the SETTINGS + // quadrant (`displayCompleted`, per-device) are saved to — and loaded from — two + // SEPARATE locations, each its own independently pluggable mechanism. Here both + // use `localStorage` under distinct keys; swap `storage` for any Storage-shaped + // backend (a cloud-backed document store, a per-device settings store, …). Each + // service auto-loads on start and auto-saves (debounced) only the entities in its + // own quadrant, and each blob carries + upgrades its own quadrant's version. + void (async () => { + await createStoragePersistenceService({ + database: service, + scope: { shared: true }, // document quadrant → the todos + storage: localStorage, + defaultFileId: "data-lit-todo/document", + autoSaveOnChange: true, + autoLoadOnStart: true, + }); + await createStoragePersistenceService({ + database: service, + scope: { nonShared: true }, // settings quadrant → displayCompleted + storage: localStorage, + defaultFileId: "data-lit-todo/settings", + autoSaveOnChange: true, + autoLoadOnStart: true, + }); + + // Seed example todos only on a first run (nothing loaded from the document store). + if (service.select(["todo"]).length === 0) { + service.actions.createTodo({ name: "Buy groceries" }); + service.actions.createTodo({ name: "Pick up dry cleaning" }); + service.actions.createTodo({ name: "Water the plants", complete: true }); + } + + render( + html` + + ${TodoApp({ service })} + + `, + app, + ); + })(); +} diff --git a/packages/data-lit-todo/src/app/persistence.test.ts b/packages/data-lit-todo/src/app/persistence.test.ts new file mode 100644 index 000000000..aa028bdc3 --- /dev/null +++ b/packages/data-lit-todo/src/app/persistence.test.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { Database } from "@adobe/data/ecs"; +import { AppSchema } from "./schema.js"; + +// Session-scoped columns are stripped on load, so no persistent archetype may pack +// one: a reloaded entity would fall out of it. +describe("app persistence", () => { + it("reloaded todos (including dragged and assigned ones) stay in the Todo archetype", async () => { + const saved = Database.create(AppSchema.plugin); + saved.actions.createTodo({ name: "a" }); + saved.actions.createTodo({ name: "b" }); + saved.actions.reorderTodo({ id: saved.select(saved.archetypes.Todo.components)[0]!, toIndex: 1 }); + + const loaded = Database.create(AppSchema.plugin); + await loaded.fromData(saved.toData({ scope: { shared: true } }), { shared: true }); + expect(loaded.select(loaded.archetypes.Todo.components)).toHaveLength(2); + }); +}); diff --git a/packages/data-lit-todo/src/app/schema.ts b/packages/data-lit-todo/src/app/schema.ts new file mode 100644 index 000000000..cc31c87c2 --- /dev/null +++ b/packages/data-lit-todo/src/app/schema.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { MainService } from "../features/main/ecs/main-service.js"; +import { CoreDatabase as AssignCoreDatabase } from "../features/assign/ecs/core/core-database.js"; + +// The app's composed schema: main's full behavior, plus assign's schema `imports`ed +// so the store knows its columns up front (its data persists and loads) while its +// behavior loads lazily when an assign element connects. Base features never name +// the features built on them; this is the one place they are composed. +const appSchemaPlugin = Database.Plugin.create({ + imports: AssignCoreDatabase.plugin, + extends: MainService.plugin, +}); + +export namespace AppSchema { + export const plugin = appSchemaPlugin; +} diff --git a/packages/data-lit-todo/src/features/main/services/main-service/versioning/versions.test.ts b/packages/data-lit-todo/src/app/versioning/versions.test.ts similarity index 86% rename from packages/data-lit-todo/src/features/main/services/main-service/versioning/versions.test.ts rename to packages/data-lit-todo/src/app/versioning/versions.test.ts index ff4c342e0..148d6577c 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/versioning/versions.test.ts +++ b/packages/data-lit-todo/src/app/versioning/versions.test.ts @@ -7,14 +7,14 @@ import { describe, it } from "vitest"; import { Database, assertVersioning, createVersionUpgrader } from "@adobe/data/ecs"; -import { MainService } from "../main-service.js"; +import { AppSchema } from "../schema.js"; import { versions } from "./versions.js"; describe("database schema versions", () => { it("the version history is consistent (schema folds + every handler tested)", () => assertVersioning({ // The version-configured database, so `db.version` = the history's current version. - database: Database.create(MainService.plugin, { versioning: createVersionUpgrader(versions) }), + database: Database.create(AppSchema.plugin, { versioning: createVersionUpgrader(versions) }), entries: versions, handlers: { // Append a case here for each version that adds a `handler`. Empty today diff --git a/packages/data-lit-todo/src/features/main/services/main-service/versioning/versions.ts b/packages/data-lit-todo/src/app/versioning/versions.ts similarity index 94% rename from packages/data-lit-todo/src/features/main/services/main-service/versioning/versions.ts rename to packages/data-lit-todo/src/app/versioning/versions.ts index 2288273a3..c58aa29b0 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/versioning/versions.ts +++ b/packages/data-lit-todo/src/app/versioning/versions.ts @@ -5,7 +5,7 @@ // and the co-located test (versions.test.ts) fails if it doesn't. // // HOW TO EVOLVE THIS FILE: -// • Change a component/resource schema in core-database, then run the tests. +// • Change a component/resource schema in a feature's data/, then run the tests. // • If a test fails, it prints the exact entry to append here. Do THAT — do not // edit an existing entry. `db.version` follows the history automatically // (= entries.length - 1); there is no resource default to set. diff --git a/packages/data-lit-todo/src/features/assign/data/components/assignees.ts b/packages/data-lit-todo/src/features/assign/data/components/assignees.ts new file mode 100644 index 000000000..e78874c98 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/data/components/assignees.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Assignees } from "../values/assignees/assignees.js"; + +// A todo's assignee names (the join key with users). Added to a todo on first assign. +export const assignees = Assignees.schema; diff --git a/packages/data-lit-todo/src/features/assign/data/components/index.ts b/packages/data-lit-todo/src/features/assign/data/components/index.ts new file mode 100644 index 000000000..48fbbaf46 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/data/components/index.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import { components as mainComponents } from "../../../main/data/components/index.js"; +import { user } from "./user.js"; +import { assignees } from "./assignees.js"; + +// This feature's vocabulary: main's components (reused by identity) plus its own. +export const components = { ...mainComponents, user, assignees }; + +// Own names must not shadow a lower feature's (a spread would overwrite silently). +type _NoShadow = Assert, never>>; diff --git a/packages/data-lit-todo/src/features/assign/data/components/user.ts b/packages/data-lit-todo/src/features/assign/data/components/user.ts new file mode 100644 index 000000000..f71c03728 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/data/components/user.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { True } from "@adobe/data/schema"; + +// Tag: marks an entity as a user. Needed because a user's other component (`name`) +// is a subset of a todo's, so without it a user query would also match todos. +export const user = True.schema; diff --git a/packages/data-lit-todo/src/features/assign/data/entities/assigned-todo.ts b/packages/data-lit-todo/src/features/assign/data/entities/assigned-todo.ts new file mode 100644 index 000000000..8a8939a9f --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/data/entities/assigned-todo.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Database } from "@adobe/data/ecs"; +import type { components } from "../components/index.js"; + +// A todo seen through this feature: main's todo plus its assignee names. +export const AssignedTodo = ["name", "complete", "order", "assignees"] as const satisfies Database.EntityComponents; +export type AssignedTodo = Database.EntityType; diff --git a/packages/data-lit-todo/src/features/assign/data/entities/index.ts b/packages/data-lit-todo/src/features/assign/data/entities/index.ts new file mode 100644 index 000000000..1e2993241 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/data/entities/index.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { User } from "./user.js"; +import { AssignedTodo } from "./assigned-todo.js"; + +export const entities = { User, AssignedTodo }; diff --git a/packages/data-lit-todo/src/features/assign/data/entities/user.ts b/packages/data-lit-todo/src/features/assign/data/entities/user.ts new file mode 100644 index 000000000..852b00e67 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/data/entities/user.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Database } from "@adobe/data/ecs"; +import type { components } from "../components/index.js"; + +export const User = ["user", "name"] as const satisfies Database.EntityComponents; +export type User = Database.EntityType; diff --git a/packages/data-lit-todo/src/features/assign/data/user/user.ts b/packages/data-lit-todo/src/features/assign/data/user/user.ts deleted file mode 100644 index 2315baf61..000000000 --- a/packages/data-lit-todo/src/features/assign/data/user/user.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -// A user a todo can be assigned to. `id` is the ECS entity; `name` is the join -// key shared with a todo's `assignees` list (see the assign feature's indexes). -export type User = { - readonly id: number; - readonly name: string; -}; diff --git a/packages/data-lit-todo/src/features/assign/data/values/assignees/assignees.ts b/packages/data-lit-todo/src/features/assign/data/values/assignees/assignees.ts new file mode 100644 index 000000000..e87c8b7ed --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/data/values/assignees/assignees.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type Assignees = readonly string[]; +export * as Assignees from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/placed-mark/public.ts b/packages/data-lit-todo/src/features/assign/data/values/assignees/public.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/placed-mark/public.ts rename to packages/data-lit-todo/src/features/assign/data/values/assignees/public.ts diff --git a/packages/data-lit-todo/src/features/assign/data/values/assignees/schema.ts b/packages/data-lit-todo/src/features/assign/data/values/assignees/schema.ts new file mode 100644 index 000000000..f11a63950 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/data/values/assignees/schema.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Assignees } from "./assignees.js"; +import { Schema } from "@adobe/data/schema"; + +// A todo's denormalized list of assignee names (the join key with users). +export const schema = { type: "array", items: { type: "string" }, default: [] } as const satisfies Schema; + +type _Pin = Assert, Assignees>>; diff --git a/packages/data-lit-todo/src/features/assign/data/assignment/assign.test.ts b/packages/data-lit-todo/src/features/assign/data/values/assignment/assign.test.ts similarity index 100% rename from packages/data-lit-todo/src/features/assign/data/assignment/assign.test.ts rename to packages/data-lit-todo/src/features/assign/data/values/assignment/assign.test.ts diff --git a/packages/data-lit-todo/src/features/assign/data/assignment/assign.ts b/packages/data-lit-todo/src/features/assign/data/values/assignment/assign.ts similarity index 100% rename from packages/data-lit-todo/src/features/assign/data/assignment/assign.ts rename to packages/data-lit-todo/src/features/assign/data/values/assignment/assign.ts diff --git a/packages/data-lit-todo/src/features/assign/data/assignment/assignment.ts b/packages/data-lit-todo/src/features/assign/data/values/assignment/assignment.ts similarity index 100% rename from packages/data-lit-todo/src/features/assign/data/assignment/assignment.ts rename to packages/data-lit-todo/src/features/assign/data/values/assignment/assignment.ts diff --git a/packages/data-lit-todo/src/features/assign/data/assignment/public.ts b/packages/data-lit-todo/src/features/assign/data/values/assignment/public.ts similarity index 100% rename from packages/data-lit-todo/src/features/assign/data/assignment/public.ts rename to packages/data-lit-todo/src/features/assign/data/values/assignment/public.ts diff --git a/packages/data-lit-todo/src/features/assign/data/assignment/unassign.test.ts b/packages/data-lit-todo/src/features/assign/data/values/assignment/unassign.test.ts similarity index 100% rename from packages/data-lit-todo/src/features/assign/data/assignment/unassign.test.ts rename to packages/data-lit-todo/src/features/assign/data/values/assignment/unassign.test.ts diff --git a/packages/data-lit-todo/src/features/assign/data/assignment/unassign.ts b/packages/data-lit-todo/src/features/assign/data/values/assignment/unassign.ts similarity index 100% rename from packages/data-lit-todo/src/features/assign/data/assignment/unassign.ts rename to packages/data-lit-todo/src/features/assign/data/values/assignment/unassign.ts diff --git a/packages/data-lit-todo/src/features/assign/ecs/actions/action-database.ts b/packages/data-lit-todo/src/features/assign/ecs/actions/action-database.ts new file mode 100644 index 000000000..ec074f667 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/actions/action-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { ComputedDatabase } from "../computed/computed-database.js"; +import * as actions from "./index.js"; + +// The feature's top layer — the plugin an assign element lazily extends the shared +// database with on first connect. +const actionDatabasePlugin = Database.Plugin.create({ + extends: ComputedDatabase.plugin, + actions, +}); + +export type ActionDatabase = Database.Plugin.ToDatabase; + +export namespace ActionDatabase { + export const plugin = actionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-todo/src/features/assign/ecs/actions/add-user.ts b/packages/data-lit-todo/src/features/assign/ecs/actions/add-user.ts new file mode 100644 index 000000000..3e4f1215a --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/actions/add-user.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ComputedDatabase } from "../computed/computed-database.js"; + +export const addUser = (db: ComputedDatabase, { name }: { readonly name: string }) => { + db.transactions.addUser({ name }); +}; diff --git a/packages/data-lit-todo/src/features/assign/ecs/actions/assign-user.ts b/packages/data-lit-todo/src/features/assign/ecs/actions/assign-user.ts new file mode 100644 index 000000000..2f956004a --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/actions/assign-user.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { ComputedDatabase } from "../computed/computed-database.js"; + +export const assignUser = (db: ComputedDatabase, { todo, name }: { readonly todo: Entity; readonly name: string }) => { + db.transactions.assignUser({ todo, name }); +}; diff --git a/packages/data-lit-todo/src/features/assign/ecs/actions/index.ts b/packages/data-lit-todo/src/features/assign/ecs/actions/index.ts new file mode 100644 index 000000000..611ac42fc --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/actions/index.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export * from "./add-user.js"; +export * from "./assign-user.js"; +export * from "./unassign-user.js"; diff --git a/packages/data-lit-todo/src/features/assign/ecs/actions/unassign-user.ts b/packages/data-lit-todo/src/features/assign/ecs/actions/unassign-user.ts new file mode 100644 index 000000000..221c35de7 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/actions/unassign-user.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { ComputedDatabase } from "../computed/computed-database.js"; + +export const unassignUser = (db: ComputedDatabase, { todo, name }: { readonly todo: Entity; readonly name: string }) => { + db.transactions.unassignUser({ todo, name }); +}; diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/assign.test.ts b/packages/data-lit-todo/src/features/assign/ecs/assign.test.ts similarity index 91% rename from packages/data-lit-todo/src/features/assign/services/main-service/assign.test.ts rename to packages/data-lit-todo/src/features/assign/ecs/assign.test.ts index 8f9206896..c043d038d 100644 --- a/packages/data-lit-todo/src/features/assign/services/main-service/assign.test.ts +++ b/packages/data-lit-todo/src/features/assign/ecs/assign.test.ts @@ -6,13 +6,13 @@ // then the two indexes are checked for correct many-to-many navigation. import { describe, it, expect } from "vitest"; import { Database } from "@adobe/data/ecs"; -import type { User } from "../../data/user/user.js"; -import { ActionDatabase } from "../../../main/services/main-service/action-database/action-database.js"; -import { ComputedDatabase } from "./computed-database/computed-database.js"; +import type { User } from "../data/entities/user.js"; +import { ActionDatabase } from "../../main/ecs/actions/action-database.js"; +import { MainService as AssignService } from "./main-service.js"; // main DB + lazy feature extend (the runtime shape a feature element produces). const createDb = () => - Database.create(ActionDatabase.plugin).extend(ComputedDatabase.plugin); + Database.create(ActionDatabase.plugin).extend(AssignService.plugin); const readUsers = (db: ReturnType): readonly User[] => { let value: readonly User[] = []; diff --git a/packages/data-lit-todo/src/features/assign/ecs/computed/computed-database.ts b/packages/data-lit-todo/src/features/assign/ecs/computed/computed-database.ts new file mode 100644 index 000000000..268bf3446 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/computed/computed-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; +import * as computed from "./index.js"; + +const computedDatabasePlugin = Database.Plugin.create({ + extends: TransactionDatabase.plugin, + computed, +}); + +export type ComputedDatabase = Database.Plugin.ToDatabase< + typeof computedDatabasePlugin +>; + +export namespace ComputedDatabase { + export const plugin = computedDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed/index.ts b/packages/data-lit-todo/src/features/assign/ecs/computed/index.ts similarity index 100% rename from packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed/index.ts rename to packages/data-lit-todo/src/features/assign/ecs/computed/index.ts diff --git a/packages/data-lit-todo/src/features/assign/ecs/computed/tasks-by-user.ts b/packages/data-lit-todo/src/features/assign/ecs/computed/tasks-by-user.ts new file mode 100644 index 000000000..bc831b702 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/computed/tasks-by-user.ts @@ -0,0 +1,31 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { cached } from "@adobe/data/cache"; +import { compare } from "@adobe/data/functions"; +import { Observe } from "@adobe/data/observe"; +import type { IndexDatabase } from "../indexes/index-database.js"; + +// Each user with the names of the tasks assigned to them (in display order) — the user→todos +// direction, resolved through the `todosByAssignee` index. `db.derive` reads the +// index, so this re-derives whenever any todo's assignees change. `withCache` +// shares one run across all subscribers. +export const tasksByUser = cached((db: IndexDatabase) => + Observe.withCache( + db.derive( + (read): readonly { readonly user: string; readonly tasks: readonly string[] }[] => { + const result: { user: string; tasks: string[] }[] = []; + for (const uid of read.select(db.archetypes.User.components)) { + const u = read.read(uid); + if (!u || u.name === undefined) continue; + const tasks: { name: string; order: number }[] = []; + for (const tid of read.indexes.todosByAssignee.find({ assignees: u.name })) { + const todo = read.read(tid); + if (todo?.name !== undefined && todo.order !== undefined) tasks.push({ name: todo.name, order: todo.order }); + } + tasks.sort((a, b) => a.order - b.order); + result.push({ user: u.name, tasks: tasks.map((task) => task.name) }); + } + return result.sort((a, b) => compare(a.user, b.user)); + }, + ), + ), +); diff --git a/packages/data-lit-todo/src/features/assign/ecs/computed/users.ts b/packages/data-lit-todo/src/features/assign/ecs/computed/users.ts new file mode 100644 index 000000000..6fec261c7 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/computed/users.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { cached } from "@adobe/data/cache"; +import { compare } from "@adobe/data/functions"; +import { Observe } from "@adobe/data/observe"; +import type { User } from "../../data/entities/user.js"; +import type { IndexDatabase } from "../indexes/index-database.js"; + +// All users, sorted by name. Feeds both the dropdown (pick a user) and the +// users tab (list). Code-point sort — never localeCompare (order determinism). +// `withCache` multicasts one db.derive run to all subscribers (and caches the +// last value for late ones), so N elements observing this share one computation. +export const users = cached((db: IndexDatabase) => + Observe.withCache( + db.derive((read): readonly User[] => { + const result: User[] = []; + for (const id of read.select(db.archetypes.User.components)) { + const u = read.read(id); + if (u && u.name !== undefined) result.push({ user: true, name: u.name }); + } + return result.sort((a, b) => compare(a.name, b.name)); + }), + ), +); diff --git a/packages/data-lit-todo/src/features/assign/ecs/conformance/conformance.test.ts b/packages/data-lit-todo/src/features/assign/ecs/conformance/conformance.test.ts new file mode 100644 index 000000000..ac187d41d --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/conformance/conformance.test.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { implementation } from "./implementation.js"; + +// Replays every spec case against the same-named action / computed. +Conformance.checkFeature(implementation); diff --git a/packages/data-lit-todo/src/features/assign/ecs/conformance/implementation.ts b/packages/data-lit-todo/src/features/assign/ecs/conformance/implementation.ts new file mode 100644 index 000000000..9e32904e0 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/conformance/implementation.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "../../spec/spec.js"; +import { MainService } from "../main-service.js"; +import { ComputedDatabase } from "../computed/computed-database.js"; +import { projection } from "./projection.js"; + +// The assign spec paired with its ECS build. +export const implementation = Conformance.implementation(spec, { + plugin: MainService.plugin, + computedPlugin: ComputedDatabase.plugin, + projection, +}); diff --git a/packages/data-lit-todo/src/features/assign/ecs/conformance/projection.ts b/packages/data-lit-todo/src/features/assign/ecs/conformance/projection.ts new file mode 100644 index 000000000..ed1c676bc --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/conformance/projection.ts @@ -0,0 +1,38 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { State } from "../../spec/state.js"; +import type { User } from "../../data/entities/user.js"; +import type { AssignedTodo } from "../../data/entities/assigned-todo.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// The test-only ecs↔`State` projection. `fromState` clears every user and todo, then +// seeds each (todos with main's implementation-only `todo` tag), returning the +// `spec id → seeded entity` map. `toState` reads them back. +export const projection = { + fromState: (store: CoreDatabase.Store, state: State): ReadonlyMap => { + for (const entity of [...store.select(["user"]), ...store.select(["todo"])]) store.delete(entity); + const seeded = new Map(); + for (const [specId, user] of state.users) { + seeded.set(specId, store.archetypes.User.insert({ user: true, name: user.name })); + } + for (const [specId, todo] of state.todos) { + seeded.set(specId, store.archetypes.AssignedTodo.insert({ todo: true, ...todo })); + } + return seeded; + }, + toState: (store: CoreDatabase.Store): State => ({ + users: new Map( + store.select(store.archetypes.User.components).flatMap((entity) => { + const row = store.read(entity, store.archetypes.User); + return row === null ? [] : [[entity, { user: true, name: row.name }]]; + }), + ), + todos: new Map( + store.select(["todo"]).flatMap((entity) => { + const row = store.read(entity); + if (row?.name === undefined || row.complete === undefined || row.order === undefined) return []; + return [[entity, { name: row.name, complete: row.complete, order: row.order, assignees: row.assignees ?? [] }]]; + }), + ), + }), +}; diff --git a/packages/data-lit-todo/src/features/assign/ecs/core/archetypes.ts b/packages/data-lit-todo/src/features/assign/ecs/core/archetypes.ts new file mode 100644 index 000000000..a3d3c8c47 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/core/archetypes.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { components } from "../../data/components/index.js"; +import { User } from "../../data/entities/user.js"; +import { AssignedTodo } from "../../data/entities/assigned-todo.js"; + +// `AssignedTodo` packs a todo that carries assignees (with main's tag), +// so conformance can seed one directly; at runtime a todo migrates into it on first assign. +export const archetypes = Database.archetypes(components, { + User, + AssignedTodo: ["todo", ...AssignedTodo], +}); diff --git a/packages/data-lit-todo/src/features/assign/ecs/core/core-database.ts b/packages/data-lit-todo/src/features/assign/ecs/core/core-database.ts new file mode 100644 index 000000000..c9b38bf50 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/core/core-database.ts @@ -0,0 +1,27 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import type { Store } from "@adobe/data/ecs"; +import { CoreDatabase as MainCoreDatabase } from "../../../main/ecs/core/core-database.js"; +import { components } from "../../data/components/index.js"; +import { archetypes } from "./archetypes.js"; + +// The assign feature's schema, on top of main's (this feature depends on main). +// The app root `imports` it so the store knows the columns up front; the feature's +// behavior loads lazily when its elements connect. +const coreDatabasePlugin = Database.Plugin.create({ + extends: MainCoreDatabase.plugin, + components, + archetypes, +}); + +export type CoreDatabase = Database.Plugin.ToDatabase; + +type CoreComponents = Store.Components< + Database.Plugin.ToStore +>; + +export namespace CoreDatabase { + export const plugin = coreDatabasePlugin; + export type Store = Database.Plugin.ToStore; + export type Index = Database.Index; +} diff --git a/packages/data-lit-todo/src/features/assign/ecs/indexes/index-database.ts b/packages/data-lit-todo/src/features/assign/ecs/indexes/index-database.ts new file mode 100644 index 000000000..3af2fc81d --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/indexes/index-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { CoreDatabase } from "../core/core-database.js"; +import * as indexes from "./index.js"; + +const indexDatabasePlugin = Database.Plugin.create({ + extends: CoreDatabase.plugin, + indexes, +}); + +export type IndexDatabase = Database.Plugin.ToDatabase< + typeof indexDatabasePlugin +>; + +export namespace IndexDatabase { + export const plugin = indexDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/index-database/indexes/index.ts b/packages/data-lit-todo/src/features/assign/ecs/indexes/index.ts similarity index 100% rename from packages/data-lit-todo/src/features/assign/services/main-service/index-database/indexes/index.ts rename to packages/data-lit-todo/src/features/assign/ecs/indexes/index.ts diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/index-database/indexes/todos-by-assignee.ts b/packages/data-lit-todo/src/features/assign/ecs/indexes/todos-by-assignee.ts similarity index 100% rename from packages/data-lit-todo/src/features/assign/services/main-service/index-database/indexes/todos-by-assignee.ts rename to packages/data-lit-todo/src/features/assign/ecs/indexes/todos-by-assignee.ts diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/index-database/indexes/users-by-name.ts b/packages/data-lit-todo/src/features/assign/ecs/indexes/users-by-name.ts similarity index 100% rename from packages/data-lit-todo/src/features/assign/services/main-service/index-database/indexes/users-by-name.ts rename to packages/data-lit-todo/src/features/assign/ecs/indexes/users-by-name.ts diff --git a/packages/data-lit-todo/src/features/assign/ecs/main-service.ts b/packages/data-lit-todo/src/features/assign/ecs/main-service.ts new file mode 100644 index 000000000..bbacf9c54 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/main-service.ts @@ -0,0 +1,2 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { ActionDatabase as MainService } from "./actions/action-database.js"; diff --git a/packages/data-lit-todo/src/features/assign/ecs/transactions/add-user.ts b/packages/data-lit-todo/src/features/assign/ecs/transactions/add-user.ts new file mode 100644 index 000000000..410b85307 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/transactions/add-user.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { IndexDatabase } from "../indexes/index-database.js"; + +// Create a user entity. Uses the unique `usersByName` index to keep names +// distinct — a duplicate name is silently ignored (idempotent under replay). +export const addUser = ( + t: IndexDatabase.Store, + { name }: { readonly name: string }, +) => { + const trimmed = name.trim(); + // Compare against null, not truthiness: a valid user entity may have id 0. + if (trimmed === "" || t.indexes.usersByName.get({ name: trimmed }) !== null) return; + t.archetypes.User.insert({ user: true, name: trimmed }); +}; diff --git a/packages/data-lit-todo/src/features/assign/ecs/transactions/assign-user.ts b/packages/data-lit-todo/src/features/assign/ecs/transactions/assign-user.ts new file mode 100644 index 000000000..2a1e7ffcf --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/transactions/assign-user.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import { Assignment } from "../../data/values/assignment/assignment.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Assign a user (by name) to a todo. Applies the pure `Assignment.assign` +// transform to the todo's denormalized `assignees` list; the store's indexes +// update eagerly, so `todosByAssignee` reflects the new link immediately. +export const assignUser = ( + t: CoreDatabase.Store, + { todo, name }: { readonly todo: Entity; readonly name: string }, +) => { + const row = t.read(todo); + if (row?.todo !== true) return; // only todos carry assignees + t.update(todo, { assignees: Assignment.assign(row.assignees ?? [], name) }); +}; diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transactions/index.ts b/packages/data-lit-todo/src/features/assign/ecs/transactions/index.ts similarity index 100% rename from packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transactions/index.ts rename to packages/data-lit-todo/src/features/assign/ecs/transactions/index.ts diff --git a/packages/data-lit-todo/src/features/assign/ecs/transactions/transaction-database.ts b/packages/data-lit-todo/src/features/assign/ecs/transactions/transaction-database.ts new file mode 100644 index 000000000..b81bb52ac --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/transactions/transaction-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { IndexDatabase } from "../indexes/index-database.js"; +import * as transactions from "./index.js"; + +const transactionDatabasePlugin = Database.Plugin.create({ + extends: IndexDatabase.plugin, + transactions, +}); + +export type TransactionDatabase = Database.Plugin.ToDatabase< + typeof transactionDatabasePlugin +>; + +export namespace TransactionDatabase { + export const plugin = transactionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-todo/src/features/assign/ecs/transactions/unassign-user.ts b/packages/data-lit-todo/src/features/assign/ecs/transactions/unassign-user.ts new file mode 100644 index 000000000..d372efbab --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ecs/transactions/unassign-user.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import { Assignment } from "../../data/values/assignment/assignment.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Remove a user (by name) from a todo's assignee list. +export const unassignUser = ( + t: CoreDatabase.Store, + { todo, name }: { readonly todo: Entity; readonly name: string }, +) => { + const row = t.read(todo); + if (row?.todo !== true) return; // only todos carry assignees + t.update(todo, { assignees: Assignment.unassign(row.assignees ?? [], name) }); +}; diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed-database.ts b/packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed-database.ts deleted file mode 100644 index a3200bac1..000000000 --- a/packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed-database.ts +++ /dev/null @@ -1,21 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { TransactionDatabase } from "../transaction-database/transaction-database.js"; -import * as computed from "./computed/index.js"; - -// The feature's composition root. This is the plugin a feature UI element -// lazily extends the shared (main) database with on first connect — adding the -// User archetype, both indexes, the transactions, and the computed at once. -const computedDatabasePlugin = Database.Plugin.create({ - extends: TransactionDatabase.plugin, - computed, -}); - -export type ComputedDatabase = Database.Plugin.ToDatabase< - typeof computedDatabasePlugin ->; - -export namespace ComputedDatabase { - export const plugin = computedDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed/tasks-by-user.ts b/packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed/tasks-by-user.ts deleted file mode 100644 index d5410456a..000000000 --- a/packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed/tasks-by-user.ts +++ /dev/null @@ -1,30 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { cached } from "@adobe/data/cache"; -import { compare } from "@adobe/data/functions"; -import { Observe } from "@adobe/data/observe"; -import type { IndexDatabase } from "../../index-database/index-database.js"; - -// Each user with the names of the tasks assigned to them — the user→todos -// direction, resolved through the `todosByAssignee` index. `db.derive` reads the -// index, so this re-derives whenever any todo's assignees change. `withCache` -// shares one run across all subscribers. -export const tasksByUser = cached((db: IndexDatabase) => - Observe.withCache( - db.derive( - (read): readonly { readonly user: string; readonly tasks: readonly string[] }[] => { - const result: { user: string; tasks: string[] }[] = []; - for (const uid of read.select(db.archetypes.User.components)) { - const u = read.read(uid); - if (!u || u.name === undefined) continue; - const tasks: string[] = []; - for (const tid of read.indexes.todosByAssignee.find({ assignees: u.name })) { - const todo = read.read(tid); - if (todo?.name !== undefined) tasks.push(todo.name); - } - result.push({ user: u.name, tasks }); - } - return result.sort((a, b) => compare(a.user, b.user)); - }, - ), - ), -); diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed/users.ts b/packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed/users.ts deleted file mode 100644 index e1ac35635..000000000 --- a/packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed/users.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { cached } from "@adobe/data/cache"; -import { compare } from "@adobe/data/functions"; -import { Observe } from "@adobe/data/observe"; -import type { User } from "../../../../data/user/user.js"; -import type { IndexDatabase } from "../../index-database/index-database.js"; - -// All users, sorted by name. Feeds both the dropdown (pick a user) and the -// users tab (list). Code-point sort — never localeCompare (order determinism). -// `withCache` multicasts one db.derive run to all subscribers (and caches the -// last value for late ones), so N elements observing this share one computation. -export const users = cached((db: IndexDatabase) => - Observe.withCache( - db.derive((read): readonly User[] => { - const result: User[] = []; - for (const id of read.select(db.archetypes.User.components)) { - const u = read.read(id); - if (u && u.name !== undefined) result.push({ id, name: u.name }); - } - return result.sort((a, b) => compare(a.name, b.name)); - }), - ), -); diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/core-database/archetypes.ts b/packages/data-lit-todo/src/features/assign/services/main-service/core-database/archetypes.ts deleted file mode 100644 index bb43f8608..000000000 --- a/packages/data-lit-todo/src/features/assign/services/main-service/core-database/archetypes.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { components } from "./components.js"; - -export const archetypes = Database.archetypes(components, { - User: ["user", "name"], -}); diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/core-database/components.ts b/packages/data-lit-todo/src/features/assign/services/main-service/core-database/components.ts deleted file mode 100644 index a087846bb..000000000 --- a/packages/data-lit-todo/src/features/assign/services/main-service/core-database/components.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { True } from "@adobe/data/schema"; -import { Name } from "../../../../main/data/name/name.js"; -import { Assignees } from "../../../../main/data/assignees/assignees.js"; - -export const components = Database.components({ - document: { - user: True.schema, // tag: presence marks the entity as a user - // Shared with main by identity (same data/ schemas) so combinePlugins dedupes. - name: Name.schema, - assignees: Assignees.schema, - }, -}); diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/core-database/core-database.ts b/packages/data-lit-todo/src/features/assign/services/main-service/core-database/core-database.ts deleted file mode 100644 index 8fbe15f83..000000000 --- a/packages/data-lit-todo/src/features/assign/services/main-service/core-database/core-database.ts +++ /dev/null @@ -1,25 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import type { Store } from "@adobe/data/ecs"; -import { components } from "./components.js"; -import { archetypes } from "./archetypes.js"; - -// The assign feature's schema. `main` imports THIS plugin so the store knows -// the feature's columns up front (persistence, data coexistence) without gaining -// its behavior. The feature's indexes, transactions, and UI load lazily on top. -const coreDatabasePlugin = Database.Plugin.create({ - components, - archetypes, -}); - -export type CoreDatabase = Database.Plugin.ToDatabase; - -type CoreComponents = Store.Components< - Database.Plugin.ToStore ->; - -export namespace CoreDatabase { - export const plugin = coreDatabasePlugin; - export type Store = Database.Plugin.ToStore; - export type Index = Database.Index; -} diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/index-database/index-database.ts b/packages/data-lit-todo/src/features/assign/services/main-service/index-database/index-database.ts deleted file mode 100644 index d23b43f05..000000000 --- a/packages/data-lit-todo/src/features/assign/services/main-service/index-database/index-database.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { CoreDatabase } from "../core-database/core-database.js"; -import * as indexes from "./indexes/index.js"; - -const indexDatabasePlugin = Database.Plugin.create({ - extends: CoreDatabase.plugin, - indexes, -}); - -export type IndexDatabase = Database.Plugin.ToDatabase< - typeof indexDatabasePlugin ->; - -export namespace IndexDatabase { - export const plugin = indexDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transaction-database.ts b/packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transaction-database.ts deleted file mode 100644 index ce4267576..000000000 --- a/packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transaction-database.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { IndexDatabase } from "../index-database/index-database.js"; -import * as transactions from "./transactions/index.js"; - -const transactionDatabasePlugin = Database.Plugin.create({ - extends: IndexDatabase.plugin, - transactions, -}); - -export type TransactionDatabase = Database.Plugin.ToDatabase< - typeof transactionDatabasePlugin ->; - -export namespace TransactionDatabase { - export const plugin = transactionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transactions/add-user.ts b/packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transactions/add-user.ts deleted file mode 100644 index d2cc32d2b..000000000 --- a/packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transactions/add-user.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { IndexDatabase } from "../../index-database/index-database.js"; - -// Create a user entity. Uses the unique `usersByName` index to keep names -// distinct — a duplicate name is silently ignored (idempotent under replay). -export const addUser = ( - t: IndexDatabase.Store, - { name }: { readonly name: string }, -) => { - const trimmed = name.trim(); - // Compare against null, not truthiness: a valid user entity may have id 0. - if (trimmed === "" || t.indexes.usersByName.get({ name: trimmed }) !== null) return; - t.archetypes.User.insert({ user: true, name: trimmed }); -}; diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transactions/assign-user.ts b/packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transactions/assign-user.ts deleted file mode 100644 index dccc8eb31..000000000 --- a/packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transactions/assign-user.ts +++ /dev/null @@ -1,15 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import { Assignment } from "../../../../data/assignment/assignment.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// Assign a user (by name) to a todo. Applies the pure `Assignment.assign` -// transform to the todo's denormalized `assignees` list; the store's indexes -// update eagerly, so `todosByAssignee` reflects the new link immediately. -export const assignUser = ( - t: CoreDatabase.Store, - { todo, name }: { readonly todo: Entity; readonly name: string }, -) => { - const current = t.read(todo)?.assignees ?? []; - t.update(todo, { assignees: Assignment.assign(current, name) }); -}; diff --git a/packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transactions/unassign-user.ts b/packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transactions/unassign-user.ts deleted file mode 100644 index 803f16111..000000000 --- a/packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transactions/unassign-user.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import { Assignment } from "../../../../data/assignment/assignment.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// Remove a user (by name) from a todo's assignee list. -export const unassignUser = ( - t: CoreDatabase.Store, - { todo, name }: { readonly todo: Entity; readonly name: string }, -) => { - const current = t.read(todo)?.assignees ?? []; - t.update(todo, { assignees: Assignment.unassign(current, name) }); -}; diff --git a/packages/data-lit-todo/src/features/assign/spec/add-user.cases.ts b/packages/data-lit-todo/src/features/assign/spec/add-user.cases.ts new file mode 100644 index 000000000..a7d7532ef --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/add-user.cases.ts @@ -0,0 +1,33 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { addUser } from "./add-user.js"; + +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "adds a user by trimmed name", + before: {}, + args: { name: " ada " }, + after: { users: new Map([[1, { user: true, name: "ada" }]]) }, + }, + { + name: "ignores a duplicate name", + before: { users: new Map([[1, { user: true, name: "ada" }]]) }, + args: { name: "ada" }, + after: { users: new Map([[1, { user: true, name: "ada" }]]) }, + }, + { + name: "ignores a blank name", + before: {}, + args: { name: " " }, + after: { users: new Map() }, + }, + { + name: "can share a name with a todo", + before: { todos: new Map([[10, { name: "ada", complete: false, order: 0, assignees: [] }]]) }, + args: { name: "ada" }, + after: { users: new Map([[11, { user: true, name: "ada" }]]) }, + }, + ], +}; diff --git a/packages/data-lit-todo/src/features/assign/spec/add-user.ts b/packages/data-lit-todo/src/features/assign/spec/add-user.ts new file mode 100644 index 000000000..daaf4affa --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/add-user.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; + +// Add a user by (trimmed) name. A blank or already-taken name is a no-op, so user +// names stay unique. Mints the next id across both entity maps. +export const addUser = ( + state: Pick, + { name }: { readonly name: string }, +): Pick => { + const trimmed = name.trim(); + const taken = [...state.users.values()].some((user) => user.name === trimmed); + if (trimmed === "" || taken) return { users: state.users }; + const id = Math.max(0, ...state.users.keys(), ...state.todos.keys()) + 1; + return { users: new Map(state.users).set(id, { user: true, name: trimmed }) }; +}; diff --git a/packages/data-lit-todo/src/features/assign/spec/assign-user.cases.ts b/packages/data-lit-todo/src/features/assign/spec/assign-user.cases.ts new file mode 100644 index 000000000..14da08474 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/assign-user.cases.ts @@ -0,0 +1,36 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "@adobe/data/ecs"; +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { assignUser } from "./assign-user.js"; + +// `todo` is an entity reference (marked in the `args` schema). +export const cases: Conformance.SpecCases = { + args: { type: "object", properties: { todo: Entity.schema } }, + cases: [ + { + name: "assigns a user to a todo", + before: { todos: new Map([[10, { name: "ship", complete: false, order: 0, assignees: [] }]]) }, + args: { todo: 10, name: "ada" }, + after: { todos: new Map([[10, { name: "ship", complete: false, order: 0, assignees: ["ada"] }]]) }, + }, + { + name: "is idempotent for an already-assigned user", + before: { todos: new Map([[10, { name: "ship", complete: false, order: 0, assignees: ["ada"] }]]) }, + args: { todo: 10, name: "ada" }, + after: { todos: new Map([[10, { name: "ship", complete: false, order: 0, assignees: ["ada"] }]]) }, + }, + { + name: "is a no-op for an id that names no todo", + before: { + users: new Map([[1, { user: true, name: "ada" }]]), + todos: new Map([[10, { name: "ship", complete: false, order: 0, assignees: [] }]]), + }, + args: { todo: 1, name: "ada" }, + after: { + users: new Map([[1, { user: true, name: "ada" }]]), + todos: new Map([[10, { name: "ship", complete: false, order: 0, assignees: [] }]]), + }, + }, + ], +}; diff --git a/packages/data-lit-todo/src/features/assign/spec/assign-user.ts b/packages/data-lit-todo/src/features/assign/spec/assign-user.ts new file mode 100644 index 000000000..86cc8ab91 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/assign-user.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Assignment } from "../data/values/assignment/assignment.js"; +import type { State } from "./state.js"; + +// Assign a user (by name) to a todo. A no-op for an id that names no todo. +export const assignUser = ( + state: Pick, + { todo, name }: { readonly todo: number; readonly name: string }, +): Pick => { + const current = state.todos.get(todo); + if (current === undefined) return { todos: state.todos }; + return { todos: new Map(state.todos).set(todo, { ...current, assignees: Assignment.assign(current.assignees, name) }) }; +}; diff --git a/packages/data-lit-todo/src/features/assign/spec/create.ts b/packages/data-lit-todo/src/features/assign/spec/create.ts new file mode 100644 index 000000000..92095fbdc --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/create.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; + +// No users and no todos. +export const create = (): State => ({ users: new Map(), todos: new Map() }); diff --git a/packages/data-lit-todo/src/features/assign/spec/public.ts b/packages/data-lit-todo/src/features/assign/spec/public.ts new file mode 100644 index 000000000..69dfc9c89 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/public.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { create } from "./create.js"; +export { samples } from "./samples.js"; diff --git a/packages/data-lit-todo/src/features/assign/spec/samples.ts b/packages/data-lit-todo/src/features/assign/spec/samples.ts new file mode 100644 index 000000000..cba0d8e89 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/samples.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; + +// Representative full states for the projection round-trip. +export const samples: readonly State[] = [ + { + users: new Map([ + [1, { user: true, name: "ada" }], + [2, { user: true, name: "linus" }], + ]), + todos: new Map([ + [10, { name: "ship", complete: false, order: 0, assignees: ["ada", "linus"] }], + [11, { name: "review", complete: true, order: 1, assignees: [] }], + ]), + }, + { users: new Map(), todos: new Map() }, +]; diff --git a/packages/data-lit-todo/src/features/assign/spec/spec.test.ts b/packages/data-lit-todo/src/features/assign/spec/spec.test.ts new file mode 100644 index 000000000..bdab1adf7 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/spec.test.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "./spec.js"; + +// Runs every case against the pure spec functions — no ECS involved. +Conformance.checkSpec(spec); diff --git a/packages/data-lit-todo/src/features/assign/spec/spec.ts b/packages/data-lit-todo/src/features/assign/spec/spec.ts new file mode 100644 index 000000000..e744eed14 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/spec.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import { components } from "../data/components/index.js"; + +import { cases as addUser } from "./add-user.cases.js"; +import { cases as assignUser } from "./assign-user.cases.js"; +import { cases as unassignUser } from "./unassign-user.cases.js"; +import { cases as users } from "./users.cases.js"; +import { cases as tasksByUser } from "./tasks-by-user.cases.js"; + +// The assign feature's spec. `schemas` lets the runner find entity references. +export const spec = Conformance.spec({ + state: State, + fns: transforms, + schemas: components, + cases: { addUser, assignUser, unassignUser, users, tasksByUser }, +}); diff --git a/packages/data-lit-todo/src/features/assign/spec/state.ts b/packages/data-lit-todo/src/features/assign/spec/state.ts new file mode 100644 index 000000000..0930eea13 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/state.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert } from "@adobe/data/types"; +import type { Conformance } from "@adobe/data-testing"; +import type { User } from "../data/entities/user.js"; +import type { AssignedTodo } from "../data/entities/assigned-todo.js"; +import type { components } from "../data/components/index.js"; + +// The assign feature's state: users, and todos with their assignee names. Both maps +// share one id space (they are entities of one store), so cases use distinct ids +// across them. +export type State = { + readonly users: ReadonlyMap; + readonly todos: ReadonlyMap; +}; + +// Entity value keys name components (this feature has no resources). +type _Pin = Assert>; + +export * as State from "./public.js"; diff --git a/packages/data-lit-todo/src/features/assign/spec/tasks-by-user.cases.ts b/packages/data-lit-todo/src/features/assign/spec/tasks-by-user.cases.ts new file mode 100644 index 000000000..e34eee865 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/tasks-by-user.cases.ts @@ -0,0 +1,31 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { tasksByUser } from "./tasks-by-user.js"; + +export const cases: Conformance.SpecDerivations = { + cases: [ + { + name: "lists each user's todos in display order", + input: { + users: new Map([ + [1, { user: true, name: "linus" }], + [2, { user: true, name: "ada" }], + ]), + todos: new Map([ + [10, { name: "review", complete: false, order: 1, assignees: ["ada"] }], + [11, { name: "ship", complete: false, order: 0, assignees: ["ada", "linus"] }], + [12, { name: "idle", complete: false, order: 2, assignees: [] }], + ]), + }, + value: [ + { user: "ada", tasks: ["ship", "review"] }, + { user: "linus", tasks: ["ship"] }, + ], + }, + { + name: "a user with no todos has no tasks", + input: { users: new Map([[1, { user: true, name: "ada" }]]), todos: new Map() }, + value: [{ user: "ada", tasks: [] }], + }, + ], +}; diff --git a/packages/data-lit-todo/src/features/assign/spec/tasks-by-user.ts b/packages/data-lit-todo/src/features/assign/spec/tasks-by-user.ts new file mode 100644 index 000000000..81180a47c --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/tasks-by-user.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { compare } from "@adobe/data/functions"; +import type { State } from "./state.js"; + +// Each user (sorted by name) with the names of the todos assigned to them, in +// display order. +export const tasksByUser = ( + state: State, +): readonly { readonly user: string; readonly tasks: readonly string[] }[] => + [...state.users.values()] + .map((user) => user.name) + .sort(compare) + .map((user) => ({ + user, + tasks: [...state.todos.values()] + .filter((todo) => todo.assignees.includes(user)) + .sort((a, b) => a.order - b.order) + .map((todo) => todo.name), + })); diff --git a/packages/data-lit-todo/src/features/assign/spec/transforms.ts b/packages/data-lit-todo/src/features/assign/spec/transforms.ts new file mode 100644 index 000000000..0298a2fe4 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/transforms.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Every spec action and derivation of this feature — the conformance surface. +export { addUser } from "./add-user.js"; +export { assignUser } from "./assign-user.js"; +export { unassignUser } from "./unassign-user.js"; +export { users } from "./users.js"; +export { tasksByUser } from "./tasks-by-user.js"; diff --git a/packages/data-lit-todo/src/features/assign/spec/unassign-user.cases.ts b/packages/data-lit-todo/src/features/assign/spec/unassign-user.cases.ts new file mode 100644 index 000000000..40c0a31bd --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/unassign-user.cases.ts @@ -0,0 +1,36 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "@adobe/data/ecs"; +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { unassignUser } from "./unassign-user.js"; + +// `todo` is an entity reference (marked in the `args` schema). +export const cases: Conformance.SpecCases = { + args: { type: "object", properties: { todo: Entity.schema } }, + cases: [ + { + name: "removes a user from a todo", + before: { todos: new Map([[10, { name: "ship", complete: false, order: 0, assignees: ["ada", "linus"] }]]) }, + args: { todo: 10, name: "ada" }, + after: { todos: new Map([[10, { name: "ship", complete: false, order: 0, assignees: ["linus"] }]]) }, + }, + { + name: "is a no-op for a user not assigned", + before: { todos: new Map([[10, { name: "ship", complete: false, order: 0, assignees: ["linus"] }]]) }, + args: { todo: 10, name: "ada" }, + after: { todos: new Map([[10, { name: "ship", complete: false, order: 0, assignees: ["linus"] }]]) }, + }, + { + name: "is a no-op for an id that names no todo", + before: { + users: new Map([[1, { user: true, name: "ada" }]]), + todos: new Map([[10, { name: "ship", complete: false, order: 0, assignees: ["ada", "linus"] }]]), + }, + args: { todo: 1, name: "ada" }, + after: { + users: new Map([[1, { user: true, name: "ada" }]]), + todos: new Map([[10, { name: "ship", complete: false, order: 0, assignees: ["ada", "linus"] }]]), + }, + }, + ], +}; diff --git a/packages/data-lit-todo/src/features/assign/spec/unassign-user.ts b/packages/data-lit-todo/src/features/assign/spec/unassign-user.ts new file mode 100644 index 000000000..d5aea0088 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/unassign-user.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Assignment } from "../data/values/assignment/assignment.js"; +import type { State } from "./state.js"; + +// Remove a user (by name) from a todo. A no-op for an id that names no todo. +export const unassignUser = ( + state: Pick, + { todo, name }: { readonly todo: number; readonly name: string }, +): Pick => { + const current = state.todos.get(todo); + if (current === undefined) return { todos: state.todos }; + return { todos: new Map(state.todos).set(todo, { ...current, assignees: Assignment.unassign(current.assignees, name) }) }; +}; diff --git a/packages/data-lit-todo/src/features/assign/spec/users.cases.ts b/packages/data-lit-todo/src/features/assign/spec/users.cases.ts new file mode 100644 index 000000000..7faefb602 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/users.cases.ts @@ -0,0 +1,22 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { users } from "./users.js"; + +export const cases: Conformance.SpecDerivations = { + cases: [ + { name: "is empty with no users", input: { users: new Map() }, value: [] }, + { + name: "lists users sorted by name", + input: { + users: new Map([ + [1, { user: true, name: "linus" }], + [2, { user: true, name: "ada" }], + ]), + }, + value: [ + { user: true, name: "ada" }, + { user: true, name: "linus" }, + ], + }, + ], +}; diff --git a/packages/data-lit-todo/src/features/assign/spec/users.ts b/packages/data-lit-todo/src/features/assign/spec/users.ts new file mode 100644 index 000000000..777a202a8 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/spec/users.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { compare } from "@adobe/data/functions"; +import type { User } from "../data/entities/user.js"; +import type { State } from "./state.js"; + +// All users, sorted by name (code-point order). +export const users = (state: Pick): readonly User[] => + [...state.users.values()].sort((a, b) => compare(a.name, b.name)); diff --git a/packages/data-lit-todo/src/features/assign/ui/assign-element.ts b/packages/data-lit-todo/src/features/assign/ui/assign-element.ts index 0f2f55bfe..87cf81c1e 100644 --- a/packages/data-lit-todo/src/features/assign/ui/assign-element.ts +++ b/packages/data-lit-todo/src/features/assign/ui/assign-element.ts @@ -1,15 +1,15 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { DatabaseElement } from "@adobe/data-lit"; -import { ComputedDatabase } from "../services/main-service/computed-database/computed-database.js"; +import { MainService } from "../ecs/main-service.js"; /** * Base class for the assign feature's elements. Typed on the feature's own * plugin — on first connect, `DatabaseElement` walks up to the ancestor (main) * database and `extend`s it with this plugin, lazily adding the User archetype, - * both indexes, and the transactions to the shared live database. + * both indexes, the transactions, computeds and actions to the shared live database. */ -export class AssignElement extends DatabaseElement { +export class AssignElement extends DatabaseElement { get plugin() { - return ComputedDatabase.plugin; + return MainService.plugin; } } diff --git a/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips-element.ts b/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips-element.ts new file mode 100644 index 000000000..bb0cb9e4e --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips-element.ts @@ -0,0 +1,31 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { customElement, property } from "lit/decorators.js"; +import type { Entity } from "@adobe/data/ecs"; +import { useObservableValues } from "@adobe/data-lit"; +import { AssignElement } from "../assign-element.js"; +import { styles } from "./assignee-chips.css.js"; +import * as presentation from "./assignee-chips-presentation.js"; + +const tagName = "assignee-chips"; + +declare global { + interface HTMLElementTagNameMap { + [tagName]: AssigneeChipsElement; + } +} + +@customElement(tagName) +export class AssigneeChipsElement extends AssignElement { + static styles = styles; + + @property({ type: Number }) + declare todo: Entity; + + render() { + const values = useObservableValues( + () => ({ todo: this.service.observe.entity(this.todo) }), + [this.todo], + ); + return presentation.render({ assignees: values?.todo?.assignees ?? [] }); + } +} diff --git a/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips-presentation.test.ts b/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips-presentation.test.ts new file mode 100644 index 000000000..ee3dae651 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips-presentation.test.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { Template } from "@adobe/data-lit"; +import { render } from "./assignee-chips-presentation.js"; + +describe("assignee-chips-presentation", () => { + it("renders a chip per assignee", () => { + const t = Template.from(render({ assignees: ["ada", "linus"] })); + expect(t.text).toContain("ada"); + expect(t.text).toContain("linus"); + }); + + it("renders no chips for no assignees", () => { + expect(Template.from(render({ assignees: [] })).has("assignee-chip")).toBe(false); + }); +}); diff --git a/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips-presentation.ts b/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips-presentation.ts new file mode 100644 index 000000000..2af31ecc3 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips-presentation.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { html } from "lit"; + +type RenderArgs = { + readonly assignees: readonly string[]; +}; + +export function render(args: RenderArgs) { + return html`${args.assignees.map((a) => html`${a}`)}`; +} diff --git a/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips.css.ts b/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips.css.ts new file mode 100644 index 000000000..cfb57bd34 --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips.css.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { css } from "lit"; + +export const styles = css` + :host { + display: inline-flex; + gap: var(--spectrum-spacing-75); + flex-shrink: 0; + } + .assignee-chip { + font-size: var(--spectrum-font-size-50); + color: var(--spectrum-gray-800); + background: var(--spectrum-gray-200); + border-radius: 999px; + padding: 2px var(--spectrum-spacing-100); + white-space: nowrap; + } +`; diff --git a/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips.ts b/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips.ts new file mode 100644 index 000000000..b316478ca --- /dev/null +++ b/packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { html, type TemplateResult } from "lit"; +import type { Entity } from "@adobe/data/ecs"; + +// Lazy wrapper — main's todo-row renders a todo's assignees through this, so main +// never reads assign's `assignees` component itself. +export const AssigneeChips = (args: { todo: Entity }): TemplateResult => { + void import("./assignee-chips-element.js"); + return html``; +}; diff --git a/packages/data-lit-todo/src/features/assign/ui/assignee-dropdown/assignee-dropdown-element.ts b/packages/data-lit-todo/src/features/assign/ui/assignee-dropdown/assignee-dropdown-element.ts index 356f3a93e..3604f5570 100644 --- a/packages/data-lit-todo/src/features/assign/ui/assignee-dropdown/assignee-dropdown-element.ts +++ b/packages/data-lit-todo/src/features/assign/ui/assignee-dropdown/assignee-dropdown-element.ts @@ -38,8 +38,8 @@ export class AssigneeDropdownElement extends AssignElement { })), toggleAssignee: (name: string) => assigned.has(name) - ? this.service.transactions.unassignUser({ todo: this.todo, name }) - : this.service.transactions.assignUser({ todo: this.todo, name }), + ? this.service.actions.unassignUser({ todo: this.todo, name }) + : this.service.actions.assignUser({ todo: this.todo, name }), }); } } diff --git a/packages/data-lit-todo/src/features/assign/ui/users-tab/users-tab-element.ts b/packages/data-lit-todo/src/features/assign/ui/users-tab/users-tab-element.ts index 808066937..67fcbd130 100644 --- a/packages/data-lit-todo/src/features/assign/ui/users-tab/users-tab-element.ts +++ b/packages/data-lit-todo/src/features/assign/ui/users-tab/users-tab-element.ts @@ -27,7 +27,7 @@ export class UsersTabElement extends AssignElement { const addUser = () => { const name = draftName.trim(); if (name === "") return; - this.service.transactions.addUser({ name }); + this.service.actions.addUser({ name }); setDraftName(""); }; diff --git a/packages/data-lit-todo/src/features/main/data/assignees/assignees.ts b/packages/data-lit-todo/src/features/main/data/assignees/assignees.ts deleted file mode 100644 index 213c5f9df..000000000 --- a/packages/data-lit-todo/src/features/main/data/assignees/assignees.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type Assignees = Schema.ToType; -export * as Assignees from "./public.js"; diff --git a/packages/data-lit-todo/src/features/main/data/assignees/schema.ts b/packages/data-lit-todo/src/features/main/data/assignees/schema.ts deleted file mode 100644 index cb65bfee2..000000000 --- a/packages/data-lit-todo/src/features/main/data/assignees/schema.ts +++ /dev/null @@ -1,5 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -// A todo's denormalized list of assignee names (the join key with users). -export const schema = { type: "array", items: { type: "string" }, default: [] } as const satisfies Schema; diff --git a/packages/data-lit-todo/src/features/main/data/components/complete.ts b/packages/data-lit-todo/src/features/main/data/components/complete.ts new file mode 100644 index 000000000..c28ffe297 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/components/complete.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Boolean } from "@adobe/data/schema"; + +export const complete = Boolean.schema; diff --git a/packages/data-lit-todo/src/features/main/data/components/drag-position.ts b/packages/data-lit-todo/src/features/main/data/components/drag-position.ts new file mode 100644 index 000000000..4fa776f39 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/components/drag-position.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { Schema } from "@adobe/data/schema"; +import { DragPosition } from "../values/drag-position/drag-position.js"; + +// Live drag offset: session scope (not saved, not shared). Implementation-only. +export const dragPosition = { ...DragPosition.schema, ...Scope.session } satisfies Schema; diff --git a/packages/data-lit-todo/src/features/main/data/components/index.ts b/packages/data-lit-todo/src/features/main/data/components/index.ts new file mode 100644 index 000000000..f25918b55 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/components/index.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { todo } from "./todo.js"; +import { name } from "./name.js"; +import { complete } from "./complete.js"; +import { order } from "./order.js"; +import { dragPosition } from "./drag-position.js"; + +export const components = { todo, name, complete, order, dragPosition }; diff --git a/packages/data-lit-todo/src/features/main/data/components/name.ts b/packages/data-lit-todo/src/features/main/data/components/name.ts new file mode 100644 index 000000000..8f23a3fc2 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/components/name.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Name } from "../values/name/name.js"; + +export const name = Name.schema; diff --git a/packages/data-lit-todo/src/features/main/data/components/order.ts b/packages/data-lit-todo/src/features/main/data/components/order.ts new file mode 100644 index 000000000..1c6b86f32 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/components/order.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { F32 } from "@adobe/data/schema"; + +// Sort key for display order. +export const order = F32.schema; diff --git a/packages/data-lit-todo/src/features/main/data/components/todo.ts b/packages/data-lit-todo/src/features/main/data/components/todo.ts new file mode 100644 index 000000000..0aa8c91f6 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/components/todo.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { True } from "@adobe/data/schema"; + +// Tag: marks an entity as a todo. Implementation-only — packed into the ECS archetype, +// not part of the spec `Todo` entity. +export const todo = True.schema; diff --git a/packages/data-lit-todo/src/features/main/data/drag-position/drag-position.ts b/packages/data-lit-todo/src/features/main/data/drag-position/drag-position.ts deleted file mode 100644 index 3a4023007..000000000 --- a/packages/data-lit-todo/src/features/main/data/drag-position/drag-position.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type DragPosition = Schema.ToType; -export * as DragPosition from "./public.js"; diff --git a/packages/data-lit-todo/src/features/main/data/drag-position/schema.ts b/packages/data-lit-todo/src/features/main/data/drag-position/schema.ts deleted file mode 100644 index 146183915..000000000 --- a/packages/data-lit-todo/src/features/main/data/drag-position/schema.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { F32, Schema } from "@adobe/data/schema"; - -// Transient vertical pixel offset while a todo is being dragged; `null` when -// the todo is not currently being dragged. -export const schema = Schema.Nullable(F32.schema); diff --git a/packages/data-lit-todo/src/features/main/data/entities/index.ts b/packages/data-lit-todo/src/features/main/data/entities/index.ts new file mode 100644 index 000000000..1c31471be --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/entities/index.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Todo } from "./todo.js"; + +export const entities = { Todo }; diff --git a/packages/data-lit-todo/src/features/main/data/entities/todo.ts b/packages/data-lit-todo/src/features/main/data/entities/todo.ts new file mode 100644 index 000000000..178b3bfa1 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/entities/todo.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Database } from "@adobe/data/ecs"; +import type { components } from "../components/index.js"; + +// A todo: plain readonly data with no id (identity is the entity key). +export const Todo = ["name", "complete", "order"] as const satisfies Database.EntityComponents; +export type Todo = Database.EntityType; diff --git a/packages/data-lit-todo/src/features/main/data/name/name.ts b/packages/data-lit-todo/src/features/main/data/name/name.ts deleted file mode 100644 index 0c091b14e..000000000 --- a/packages/data-lit-todo/src/features/main/data/name/name.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; -import { schema } from "./schema.js"; - -export type Name = Schema.ToType; -export * as Name from "./public.js"; diff --git a/packages/data-lit-todo/src/features/main/data/name/schema.ts b/packages/data-lit-todo/src/features/main/data/name/schema.ts deleted file mode 100644 index d385085a8..000000000 --- a/packages/data-lit-todo/src/features/main/data/name/schema.ts +++ /dev/null @@ -1,4 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Schema } from "@adobe/data/schema"; - -export const schema = { type: "string" } as const satisfies Schema; diff --git a/packages/data-lit-todo/src/features/main/data/resources/display-completed.ts b/packages/data-lit-todo/src/features/main/data/resources/display-completed.ts new file mode 100644 index 000000000..bf6a842fa --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/resources/display-completed.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Boolean } from "@adobe/data/schema"; + +// Per-device view toggle: settings scope (durable, not shared). +export const displayCompleted = { ...Boolean.schema, ...Scope.settings } satisfies ResourceSchema; diff --git a/packages/data-lit-todo/src/features/main/data/resources/index.ts b/packages/data-lit-todo/src/features/main/data/resources/index.ts new file mode 100644 index 000000000..46f88420e --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/resources/index.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { displayCompleted } from "./display-completed.js"; +import { selectedTodo } from "./selected-todo.js"; + +export const resources = { displayCompleted, selectedTodo }; diff --git a/packages/data-lit-todo/src/features/main/data/resources/selected-todo.ts b/packages/data-lit-todo/src/features/main/data/resources/selected-todo.ts new file mode 100644 index 000000000..8b41a03ac --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/resources/selected-todo.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity, Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; + +// The selected todo: a reference to one entity (`Entity.none` = no selection). The +// `Entity.schema` mark makes conformance compare it up to the id-bijection. Session scope. +export const selectedTodo = { ...Entity.schema, default: Entity.none, ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts b/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts deleted file mode 100644 index a02f40b0a..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts +++ /dev/null @@ -1,21 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Services } from "../../services/services.js"; -import type { State } from "./state.js"; -import { appendTodo } from "./append-todo.js"; -/** Adds numbered placeholder todos for demos and performance testing. Reads and - * writes the entities — an `{ entities }` patch. */ -export const createBulkTodos = ( - state: Pick, - { - count, - analytics, - }: { readonly count: number } & Pick, -): Pick => { - analytics.bulkTodosCreated({ count }); - const total = Math.max(0, Math.floor(count)); - let next: Pick = state; - for (let index = 0; index < total; index++) { - next = appendTodo(next, { name: `Todo ${state.entities.size + index}` }); - } - return next; -}; diff --git a/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts b/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts deleted file mode 100644 index 357a7583c..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts +++ /dev/null @@ -1,25 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Services } from "../../services/services.js"; -import type { State } from "./state.js"; -import { appendTodo } from "./append-todo.js"; -/** - * Async, service-injected transition: brackets the slow name generation with - * analytics timing, then appends the todo via the shared {@link appendTodo} (so - * it does NOT fire `todoCreated` — it logs its own `randomTodoAdded`). Reads and - * writes the entities — an `{ entities }` patch. Awaiting an async port makes it - * `Promise>`, but it stays deterministic given its - * injected services — which is how it is unit-tested. - */ -export const createRandomTodo = async ( - state: Pick, - { - nameGenerator, - analytics, - }: Pick, -): Promise> => { - const timing = await analytics.randomTodoRequested(); - const name = await nameGenerator.generateName(); - const next = appendTodo(state, { name }); - analytics.randomTodoAdded({ timing, name }); - return next; -}; diff --git a/packages/data-lit-todo/src/features/main/data/state/create-todo.ts b/packages/data-lit-todo/src/features/main/data/state/create-todo.ts deleted file mode 100644 index 4c7c698ff..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/create-todo.ts +++ /dev/null @@ -1,21 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Services } from "../../services/services.js"; -import type { State } from "./state.js"; -import { appendTodo } from "./append-todo.js"; - -// Reads the entities, writes the entities — an `{ entities }` patch — by -// delegating to the shared `appendTodo`; also logs `todoCreated`. -export const createTodo = ( - state: Pick, - { - name, - complete, - analytics, - }: { - readonly name: string; - readonly complete?: boolean; - } & Pick, -): Pick => { - analytics.todoCreated({ name }); - return appendTodo(state, { name, complete }); -}; diff --git a/packages/data-lit-todo/src/features/main/data/state/create.ts b/packages/data-lit-todo/src/features/main/data/state/create.ts deleted file mode 100644 index b9a4b82cc..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/create.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Entity } from "@adobe/data/ecs"; -import type { State } from "./state.js"; -import type { Todo } from "../todo/todo.js"; - -// The default application state: no todos, completed items hidden, nothing selected. -// It is the baseline the conformance cases author their `before`/`input` as deltas -// over (passed to the runners as `initial`), and the state a fresh app starts in. -export const create = (): State => ({ - displayCompleted: false, - entities: new Map(), - selectedTodo: Entity.none, -}); diff --git a/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.ts b/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.ts deleted file mode 100644 index 6e20df485..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Services } from "../../services/services.js"; -import type { State } from "./state.js"; -import type { Todo } from "../todo/todo.js"; - -// Reads the entities, writes the entities — an `{ entities }` patch — clearing -// them; `displayCompleted` is untouched. Logs `allTodosCleared`. -export const deleteAllTodos = ( - state: Pick, - { analytics }: Pick, -): Pick => { - analytics.allTodosCleared(); - return { entities: new Map() }; -}; diff --git a/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts b/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts deleted file mode 100644 index a13b175e6..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Services } from "../../services/services.js"; -import type { State } from "./state.js"; - -// Reads the entities, writes the entities — an `{ entities }` patch — dropping the -// addressed id; also logs `todoDeleted`. Surviving todos keep their `order` (the -// ecs `deleteTodo` does not renumber either). -export const deleteTodo = ( - state: Pick, - { - id, - analytics, - }: { readonly id: number } & Pick, -): Pick => { - analytics.todoDeleted(); - const entities = new Map(state.entities); - entities.delete(id); - return { entities }; -}; diff --git a/packages/data-lit-todo/src/features/main/data/state/public.ts b/packages/data-lit-todo/src/features/main/data/state/public.ts deleted file mode 100644 index 2b941cbe9..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/public.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { create } from "./create.js"; -export { samples } from "./samples.js"; -export { createTodo } from "./create-todo.js"; -export { createRandomTodo } from "./create-random-todo.js"; -export { createBulkTodos } from "./create-bulk-todos.js"; -export { deleteTodo } from "./delete-todo.js"; -export { deleteAllTodos } from "./delete-all-todos.js"; -export { toggleComplete } from "./toggle-complete.js"; -export { toggleDisplayCompleted } from "./toggle-display-completed.js"; -export { reorderTodo } from "./reorder-todo.js"; -export { visibleTodos } from "./visible-todos.js"; diff --git a/packages/data-lit-todo/src/features/main/data/state/spec.test.ts b/packages/data-lit-todo/src/features/main/data/state/spec.test.ts deleted file mode 100644 index ca20b296c..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/spec.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "./spec.js"; - -// The single pure-spec test for every transform AND derivation in this folder. It -// runs the manifest's inert cases against the pure functions, synthesizing each -// injected service's recording double from the `services` templates + the case's -// `responses`, and asserting `after` (up to an id-bijection) and declared `effects`. -Conformance.checkSpec(spec); diff --git a/packages/data-lit-todo/src/features/main/data/state/spec.ts b/packages/data-lit-todo/src/features/main/data/state/spec.ts deleted file mode 100644 index 502078c70..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import * as transforms from "./transforms.js"; -import { MainService } from "../../services/main-service/main-service.js"; -import { ComputedDatabase } from "../../services/main-service/computed-database/computed-database.js"; -import { projection } from "../../services/main-service/conformance/projection.js"; -import { createFake as analytics } from "../../services/analytics-service/analytics.fake.js"; -import { createFake as nameGenerator } from "../../services/name-generator-service/name-generator.fake.js"; - -import { cases as createTodo } from "./create-todo.cases.js"; -import { cases as createRandomTodo } from "./create-random-todo.cases.js"; -import { cases as createBulkTodos } from "./create-bulk-todos.cases.js"; -import { cases as deleteTodo } from "./delete-todo.cases.js"; -import { cases as deleteAllTodos } from "./delete-all-todos.cases.js"; -import { cases as toggleComplete } from "./toggle-complete.cases.js"; -import { cases as toggleDisplayCompleted } from "./toggle-display-completed.cases.js"; -import { cases as reorderTodo } from "./reorder-todo.cases.js"; -import { cases as selectTodo } from "./select-todo.cases.js"; -import { cases as visibleTodos } from "./visible-todos.cases.js"; - -// The feature's conformance manifest — the single object `spec.test.ts` (pure) and -// `conformance.test.ts` (ecs) both import. It wires each conformed fn to its inert cases module explicitly: `cases` names one module per conformed fn, -// checked at COMPILE TIME against `transforms` (a fn without cases, or cases without -// a fn, won't type). `services` are the shape-only recording-double templates the -// runner synthesizes returns into from each case's `responses`; `visibleTodos` emits -// entity ids, so it is named in `hydrate` to project each through `toData`. -// -// This is a test-tier module (excluded from the runtime program) — the ONE place the -// feature touches `@adobe/data-testing`. No transform or `*.cases.ts` file does. -export const spec = Conformance.feature({ - state: State, - fns: transforms, - plugin: MainService.plugin, - computedPlugin: ComputedDatabase.plugin, - projection, - hydrate: ["visibleTodos"], - services: { analytics, nameGenerator }, - cases: { - createTodo, - createRandomTodo, - createBulkTodos, - deleteTodo, - deleteAllTodos, - toggleComplete, - toggleDisplayCompleted, - reorderTodo, - selectTodo, - visibleTodos, - }, -}); diff --git a/packages/data-lit-todo/src/features/main/data/state/state.ts b/packages/data-lit-todo/src/features/main/data/state/state.ts deleted file mode 100644 index b1c00c9d3..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/state.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { Todo } from "../todo/todo.js"; - -// The full persistent application state as one immutable object — the -// specification the ECS implementation is verified against. `entities` holds every -// todo keyed by a numeric id (identity is the key, never a value field); each -// value carries its own `order` for display sorting. `displayCompleted` is a -// singleton toggle. `selectedTodo` is a **reference singleton** — it points at one -// entity by id (`Entity.none` = no selection). Reusing the ECS `Entity` type here -// is deliberate and allowed (it is a plain branded `number`): the same -// `Entity.schema` that types this reference is what conformance walks to compare it -// to the ECS up to an id-bijection. -export type State = { - readonly displayCompleted: boolean; - readonly entities: ReadonlyMap; - readonly selectedTodo: Entity; -}; -export * as State from "./public.js"; diff --git a/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts b/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts deleted file mode 100644 index 07c5d9e90..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Services } from "../../services/services.js"; -import type { State } from "./state.js"; - -// Reads the entities, writes the entities — an `{ entities }` patch — flipping the -// addressed todo's `complete`; also logs `todoToggled`. -export const toggleComplete = ( - state: Pick, - { - id, - analytics, - }: { readonly id: number } & Pick, -): Pick => { - analytics.todoToggled(); - const target = state.entities.get(id); - if (target === undefined) return { entities: state.entities }; - return { - entities: new Map(state.entities).set(id, { - ...target, - complete: !target.complete, - }), - }; -}; diff --git a/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts b/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts deleted file mode 100644 index a670687c5..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Services } from "../../services/services.js"; -import type { State } from "./state.js"; - -// Reads `displayCompleted`, writes `displayCompleted` — a `{ displayCompleted }` -// patch — flipping the flag; also logs `displayCompletedToggled`. -export const toggleDisplayCompleted = ( - state: Pick, - { analytics }: Pick, -): Pick => { - analytics.displayCompletedToggled(); - return { displayCompleted: !state.displayCompleted }; -}; diff --git a/packages/data-lit-todo/src/features/main/data/state/transforms.ts b/packages/data-lit-todo/src/features/main/data/state/transforms.ts deleted file mode 100644 index 4bd1570c7..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/transforms.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -// The conformed functions of this feature — every pure transform and derivation the -// spec verifies, and nothing else (helpers like `appendTodo`, `create`, `samples` -// are deliberately absent). Its keys ARE the conformance surface: the manifest's -// coverage guard (`Conformance.feature`) requires exactly one `*.cases.ts` module -// per key here, so adding a transform without cases — or cases without a transform — -// fails to compile. This is a per-feature, local barrel (not a cross-feature -// registry), authored where the transforms live. -export { createTodo } from "./create-todo.js"; -export { createRandomTodo } from "./create-random-todo.js"; -export { createBulkTodos } from "./create-bulk-todos.js"; -export { deleteTodo } from "./delete-todo.js"; -export { deleteAllTodos } from "./delete-all-todos.js"; -export { toggleComplete } from "./toggle-complete.js"; -export { toggleDisplayCompleted } from "./toggle-display-completed.js"; -export { reorderTodo } from "./reorder-todo.js"; -export { selectTodo } from "./select-todo.js"; -export { visibleTodos } from "./visible-todos.js"; diff --git a/packages/data-lit-todo/src/features/main/data/state/visible-todos.ts b/packages/data-lit-todo/src/features/main/data/state/visible-todos.ts deleted file mode 100644 index 7bf0c0029..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/visible-todos.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; -import type { Todo } from "../todo/todo.js"; -// The todos the user should see, in display order (ascending `order`): all of them -// when `displayCompleted`, otherwise only the incomplete ones. Yields id-less -// values — identity is the `entities` key, so the visible list compares by content. -export const visibleTodos = ( - state: Pick, -): readonly Todo[] => - [...state.entities.values()] - .filter((todo) => state.displayCompleted || !todo.complete) - .sort((a, b) => a.order - b.order); diff --git a/packages/data-lit-todo/src/features/main/data/todo/is.ts b/packages/data-lit-todo/src/features/main/data/todo/is.ts deleted file mode 100644 index d6dd04905..000000000 --- a/packages/data-lit-todo/src/features/main/data/todo/is.ts +++ /dev/null @@ -1,15 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Todo } from "./todo.js"; - -// A structural type guard, re-exported through `public.ts` so it reads `Todo.is`. -// Matches purely on structure (name/complete/order), mirroring how the ECS matches -// on components — there is no `id` to check because identity is the map key. -export const is = (v: unknown): v is Todo => - typeof v === "object" && - v !== null && - "name" in v && - typeof v.name === "string" && - "complete" in v && - typeof v.complete === "boolean" && - "order" in v && - typeof v.order === "number"; diff --git a/packages/data-lit-todo/src/features/main/data/todo/public.ts b/packages/data-lit-todo/src/features/main/data/todo/public.ts deleted file mode 100644 index f0c37fea2..000000000 --- a/packages/data-lit-todo/src/features/main/data/todo/public.ts +++ /dev/null @@ -1,2 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { is } from "./is.js"; diff --git a/packages/data-lit-todo/src/features/main/data/todo/todo.ts b/packages/data-lit-todo/src/features/main/data/todo/todo.ts deleted file mode 100644 index 4a7a2781e..000000000 --- a/packages/data-lit-todo/src/features/main/data/todo/todo.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -// A single todo entity value in the logical application State — plain readonly -// data with NO id: identity is the key of `State.entities`, never a field. The -// `order` component carries the display order (the ECS materialises it the same -// way), so entity values compare by content across the spec↔ecs boundary. -export type Todo = { - readonly name: string; - readonly complete: boolean; - readonly order: number; -}; -export * as Todo from "./public.js"; diff --git a/packages/data-lit-todo/src/features/main/data/values/drag-position/drag-position.ts b/packages/data-lit-todo/src/features/main/data/values/drag-position/drag-position.ts new file mode 100644 index 000000000..1c4828b3c --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/values/drag-position/drag-position.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type DragPosition = number | null; +export * as DragPosition from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/score/public.ts b/packages/data-lit-todo/src/features/main/data/values/drag-position/public.ts similarity index 100% rename from packages/data-lit-tictactoe/src/features/main/data/score/public.ts rename to packages/data-lit-todo/src/features/main/data/values/drag-position/public.ts diff --git a/packages/data-lit-todo/src/features/main/data/values/drag-position/schema.ts b/packages/data-lit-todo/src/features/main/data/values/drag-position/schema.ts new file mode 100644 index 000000000..7df08b179 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/values/drag-position/schema.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { DragPosition } from "./drag-position.js"; +import { F32, Schema } from "@adobe/data/schema"; + +// Transient vertical pixel offset while a todo is being dragged; `null` when +// the todo is not currently being dragged. +export const schema = Schema.Nullable(F32.schema); + +type _Pin = Assert, DragPosition>>; diff --git a/packages/data-lit-todo/src/features/main/data/values/name/name.ts b/packages/data-lit-todo/src/features/main/data/values/name/name.ts new file mode 100644 index 000000000..63069a0fe --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/values/name/name.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type Name = string; +export * as Name from "./public.js"; diff --git a/packages/data-lit-todo/src/features/main/data/assignees/public.ts b/packages/data-lit-todo/src/features/main/data/values/name/public.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/assignees/public.ts rename to packages/data-lit-todo/src/features/main/data/values/name/public.ts diff --git a/packages/data-lit-todo/src/features/main/data/values/name/schema.ts b/packages/data-lit-todo/src/features/main/data/values/name/schema.ts new file mode 100644 index 000000000..b1eb5fd9d --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/values/name/schema.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { Name } from "./name.js"; +import { Schema } from "@adobe/data/schema"; + +export const schema = { type: "string" } as const satisfies Schema; + +type _Pin = Assert, Name>>; diff --git a/packages/data-lit-todo/src/features/main/ecs/actions/action-database.ts b/packages/data-lit-todo/src/features/main/ecs/actions/action-database.ts new file mode 100644 index 000000000..7a3100e82 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/actions/action-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { ComputedDatabase } from "../computed/computed-database.js"; +import * as actions from "./index.js"; + +const actionDatabasePlugin = Database.Plugin.create({ + extends: ComputedDatabase.plugin, + actions, +}); + +export type ActionDatabase = Database.Plugin.ToDatabase< + typeof actionDatabasePlugin +>; + +export namespace ActionDatabase { + export const plugin = actionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-todo/src/features/main/ecs/actions/create-bulk-todos.ts b/packages/data-lit-todo/src/features/main/ecs/actions/create-bulk-todos.ts new file mode 100644 index 000000000..266735677 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/actions/create-bulk-todos.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const createBulkTodos = ( + db: ServiceDatabase, + input: { readonly count: number }, +) => { + db.services.analytics.bulkTodosCreated({ count: input.count }); + db.transactions.createBulkTodos(input); +}; diff --git a/packages/data-lit-todo/src/features/main/ecs/actions/create-random-todo.ts b/packages/data-lit-todo/src/features/main/ecs/actions/create-random-todo.ts new file mode 100644 index 000000000..7fc46c9f9 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/actions/create-random-todo.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +/** + * Efficient ecs counterpart to the pure `State.createRandomTodo` spec (which + * injects the same `nameGenerator` port): here the port is reached through the + * `services` facet and the append is an O(1) `createTodo` transaction rather + * than a whole-`State` rebuild. This action additionally brackets the slow + * name-generation call with analytics start/end events so the telemetry + * captures how long the outbound call took. The UI never awaits this — state + * flows back via observables. + */ +export const createRandomTodo = async (db: ServiceDatabase) => { + // The service owns timing: it mints an opaque token here and computes the + // elapsed time when we hand it back — this action never sees the clock. + const timing = await db.services.analytics.randomTodoRequested(); + const name = await db.services.nameGenerator.generateName(); + db.transactions.createTodo({ name }); + db.services.analytics.randomTodoAdded({ timing, name }); +}; diff --git a/packages/data-lit-todo/src/features/main/ecs/actions/create-todo.ts b/packages/data-lit-todo/src/features/main/ecs/actions/create-todo.ts new file mode 100644 index 000000000..c98ec6347 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/actions/create-todo.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const createTodo = ( + db: ServiceDatabase, + input: { readonly name: string; readonly complete?: boolean }, +) => { + db.services.analytics.todoCreated({ name: input.name }); + db.transactions.createTodo(input); +}; diff --git a/packages/data-lit-todo/src/features/main/ecs/actions/delete-all-todos.ts b/packages/data-lit-todo/src/features/main/ecs/actions/delete-all-todos.ts new file mode 100644 index 000000000..a5739ad1b --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/actions/delete-all-todos.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const deleteAllTodos = (db: ServiceDatabase) => { + db.services.analytics.allTodosCleared(); + db.transactions.deleteAllTodos(); +}; diff --git a/packages/data-lit-todo/src/features/main/ecs/actions/delete-todo.ts b/packages/data-lit-todo/src/features/main/ecs/actions/delete-todo.ts new file mode 100644 index 000000000..b7063a707 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/actions/delete-todo.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { ServiceDatabase } from "../services/service-database.js"; + +export const deleteTodo = (db: ServiceDatabase, { id }: { id: Entity }) => { + db.services.analytics.todoDeleted(); + db.transactions.deleteTodo({ id }); +}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/drag-todo.test.ts b/packages/data-lit-todo/src/features/main/ecs/actions/drag-todo.test.ts similarity index 96% rename from packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/drag-todo.test.ts rename to packages/data-lit-todo/src/features/main/ecs/actions/drag-todo.test.ts index 3283cf36d..5196ee785 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/drag-todo.test.ts +++ b/packages/data-lit-todo/src/features/main/ecs/actions/drag-todo.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { Database } from "@adobe/data/ecs"; import type { DragState } from "@adobe/data-lit"; -import { ServiceDatabase } from "../../service-database/service-database.js"; +import { ServiceDatabase } from "../services/service-database.js"; import { dragTodo } from "./drag-todo.js"; // `dragTodo` is the "drag UI op" — conformance intentionally skips it (see diff --git a/packages/data-lit-todo/src/features/main/ecs/actions/drag-todo.ts b/packages/data-lit-todo/src/features/main/ecs/actions/drag-todo.ts new file mode 100644 index 000000000..c85d5534c --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/actions/drag-todo.ts @@ -0,0 +1,41 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { DragState } from "@adobe/data-lit"; +import type { ServiceDatabase } from "../services/service-database.js"; + +// `useDragGenerator` hands us the raw pointer stream; this action maps each +// yielded `DragState` frame to the `dragTodo` transaction's args and drives +// the transaction directly with that mapped async generator, so every frame — +// including the final drop — commits as one coalesced, undoable step. A +// cancelled gesture yields a `dragPosition: null` frame with no `finalIndex`, +// which abandons the drag without reordering. +// +// `rowHeight` is a UI layout measure passed in by the caller (never imported — +// `services/` must not depend on `ui/`), used to convert the final pixel offset +// into a list-index delta. +export const dragTodo = ( + db: ServiceDatabase, + args: { + readonly entity: Entity; + readonly index: number; + readonly rowHeight: number; + readonly drag: AsyncGenerator; + }, +) => { + const { entity, index, rowHeight, drag } = args; + return db.transactions.dragTodo(async function* () { + for await (const state of drag) { + if (state.type === "move") { + yield { entity, dragPosition: state.delta[1] }; + } else if (state.type === "end") { + yield { + entity, + dragPosition: state.delta[1], + finalIndex: index + Math.round(state.delta[1] / rowHeight), + }; + } else if (state.type === "cancel") { + yield { entity, dragPosition: null }; + } + } + }); +}; diff --git a/packages/data-lit-todo/src/features/main/ecs/actions/index.ts b/packages/data-lit-todo/src/features/main/ecs/actions/index.ts new file mode 100644 index 000000000..678930fc5 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/actions/index.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export * from "./create-todo.js"; +export * from "./create-bulk-todos.js"; +export * from "./create-random-todo.js"; +export * from "./toggle-complete.js"; +export * from "./delete-todo.js"; +export * from "./delete-all-todos.js"; +export * from "./toggle-display-completed.js"; +export * from "./reorder-todo.js"; +export * from "./drag-todo.js"; +export * from "./select-todo.js"; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/reorder-todo.ts b/packages/data-lit-todo/src/features/main/ecs/actions/reorder-todo.ts similarity index 89% rename from packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/reorder-todo.ts rename to packages/data-lit-todo/src/features/main/ecs/actions/reorder-todo.ts index 19fcb8782..e9b2b162f 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/reorder-todo.ts +++ b/packages/data-lit-todo/src/features/main/ecs/actions/reorder-todo.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Entity } from "@adobe/data/ecs"; -import type { ServiceDatabase } from "../../service-database/service-database.js"; +import type { ServiceDatabase } from "../services/service-database.js"; // Move a todo to `toIndex` — the programmatic counterpart of the drag UI (which // drives the same `dragTodo` transaction through the richer `dragTodo` action, diff --git a/packages/data-lit-todo/src/features/main/ecs/actions/select-todo.ts b/packages/data-lit-todo/src/features/main/ecs/actions/select-todo.ts new file mode 100644 index 000000000..3da115ce8 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/actions/select-todo.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { ServiceDatabase } from "../services/service-database.js"; + +// Select a todo (a no-op for an id that names no todo). +export const selectTodo = (db: ServiceDatabase, { id }: { id: Entity }) => { + db.transactions.selectTodo({ id }); +}; diff --git a/packages/data-lit-todo/src/features/main/ecs/actions/toggle-complete.ts b/packages/data-lit-todo/src/features/main/ecs/actions/toggle-complete.ts new file mode 100644 index 000000000..da7514da5 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/actions/toggle-complete.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { ServiceDatabase } from "../services/service-database.js"; + +export const toggleComplete = (db: ServiceDatabase, { id }: { id: Entity }) => { + db.services.analytics.todoToggled(); + db.transactions.toggleComplete({ id }); +}; diff --git a/packages/data-lit-todo/src/features/main/ecs/actions/toggle-display-completed.ts b/packages/data-lit-todo/src/features/main/ecs/actions/toggle-display-completed.ts new file mode 100644 index 000000000..42ec37566 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/actions/toggle-display-completed.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const toggleDisplayCompleted = (db: ServiceDatabase) => { + db.services.analytics.displayCompletedToggled(); + db.transactions.toggleDisplayCompleted(); +}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/all-todos.ts b/packages/data-lit-todo/src/features/main/ecs/computed/all-todos.ts similarity index 75% rename from packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/all-todos.ts rename to packages/data-lit-todo/src/features/main/ecs/computed/all-todos.ts index 5244baebf..486a3ee08 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/all-todos.ts +++ b/packages/data-lit-todo/src/features/main/ecs/computed/all-todos.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { cached } from "@adobe/data/cache"; -import type { IndexDatabase } from "../../index-database/index-database.js"; +import type { IndexDatabase } from "../indexes/index-database.js"; export const allTodos = cached((db: IndexDatabase) => db.observe.select(db.archetypes.Todo.components, { order: { order: true } }), diff --git a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/complete-todos.ts b/packages/data-lit-todo/src/features/main/ecs/computed/complete-todos.ts similarity index 78% rename from packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/complete-todos.ts rename to packages/data-lit-todo/src/features/main/ecs/computed/complete-todos.ts index d06555aab..754e93260 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/complete-todos.ts +++ b/packages/data-lit-todo/src/features/main/ecs/computed/complete-todos.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { cached } from "@adobe/data/cache"; -import type { IndexDatabase } from "../../index-database/index-database.js"; +import type { IndexDatabase } from "../indexes/index-database.js"; export const completeTodos = cached((db: IndexDatabase) => db.observe.select(db.archetypes.Todo.components, { diff --git a/packages/data-lit-todo/src/features/main/ecs/computed/computed-database.ts b/packages/data-lit-todo/src/features/main/ecs/computed/computed-database.ts new file mode 100644 index 000000000..1668fa07f --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/computed/computed-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { ServiceDatabase } from "../services/service-database.js"; +import * as computed from "./index.js"; + +const computedDatabasePlugin = Database.Plugin.create({ + extends: ServiceDatabase.plugin, + computed, +}); + +export type ComputedDatabase = Database.Plugin.ToDatabase< + typeof computedDatabasePlugin +>; + +export namespace ComputedDatabase { + export const plugin = computedDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/incomplete-todos.ts b/packages/data-lit-todo/src/features/main/ecs/computed/incomplete-todos.ts similarity index 78% rename from packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/incomplete-todos.ts rename to packages/data-lit-todo/src/features/main/ecs/computed/incomplete-todos.ts index 38284d43f..6a4d79aed 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/incomplete-todos.ts +++ b/packages/data-lit-todo/src/features/main/ecs/computed/incomplete-todos.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { cached } from "@adobe/data/cache"; -import type { IndexDatabase } from "../../index-database/index-database.js"; +import type { IndexDatabase } from "../indexes/index-database.js"; export const incompleteTodos = cached((db: IndexDatabase) => db.observe.select(db.archetypes.Todo.components, { diff --git a/packages/data-lit-todo/src/features/main/ecs/computed/index.ts b/packages/data-lit-todo/src/features/main/ecs/computed/index.ts new file mode 100644 index 000000000..41dbe9d68 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/computed/index.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export * from "./all-todos.js"; +export * from "./complete-todos.js"; +export * from "./incomplete-todos.js"; +export * from "./visible-todos.js"; diff --git a/packages/data-lit-todo/src/features/main/ecs/computed/visible-todos.ts b/packages/data-lit-todo/src/features/main/ecs/computed/visible-todos.ts new file mode 100644 index 000000000..bd384d019 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/computed/visible-todos.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { cached } from "@adobe/data/cache"; +import { Observe } from "@adobe/data/observe"; +import type { IndexDatabase } from "../indexes/index-database.js"; +import { allTodos } from "./all-todos.js"; +import { incompleteTodos } from "./incomplete-todos.js"; + +// Optimized, id-based equivalent of `State.visibleTodos` (verified by +// conformance test): the incomplete/all lists are already index-maintained, +// so visibility is a choice between two pre-sorted id lists. +export const visibleTodos = cached((db: IndexDatabase) => + Observe.withFilter( + Observe.fromProperties({ + displayCompleted: db.observe.resources.displayCompleted, + allTodos: allTodos(db), + incompleteTodos: incompleteTodos(db), + }), + ({ displayCompleted, allTodos, incompleteTodos }) => + displayCompleted ? allTodos : incompleteTodos, + ), +); diff --git a/packages/data-lit-todo/src/features/main/ecs/conformance/conformance.test.ts b/packages/data-lit-todo/src/features/main/ecs/conformance/conformance.test.ts new file mode 100644 index 000000000..8eb084a01 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/conformance/conformance.test.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { implementation } from "./implementation.js"; + +// Replays every spec case against the same-named action / computed, and round-trips +// `State.samples` through the projection. +Conformance.checkFeature(implementation); diff --git a/packages/data-lit-todo/src/features/main/ecs/conformance/implementation.ts b/packages/data-lit-todo/src/features/main/ecs/conformance/implementation.ts new file mode 100644 index 000000000..ee30ae635 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/conformance/implementation.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "../../spec/spec.js"; +import { MainService } from "../main-service.js"; +import { ComputedDatabase } from "../computed/computed-database.js"; +import { projection } from "./projection.js"; + +// The spec paired with its ECS build: actions from `MainService`, computeds from the +// `ComputedDatabase` layer, and the projection between the store and `State`. +// `visibleTodos` emits entity ids, so it is hydrated through `toData`. +export const implementation = Conformance.implementation(spec, { + plugin: MainService.plugin, + computedPlugin: ComputedDatabase.plugin, + projection, + hydrate: ["visibleTodos"], +}); diff --git a/packages/data-lit-todo/src/features/main/ecs/conformance/projection.ts b/packages/data-lit-todo/src/features/main/ecs/conformance/projection.ts new file mode 100644 index 000000000..468e6c189 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/conformance/projection.ts @@ -0,0 +1,70 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "@adobe/data/ecs"; +import type { State } from "../../spec/state.js"; +import type { Todo } from "../../data/entities/todo.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Read one entity back into its id-less `data/` value — the per-entity projection +// `toState` is built on, and the single place the ecs↔data mapping for a todo +// lives. Reused by the computed conformance to hydrate id-based computed outputs +// (e.g. `visibleTodos` returns entity ids) into the value shape a derivation +// yields, so those computeds need no bespoke projection. Identity is the map key, +// so no `id` is projected. Test-only. +const toData = (store: CoreDatabase.Store, entity: Entity): Todo => { + const row = store.read(entity, store.archetypes.Todo); + if (row === null) + throw new Error("conformance projection: expected a todo entity"); + return { name: row.name, complete: row.complete, order: row.order }; +}; + +// The test-only ecs↔`State` projection, carried on the manifest and used by `Conformance.checkFeature`. +// `fromState` seeds a store to a `State` (clearing every todo, setting the +// `displayCompleted` resource, then inserting each entity with its own `order`; +// the implementation-only `todo` tag is seeded here). Clearing iterates tail→head so each delete is from the tail +// (no hole-fill shift). The ecs assigns entity ids from its own id-space, unrelated +// to the spec's domain id; `fromState` returns the `spec id → seeded entity` map so +// the runners resolve id-addressed operations generically. `toState` reads it back +// (todos keyed by the assigned entity, each through its full archetype so the row +// shape never aliases; only the spec fields are projected); `toData` reads one +// entity. +export const projection = { + fromState: ( + store: CoreDatabase.Store, + state: State, + ): ReadonlyMap => { + for (const arch of store.queryArchetypes( + store.archetypes.Todo.components, + )) { + for (let row = arch.rowCount - 1; row >= 0; row--) { + store.delete(arch.columns.id.get(row)); + } + } + store.resources.displayCompleted = state.displayCompleted; + const seeded = new Map( + [...state.entities].map(([specId, todo]) => [ + specId, + store.archetypes.Todo.insert({ + todo: true, + name: todo.name, + complete: todo.complete, + order: todo.order, + }), + ]), + ); + // Resolve the `selectedTodo` REFERENCE (a spec-id) to the entity just seeded for + // it — the ecs mints its own ids, so the resource must hold the ecs id, not the + // spec-id. An unresolved reference (or none) collapses to `Entity.none`. + store.resources.selectedTodo = seeded.get(state.selectedTodo) ?? Entity.none; + return seeded; + }, + toState: (store: CoreDatabase.Store): State => ({ + displayCompleted: store.resources.displayCompleted, + entities: new Map( + store + .select(store.archetypes.Todo.components, { order: { order: true } }) + .map((entity) => [entity, toData(store, entity)]), + ), + selectedTodo: store.resources.selectedTodo, + }), + toData, +}; diff --git a/packages/data-lit-todo/src/features/main/ecs/core/archetypes.ts b/packages/data-lit-todo/src/features/main/ecs/core/archetypes.ts new file mode 100644 index 000000000..2a3045822 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/core/archetypes.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { components } from "../../data/components/index.js"; +import { Todo } from "../../data/entities/todo.js"; + +// Archetype packing: the spec `Todo` entity plus the implementation-only `todo` tag. +// The session-scoped `dragPosition` is deliberately NOT packed in: it is stripped on +// load (no default), which would drop every reloaded todo out of this archetype. The +// drag transaction adds it on demand. +export const archetypes = Database.archetypes(components, { + Todo: ["todo", ...Todo], +}); diff --git a/packages/data-lit-todo/src/features/main/ecs/core/core-database.ts b/packages/data-lit-todo/src/features/main/ecs/core/core-database.ts new file mode 100644 index 000000000..8103a653d --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/core/core-database.ts @@ -0,0 +1,31 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import type { Store } from "@adobe/data/ecs"; +import { components } from "../../data/components/index.js"; +import { resources } from "../../data/resources/index.js"; +import { archetypes } from "./archetypes.js"; + +// The core schema: the feature's data/ components and resources, packed into archetypes. +const coreDatabasePlugin = Database.Plugin.create({ + components, + resources, + archetypes, +}); + +export type CoreDatabase = Database.Plugin.ToDatabase; + +type CoreComponents = Store.Components< + Database.Plugin.ToStore +>; + +export namespace CoreDatabase { + export const plugin = coreDatabasePlugin; + export type Store = Database.Plugin.ToStore; + /** Index-declaration type bound to this database's components. */ + export type Index = Database.Index; + /** A component name, and a component-key tuple (an archetype's shape / a `count`/`select` + * query-key list). Derived from `components` — NOT `CoreComponents` (plugin-derived, so it + * would cycle when used to `satisfies`-check the archetypes it is derived from). */ + export type Component = Extract; + export type Archetype = readonly Component[]; +} diff --git a/packages/data-lit-todo/src/features/main/ecs/indexes/by-complete.ts b/packages/data-lit-todo/src/features/main/ecs/indexes/by-complete.ts new file mode 100644 index 000000000..dc4fd74b9 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/indexes/by-complete.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; +export const byComplete = { key: "complete" } as const satisfies CoreDatabase.Index; diff --git a/packages/data-lit-todo/src/features/main/ecs/indexes/index-database.ts b/packages/data-lit-todo/src/features/main/ecs/indexes/index-database.ts new file mode 100644 index 000000000..3002e615c --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/indexes/index-database.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { CoreDatabase } from "../core/core-database.js"; +import * as indexes from "./index.js"; + +const indexDatabasePlugin = Database.Plugin.create({ + extends: CoreDatabase.plugin, + indexes, +}); + +export type IndexDatabase = Database.Plugin.ToDatabase; + +export namespace IndexDatabase { + export const plugin = indexDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-todo/src/features/main/services/main-service/index-database/indexes/index.ts b/packages/data-lit-todo/src/features/main/ecs/indexes/index.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/services/main-service/index-database/indexes/index.ts rename to packages/data-lit-todo/src/features/main/ecs/indexes/index.ts diff --git a/packages/data-lit-todo/src/features/main/ecs/main-service.ts b/packages/data-lit-todo/src/features/main/ecs/main-service.ts new file mode 100644 index 000000000..bbacf9c54 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/main-service.ts @@ -0,0 +1,2 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { ActionDatabase as MainService } from "./actions/action-database.js"; diff --git a/packages/data-lit-todo/src/features/main/ecs/services/service-database.ts b/packages/data-lit-todo/src/features/main/ecs/services/service-database.ts new file mode 100644 index 000000000..047f76a88 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/services/service-database.ts @@ -0,0 +1,32 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import type { Assert, Equal } from "@adobe/data/types"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; +import { NameGeneratorService } from "../../services/name-generator-service/name-generator-service.js"; +import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; +import type { Services } from "../../services/services.js"; + +// These services are async ports with no ECS state to bind, so they are +// registered directly from their `services/` contracts. A service that reads +// db observables or calls transactions would instead get a factory in +// `ecs/services/` (see the tic-tac-toe sample). +const serviceDatabasePlugin = Database.Plugin.create({ + extends: TransactionDatabase.plugin, + services: { + nameGenerator: NameGeneratorService.create, + analytics: AnalyticsService.create, + }, +}); + +export type ServiceDatabase = Database.Plugin.ToDatabase< + typeof serviceDatabasePlugin +>; + +// Drift-guard: the services the ECS resolves onto `db.services` must exactly match +// the injectable `Services` map the data transitions `Pick` from. +type _ServicesPin = Assert>; + +export namespace ServiceDatabase { + export const plugin = serviceDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-todo/src/features/main/ecs/transactions/create-bulk-todos.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/create-bulk-todos.ts new file mode 100644 index 000000000..3d9ecea75 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/transactions/create-bulk-todos.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; +import { createTodo } from "./create-todo.js"; + +export const createBulkTodos = ( + t: CoreDatabase.Store, + input: { readonly count: number }, +) => { + const count = Math.max(0, Math.floor(input.count)); + const startIndex = t.archetypes.Todo.rowCount; + for (let index = 0; index < count; index++) { + createTodo(t, { name: `Todo ${startIndex + index}`, complete: false }); + } +}; diff --git a/packages/data-lit-todo/src/features/main/ecs/transactions/create-todo.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/create-todo.ts new file mode 100644 index 000000000..d0e40b9f9 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/transactions/create-todo.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; +import { nextOrder } from "./order/index.js"; + +export const createTodo = ( + t: CoreDatabase.Store, + input: { readonly name: string; readonly complete?: boolean }, +) => + t.archetypes.Todo.insert({ + todo: true, + name: input.name, + complete: input.complete ?? false, + order: nextOrder(t), + }); diff --git a/packages/data-lit-todo/src/features/main/ecs/transactions/delete-all-todos.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/delete-all-todos.ts new file mode 100644 index 000000000..98686ae10 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/transactions/delete-all-todos.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +export const deleteAllTodos = (t: CoreDatabase.Store) => { + for (const archetype of t.queryArchetypes(t.archetypes.Todo.components)) { + for (let row = archetype.rowCount - 1; row >= 0; row--) { + t.delete(archetype.columns.id.get(row)); + } + } +}; diff --git a/packages/data-lit-todo/src/features/main/ecs/transactions/delete-todo.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/delete-todo.ts new file mode 100644 index 000000000..1958427fd --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/transactions/delete-todo.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { CoreDatabase } from "../core/core-database.js"; + +// A no-op for an id that names no todo (including another feature's entity). +export const deleteTodo = (t: CoreDatabase.Store, { id }: { id: Entity }) => { + if (t.read(id, t.archetypes.Todo) !== null) t.delete(id); +}; diff --git a/packages/data-lit-todo/src/features/main/ecs/transactions/drag-todo.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/drag-todo.ts new file mode 100644 index 000000000..8da229e2b --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/transactions/drag-todo.ts @@ -0,0 +1,69 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { CoreDatabase } from "../core/core-database.js"; +import { normalizeOrder, selectOrderedTodos } from "./order/index.js"; + +// Declared, not exported (one public export per file — the transaction). A +// consumer that needs this shape infers it from the function signature: +// `type DragTodoInput = Parameters[1]`. +type DragTodoInput = { + readonly entity: Entity; + /** + * Live vertical pixel offset from the todo's resting position. `null` + * abandons the drag with no reorder — the same shape a cancelled gesture + * yields as its final frame. + */ + readonly dragPosition: number | null; + /** + * Target index within the currently visible list. Present only on the final + * frame of a drag; while omitted the drag is still in progress (or was + * cancelled). + */ + readonly finalIndex?: number; +}; + +/** + * Drives a drag-to-reorder gesture as a single coalesced transaction. While + * dragging (no `finalIndex`) it records the live pixel offset in + * `dragPosition`; on the final frame it drops the todo into place by assigning + * a fractional `order` between its new visible neighbours, clears + * `dragPosition`, then normalizes every todo back to contiguous integers. + * + * Intended to be driven by the `dragTodo` action, which maps a + * `useDragGenerator` stream into this shape and drives this transaction with + * the resulting async generator so every frame — including the final drop — + * commits as one undoable step. + */ +export const dragTodo = (t: CoreDatabase.Store, input: DragTodoInput): void => { + t.undoable = { coalesce: false }; + const { entity, dragPosition, finalIndex } = input; + + if (finalIndex === undefined) { + t.update(entity, { dragPosition }); + return; + } + + const ordered = selectOrderedTodos(t); + const incompleteTodos = ordered.filter((e) => t.read(e)?.complete === false); + const visible = t.resources.displayCompleted ? ordered : incompleteTodos; + + const withoutDragged = visible.filter((e) => e !== entity); + const targetIndex = Math.max(0, Math.min(finalIndex, withoutDragged.length)); + + const before = withoutDragged[targetIndex - 1]; + const after = withoutDragged[targetIndex]; + const beforeOrder = before === undefined ? undefined : t.read(before)?.order; + const afterOrder = after === undefined ? undefined : t.read(after)?.order; + + const newOrder = + beforeOrder === undefined && afterOrder === undefined + ? 0 + : beforeOrder === undefined + ? afterOrder! - 1 + : afterOrder === undefined + ? beforeOrder + 1 + : (beforeOrder + afterOrder) / 2; + + t.update(entity, { dragPosition: null, order: newOrder }); + normalizeOrder(t); +}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/index.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/index.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/index.ts rename to packages/data-lit-todo/src/features/main/ecs/transactions/index.ts diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/order/index.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/order/index.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/order/index.ts rename to packages/data-lit-todo/src/features/main/ecs/transactions/order/index.ts diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/order/next-order.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/order/next-order.ts similarity index 85% rename from packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/order/next-order.ts rename to packages/data-lit-todo/src/features/main/ecs/transactions/order/next-order.ts index 2e60a1c26..24204f709 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/order/next-order.ts +++ b/packages/data-lit-todo/src/features/main/ecs/transactions/order/next-order.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../../core-database/core-database.js"; +import type { CoreDatabase } from "../../core/core-database.js"; import { selectOrderedTodos } from "./select-ordered-todos.js"; /** diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/order/normalize-order.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/order/normalize-order.ts similarity index 88% rename from packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/order/normalize-order.ts rename to packages/data-lit-todo/src/features/main/ecs/transactions/order/normalize-order.ts index 77234bcfa..ae0fe331e 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/order/normalize-order.ts +++ b/packages/data-lit-todo/src/features/main/ecs/transactions/order/normalize-order.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../../core-database/core-database.js"; +import type { CoreDatabase } from "../../core/core-database.js"; import { selectOrderedTodos } from "./select-ordered-todos.js"; /** diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/order/select-ordered-todos.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/order/select-ordered-todos.ts similarity index 82% rename from packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/order/select-ordered-todos.ts rename to packages/data-lit-todo/src/features/main/ecs/transactions/order/select-ordered-todos.ts index 662b6711f..e2c32c781 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/order/select-ordered-todos.ts +++ b/packages/data-lit-todo/src/features/main/ecs/transactions/order/select-ordered-todos.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Entity } from "@adobe/data/ecs"; -import type { CoreDatabase } from "../../../core-database/core-database.js"; +import type { CoreDatabase } from "../../core/core-database.js"; /** * Returns every todo entity in ascending `order`, matching the sequence the diff --git a/packages/data-lit-todo/src/features/main/ecs/transactions/select-todo.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/select-todo.ts new file mode 100644 index 000000000..090a66828 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/transactions/select-todo.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Point the `selectedTodo` resource at an existing todo; a no-op for an id that names +// no todo, so the selection never dangles or points at another feature's entity. +export const selectTodo = (t: CoreDatabase.Store, { id }: { id: Entity }) => { + if (t.read(id, t.archetypes.Todo) !== null) t.resources.selectedTodo = id; +}; diff --git a/packages/data-lit-todo/src/features/main/ecs/transactions/toggle-complete.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/toggle-complete.ts new file mode 100644 index 000000000..3b7c6009c --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/transactions/toggle-complete.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { CoreDatabase } from "../core/core-database.js"; + +// A no-op for an id that names no todo (including another feature's entity). +export const toggleComplete = (t: CoreDatabase.Store, { id }: { id: Entity }) => { + const todo = t.read(id, t.archetypes.Todo); + if (todo !== null) t.update(id, { complete: !todo.complete }); +}; diff --git a/packages/data-lit-todo/src/features/main/ecs/transactions/toggle-display-completed.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/toggle-display-completed.ts new file mode 100644 index 000000000..f367e278a --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/transactions/toggle-display-completed.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +export const toggleDisplayCompleted = (t: CoreDatabase.Store) => { + t.resources.displayCompleted = !t.resources.displayCompleted; +}; diff --git a/packages/data-lit-todo/src/features/main/ecs/transactions/transaction-database.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/transaction-database.ts new file mode 100644 index 000000000..b9ce84715 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/transactions/transaction-database.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { IndexDatabase } from "../indexes/index-database.js"; +import * as transactions from "./index.js"; + +const transactionDatabasePlugin = Database.Plugin.create({ + extends: IndexDatabase.plugin, + transactions, +}); + +export type TransactionDatabase = Database.Plugin.ToDatabase; + +export namespace TransactionDatabase { + export const plugin = transactionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-todo/src/features/main/services/analytics-service/analytics-service.ts b/packages/data-lit-todo/src/features/main/services/analytics-service/analytics-service.ts index 4f07d838a..cbb6a56f3 100644 --- a/packages/data-lit-todo/src/features/main/services/analytics-service/analytics-service.ts +++ b/packages/data-lit-todo/src/features/main/services/analytics-service/analytics-service.ts @@ -7,8 +7,7 @@ import type { Assert } from "@adobe/data/types"; * Opaque handle returned by `randomTodoRequested` and handed straight back to * `randomTodoAdded`. The action treats it as a black box — it never inspects or * constructs one — so timing lives entirely in the service without the service - * holding any state between the two calls. Exported (not re-exported through - * `public.ts`) so the adjacent `create-fake.ts` double can name it. + * holding any state between the two calls. */ export type Timing = { readonly startedAt: number }; diff --git a/packages/data-lit-todo/src/features/main/services/analytics-service/analytics.fake.ts b/packages/data-lit-todo/src/features/main/services/analytics-service/analytics.fake.ts deleted file mode 100644 index 2fab83c45..000000000 --- a/packages/data-lit-todo/src/features/main/services/analytics-service/analytics.fake.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { AnalyticsService } from "./analytics-service.js"; - -/** - * Shape-only recording template for {@link AnalyticsService} — a TEST-TIER double, - * NOT re-exported through `public.ts`, so it can never be reached as - * `AnalyticsService.createFake` from runtime code. Conformance calls it once purely - * to enumerate the service's methods; the RETURNS come from each case's `responses` - * (e.g. the `randomTodoRequested` timing the case owns), so this file no longer - * hardcodes any value a case then has to guess at. Imported directly by the - * conformance manifest (`data/state/spec.ts`) and nowhere else. - */ -export const createFake = (): AnalyticsService => ({ - serviceName: "analytics", - todoCreated: () => {}, - bulkTodosCreated: () => {}, - todoToggled: () => {}, - todoDeleted: () => {}, - allTodosCleared: () => {}, - displayCompletedToggled: () => {}, - randomTodoRequested: () => Promise.resolve({ startedAt: 0 }), - randomTodoAdded: () => {}, -}); diff --git a/packages/data-lit-todo/src/features/main/services/analytics-service/create-fake.ts b/packages/data-lit-todo/src/features/main/services/analytics-service/create-fake.ts new file mode 100644 index 000000000..e1c2343ed --- /dev/null +++ b/packages/data-lit-todo/src/features/main/services/analytics-service/create-fake.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { AnalyticsService } from "./analytics-service.js"; + +/** + * Fake {@link AnalyticsService}, exported as `AnalyticsService.createFake` (tree-shaken when unused). + * Conformance calls it only to enumerate the service's methods; each case's + * `responses` supply the returns. + */ +export const createFake = (): AnalyticsService => ({ + serviceName: "analytics", + todoCreated: () => {}, + bulkTodosCreated: () => {}, + todoToggled: () => {}, + todoDeleted: () => {}, + allTodosCleared: () => {}, + displayCompletedToggled: () => {}, + randomTodoRequested: () => Promise.resolve({ startedAt: 0 }), + randomTodoAdded: () => {}, +}); diff --git a/packages/data-lit-todo/src/features/main/services/analytics-service/public.ts b/packages/data-lit-todo/src/features/main/services/analytics-service/public.ts index 6523855b6..56c941d3d 100644 --- a/packages/data-lit-todo/src/features/main/services/analytics-service/public.ts +++ b/packages/data-lit-todo/src/features/main/services/analytics-service/public.ts @@ -1,2 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; +export { createFake } from "./create-fake.js"; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/action-database.ts b/packages/data-lit-todo/src/features/main/services/main-service/action-database/action-database.ts deleted file mode 100644 index 84dc6b4ac..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/action-database.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { ServiceDatabase } from "../service-database/service-database.js"; -import * as actions from "./actions/index.js"; - -const actionDatabasePlugin = Database.Plugin.create({ - extends: ServiceDatabase.plugin, - actions, -}); - -export type ActionDatabase = Database.Plugin.ToDatabase< - typeof actionDatabasePlugin ->; - -export namespace ActionDatabase { - export const plugin = actionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/create-bulk-todos.ts b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/create-bulk-todos.ts deleted file mode 100644 index 1fe87f842..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/create-bulk-todos.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -export const createBulkTodos = ( - db: ServiceDatabase, - input: { readonly count: number }, -) => { - db.services.analytics.bulkTodosCreated({ count: input.count }); - db.transactions.createBulkTodos(input); -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/create-random-todo.ts b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/create-random-todo.ts deleted file mode 100644 index c5abe52ee..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/create-random-todo.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -/** - * Efficient ecs counterpart to the pure `State.createRandomTodo` spec (which - * injects the same `nameGenerator` port): here the port is reached through the - * `services` facet and the append is an O(1) `createTodo` transaction rather - * than a whole-`State` rebuild. This action additionally brackets the slow - * name-generation call with analytics start/end events so the telemetry - * captures how long the outbound call took. The UI never awaits this — state - * flows back via observables. - */ -export const createRandomTodo = async (db: ServiceDatabase) => { - // The service owns timing: it mints an opaque token here and computes the - // elapsed time when we hand it back — this action never sees the clock. - const timing = await db.services.analytics.randomTodoRequested(); - const name = await db.services.nameGenerator.generateName(); - db.transactions.createTodo({ name }); - db.services.analytics.randomTodoAdded({ timing, name }); -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/create-todo.ts b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/create-todo.ts deleted file mode 100644 index 7d2ee364d..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/create-todo.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -export const createTodo = ( - db: ServiceDatabase, - input: { readonly name: string; readonly complete?: boolean }, -) => { - db.services.analytics.todoCreated({ name: input.name }); - db.transactions.createTodo(input); -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/delete-all-todos.ts b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/delete-all-todos.ts deleted file mode 100644 index 9c4c5afa8..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/delete-all-todos.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -export const deleteAllTodos = (db: ServiceDatabase) => { - db.services.analytics.allTodosCleared(); - db.transactions.deleteAllTodos(); -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/delete-todo.ts b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/delete-todo.ts deleted file mode 100644 index 07a4da8e8..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/delete-todo.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -export const deleteTodo = (db: ServiceDatabase, { id }: { id: Entity }) => { - db.services.analytics.todoDeleted(); - db.transactions.deleteTodo({ id }); -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/drag-todo.ts b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/drag-todo.ts deleted file mode 100644 index 0d47ab5d0..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/drag-todo.ts +++ /dev/null @@ -1,41 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { DragState } from "@adobe/data-lit"; -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// `useDragGenerator` hands us the raw pointer stream; this action maps each -// yielded `DragState` frame to the `dragTodo` transaction's args and drives -// the transaction directly with that mapped async generator, so every frame — -// including the final drop — commits as one coalesced, undoable step. A -// cancelled gesture yields a `dragPosition: null` frame with no `finalIndex`, -// which abandons the drag without reordering. -// -// `rowHeight` is a UI layout measure passed in by the caller (never imported — -// `services/` must not depend on `ui/`), used to convert the final pixel offset -// into a list-index delta. -export const dragTodo = ( - db: ServiceDatabase, - args: { - readonly entity: Entity; - readonly index: number; - readonly rowHeight: number; - readonly drag: AsyncGenerator; - }, -) => { - const { entity, index, rowHeight, drag } = args; - return db.transactions.dragTodo(async function* () { - for await (const state of drag) { - if (state.type === "move") { - yield { entity, dragPosition: state.delta[1] }; - } else if (state.type === "end") { - yield { - entity, - dragPosition: state.delta[1], - finalIndex: index + Math.round(state.delta[1] / rowHeight), - }; - } else if (state.type === "cancel") { - yield { entity, dragPosition: null }; - } - } - }); -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/index.ts b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/index.ts deleted file mode 100644 index e4787154e..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export * from "./create-todo.js"; -export * from "./create-bulk-todos.js"; -export * from "./create-random-todo.js"; -export * from "./toggle-complete.js"; -export * from "./delete-todo.js"; -export * from "./delete-all-todos.js"; -export * from "./toggle-display-completed.js"; -export * from "./reorder-todo.js"; -export * from "./drag-todo.js"; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/toggle-complete.ts b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/toggle-complete.ts deleted file mode 100644 index 1c28b6240..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/toggle-complete.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -export const toggleComplete = (db: ServiceDatabase, { id }: { id: Entity }) => { - db.services.analytics.todoToggled(); - db.transactions.toggleComplete({ id }); -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/toggle-display-completed.ts b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/toggle-display-completed.ts deleted file mode 100644 index 2207e7060..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/toggle-display-completed.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -export const toggleDisplayCompleted = (db: ServiceDatabase) => { - db.services.analytics.displayCompletedToggled(); - db.transactions.toggleDisplayCompleted(); -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed-database.ts b/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed-database.ts deleted file mode 100644 index d66e1ad68..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed-database.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { TransactionDatabase } from "../transaction-database/transaction-database.js"; -import * as computed from "./computed/index.js"; - -const computedDatabasePlugin = Database.Plugin.create({ - extends: TransactionDatabase.plugin, - computed, -}); - -export type ComputedDatabase = Database.Plugin.ToDatabase< - typeof computedDatabasePlugin ->; - -export namespace ComputedDatabase { - export const plugin = computedDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/index.ts b/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/index.ts deleted file mode 100644 index 68865d203..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export * from "./state.js"; -export * from "./all-todos.js"; -export * from "./complete-todos.js"; -export * from "./incomplete-todos.js"; -export * from "./visible-todos.js"; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/state.ts b/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/state.ts deleted file mode 100644 index addce4acb..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/state.ts +++ /dev/null @@ -1,33 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { cached } from "@adobe/data/cache"; -import { Observe } from "@adobe/data/observe"; -import type { State } from "../../../../data/state/state.js"; -import type { Todo } from "../../../../data/todo/todo.js"; -import type { IndexDatabase } from "../../index-database/index-database.js"; - -// The full logical `State` projected from the ECS — the conformance anchor -// between the data-layer spec and this implementation. Each entity is keyed by its -// id in `entities` and carries its `order`; the transient `dragPosition` is -// dropped (it has no place in the spec). -export const state = cached((db: IndexDatabase) => - Observe.withCache(db.derive((read): State => { - const rows = read - .select(db.archetypes.Todo.components) - .map((id) => ({ id, values: read.read(id) })) - .filter((row) => row.values !== undefined); - return { - entities: new Map( - rows.map(({ id, values }) => [ - id, - { - name: values!.name ?? "", - complete: values!.complete ?? false, - order: values!.order ?? 0, - }, - ]), - ), - displayCompleted: read.resources.displayCompleted, - selectedTodo: read.resources.selectedTodo, - }; - })), -); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/visible-todos.ts b/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/visible-todos.ts deleted file mode 100644 index cdc534db1..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/visible-todos.ts +++ /dev/null @@ -1,21 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { cached } from "@adobe/data/cache"; -import { Observe } from "@adobe/data/observe"; -import type { IndexDatabase } from "../../index-database/index-database.js"; -import { allTodos } from "./all-todos.js"; -import { incompleteTodos } from "./incomplete-todos.js"; - -// Optimized, id-based equivalent of `State.visibleTodos` (verified by -// conformance test): the incomplete/all lists are already index-maintained, -// so visibility is a choice between two pre-sorted id lists. -export const visibleTodos = cached((db: IndexDatabase) => - Observe.withFilter( - Observe.fromProperties({ - displayCompleted: db.observe.resources.displayCompleted, - allTodos: allTodos(db), - incompleteTodos: incompleteTodos(db), - }), - ({ displayCompleted, allTodos, incompleteTodos }) => - displayCompleted ? allTodos : incompleteTodos, - ), -); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/conformance.test.ts deleted file mode 100644 index 86fd6af93..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/conformance.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "../../../data/state/spec.js"; - -// The whole ecs conformance for this feature in one call: `checkFeature` replays the -// manifest's shared cases against the transactions/actions on `MainService.plugin` -// and the computeds on `ComputedDatabase`, seeds each `before` over `State.create()`, -// round-trips `State.samples` through the `projection`, and hydrates the id-emitting -// `visibleTodos` computed through `toData`. It shares `spec` with `spec.test.ts` — -// same cases, substituted implementation. -Conformance.checkFeature(spec); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts deleted file mode 100644 index 98e0b7163..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts +++ /dev/null @@ -1,73 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Entity } from "@adobe/data/ecs"; -import type { State } from "../../../data/state/state.js"; -import type { Todo } from "../../../data/todo/todo.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Read one entity back into its id-less `data/` value — the per-entity projection -// `toState` is built on, and the single place the ecs↔data mapping for a todo -// lives. Reused by the computed conformance to hydrate id-based computed outputs -// (e.g. `visibleTodos` returns entity ids) into the value shape a derivation -// yields, so those computeds need no bespoke projection. Identity is the map key, -// so no `id` is projected. Test-only. -const toData = (store: CoreDatabase.Store, entity: Entity): Todo => { - const row = store.read(entity, store.archetypes.Todo); - if (row === null) - throw new Error("conformance projection: expected a todo entity"); - return { name: row.name, complete: row.complete, order: row.order }; -}; - -// The test-only ecs↔`State` projection, carried on the manifest and used by `Conformance.checkFeature`. -// `fromState` seeds a store to a `State` (clearing every todo, setting the -// `displayCompleted` resource, then inserting each entity with its own `order`; -// the implementation-only slots (`dragPosition`, `assignees`) and the `todo` tag -// are seeded here). Clearing iterates tail→head so each delete is from the tail -// (no hole-fill shift). The ecs assigns entity ids from its own id-space, unrelated -// to the spec's domain id; `fromState` returns the `spec id → seeded entity` map so -// the runners resolve id-addressed operations generically. `toState` reads it back -// (todos keyed by the assigned entity, each through its full archetype so the row -// shape never aliases; only the spec fields are projected); `toData` reads one -// entity. -export const projection = { - fromState: ( - store: CoreDatabase.Store, - state: State, - ): ReadonlyMap => { - for (const arch of store.queryArchetypes( - store.archetypes.Todo.components, - )) { - for (let row = arch.rowCount - 1; row >= 0; row--) { - store.delete(arch.columns.id.get(row)); - } - } - store.resources.displayCompleted = state.displayCompleted; - const seeded = new Map( - [...state.entities].map(([specId, todo]) => [ - specId, - store.archetypes.Todo.insert({ - todo: true, - name: todo.name, - complete: todo.complete, - order: todo.order, - dragPosition: null, - assignees: [], - }), - ]), - ); - // Resolve the `selectedTodo` REFERENCE (a spec-id) to the entity just seeded for - // it — the ecs mints its own ids, so the resource must hold the ecs id, not the - // spec-id. An unresolved reference (or none) collapses to `Entity.none`. - store.resources.selectedTodo = seeded.get(state.selectedTodo) ?? Entity.none; - return seeded; - }, - toState: (store: CoreDatabase.Store): State => ({ - displayCompleted: store.resources.displayCompleted, - entities: new Map( - store - .select(store.archetypes.Todo.components, { order: { order: true } }) - .map((entity) => [entity, toData(store, entity)]), - ), - selectedTodo: store.resources.selectedTodo, - }), - toData, -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/core-database/archetypes.ts b/packages/data-lit-todo/src/features/main/services/main-service/core-database/archetypes.ts deleted file mode 100644 index 1f1f048ca..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/core-database/archetypes.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { components } from "./components.js"; - -export const archetypes = Database.archetypes(components, { - Todo: ["todo", "name", "complete", "order", "dragPosition", "assignees"], -}); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/core-database/components.ts b/packages/data-lit-todo/src/features/main/services/main-service/core-database/components.ts deleted file mode 100644 index 4c1a0f75e..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/core-database/components.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { Boolean, F32, True } from "@adobe/data/schema"; -import { Name } from "../../../data/name/name.js"; -import { Assignees } from "../../../data/assignees/assignees.js"; -import { DragPosition } from "../../../data/drag-position/drag-position.js"; - -export const components = Database.components({ - document: { - todo: True.schema, // tag: presence marks the entity as a todo - name: Name.schema, - complete: Boolean.schema, - order: F32.schema, // sort key for display order - assignees: Assignees.schema, - }, - session: { - dragPosition: DragPosition.schema, // live drag offset; not saved, not shared - }, -}); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/core-database/core-database.ts b/packages/data-lit-todo/src/features/main/services/main-service/core-database/core-database.ts deleted file mode 100644 index d996f71fd..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/core-database/core-database.ts +++ /dev/null @@ -1,36 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import type { Store } from "@adobe/data/ecs"; -import { CoreDatabase as AssignSchema } from "../../../../assign/services/main-service/core-database/core-database.js"; -import { components } from "./components.js"; -import { resources } from "./resources.js"; -import { archetypes } from "./archetypes.js"; - -// The core schema, grouped by scope in components.ts / resources.ts. `imports` -// (not `extends`) the assign feature's schema so the store knows its columns up -// front — data coexists and persists — while main's type stays free of the -// feature; assign's behavior loads lazily. -const coreDatabasePlugin = Database.Plugin.create({ - imports: AssignSchema.plugin, - components, - resources, - archetypes, -}); - -export type CoreDatabase = Database.Plugin.ToDatabase; - -type CoreComponents = Store.Components< - Database.Plugin.ToStore ->; - -export namespace CoreDatabase { - export const plugin = coreDatabasePlugin; - export type Store = Database.Plugin.ToStore; - /** Index-declaration type bound to this database's components. */ - export type Index = Database.Index; - /** A component name, and a component-key tuple (an archetype's shape / a `count`/`select` - * query-key list). Derived from `components` — NOT `CoreComponents` (plugin-derived, so it - * would cycle when used to `satisfies`-check the archetypes it is derived from). */ - export type Component = Extract; - export type Archetype = readonly Component[]; -} diff --git a/packages/data-lit-todo/src/features/main/services/main-service/core-database/resources.ts b/packages/data-lit-todo/src/features/main/services/main-service/core-database/resources.ts deleted file mode 100644 index cdc024996..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/core-database/resources.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database, Entity } from "@adobe/data/ecs"; -import { Boolean } from "@adobe/data/schema"; - -export const resources = Database.resources({ - // The schema version is NOT a resource — it rides the save metadata (see - // db.version and ../versioning/versions.ts). - settings: { - displayCompleted: Boolean.schema, // per-device view toggle; durable, not shared - }, - session: { - // The selected todo: a reference to one entity (`Entity.none` = no selection). - // `Entity.schema` carries the `entity` mark, so conformance treats this as an id - // and compares it to the ecs up to a bijection. Local, ephemeral view state. - selectedTodo: { ...Entity.schema, default: Entity.none }, - }, -}); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/index-database/index-database.ts b/packages/data-lit-todo/src/features/main/services/main-service/index-database/index-database.ts deleted file mode 100644 index 04b996c61..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/index-database/index-database.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { CoreDatabase } from "../core-database/core-database.js"; -import * as indexes from "./indexes/index.js"; - -const indexDatabasePlugin = Database.Plugin.create({ - extends: CoreDatabase.plugin, - indexes, -}); - -export type IndexDatabase = Database.Plugin.ToDatabase; - -export namespace IndexDatabase { - export const plugin = indexDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-todo/src/features/main/services/main-service/index-database/indexes/by-complete.ts b/packages/data-lit-todo/src/features/main/services/main-service/index-database/indexes/by-complete.ts deleted file mode 100644 index ebdb24e52..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/index-database/indexes/by-complete.ts +++ /dev/null @@ -1,3 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; -export const byComplete = { key: "complete" } as const satisfies CoreDatabase.Index; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/main-service.ts b/packages/data-lit-todo/src/features/main/services/main-service/main-service.ts deleted file mode 100644 index b0c4bfc72..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/main-service.ts +++ /dev/null @@ -1,2 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { ActionDatabase as MainService } from "./action-database/action-database.js"; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/service-database/service-database.ts b/packages/data-lit-todo/src/features/main/services/main-service/service-database/service-database.ts deleted file mode 100644 index 9a5c03909..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/service-database/service-database.ts +++ /dev/null @@ -1,32 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import type { Assert, Equal } from "@adobe/data/types"; -import { ComputedDatabase } from "../computed-database/computed-database.js"; -import { NameGeneratorService } from "../../name-generator-service/name-generator-service.js"; -import { AnalyticsService } from "../../analytics-service/analytics-service.js"; -import type { Services } from "../../services.js"; - -// These services are async ports with no ECS state to bind, so they are -// registered directly from their `services/` contracts. A service that reads -// db observables or calls transactions would instead get a factory in -// `ecs/services/` (see the tic-tac-toe sample). -const serviceDatabasePlugin = Database.Plugin.create({ - extends: ComputedDatabase.plugin, - services: { - nameGenerator: NameGeneratorService.create, - analytics: AnalyticsService.create, - }, -}); - -export type ServiceDatabase = Database.Plugin.ToDatabase< - typeof serviceDatabasePlugin ->; - -// Drift-guard: the services the ECS resolves onto `db.services` must exactly match -// the injectable `Services` map the data transitions `Pick` from. -type _ServicesPin = Assert>; - -export namespace ServiceDatabase { - export const plugin = serviceDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transaction-database.ts b/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transaction-database.ts deleted file mode 100644 index d3c8c0d6e..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transaction-database.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { IndexDatabase } from "../index-database/index-database.js"; -import * as transactions from "./transactions/index.js"; - -const transactionDatabasePlugin = Database.Plugin.create({ - extends: IndexDatabase.plugin, - transactions, -}); - -export type TransactionDatabase = Database.Plugin.ToDatabase; - -export namespace TransactionDatabase { - export const plugin = transactionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/create-bulk-todos.ts b/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/create-bulk-todos.ts deleted file mode 100644 index 91597a7e4..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/create-bulk-todos.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; -import { createTodo } from "./create-todo.js"; - -export const createBulkTodos = ( - t: CoreDatabase.Store, - input: { readonly count: number }, -) => { - const count = Math.max(0, Math.floor(input.count)); - const startIndex = t.archetypes.Todo.rowCount; - for (let index = 0; index < count; index++) { - createTodo(t, { name: `Todo ${startIndex + index}`, complete: false }); - } -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/create-todo.ts b/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/create-todo.ts deleted file mode 100644 index f83288652..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/create-todo.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; -import { nextOrder } from "./order/index.js"; - -export const createTodo = ( - t: CoreDatabase.Store, - input: { readonly name: string; readonly complete?: boolean }, -) => - t.archetypes.Todo.insert({ - todo: true, - name: input.name, - complete: input.complete ?? false, - order: nextOrder(t), - dragPosition: null, - assignees: [], - }); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/delete-all-todos.ts b/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/delete-all-todos.ts deleted file mode 100644 index 36fe34708..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/delete-all-todos.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const deleteAllTodos = (t: CoreDatabase.Store) => { - for (const archetype of t.queryArchetypes(t.archetypes.Todo.components)) { - for (let row = archetype.rowCount - 1; row >= 0; row--) { - t.delete(archetype.columns.id.get(row)); - } - } -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/delete-todo.ts b/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/delete-todo.ts deleted file mode 100644 index ae375c230..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/delete-todo.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const deleteTodo = (t: CoreDatabase.Store, { id }: { id: Entity }) => { - const todo = t.read(id); - if (todo) { - t.delete(id); - } -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/drag-todo.ts b/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/drag-todo.ts deleted file mode 100644 index f2c4aafa6..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/drag-todo.ts +++ /dev/null @@ -1,69 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { CoreDatabase } from "../../core-database/core-database.js"; -import { normalizeOrder, selectOrderedTodos } from "./order/index.js"; - -// Declared, not exported (one public export per file — the transaction). A -// consumer that needs this shape infers it from the function signature: -// `type DragTodoInput = Parameters[1]`. -type DragTodoInput = { - readonly entity: Entity; - /** - * Live vertical pixel offset from the todo's resting position. `null` - * abandons the drag with no reorder — the same shape a cancelled gesture - * yields as its final frame. - */ - readonly dragPosition: number | null; - /** - * Target index within the currently visible list. Present only on the final - * frame of a drag; while omitted the drag is still in progress (or was - * cancelled). - */ - readonly finalIndex?: number; -}; - -/** - * Drives a drag-to-reorder gesture as a single coalesced transaction. While - * dragging (no `finalIndex`) it records the live pixel offset in - * `dragPosition`; on the final frame it drops the todo into place by assigning - * a fractional `order` between its new visible neighbours, clears - * `dragPosition`, then normalizes every todo back to contiguous integers. - * - * Intended to be driven by the `dragTodo` action, which maps a - * `useDragGenerator` stream into this shape and drives this transaction with - * the resulting async generator so every frame — including the final drop — - * commits as one undoable step. - */ -export const dragTodo = (t: CoreDatabase.Store, input: DragTodoInput): void => { - t.undoable = { coalesce: false }; - const { entity, dragPosition, finalIndex } = input; - - if (finalIndex === undefined) { - t.update(entity, { dragPosition }); - return; - } - - const ordered = selectOrderedTodos(t); - const incompleteTodos = ordered.filter((e) => t.read(e)?.complete === false); - const visible = t.resources.displayCompleted ? ordered : incompleteTodos; - - const withoutDragged = visible.filter((e) => e !== entity); - const targetIndex = Math.max(0, Math.min(finalIndex, withoutDragged.length)); - - const before = withoutDragged[targetIndex - 1]; - const after = withoutDragged[targetIndex]; - const beforeOrder = before === undefined ? undefined : t.read(before)?.order; - const afterOrder = after === undefined ? undefined : t.read(after)?.order; - - const newOrder = - beforeOrder === undefined && afterOrder === undefined - ? 0 - : beforeOrder === undefined - ? afterOrder! - 1 - : afterOrder === undefined - ? beforeOrder + 1 - : (beforeOrder + afterOrder) / 2; - - t.update(entity, { dragPosition: null, order: newOrder }); - normalizeOrder(t); -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/select-todo.ts b/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/select-todo.ts deleted file mode 100644 index bc17c21a9..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/select-todo.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// Point the `selectedTodo` resource at an existing todo; a no-op if the id names no -// entity, so the selection never dangles. Conforms to the `selectTodo` transition. -export const selectTodo = (t: CoreDatabase.Store, { id }: { id: Entity }) => { - if (t.read(id)) t.resources.selectedTodo = id; -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/toggle-complete.ts b/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/toggle-complete.ts deleted file mode 100644 index 704bcbe0b..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/toggle-complete.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const toggleComplete = (t: CoreDatabase.Store, { id }: { id: Entity }) => { - const todo = t.read(id); - if (todo) { - t.update(id, { complete: !todo.complete }); - } -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/toggle-display-completed.ts b/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/toggle-display-completed.ts deleted file mode 100644 index de7859c88..000000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/toggle-display-completed.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const toggleDisplayCompleted = (t: CoreDatabase.Store) => { - t.resources.displayCompleted = !t.resources.displayCompleted; -}; diff --git a/packages/data-lit-todo/src/features/main/services/name-generator-service/create-fake.ts b/packages/data-lit-todo/src/features/main/services/name-generator-service/create-fake.ts new file mode 100644 index 000000000..c40e4e1d7 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/services/name-generator-service/create-fake.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { NameGeneratorService } from "./name-generator-service.js"; + +/** + * Fake {@link NameGeneratorService}, exported as `NameGeneratorService.createFake` (tree-shaken when unused). + * Conformance calls it only to enumerate the service's methods; each case's + * `responses` supply the returns. + */ +export const createFake = (): NameGeneratorService => ({ + serviceName: "nameGenerator", + generateName: () => Promise.resolve(""), +}); diff --git a/packages/data-lit-todo/src/features/main/services/name-generator-service/name-generator.fake.ts b/packages/data-lit-todo/src/features/main/services/name-generator-service/name-generator.fake.ts deleted file mode 100644 index 4b7913667..000000000 --- a/packages/data-lit-todo/src/features/main/services/name-generator-service/name-generator.fake.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { NameGeneratorService } from "./name-generator-service.js"; - -/** - * Shape-only recording template for {@link NameGeneratorService} — a TEST-TIER - * double, NOT re-exported through `public.ts`. Conformance calls it once to - * enumerate the service's methods; `generateName`'s returns come from each case's - * `responses` schedule, not from any behavior here (the old double cycled a default - * list, silently masking over-calls). Imported directly by the conformance manifest. - */ -export const createFake = (): NameGeneratorService => ({ - serviceName: "nameGenerator", - generateName: () => Promise.resolve(""), -}); diff --git a/packages/data-lit-todo/src/features/main/services/name-generator-service/public.ts b/packages/data-lit-todo/src/features/main/services/name-generator-service/public.ts index 6523855b6..56c941d3d 100644 --- a/packages/data-lit-todo/src/features/main/services/name-generator-service/public.ts +++ b/packages/data-lit-todo/src/features/main/services/name-generator-service/public.ts @@ -1,2 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; +export { createFake } from "./create-fake.js"; diff --git a/packages/data-lit-todo/src/features/main/data/state/append-todo.ts b/packages/data-lit-todo/src/features/main/spec/append-todo.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/append-todo.ts rename to packages/data-lit-todo/src/features/main/spec/append-todo.ts diff --git a/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.cases.ts b/packages/data-lit-todo/src/features/main/spec/create-bulk-todos.cases.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.cases.ts rename to packages/data-lit-todo/src/features/main/spec/create-bulk-todos.cases.ts diff --git a/packages/data-lit-todo/src/features/main/spec/create-bulk-todos.ts b/packages/data-lit-todo/src/features/main/spec/create-bulk-todos.ts new file mode 100644 index 000000000..1a72b554a --- /dev/null +++ b/packages/data-lit-todo/src/features/main/spec/create-bulk-todos.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Services } from "../services/services.js"; +import type { State } from "./state.js"; +import { appendTodo } from "./append-todo.js"; +/** Adds numbered placeholder todos for demos and performance testing. Reads and + * writes the entities — an `{ entities }` patch. */ +export const createBulkTodos = ( + state: Pick, + { + count, + analytics, + }: { readonly count: number } & Pick, +): Pick => { + analytics.bulkTodosCreated({ count }); + const total = Math.max(0, Math.floor(count)); + let next: Pick = state; + for (let index = 0; index < total; index++) { + next = appendTodo(next, { name: `Todo ${state.entities.size + index}` }); + } + return next; +}; diff --git a/packages/data-lit-todo/src/features/main/data/state/create-random-todo.cases.ts b/packages/data-lit-todo/src/features/main/spec/create-random-todo.cases.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/create-random-todo.cases.ts rename to packages/data-lit-todo/src/features/main/spec/create-random-todo.cases.ts diff --git a/packages/data-lit-todo/src/features/main/spec/create-random-todo.ts b/packages/data-lit-todo/src/features/main/spec/create-random-todo.ts new file mode 100644 index 000000000..844b7f54e --- /dev/null +++ b/packages/data-lit-todo/src/features/main/spec/create-random-todo.ts @@ -0,0 +1,25 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Services } from "../services/services.js"; +import type { State } from "./state.js"; +import { appendTodo } from "./append-todo.js"; +/** + * Async, service-injected transition: brackets the slow name generation with + * analytics timing, then appends the todo via the shared {@link appendTodo} (so + * it does NOT fire `todoCreated` — it logs its own `randomTodoAdded`). Reads and + * writes the entities — an `{ entities }` patch. Awaiting an async port makes it + * `Promise>`, but it stays deterministic given its + * injected services — which is how it is unit-tested. + */ +export const createRandomTodo = async ( + state: Pick, + { + nameGenerator, + analytics, + }: Pick, +): Promise> => { + const timing = await analytics.randomTodoRequested(); + const name = await nameGenerator.generateName(); + const next = appendTodo(state, { name }); + analytics.randomTodoAdded({ timing, name }); + return next; +}; diff --git a/packages/data-lit-todo/src/features/main/data/state/create-todo.cases.ts b/packages/data-lit-todo/src/features/main/spec/create-todo.cases.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/create-todo.cases.ts rename to packages/data-lit-todo/src/features/main/spec/create-todo.cases.ts diff --git a/packages/data-lit-todo/src/features/main/spec/create-todo.ts b/packages/data-lit-todo/src/features/main/spec/create-todo.ts new file mode 100644 index 000000000..ac4df34be --- /dev/null +++ b/packages/data-lit-todo/src/features/main/spec/create-todo.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Services } from "../services/services.js"; +import type { State } from "./state.js"; +import { appendTodo } from "./append-todo.js"; + +// Reads the entities, writes the entities — an `{ entities }` patch — by +// delegating to the shared `appendTodo`; also logs `todoCreated`. +export const createTodo = ( + state: Pick, + { + name, + complete, + analytics, + }: { + readonly name: string; + readonly complete?: boolean; + } & Pick, +): Pick => { + analytics.todoCreated({ name }); + return appendTodo(state, { name, complete }); +}; diff --git a/packages/data-lit-todo/src/features/main/spec/create.ts b/packages/data-lit-todo/src/features/main/spec/create.ts new file mode 100644 index 000000000..fd9db40da --- /dev/null +++ b/packages/data-lit-todo/src/features/main/spec/create.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "@adobe/data/ecs"; +import type { State } from "./state.js"; +import type { Todo } from "../data/entities/todo.js"; + +// The default application state: no todos, completed items hidden, nothing selected. +// It is the baseline the conformance cases author their `before`/`input` as deltas +// over (passed to the runners as `initial`), and the state a fresh app starts in. +export const create = (): State => ({ + displayCompleted: false, + entities: new Map(), + selectedTodo: Entity.none, +}); diff --git a/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.cases.ts b/packages/data-lit-todo/src/features/main/spec/delete-all-todos.cases.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/delete-all-todos.cases.ts rename to packages/data-lit-todo/src/features/main/spec/delete-all-todos.cases.ts diff --git a/packages/data-lit-todo/src/features/main/spec/delete-all-todos.ts b/packages/data-lit-todo/src/features/main/spec/delete-all-todos.ts new file mode 100644 index 000000000..ef37a33fb --- /dev/null +++ b/packages/data-lit-todo/src/features/main/spec/delete-all-todos.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Services } from "../services/services.js"; +import type { State } from "./state.js"; +import type { Todo } from "../data/entities/todo.js"; + +// Reads the entities, writes the entities — an `{ entities }` patch — clearing +// them; `displayCompleted` is untouched. Logs `allTodosCleared`. +export const deleteAllTodos = ( + state: Pick, + { analytics }: Pick, +): Pick => { + analytics.allTodosCleared(); + return { entities: new Map() }; +}; diff --git a/packages/data-lit-todo/src/features/main/data/state/delete-todo.cases.ts b/packages/data-lit-todo/src/features/main/spec/delete-todo.cases.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/delete-todo.cases.ts rename to packages/data-lit-todo/src/features/main/spec/delete-todo.cases.ts diff --git a/packages/data-lit-todo/src/features/main/spec/delete-todo.ts b/packages/data-lit-todo/src/features/main/spec/delete-todo.ts new file mode 100644 index 000000000..1cbf6fa1f --- /dev/null +++ b/packages/data-lit-todo/src/features/main/spec/delete-todo.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Services } from "../services/services.js"; +import type { State } from "./state.js"; + +// Reads the entities, writes the entities — an `{ entities }` patch — dropping the +// addressed id; also logs `todoDeleted`. Surviving todos keep their `order` (the +// ecs `deleteTodo` does not renumber either). +export const deleteTodo = ( + state: Pick, + { + id, + analytics, + }: { readonly id: number } & Pick, +): Pick => { + analytics.todoDeleted(); + const entities = new Map(state.entities); + entities.delete(id); + return { entities }; +}; diff --git a/packages/data-lit-todo/src/features/main/spec/public.ts b/packages/data-lit-todo/src/features/main/spec/public.ts new file mode 100644 index 000000000..69dfc9c89 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/spec/public.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { create } from "./create.js"; +export { samples } from "./samples.js"; diff --git a/packages/data-lit-todo/src/features/main/data/state/reorder-todo.cases.ts b/packages/data-lit-todo/src/features/main/spec/reorder-todo.cases.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/reorder-todo.cases.ts rename to packages/data-lit-todo/src/features/main/spec/reorder-todo.cases.ts diff --git a/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts b/packages/data-lit-todo/src/features/main/spec/reorder-todo.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts rename to packages/data-lit-todo/src/features/main/spec/reorder-todo.ts diff --git a/packages/data-lit-todo/src/features/main/data/state/samples.ts b/packages/data-lit-todo/src/features/main/spec/samples.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/samples.ts rename to packages/data-lit-todo/src/features/main/spec/samples.ts diff --git a/packages/data-lit-todo/src/features/main/data/state/select-todo.cases.ts b/packages/data-lit-todo/src/features/main/spec/select-todo.cases.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/select-todo.cases.ts rename to packages/data-lit-todo/src/features/main/spec/select-todo.cases.ts diff --git a/packages/data-lit-todo/src/features/main/data/state/select-todo.ts b/packages/data-lit-todo/src/features/main/spec/select-todo.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/select-todo.ts rename to packages/data-lit-todo/src/features/main/spec/select-todo.ts diff --git a/packages/data-lit-todo/src/features/main/spec/spec.test.ts b/packages/data-lit-todo/src/features/main/spec/spec.test.ts new file mode 100644 index 000000000..bdab1adf7 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/spec/spec.test.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "./spec.js"; + +// Runs every case against the pure spec functions — no ECS involved. +Conformance.checkSpec(spec); diff --git a/packages/data-lit-todo/src/features/main/spec/spec.ts b/packages/data-lit-todo/src/features/main/spec/spec.ts new file mode 100644 index 000000000..e3b9ab7cf --- /dev/null +++ b/packages/data-lit-todo/src/features/main/spec/spec.ts @@ -0,0 +1,42 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import { components } from "../data/components/index.js"; +import { resources } from "../data/resources/index.js"; +import { AnalyticsService } from "../services/analytics-service/analytics-service.js"; +import { NameGeneratorService } from "../services/name-generator-service/name-generator-service.js"; + +import { cases as createTodo } from "./create-todo.cases.js"; +import { cases as createRandomTodo } from "./create-random-todo.cases.js"; +import { cases as createBulkTodos } from "./create-bulk-todos.cases.js"; +import { cases as deleteTodo } from "./delete-todo.cases.js"; +import { cases as deleteAllTodos } from "./delete-all-todos.cases.js"; +import { cases as toggleComplete } from "./toggle-complete.cases.js"; +import { cases as toggleDisplayCompleted } from "./toggle-display-completed.cases.js"; +import { cases as reorderTodo } from "./reorder-todo.cases.js"; +import { cases as selectTodo } from "./select-todo.cases.js"; +import { cases as visibleTodos } from "./visible-todos.cases.js"; + +// The feature's spec: every action and derivation, one inert cases module each +// (coverage checked at compile time against `transforms`). `schemas` lets the runner +// find entity references (`selectedTodo`); `services` are the fake templates the +// runner builds recording doubles from. `ecs/conformance/` pairs this with the ECS build. +export const spec = Conformance.spec({ + state: State, + fns: transforms, + schemas: { ...components, ...resources }, + services: { analytics: AnalyticsService.createFake, nameGenerator: NameGeneratorService.createFake }, + cases: { + createTodo, + createRandomTodo, + createBulkTodos, + deleteTodo, + deleteAllTodos, + toggleComplete, + toggleDisplayCompleted, + reorderTodo, + selectTodo, + visibleTodos, + }, +}); diff --git a/packages/data-lit-todo/src/features/main/spec/state.ts b/packages/data-lit-todo/src/features/main/spec/state.ts new file mode 100644 index 000000000..93cb46ed9 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/spec/state.ts @@ -0,0 +1,27 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { Assert } from "@adobe/data/types"; +import type { Conformance } from "@adobe/data-testing"; +import type { Todo } from "../data/entities/todo.js"; +import type { components } from "../data/components/index.js"; +import type { resources } from "../data/resources/index.js"; + +// The application state as one immutable object — the specification the ECS +// implementation is verified against. `entities` holds every +// todo keyed by a numeric id (identity is the key, never a value field); each +// value carries its own `order` for display sorting. `displayCompleted` is a +// singleton toggle. `selectedTodo` is a **reference singleton** — it points at one +// entity by id (`Entity.none` = no selection). Reusing the ECS `Entity` type here +// is deliberate and allowed (it is a plain branded `number`): the same +// `Entity.schema` that types this reference is what conformance walks to compare it +// to the ECS up to an id-bijection. +export type State = { + readonly displayCompleted: boolean; + readonly entities: ReadonlyMap; + readonly selectedTodo: Entity; +}; + +// Singleton keys name resources; entity value keys name components. +type _Pin = Assert>; + +export * as State from "./public.js"; diff --git a/packages/data-lit-todo/src/features/main/data/state/toggle-complete.cases.ts b/packages/data-lit-todo/src/features/main/spec/toggle-complete.cases.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/toggle-complete.cases.ts rename to packages/data-lit-todo/src/features/main/spec/toggle-complete.cases.ts diff --git a/packages/data-lit-todo/src/features/main/spec/toggle-complete.ts b/packages/data-lit-todo/src/features/main/spec/toggle-complete.ts new file mode 100644 index 000000000..d9326532a --- /dev/null +++ b/packages/data-lit-todo/src/features/main/spec/toggle-complete.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Services } from "../services/services.js"; +import type { State } from "./state.js"; + +// Reads the entities, writes the entities — an `{ entities }` patch — flipping the +// addressed todo's `complete`; also logs `todoToggled`. +export const toggleComplete = ( + state: Pick, + { + id, + analytics, + }: { readonly id: number } & Pick, +): Pick => { + analytics.todoToggled(); + const target = state.entities.get(id); + if (target === undefined) return { entities: state.entities }; + return { + entities: new Map(state.entities).set(id, { + ...target, + complete: !target.complete, + }), + }; +}; diff --git a/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.cases.ts b/packages/data-lit-todo/src/features/main/spec/toggle-display-completed.cases.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.cases.ts rename to packages/data-lit-todo/src/features/main/spec/toggle-display-completed.cases.ts diff --git a/packages/data-lit-todo/src/features/main/spec/toggle-display-completed.ts b/packages/data-lit-todo/src/features/main/spec/toggle-display-completed.ts new file mode 100644 index 000000000..deb900670 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/spec/toggle-display-completed.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Services } from "../services/services.js"; +import type { State } from "./state.js"; + +// Reads `displayCompleted`, writes `displayCompleted` — a `{ displayCompleted }` +// patch — flipping the flag; also logs `displayCompletedToggled`. +export const toggleDisplayCompleted = ( + state: Pick, + { analytics }: Pick, +): Pick => { + analytics.displayCompletedToggled(); + return { displayCompleted: !state.displayCompleted }; +}; diff --git a/packages/data-lit-todo/src/features/main/spec/transforms.ts b/packages/data-lit-todo/src/features/main/spec/transforms.ts new file mode 100644 index 000000000..84ef035c6 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/spec/transforms.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Every spec action and derivation of this feature, and nothing else (helpers like +// `appendTodo`, `create`, `samples` are absent). Its keys are the conformance surface: +// `Conformance.spec` requires exactly one `*.cases.ts` module per key. +export { createTodo } from "./create-todo.js"; +export { createRandomTodo } from "./create-random-todo.js"; +export { createBulkTodos } from "./create-bulk-todos.js"; +export { deleteTodo } from "./delete-todo.js"; +export { deleteAllTodos } from "./delete-all-todos.js"; +export { toggleComplete } from "./toggle-complete.js"; +export { toggleDisplayCompleted } from "./toggle-display-completed.js"; +export { reorderTodo } from "./reorder-todo.js"; +export { selectTodo } from "./select-todo.js"; +export { visibleTodos } from "./visible-todos.js"; diff --git a/packages/data-lit-todo/src/features/main/data/state/visible-todos.cases.ts b/packages/data-lit-todo/src/features/main/spec/visible-todos.cases.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/visible-todos.cases.ts rename to packages/data-lit-todo/src/features/main/spec/visible-todos.cases.ts diff --git a/packages/data-lit-todo/src/features/main/spec/visible-todos.ts b/packages/data-lit-todo/src/features/main/spec/visible-todos.ts new file mode 100644 index 000000000..b22c7950f --- /dev/null +++ b/packages/data-lit-todo/src/features/main/spec/visible-todos.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import type { Todo } from "../data/entities/todo.js"; +// The todos the user should see, in display order (ascending `order`): all of them +// when `displayCompleted`, otherwise only the incomplete ones. Yields id-less +// values — identity is the `entities` key, so the visible list compares by content. +export const visibleTodos = ( + state: Pick, +): readonly Todo[] => + [...state.entities.values()] + .filter((todo) => state.displayCompleted || !todo.complete) + .sort((a, b) => a.order - b.order); diff --git a/packages/data-lit-todo/src/features/main/ui/todo-app/todo-app.ts b/packages/data-lit-todo/src/features/main/ui/todo-app/todo-app.ts index a194ff732..074b5d261 100644 --- a/packages/data-lit-todo/src/features/main/ui/todo-app/todo-app.ts +++ b/packages/data-lit-todo/src/features/main/ui/todo-app/todo-app.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { html, type TemplateResult } from "lit"; import type { Database } from "@adobe/data/ecs"; -import type { MainService } from "../../services/main-service/main-service.js"; +import type { MainService } from "../../ecs/main-service.js"; type TodoService = Database.Plugin.ToDatabase; diff --git a/packages/data-lit-todo/src/features/main/ui/todo-element.ts b/packages/data-lit-todo/src/features/main/ui/todo-element.ts index b8196b853..e65efe46f 100644 --- a/packages/data-lit-todo/src/features/main/ui/todo-element.ts +++ b/packages/data-lit-todo/src/features/main/ui/todo-element.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { DatabaseElement } from "@adobe/data-lit"; -import { MainService } from "../services/main-service/main-service.js"; +import { MainService } from "../ecs/main-service.js"; /** * Base class for all todo elements. Typed on the assembled `MainService` diff --git a/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-element.ts b/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-element.ts index df080ce3c..583fd6cb0 100644 --- a/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-element.ts +++ b/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-element.ts @@ -60,7 +60,6 @@ export class TodoRowElement extends TodoElement { name: todo?.name ?? "", complete: todo?.complete ?? false, dragPosition: todo?.dragPosition ?? null, - assignees: todo?.assignees ?? [], editing, toggleEditing: () => setEditing(!editing), index: this.index, diff --git a/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-presentation.test.ts b/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-presentation.test.ts index 1952ca4e9..9f6dd20da 100644 --- a/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-presentation.test.ts +++ b/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-presentation.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. // @vitest-environment jsdom -// jsdom only so the lazy assignee-dropdown wrapper can load; assertions never touch the DOM. +// jsdom only so the lazy assign wrappers can load; assertions never touch the DOM. import { describe, it, expect } from "vitest"; import { nothing } from "lit"; import { Template } from "@adobe/data-lit"; @@ -11,7 +11,6 @@ const props = (over: Partial[0]> = {}) => ({ name: "Buy milk", complete: false, dragPosition: null, - assignees: [] as readonly string[], editing: false, toggleEditing: () => {}, index: 0, @@ -33,10 +32,9 @@ describe("todo-row-presentation", () => { expect(t.text).toContain("Assign"); }); - it("renders a chip per assignee", () => { - const t = Template.from(render(props({ assignees: ["ada", "linus"] }))); - expect(t.text).toContain("ada"); - expect(t.text).toContain("linus"); + it("renders the assignee chips for its todo", () => { + const t = Template.from(render(props())); + expect(t.has(" { diff --git a/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-presentation.ts b/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-presentation.ts index 9a9a6ef2a..4166b86aa 100644 --- a/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-presentation.ts +++ b/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-presentation.ts @@ -5,8 +5,9 @@ import "@spectrum-web-components/checkbox/sp-checkbox.js"; import "@spectrum-web-components/action-button/sp-action-button.js"; import "@spectrum-web-components/icons-workflow/icons/sp-icon-delete.js"; import { TODO_ROW_HEIGHT } from "./todo-row.constants.js"; -// Lazy wrapper into the assign feature — the assignee names are main's data -// (rendered here as chips); editing them lazily loads the feature's dropdown. +// Lazy wrappers into the assign feature: its chips show a todo's assignees and its +// dropdown edits them. Main never reads assign's data itself. +import { AssigneeChips } from "../../../assign/ui/assignee-chips/assignee-chips.js"; import { AssigneeDropdown } from "../../../assign/ui/assignee-dropdown/assignee-dropdown.js"; type RenderArgs = { @@ -14,7 +15,6 @@ type RenderArgs = { readonly name: string; readonly complete: boolean; readonly dragPosition: number | null; - readonly assignees: readonly string[]; readonly editing: boolean; readonly toggleEditing: () => void; readonly index: number; @@ -24,7 +24,7 @@ type RenderArgs = { }; export function render(args: RenderArgs) { - const { ready, name, complete, dragPosition, assignees, editing, index, entity } = args; + const { ready, name, complete, dragPosition, editing, index, entity } = args; if (!ready) return nothing; @@ -42,7 +42,7 @@ export function render(args: RenderArgs) { aria-label="Toggle complete" > ${name} - ${assignees.map((a) => html`${a}`)} + ${AssigneeChips({ todo: entity })} { - await createStoragePersistenceService({ - database: service, - scope: { shared: true }, // document quadrant → the todos - storage: localStorage, - defaultFileId: "data-lit-todo/document", - autoSaveOnChange: true, - autoLoadOnStart: true, - }); - await createStoragePersistenceService({ - database: service, - scope: { nonShared: true }, // settings quadrant → displayCompleted - storage: localStorage, - defaultFileId: "data-lit-todo/settings", - autoSaveOnChange: true, - autoLoadOnStart: true, - }); - - // Seed example todos only on a first run (nothing loaded from the document store). - if (service.select(["todo"]).length === 0) { - service.actions.createTodo({ name: "Buy groceries" }); - service.actions.createTodo({ name: "Pick up dry cleaning" }); - service.actions.createTodo({ name: "Water the plants", complete: true }); - } - - render( - html` - - ${TodoApp({ service })} - - `, - app, - ); - })(); -} diff --git a/packages/data-lit-todo/tsconfig.build.json b/packages/data-lit-todo/tsconfig.build.json deleted file mode 100644 index 1395b61d5..000000000 --- a/packages/data-lit-todo/tsconfig.build.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - // The RUNTIME wall (dependency isolation). It compiles the shippable data + services - // tiers as a `composite` project, so a data/ or services/ runtime file that imports a - // test-tier file (`*.cases.ts`, `*.fake.ts`, `spec.ts`, anything under `conformance/`) - // is a COMPILE ERROR (TS6307: not in this project's file list). `ui/` and the app-shell - // (`src/*.ts`) live in the test project instead — they import test-tier only in their own - // `*.test.ts`, and keeping their (often very large) inferred element/plugin types out of - // this project avoids forcing them into serialized declarations. Emits declarations only, - // to a throwaway dir, purely to satisfy the composite contract `tsconfig.test.json` refs. - "extends": "./tsconfig.json", - "compilerOptions": { - "composite": true, - "noEmit": false, - "declaration": true, - "emitDeclarationOnly": true, - "outDir": "./.tsbuild", - "rootDir": "src" - }, - "include": ["src/features/**/*"], - "exclude": [ - "**/ui/**", - "**/*.test.ts", - "**/*.type-test.ts", - "**/*.cases.ts", - "**/*.fake.ts", - "**/spec.ts", - "**/conformance/**" - ] -} diff --git a/packages/data-lit-todo/tsconfig.data.json b/packages/data-lit-todo/tsconfig.data.json new file mode 100644 index 000000000..ed8fdef71 --- /dev/null +++ b/packages/data-lit-todo/tsconfig.data.json @@ -0,0 +1,16 @@ +{ + // Layer 1+2 runtime: pure data declarations (data/) and service namespaces (services/). + // Composite so higher layers reference it; a data/services file importing spec/, ecs/ + // or any test file is a compile error (TS6307: not in this project). + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/data", + "rootDir": "src" + }, + "include": ["src/features/*/data/**/*", "src/features/*/services/**/*"], + "exclude": ["**/*.test.ts", "**/*.type-test.ts"] +} diff --git a/packages/data-lit-todo/tsconfig.ecs.json b/packages/data-lit-todo/tsconfig.ecs.json new file mode 100644 index 000000000..78227f085 --- /dev/null +++ b/packages/data-lit-todo/tsconfig.ecs.json @@ -0,0 +1,16 @@ +{ + // Runtime: the ECS implementation (ecs/, minus conformance). References only data + + // services, so an ecs file importing spec/ is a compile error. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/ecs", + "rootDir": "src" + }, + "include": ["src/features/*/ecs/**/*"], + "exclude": ["**/conformance/**", "**/*.test.ts", "**/*.type-test.ts"], + "references": [{ "path": "./tsconfig.data.json" }] +} diff --git a/packages/data-lit-todo/tsconfig.spec.json b/packages/data-lit-todo/tsconfig.spec.json new file mode 100644 index 000000000..e4dc0a8ff --- /dev/null +++ b/packages/data-lit-todo/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + // Test tier: the pure spec (spec/). References only data + services, so a spec file + // importing ecs/ is a compile error. May use @adobe/data-testing. No runtime file + // imports it (runtime projects never reference it). + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/spec", + "rootDir": "src" + }, + "include": ["src/features/*/spec/**/*"], + "exclude": ["**/*.test.ts", "**/*.type-test.ts"], + "references": [{ "path": "./tsconfig.data.json" }] +} diff --git a/packages/data-lit-todo/tsconfig.test.json b/packages/data-lit-todo/tsconfig.test.json index f328ac324..0f21c5b66 100644 --- a/packages/data-lit-todo/tsconfig.test.json +++ b/packages/data-lit-todo/tsconfig.test.json @@ -1,11 +1,7 @@ { - // The NON-EMITTING tier: everything the runtime wall (`tsconfig.build.json`) doesn't own — - // the test-tier (conformance manifest + inert cases, service fakes, `*.test.ts`, - // `conformance/`), plus `ui/` and the app-shell (`src/*.ts`). It `references` the runtime - // project, so its imports of data/services resolve through that project's declarations, - // and it alone may see `@adobe/data-testing`. Nothing here emits, so large element/plugin - // types are type-checked without being serialized. `tsc -b tsconfig.test.json` builds the - // wall first, then type-checks this tier — together covering all of `src`. + // Everything else, non-emitting: ui/, app/, ecs/conformance/, and every *.test.ts. + // References the ecs and spec projects, so it sees both. `tsc -b tsconfig.test.json` + // builds the layer projects first, then type-checks this tier — together covering src. "extends": "./tsconfig.json", "compilerOptions": { "composite": false, @@ -13,14 +9,12 @@ }, "include": [ "src/*.ts", - "src/**/ui/**/*", + "src/app/**/*", + "src/features/*/ui/**/*", + "src/features/*/ecs/conformance/**/*", "src/**/*.test.ts", - "src/**/*.type-test.ts", - "src/**/*.cases.ts", - "src/**/*.fake.ts", - "src/**/spec.ts", - "src/**/conformance/**/*" + "src/**/*.type-test.ts" ], "exclude": [], - "references": [{ "path": "./tsconfig.build.json" }] + "references": [{ "path": "./tsconfig.ecs.json" }, { "path": "./tsconfig.spec.json" }] } diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index 7449a6fdf..78a8e7173 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.10.21", + "version": "0.10.22", "description": "Adobe data Lit bindings - hooks, elements, decorators", "type": "module", "private": false, diff --git a/packages/data-p2p-tictactoe/ARCHITECTURE.md b/packages/data-p2p-tictactoe/ARCHITECTURE.md index 5b778cd12..03eb15513 100644 --- a/packages/data-p2p-tictactoe/ARCHITECTURE.md +++ b/packages/data-p2p-tictactoe/ARCHITECTURE.md @@ -55,7 +55,7 @@ transparently. ```mermaid flowchart TB subgraph p2pttt [data-p2p-tictactoe sample] - app[" (thin shell — combines plugins, sets roles)"] + app["app/main.ts (thin shell — combines plugins, injects the game)"] neg[" (generic, NegotiationDatabase local-only)"] overlay[" (optional, reads cursorX/O)"] end diff --git a/packages/data-p2p-tictactoe/index.html b/packages/data-p2p-tictactoe/index.html index d9c9ead76..a8d0d1abc 100644 --- a/packages/data-p2p-tictactoe/index.html +++ b/packages/data-p2p-tictactoe/index.html @@ -49,6 +49,6 @@

⬡ P2P Tic-Tac-Toe

- + diff --git a/packages/data-p2p-tictactoe/src/app/main.ts b/packages/data-p2p-tictactoe/src/app/main.ts new file mode 100644 index 000000000..27d2e24b5 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/app/main.ts @@ -0,0 +1,27 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { render } from "lit"; +import { Tictactoe, PlayerMark } from "data-lit-tictactoe"; +import { GameSchema } from "./schema.js"; +import type { GameService } from "../features/negotiation/services/game-service/game-service.js"; +import { Negotiation } from "../features/negotiation/ui/p2p-negotiation/p2p-negotiation.js"; +import { PresenceOverlay } from "../features/presence/ui/p2p-presence-overlay/p2p-presence-overlay.js"; + +// The game negotiation connects peers into, injected into the negotiation database: +// the host plays X, the joiner O. +const game: GameService = { + serviceName: "game", + plugin: GameSchema.plugin, + assignUserId: (role) => PlayerMark.values[role === "host" ? 0 : 1], +}; + +const app = document.getElementById("app"); +if (app) { + render( + Negotiation({ + game, + renderGame: ({ service }) => Tictactoe({ service }), + renderPresence: ({ service, children }) => PresenceOverlay({ service, children }), + }), + app, + ); +} diff --git a/packages/data-p2p-tictactoe/src/app/schema.ts b/packages/data-p2p-tictactoe/src/app/schema.ts new file mode 100644 index 000000000..17f791b4c --- /dev/null +++ b/packages/data-p2p-tictactoe/src/app/schema.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { TictactoeGameDatabase } from "data-lit-tictactoe"; +import { MainService as PresenceMainService } from "../features/presence/ecs/main-service.js"; + +// The synced game database's schema: the tic-tac-toe game plus the presence peer +// plugin. The negotiation database is separate and local-only. +const gameSchemaPlugin = Database.Plugin.combine(TictactoeGameDatabase.plugin, PresenceMainService.plugin); + +export namespace GameSchema { + export const plugin = gameSchemaPlugin; +} diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/phase/phase.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/phase/phase.ts deleted file mode 100644 index f48b46a6a..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/phase/phase.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -/** - * Local-only screen the peer is currently on. Held as a nonPersistent resource - * so it never reaches the wire — only the local signaling UI reads it. - */ -export type Phase = "idle" | "host-signaling" | "join-signaling" | "game"; -export * as Phase from "./public.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/answer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/answer-code.ts new file mode 100644 index 000000000..7595e5ff8 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/answer-code.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; + +// The joiner's generated answer code. +export const answerCode = { type: "string", default: "", ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-error.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-error.ts new file mode 100644 index 000000000..9ef25ac18 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-error.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Boolean } from "@adobe/data/schema"; + +// Whether the banner is styled as an error. +export const bannerError = { ...Boolean.schema, ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-text.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-text.ts new file mode 100644 index 000000000..1e0725605 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-text.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; + +// The status banner text; empty hides the banner. +export const bannerText = { type: "string", default: "", ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/connection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/connection.ts new file mode 100644 index 000000000..09208807b --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/connection.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { ConnectionState } from "../values/connection-state/connection-state.js"; + +export const connection = { ...ConnectionState.schema, default: "idle", ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/game-db.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/game-db.ts new file mode 100644 index 000000000..4d828cd19 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/game-db.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; + +// The live synced game database, set once the peer link is up. A non-serializable +// handle no schema can express, so it has only a default. `unknown` keeps the +// negotiation feature game-agnostic; the UI narrows it at the render boundary. +export const gameDb = { default: null as unknown, ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/host-answer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/host-answer-input.ts new file mode 100644 index 000000000..3ff5f6036 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/host-answer-input.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; + +// Live value of the host's "paste answer" textarea. +export const hostAnswerInput = { type: "string", default: "", ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/index.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/index.ts new file mode 100644 index 000000000..a8b93c03a --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/index.ts @@ -0,0 +1,26 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { phase } from "./phase.js"; +import { connection } from "./connection.js"; +import { role } from "./role.js"; +import { sessionId } from "./session-id.js"; +import { offerCode } from "./offer-code.js"; +import { answerCode } from "./answer-code.js"; +import { bannerText } from "./banner-text.js"; +import { bannerError } from "./banner-error.js"; +import { hostAnswerInput } from "./host-answer-input.js"; +import { joinerOfferInput } from "./joiner-offer-input.js"; +import { gameDb } from "./game-db.js"; + +export const resources = { + phase, + connection, + role, + sessionId, + offerCode, + answerCode, + bannerText, + bannerError, + hostAnswerInput, + joinerOfferInput, + gameDb, +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/joiner-offer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/joiner-offer-input.ts new file mode 100644 index 000000000..ea312ef38 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/joiner-offer-input.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; + +// Live value of the joiner's "paste invite" textarea. +export const joinerOfferInput = { type: "string", default: "", ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/offer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/offer-code.ts new file mode 100644 index 000000000..6e533c210 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/offer-code.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; + +// The host's generated invite code. +export const offerCode = { type: "string", default: "", ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/phase.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/phase.ts new file mode 100644 index 000000000..8d47eb1e8 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/phase.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Phase } from "../values/phase/phase.js"; + +// The screen this peer is on. Session scope: the negotiation database is local-only. +export const phase = { ...Phase.schema, default: "idle", ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/role.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/role.ts new file mode 100644 index 000000000..dd0102e12 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/role.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Schema } from "@adobe/data/schema"; +import { Role } from "../values/role/role.js"; + +// This peer's side of the handshake; `null` until it hosts or joins. +export const role = { ...Schema.Nullable(Role.schema), default: null, ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/session-id.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/session-id.ts new file mode 100644 index 000000000..5520997e4 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/session-id.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { Schema } from "@adobe/data/schema"; + +// The sync server's session id; `null` until the sync link is up. +export const sessionId = { ...Schema.Nullable({ type: "string" }), default: null, ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.cases.ts deleted file mode 100644 index de378a2d4..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.cases.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { enterGame } from "./enter-game.js"; - -// Inert, spec-owned cases for `enterGame`, shared with the ecs `setGameDb` -// transaction (a differently named transaction whose visible effect is exactly -// this) and the `enterGame` action. A no-arg transition. -export const cases: Conformance.SpecCases = { - cases: [ - { - name: "moves to the game phase, connected", - before: { phase: "host-signaling", connection: "connecting", role: "host" }, - after: { phase: "game", connection: "connected" }, - }, - ], -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/public.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/public.ts deleted file mode 100644 index c8049674c..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/public.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { create } from "./create.js"; -export { samples } from "./samples.js"; -export { startHostSignaling } from "./start-host-signaling.js"; -export { startJoinSignaling } from "./start-join-signaling.js"; -export { setOfferCode } from "./set-offer-code.js"; -export { setAnswerCode } from "./set-answer-code.js"; -export { setBanner } from "./set-banner.js"; -export { setConnection } from "./set-connection.js"; -export { setHostAnswerInput } from "./set-host-answer-input.js"; -export { setJoinerOfferInput } from "./set-joiner-offer-input.js"; -export { enterGame } from "./enter-game.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.ts deleted file mode 100644 index a02921417..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ConnectionState } from "../connection-state/connection-state.js"; -import type { State } from "./state.js"; - -/** - * Update the connection lifecycle, optionally recording the sync session id. - * Omitting `sessionId` leaves the existing value untouched. - */ -export const setConnection = ( - state: T, - { connection, sessionId }: { connection: ConnectionState; sessionId?: string | null }, -): T => ({ - ...state, - connection, - sessionId: sessionId !== undefined ? sessionId : state.sessionId, -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts deleted file mode 100644 index e6614eb52..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "./spec.js"; - -// The single pure-spec test for every transform in this folder — runs the manifest's -// inert cases against the pure functions, seeding each case's `before` over -// `State.create()` and asserting the `after` writes patch. -Conformance.checkSpec(spec); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.ts deleted file mode 100644 index d57500e66..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import * as transforms from "./transforms.js"; -import { MainService } from "../../services/main-service/main-service.js"; -import { projection } from "../../services/main-service/conformance/projection.js"; - -import { cases as startHostSignaling } from "./start-host-signaling.cases.js"; -import { cases as startJoinSignaling } from "./start-join-signaling.cases.js"; -import { cases as setOfferCode } from "./set-offer-code.cases.js"; -import { cases as setAnswerCode } from "./set-answer-code.cases.js"; -import { cases as setBanner } from "./set-banner.cases.js"; -import { cases as setConnection } from "./set-connection.cases.js"; -import { cases as setHostAnswerInput } from "./set-host-answer-input.cases.js"; -import { cases as setJoinerOfferInput } from "./set-joiner-offer-input.cases.js"; -import { cases as enterGame } from "./enter-game.cases.js"; - -// The negotiation conformance manifest — the single object `spec.test.ts` (pure) and -// `conformance.test.ts` (ecs) both import. No services (none of the pure transitions -// inject one — the signaling capability is driven imperatively from the connection -// service, not from a conformed transition), and no derivations (no `computedPlugin`/ -// `hydrate`). Negotiation's per-transition actions are deliberately kept OUT of the -// plugin's `actions` facet (registering them would grow the composed database's type -// past tsc's instantiation budget), so they are supplied to `checkFeature` via the -// `ops.actions` directory glob rather than discovered off `plugin.actions`. -// -// A test-tier module (excluded from the runtime build) — the one place the feature -// touches `@adobe/data-testing`. -export const spec = Conformance.feature({ - state: State, - fns: transforms, - plugin: MainService.plugin, - projection, - cases: { - startHostSignaling, - startJoinSignaling, - setOfferCode, - setAnswerCode, - setBanner, - setConnection, - setHostAnswerInput, - setJoinerOfferInput, - enterGame, - }, - ops: { - actions: import.meta.glob( - ["../../services/main-service/action-database/actions/*.ts", "!**/index.ts"], - { eager: true }, - ), - }, -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.cases.ts deleted file mode 100644 index 19b5d7e50..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.cases.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type { startHostSignaling } from "./start-host-signaling.js"; - -// Inert, spec-owned cases for `startHostSignaling` — data only, `Conformance` is -// `import type`. Shared with the ecs `startHostSignaling` transaction and action. A -// no-arg transition, so no `args`. `before` is a delta over `State.create()`; -// `after` is the writes patch. -export const cases: Conformance.SpecCases = { - cases: [ - { - name: "enters host-signaling as host, connecting, with a waiting banner", - before: {}, - after: { - phase: "host-signaling", - role: "host", - connection: "connecting", - bannerText: "Generating invite code — please wait…", - }, - }, - ], -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/state.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/state.ts deleted file mode 100644 index 20b752c1e..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/state.ts +++ /dev/null @@ -1,24 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Phase } from "../phase/phase.js"; -import type { ConnectionState } from "../connection-state/connection-state.js"; -import type { Role } from "../role/role.js"; - -/** - * The serializable negotiation (signaling) state as one immutable object — the - * spec the ECS main-service is verified against. The non-serializable game - * database handle is deliberately *not* modelled here: it is session-only ECS - * state, invisible to this pure spec. - */ -export type State = { - readonly phase: Phase; - readonly connection: ConnectionState; - readonly role: Role | null; - readonly sessionId: string | null; - readonly offerCode: string; - readonly answerCode: string; - readonly bannerText: string; - readonly bannerError: boolean; - readonly hostAnswerInput: string; - readonly joinerOfferInput: string; -}; -export * as State from "./public.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/transforms.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/transforms.ts deleted file mode 100644 index 381585d78..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/transforms.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -// The conformed functions of this feature — every pure transform the spec verifies, -// and nothing else (`create`/`samples` are deliberately absent). Its keys ARE the -// conformance surface: `Conformance.feature`'s coverage guard requires exactly one -// `*.cases.ts` module per key here, so adding a transform without cases — or cases -// without a transform — fails to compile. -export { startHostSignaling } from "./start-host-signaling.js"; -export { startJoinSignaling } from "./start-join-signaling.js"; -export { setOfferCode } from "./set-offer-code.js"; -export { setAnswerCode } from "./set-answer-code.js"; -export { setBanner } from "./set-banner.js"; -export { setConnection } from "./set-connection.js"; -export { setHostAnswerInput } from "./set-host-answer-input.js"; -export { setJoinerOfferInput } from "./set-joiner-offer-input.js"; -export { enterGame } from "./enter-game.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/connection-state/connection-state.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/values/connection-state/connection-state.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/connection-state/connection-state.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/data/values/connection-state/connection-state.ts diff --git a/packages/data-lit-todo/src/features/main/data/drag-position/public.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/values/connection-state/public.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/drag-position/public.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/data/values/connection-state/public.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/connection-state/schema.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/values/connection-state/schema.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/connection-state/schema.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/data/values/connection-state/schema.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/values/phase/phase.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/values/phase/phase.ts new file mode 100644 index 000000000..4a146d9dd --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/values/phase/phase.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +/** + * Local-only screen the peer is currently on. Held as a session-scoped resource + * so it never reaches the wire — only the local signaling UI reads it. + */ +export type Phase = "idle" | "host-signaling" | "join-signaling" | "game"; +export * as Phase from "./public.js"; diff --git a/packages/data-lit-todo/src/features/main/data/name/public.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/values/phase/public.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/name/public.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/data/values/phase/public.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/phase/schema.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/values/phase/schema.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/phase/schema.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/data/values/phase/schema.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/connection-state/public.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/values/role/public.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/connection-state/public.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/data/values/role/public.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/role/role.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/values/role/role.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/role/role.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/data/values/role/role.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/role/schema.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/values/role/schema.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/role/schema.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/data/values/role/schema.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/action-database.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/action-database.ts new file mode 100644 index 000000000..7dec5e304 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/action-database.ts @@ -0,0 +1,24 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { ServiceDatabase } from "../services/service-database.js"; +import * as actions from "./index.js"; + +/** + * The full negotiation database: state surface + the imperative `connection` + * service + the UI-facing actions that drive it. Each action is a one-line + * delegation, so a container element calls `service.actions.startHost()` and + * never touches the full database. + */ +const actionDatabasePlugin = Database.Plugin.create({ + extends: ServiceDatabase.plugin, + actions, +}); + +export type ActionDatabase = Database.Plugin.ToDatabase< + typeof actionDatabasePlugin +>; + +export namespace ActionDatabase { + export const plugin = actionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/dispose.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/dispose.ts new file mode 100644 index 000000000..13c4a6461 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/dispose.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const dispose = (db: ServiceDatabase) => db.services.connection.dispose(); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/enter-game.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/enter-game.ts new file mode 100644 index 000000000..07c0fc7a4 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/enter-game.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +// Commits `setGameDb` (whose visible phase / connection change is exactly the spec's +// `enterGame`), keeping the current game-database handle. +export const enterGame = (db: ServiceDatabase) => { + db.transactions.setGameDb({ gameDb: db.resources.gameDb }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/generate-answer.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/generate-answer.ts new file mode 100644 index 000000000..d44c1b68b --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/generate-answer.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const generateAnswer = (db: ServiceDatabase) => + db.services.connection.generateAnswer(); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/index.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/index.ts new file mode 100644 index 000000000..9bf3ef5e2 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/index.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// Orchestration verbs: delegate to the imperative `connection` service. +export * from "./start-host.js"; +export * from "./start-join.js"; +export * from "./submit-answer.js"; +export * from "./generate-answer.js"; +export * from "./reconnect.js"; +export * from "./dispose.js"; +// One action per spec transition, each committing its same-named transaction +// (`enterGame` commits `setGameDb`). +export * from "./start-host-signaling.js"; +export * from "./start-join-signaling.js"; +export * from "./set-offer-code.js"; +export * from "./set-answer-code.js"; +export * from "./set-banner.js"; +export * from "./set-connection.js"; +export * from "./set-host-answer-input.js"; +export * from "./set-joiner-offer-input.js"; +export * from "./enter-game.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/reconnect.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/reconnect.ts new file mode 100644 index 000000000..35ffb3eb5 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/reconnect.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const reconnect = (db: ServiceDatabase) => db.services.connection.reconnect(); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-answer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-answer-code.ts new file mode 100644 index 000000000..9fc788fff --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-answer-code.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const setAnswerCode = (db: ServiceDatabase, { code }: { code: string }) => { + db.transactions.setAnswerCode({ code }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-banner.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-banner.ts new file mode 100644 index 000000000..c5c8cbdc3 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-banner.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const setBanner = (db: ServiceDatabase, { text, error }: { text: string; error?: boolean }) => { + db.transactions.setBanner({ text, error }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-connection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-connection.ts new file mode 100644 index 000000000..bd55bf3e3 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-connection.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ConnectionState } from "../../data/values/connection-state/connection-state.js"; +import type { ServiceDatabase } from "../services/service-database.js"; + +export const setConnection = ( + db: ServiceDatabase, + { connection, sessionId }: { connection: ConnectionState; sessionId?: string | null }, +) => { + db.transactions.setConnection({ connection, sessionId }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-host-answer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-host-answer-input.ts new file mode 100644 index 000000000..e76f39cb3 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-host-answer-input.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const setHostAnswerInput = (db: ServiceDatabase, { value }: { value: string }) => { + db.transactions.setHostAnswerInput({ value }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-joiner-offer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-joiner-offer-input.ts new file mode 100644 index 000000000..ce7bbdff0 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-joiner-offer-input.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const setJoinerOfferInput = (db: ServiceDatabase, { value }: { value: string }) => { + db.transactions.setJoinerOfferInput({ value }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-offer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-offer-code.ts new file mode 100644 index 000000000..3c368ac91 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-offer-code.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const setOfferCode = (db: ServiceDatabase, { code }: { code: string }) => { + db.transactions.setOfferCode({ code }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-host-signaling.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-host-signaling.ts new file mode 100644 index 000000000..9e01514f7 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-host-signaling.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const startHostSignaling = (db: ServiceDatabase) => { + db.transactions.startHostSignaling(); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-host.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-host.ts new file mode 100644 index 000000000..7824ae010 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-host.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const startHost = (db: ServiceDatabase) => db.services.connection.startHost(); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-join-signaling.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-join-signaling.ts new file mode 100644 index 000000000..58969428c --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-join-signaling.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const startJoinSignaling = (db: ServiceDatabase) => { + db.transactions.startJoinSignaling(); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-join.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-join.ts new file mode 100644 index 000000000..31554dd2f --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-join.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const startJoin = (db: ServiceDatabase) => db.services.connection.startJoin(); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/submit-answer.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/submit-answer.ts new file mode 100644 index 000000000..e3c0649b1 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/submit-answer.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../services/service-database.js"; + +export const submitAnswer = (db: ServiceDatabase) => + db.services.connection.submitAnswer(); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/conformance/conformance.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/conformance/conformance.test.ts new file mode 100644 index 000000000..e22629e75 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/conformance/conformance.test.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { implementation } from "./implementation.js"; + +// Replays every spec case against the same-named action, and round-trips +// `State.samples` through the projection. +Conformance.checkFeature(implementation); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/conformance/implementation.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/conformance/implementation.ts new file mode 100644 index 000000000..f46caf4dd --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/conformance/implementation.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "../../spec/spec.js"; +import { MainService } from "../main-service.js"; +import { GameService } from "../../services/game-service/game-service.js"; +import { projection } from "./projection.js"; + +// The spec paired with its ECS build: each transition against its same-named +// `MainService` action. `game` is injected by the app, so the fake stands in. +export const implementation = Conformance.implementation(spec, { + plugin: MainService.plugin, + projection, + services: { game: GameService.createFake }, +}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/conformance/projection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/conformance/projection.ts new file mode 100644 index 000000000..9f4704e76 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/conformance/projection.ts @@ -0,0 +1,35 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "../../spec/state.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// The test-only store↔`State` projection, carried on the conformance manifest. +// Negotiation is resource-only (no entity collections), so `fromState` seeds the +// scalar resources and returns nothing — ids resolve to `Entity.none` — and there +// is no per-entity `toData`. The non-serializable `gameDb` resource is deliberately +// left at its default: it is session-only ECS state, invisible to the spec. +export const projection = { + fromState: (store: CoreDatabase.Store, state: State): void => { + store.resources.phase = state.phase; + store.resources.connection = state.connection; + store.resources.role = state.role; + store.resources.sessionId = state.sessionId; + store.resources.offerCode = state.offerCode; + store.resources.answerCode = state.answerCode; + store.resources.bannerText = state.bannerText; + store.resources.bannerError = state.bannerError; + store.resources.hostAnswerInput = state.hostAnswerInput; + store.resources.joinerOfferInput = state.joinerOfferInput; + }, + toState: (store: CoreDatabase.Store): State => ({ + phase: store.resources.phase, + connection: store.resources.connection, + role: store.resources.role, + sessionId: store.resources.sessionId, + offerCode: store.resources.offerCode, + answerCode: store.resources.answerCode, + bannerText: store.resources.bannerText, + bannerError: store.resources.bannerError, + hostAnswerInput: store.resources.hostAnswerInput, + joinerOfferInput: store.resources.joinerOfferInput, + }), +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/core/core-database.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/core/core-database.ts new file mode 100644 index 000000000..cbbc77eb2 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/core/core-database.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { resources } from "../../data/resources/index.js"; + +const coreDatabasePlugin = Database.Plugin.create({ + resources, +}); + +export type CoreDatabase = Database.Plugin.ToDatabase; + +export namespace CoreDatabase { + export const plugin = coreDatabasePlugin; + /** + * The store this database's transactions operate on — resources and the + * initiating `userId`. A store *is* the transaction context; there is no + * separate transaction type. + */ + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/main-service.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/main-service.ts new file mode 100644 index 000000000..507de89cb --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/main-service.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// The assembled negotiation feature database. Its topmost layer is +// `action-database` (adds the UI-facing actions). Every consumer — `ui/`, the app +// entry, `conformance/` — references `MainService`, never the topmost layer, so +// adding or dropping a layer changes only this one line. +export { ActionDatabase as MainService } from "./actions/action-database.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/services/create-connection-service.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/services/create-connection-service.test.ts new file mode 100644 index 000000000..165b0cd93 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/services/create-connection-service.test.ts @@ -0,0 +1,38 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { Database } from "@adobe/data/ecs"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; +import { GameService } from "../../services/game-service/game-service.js"; +import { SignalingService } from "../../services/signaling-service/signaling-service.js"; +import { createConnectionService } from "./create-connection-service.js"; + +// A microtask/timer flush so the fake signaling promises resolve. +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("connection service (deterministic fake signaling)", () => { + it("startHost enters host-signaling and records the invite code", async () => { + const db = Database.create(TransactionDatabase.plugin); + const service = createConnectionService(db, GameService.createFake(), SignalingService.createFake); + + service.startHost(); + expect(db.resources.phase).toBe("host-signaling"); + expect(db.resources.role).toBe("host"); + expect(db.resources.connection).toBe("connecting"); + + await flush(); + expect(db.resources.offerCode).toBe("fake-invite-code"); + }); + + it("generateAnswer records the answer code", async () => { + const db = Database.create(TransactionDatabase.plugin); + const service = createConnectionService(db, GameService.createFake(), SignalingService.createFake); + + db.transactions.setJoinerOfferInput({ value: "OFFER-xyz" }); + service.startJoin(); + expect(db.resources.phase).toBe("join-signaling"); + + service.generateAnswer(); + await flush(); + expect(db.resources.answerCode).toBe("fake-answer-code"); + }); +}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/services/create-connection-service.ts similarity index 80% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/services/create-connection-service.ts index e2535c613..85a57234b 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/services/create-connection-service.ts @@ -14,24 +14,13 @@ import { Database, createRebaseReplayConcurrency } from "@adobe/data/ecs"; import { createSyncServer, createSyncService, createLoopbackTransport, type SyncService, type ServerTransport, type ClientTransport } from "@adobe/data-sync"; -import { SignalingService } from "../../../signaling-service/signaling-service.js"; -import type { Role } from "../../../../data/role/role.js"; -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; +import { SignalingService } from "../../services/signaling-service/signaling-service.js"; +import type { GameService } from "../../services/game-service/game-service.js"; +import type { TransactionDatabase } from "../transactions/transaction-database.js"; type GameDb = Database; -/** - * Per-instance configuration handed to the service via `configure()` after - * mount. The game plugin / userId mapping live here because they are necessarily - * game-specific and cannot be encoded into the negotiation plugin itself. - */ -export interface NegotiationConfig { - readonly gamePlugin: Database.Plugin; - readonly assignUserId: (role: Role) => string; -} - export interface ConnectionService { - configure(config: NegotiationConfig): void; startHost(): void; startJoin(): void; submitAnswer(): void; @@ -45,20 +34,15 @@ const syncLog = (msg: string) => console.log(`[sync] ${msg}`); const serverLog = (msg: string) => console.log(`[sync-server] ${msg}`); /** - * Create the connection service bound to its database. `signalingFactory` - * defaults to the production {@link SignalingService.create}; tests inject the - * shape-only `createFake` from `signaling.fake.ts` for deterministic code exchange. + * Create the connection service bound to its database and the injected `game`. + * `signalingFactory` defaults to the production {@link SignalingService.create}; + * tests pass {@link SignalingService.createFake} for deterministic code exchange. */ export const createConnectionService = ( db: TransactionDatabase, + game: GameService, signalingFactory: (handlers: SignalingService.Handlers) => SignalingService = SignalingService.create, ): ConnectionService => { - let config: NegotiationConfig | undefined; - const requireConfig = (): NegotiationConfig => { - if (!config) throw new Error("connection service used before configure()"); - return config; - }; - let joinStarted = false; let syncService: SyncService | undefined; let syncServer: ReturnType | undefined; @@ -129,7 +113,7 @@ export const createConnectionService = ( const wireGameDb = (userId: string, wireSync: () => void) => { if (!gameDb) { log(`creating game DB for userId=${userId}`); - gameDb = Database.create(requireConfig().gamePlugin, { concurrency: createRebaseReplayConcurrency(userId) }); + gameDb = Database.create(game.plugin, { concurrency: createRebaseReplayConcurrency(userId) }); } wireSync(); db.transactions.setGameDb({ gameDb }); @@ -141,9 +125,9 @@ export const createConnectionService = ( onConnected: (connection) => { log(`${connection.role} peer link established`); if (connection.role === "host") { - wireGameDb(requireConfig().assignUserId("host"), () => wireHostSync(connection.transport)); + wireGameDb(game.assignUserId("host"), () => wireHostSync(connection.transport)); } else { - wireGameDb(requireConfig().assignUserId("joiner"), () => wireJoinerSync(connection.transport)); + wireGameDb(game.assignUserId("joiner"), () => wireJoinerSync(connection.transport)); } }, }); @@ -217,7 +201,5 @@ export const createConnectionService = ( syncServer?.dispose(); }; - const configure = (c: NegotiationConfig) => { config = c; }; - - return { configure, startHost, startJoin, submitAnswer, generateAnswer, reconnect, dispose }; + return { startHost, startJoin, submitAnswer, generateAnswer, reconnect, dispose }; }; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/index.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/services/index.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/index.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/services/index.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/services/service-database.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/services/service-database.ts new file mode 100644 index 000000000..2f83b4c35 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/services/service-database.ts @@ -0,0 +1,31 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; +import { GameService } from "../../services/game-service/game-service.js"; +import { createConnectionService } from "./index.js"; + +// `game` has no default (its factory throws), so the app injects it. `connection` +// is built from it in a second step, because a service factory sees only the +// services of the plugins it extends. +const gameDatabasePlugin = Database.Plugin.create({ + extends: TransactionDatabase.plugin, + services: { + game: GameService.create, + }, +}); + +const serviceDatabasePlugin = Database.Plugin.create({ + extends: gameDatabasePlugin, + services: { + connection: (db) => createConnectionService(db, db.services.game), + }, +}); + +export type ServiceDatabase = Database.Plugin.ToDatabase< + typeof serviceDatabasePlugin +>; + +export namespace ServiceDatabase { + export const plugin = serviceDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/index.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/index.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/index.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/index.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-answer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-answer-code.ts new file mode 100644 index 000000000..58b46c81a --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-answer-code.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +export const setAnswerCode = (t: CoreDatabase.Store, { code }: { code: string }) => { + t.resources.answerCode = code; + t.resources.bannerText = ""; +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-banner.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-banner.ts new file mode 100644 index 000000000..79543841b --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-banner.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +export const setBanner = ( + t: CoreDatabase.Store, + { text, error = false }: { text: string; error?: boolean }, +) => { + t.resources.bannerText = text; + t.resources.bannerError = error; +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-connection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-connection.ts new file mode 100644 index 000000000..9fab6ca61 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-connection.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ConnectionState } from "../../data/values/connection-state/connection-state.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +export const setConnection = ( + t: CoreDatabase.Store, + { connection, sessionId }: { connection: ConnectionState; sessionId?: string | null }, +) => { + t.resources.connection = connection; + if (sessionId !== undefined) t.resources.sessionId = sessionId; +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-game-db.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-game-db.ts new file mode 100644 index 000000000..0fba2963f --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-game-db.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +/** + * Store the constructed (non-serializable) game database handle and transition + * to the live game. Its visible phase / connection change is the spec's + * `enterGame`, conformed through the `enterGame` action. + */ +export const setGameDb = (t: CoreDatabase.Store, { gameDb }: { gameDb: unknown }) => { + t.resources.gameDb = gameDb; + t.resources.phase = "game"; + t.resources.connection = "connected"; +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-host-answer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-host-answer-input.ts new file mode 100644 index 000000000..b92feda6c --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-host-answer-input.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +export const setHostAnswerInput = ( + t: CoreDatabase.Store, + { value }: { value: string }, +) => { + t.resources.hostAnswerInput = value; +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-joiner-offer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-joiner-offer-input.ts new file mode 100644 index 000000000..16f7db9ba --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-joiner-offer-input.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +export const setJoinerOfferInput = ( + t: CoreDatabase.Store, + { value }: { value: string }, +) => { + t.resources.joinerOfferInput = value; +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-offer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-offer-code.ts new file mode 100644 index 000000000..bea25ec7c --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-offer-code.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +export const setOfferCode = (t: CoreDatabase.Store, { code }: { code: string }) => { + t.resources.offerCode = code; + t.resources.bannerText = ""; +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/start-host-signaling.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/start-host-signaling.ts new file mode 100644 index 000000000..f966cff4c --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/start-host-signaling.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +export const startHostSignaling = (t: CoreDatabase.Store) => { + t.resources.phase = "host-signaling"; + t.resources.role = "host"; + t.resources.connection = "connecting"; + t.resources.bannerText = "Generating invite code — please wait…"; + t.resources.bannerError = false; +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/start-join-signaling.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/start-join-signaling.ts new file mode 100644 index 000000000..a8057a7d7 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/start-join-signaling.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +export const startJoinSignaling = (t: CoreDatabase.Store) => { + t.resources.phase = "join-signaling"; + t.resources.role = "joiner"; + t.resources.connection = "connecting"; + t.resources.bannerText = ""; + t.resources.bannerError = false; +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/transaction-database.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/transaction-database.ts new file mode 100644 index 000000000..8931d4e39 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/transaction-database.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { CoreDatabase } from "../core/core-database.js"; +import * as transactions from "./index.js"; + +const transactionDatabasePlugin = Database.Plugin.create({ + extends: CoreDatabase.plugin, + transactions, +}); + +/** + * The negotiation state surface the connection service writes to (resources + + * transactions). The imperative `connection` service is layered on top by the + * service database. + */ +export type TransactionDatabase = Database.Plugin.ToDatabase< + typeof transactionDatabasePlugin +>; + +export namespace TransactionDatabase { + export const plugin = transactionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/create-fake.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/create-fake.ts new file mode 100644 index 000000000..599d74010 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/create-fake.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import type { GameService } from "./game-service.js"; + +/** Fake {@link GameService}: an empty game plugin, each role playing as its own name. */ +export const createFake = (): GameService => ({ + serviceName: "game", + plugin: Database.Plugin.create({}), + assignUserId: (role) => role, +}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/create.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/create.ts new file mode 100644 index 000000000..dc67e80a5 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/create.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { GameService } from "./game-service.js"; + +/** No default game: the app must inject one (see {@link GameService}). */ +export const create = (): GameService => { + throw new Error("GameService must be injected: Database.create(plugin, { services: { game } })"); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/game-service.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/game-service.ts new file mode 100644 index 000000000..b0b37b5a5 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/game-service.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Database } from "@adobe/data/ecs"; +import type { Service } from "@adobe/data/service"; +import type { Role } from "../../data/values/role/role.js"; + +/** + * The game this negotiation connects peers into: the plugin the synced game + * database is built from, and the sync `userId` each role plays as. Game-specific, + * so negotiation has no implementation of its own: the app injects one with + * `Database.create(plugin, { services: { game } })`. + * + * Not an async data service: it carries a plugin and a sync mapping, which are + * configuration rather than Data. + */ +export interface GameService

extends Service { + readonly plugin: P; + readonly assignUserId: (role: Role) => string; +} + +export * as GameService from "./public.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/public.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/public.ts new file mode 100644 index 000000000..56c941d3d --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/public.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { create } from "./create.js"; +export { createFake } from "./create-fake.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/action-database.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/action-database.ts deleted file mode 100644 index d7ed2b086..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/action-database.ts +++ /dev/null @@ -1,24 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { ServiceDatabase } from "../service-database/service-database.js"; -import * as actions from "./actions/index.js"; - -/** - * The full negotiation database: state surface + the imperative `connection` - * service + the UI-facing actions that drive it. Each action is a one-line - * delegation, so a container element calls `service.actions.startHost()` and - * never touches the full database. - */ -const actionDatabasePlugin = Database.Plugin.create({ - extends: ServiceDatabase.plugin, - actions, -}); - -export type ActionDatabase = Database.Plugin.ToDatabase< - typeof actionDatabasePlugin ->; - -export namespace ActionDatabase { - export const plugin = actionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/configure.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/configure.ts deleted file mode 100644 index 4d76e7dae..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/configure.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { NegotiationConfig } from "../../service-database/services/create-connection-service.js"; -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -export const configure = (db: ServiceDatabase, config: NegotiationConfig) => - db.services.connection.configure(config); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/dispose.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/dispose.ts deleted file mode 100644 index baf2a8ee0..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/dispose.ts +++ /dev/null @@ -1,4 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -export const dispose = (db: ServiceDatabase) => db.services.connection.dispose(); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/enter-game.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/enter-game.ts deleted file mode 100644 index 7c866e877..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/enter-game.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// App-facing realization of `State.enterGame`: reuses the `setGameDb` transaction -// (a differently-named transaction whose visible phase/connection effect is -// exactly `enterGame`), preserving the current game-database handle read -// synchronously from the store. There is no same-named transaction — transactions -// are the looser layer. -export const enterGame = (db: ServiceDatabase) => { - db.transactions.setGameDb({ gameDb: db.resources.gameDb }); -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/generate-answer.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/generate-answer.ts deleted file mode 100644 index e68b2cd39..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/generate-answer.ts +++ /dev/null @@ -1,5 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -export const generateAnswer = (db: ServiceDatabase) => - db.services.connection.generateAnswer(); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/index.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/index.ts deleted file mode 100644 index a7aa1cd56..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// The `actions` plugin facet. Only the capability-orchestration verbs (the -// UI-facing actions that drive the imperative `connection` service) are -// registered here. -// -// The per-transition actions (`start-host-signaling.ts`, `set-offer-code.ts`, … -// — the same-named app-facing realization of each `data/state` transition) are -// deliberately NOT re-exported into the facet: registering them would grow the -// composed database's `actions`/`transactions` type past tsc's instantiation -// budget (Database.Plugin extends is quadratic), which silently degrades the -// inferred restricted-service type the `ui/` binds to. They live beside this -// barrel as plain functions and are exercised directly by -// `conformance/actions.test.ts` — exactly how the reference runner imports and -// calls action functions (never through `db.actions`). The UI never dispatches -// them (it uses `transactions.*` and the orchestration verbs), so keeping them -// unregistered costs nothing at runtime. -export * from "./configure.js"; -export * from "./start-host.js"; -export * from "./start-join.js"; -export * from "./submit-answer.js"; -export * from "./generate-answer.js"; -export * from "./reconnect.js"; -export * from "./dispose.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/reconnect.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/reconnect.ts deleted file mode 100644 index 692c02de8..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/reconnect.ts +++ /dev/null @@ -1,4 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -export const reconnect = (db: ServiceDatabase) => db.services.connection.reconnect(); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-answer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-answer-code.ts deleted file mode 100644 index cc289a1a4..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-answer-code.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// App-facing realization of `State.setAnswerCode`: commits the state transition -// through a single transaction. -export const setAnswerCode = (db: ServiceDatabase, { code }: { code: string }) => { - db.transactions.setAnswerCode({ code }); -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-banner.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-banner.ts deleted file mode 100644 index 2fbff926e..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-banner.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// App-facing realization of `State.setBanner`: commits the state transition -// through a single transaction. -export const setBanner = (db: ServiceDatabase, { text, error }: { text: string; error?: boolean }) => { - db.transactions.setBanner({ text, error }); -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-connection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-connection.ts deleted file mode 100644 index 0263d4a88..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-connection.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ConnectionState } from "../../../../data/connection-state/connection-state.js"; -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// App-facing realization of `State.setConnection`: commits the state transition -// through a single transaction. -export const setConnection = ( - db: ServiceDatabase, - { connection, sessionId }: { connection: ConnectionState; sessionId?: string | null }, -) => { - db.transactions.setConnection({ connection, sessionId }); -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-host-answer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-host-answer-input.ts deleted file mode 100644 index 2faa79603..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-host-answer-input.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// App-facing realization of `State.setHostAnswerInput`: commits the state -// transition through a single transaction. -export const setHostAnswerInput = (db: ServiceDatabase, { value }: { value: string }) => { - db.transactions.setHostAnswerInput({ value }); -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-joiner-offer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-joiner-offer-input.ts deleted file mode 100644 index a756d8192..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-joiner-offer-input.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// App-facing realization of `State.setJoinerOfferInput`: commits the state -// transition through a single transaction. -export const setJoinerOfferInput = (db: ServiceDatabase, { value }: { value: string }) => { - db.transactions.setJoinerOfferInput({ value }); -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-offer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-offer-code.ts deleted file mode 100644 index 542bf9bf2..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-offer-code.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// App-facing realization of `State.setOfferCode`: commits the state transition -// through a single transaction. -export const setOfferCode = (db: ServiceDatabase, { code }: { code: string }) => { - db.transactions.setOfferCode({ code }); -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-host-signaling.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-host-signaling.ts deleted file mode 100644 index 653826680..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-host-signaling.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// App-facing realization of `State.startHostSignaling`: a pure state transition, -// so it commits through a single transaction. (The imperative signaling that -// follows is orchestrated by the `connection` service, driven from `startHost`.) -export const startHostSignaling = (db: ServiceDatabase) => { - db.transactions.startHostSignaling(); -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-host.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-host.ts deleted file mode 100644 index 0907a097a..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-host.ts +++ /dev/null @@ -1,4 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -export const startHost = (db: ServiceDatabase) => db.services.connection.startHost(); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-join-signaling.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-join-signaling.ts deleted file mode 100644 index 2aa37a560..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-join-signaling.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// App-facing realization of `State.startJoinSignaling`: commits the state -// transition through a single transaction. -export const startJoinSignaling = (db: ServiceDatabase) => { - db.transactions.startJoinSignaling(); -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-join.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-join.ts deleted file mode 100644 index b0f4be603..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-join.ts +++ /dev/null @@ -1,4 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -export const startJoin = (db: ServiceDatabase) => db.services.connection.startJoin(); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/submit-answer.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/submit-answer.ts deleted file mode 100644 index fa8582651..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/submit-answer.ts +++ /dev/null @@ -1,5 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -export const submitAnswer = (db: ServiceDatabase) => - db.services.connection.submitAnswer(); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/conformance.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/conformance.test.ts deleted file mode 100644 index 6add10c66..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/conformance.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "../../../data/state/spec.js"; - -// The whole ecs conformance for this feature in one call: `checkFeature` replays the -// manifest's shared cases against the transactions on `MainService.plugin` and the -// per-transition actions supplied via `spec.ops.actions`, seeds each case's `before` -// over `State.create()`, and round-trips `State.samples` through the projection. It -// shares `spec` with `spec.test.ts` — same cases, substituted implementation. -Conformance.checkFeature(spec); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.ts deleted file mode 100644 index 0a25a4eef..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.ts +++ /dev/null @@ -1,35 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "../../../data/state/state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// The test-only store↔`State` projection, carried on the conformance manifest. -// Negotiation is resource-only (no entity collections), so `fromState` seeds the -// scalar resources and returns nothing — ids resolve to `Entity.none` — and there -// is no per-entity `toData`. The non-serializable `gameDb` resource is deliberately -// left at its default: it is session-only ECS state, invisible to the spec. -export const projection = { - fromState: (store: CoreDatabase.Store, state: State): void => { - store.resources.phase = state.phase; - store.resources.connection = state.connection; - store.resources.role = state.role; - store.resources.sessionId = state.sessionId; - store.resources.offerCode = state.offerCode; - store.resources.answerCode = state.answerCode; - store.resources.bannerText = state.bannerText; - store.resources.bannerError = state.bannerError; - store.resources.hostAnswerInput = state.hostAnswerInput; - store.resources.joinerOfferInput = state.joinerOfferInput; - }, - toState: (store: CoreDatabase.Store): State => ({ - phase: store.resources.phase, - connection: store.resources.connection, - role: store.resources.role, - sessionId: store.resources.sessionId, - offerCode: store.resources.offerCode, - answerCode: store.resources.answerCode, - bannerText: store.resources.bannerText, - bannerError: store.resources.bannerError, - hostAnswerInput: store.resources.hostAnswerInput, - joinerOfferInput: store.resources.joinerOfferInput, - }), -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/core-database/core-database.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/core-database/core-database.ts deleted file mode 100644 index c79c4f708..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/core-database/core-database.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { resources } from "./resources.js"; - -const coreDatabasePlugin = Database.Plugin.create({ - resources, -}); - -export type CoreDatabase = Database.Plugin.ToDatabase; - -export namespace CoreDatabase { - export const plugin = coreDatabasePlugin; - /** - * The store this database's transactions operate on — resources and the - * initiating `userId`. A store *is* the transaction context; there is no - * separate transaction type. - */ - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/core-database/resources.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/core-database/resources.ts deleted file mode 100644 index 3f8e370e9..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/core-database/resources.ts +++ /dev/null @@ -1,29 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Phase } from "../../../data/phase/phase.js"; -import type { ConnectionState } from "../../../data/connection-state/connection-state.js"; -import type { Role } from "../../../data/role/role.js"; - -/** - * Negotiation state surface: resources only. Every resource is `nonPersistent` - * (session scope) so the negotiation database is never replicated — it is always - * local-only. Authored as plain `{ default, nonPersistent }` entries rather than - * `Database.resources({ session: … })`: `gameDb` holds a non-serializable live - * database handle that no `Schema` can express, and the whole set is session-only - * runtime state. - */ -export const resources = { - phase: { default: "idle" as Phase, nonPersistent: true }, - connection: { default: "idle" as ConnectionState, nonPersistent: true }, - role: { default: null as Role | null, nonPersistent: true }, - sessionId: { default: null as string | null, nonPersistent: true }, - offerCode: { default: "" as string, nonPersistent: true }, - answerCode: { default: "" as string, nonPersistent: true }, - bannerText: { default: "" as string, nonPersistent: true }, - bannerError: { default: false as boolean, nonPersistent: true }, - hostAnswerInput: { default: "" as string, nonPersistent: true }, - joinerOfferInput: { default: "" as string, nonPersistent: true }, - // The synced game database, populated by the connection service after the - // WebRTC channel opens. `unknown` so the negotiation plugin stays - // game-agnostic; the UI narrows it at the render boundary. - gameDb: { default: null as unknown, nonPersistent: true }, -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/main-service.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/main-service.ts deleted file mode 100644 index 6df0f9303..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/main-service.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// The assembled negotiation feature database. Its topmost layer is -// `action-database` (adds the UI-facing actions). Every consumer — `ui/`, the app -// entry, `conformance/` — references `MainService`, never the topmost layer, so -// adding or dropping a layer changes only this one line. -export { ActionDatabase as MainService } from "./action-database/action-database.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/service-database.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/service-database.ts deleted file mode 100644 index db37f1cf0..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/service-database.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { TransactionDatabase } from "../transaction-database/transaction-database.js"; -import { createConnectionService } from "./services/index.js"; - -const serviceDatabasePlugin = Database.Plugin.create({ - extends: TransactionDatabase.plugin, - services: { - connection: (db) => createConnectionService(db), - }, -}); - -export type ServiceDatabase = Database.Plugin.ToDatabase< - typeof serviceDatabasePlugin ->; - -export namespace ServiceDatabase { - export const plugin = serviceDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.test.ts deleted file mode 100644 index f3902a55e..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { Database } from "@adobe/data/ecs"; -import { TransactionDatabase } from "../../transaction-database/transaction-database.js"; -import { createFake } from "../../../signaling-service/signaling.fake.js"; -import { createConnectionService } from "./create-connection-service.js"; - -// A microtask/timer flush so the fake signaling promises resolve. -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -describe("connection service (deterministic fake signaling)", () => { - it("startHost enters host-signaling and records the invite code", async () => { - const db = Database.create(TransactionDatabase.plugin); - const service = createConnectionService(db, createFake); - - service.startHost(); - expect(db.resources.phase).toBe("host-signaling"); - expect(db.resources.role).toBe("host"); - expect(db.resources.connection).toBe("connecting"); - - await flush(); - expect(db.resources.offerCode).toBe("fake-invite-code"); - }); - - it("generateAnswer records the answer code", async () => { - const db = Database.create(TransactionDatabase.plugin); - const service = createConnectionService(db, createFake); - - db.transactions.setJoinerOfferInput({ value: "OFFER-xyz" }); - service.startJoin(); - expect(db.resources.phase).toBe("join-signaling"); - - service.generateAnswer(); - await flush(); - expect(db.resources.answerCode).toBe("fake-answer-code"); - }); -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transaction-database.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transaction-database.ts deleted file mode 100644 index 6f05583e0..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transaction-database.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { CoreDatabase } from "../core-database/core-database.js"; -import * as transactions from "./transactions/index.js"; - -const transactionDatabasePlugin = Database.Plugin.create({ - extends: CoreDatabase.plugin, - transactions, -}); - -/** - * The negotiation state surface the connection service writes to (resources + - * transactions). The imperative `connection` service is layered on top by the - * service database. - */ -export type TransactionDatabase = Database.Plugin.ToDatabase< - typeof transactionDatabasePlugin ->; - -export namespace TransactionDatabase { - export const plugin = transactionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-answer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-answer-code.ts deleted file mode 100644 index 83a011707..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-answer-code.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const setAnswerCode = (t: CoreDatabase.Store, { code }: { code: string }) => { - t.resources.answerCode = code; - t.resources.bannerText = ""; -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-banner.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-banner.ts deleted file mode 100644 index 6a2bd1081..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-banner.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const setBanner = ( - t: CoreDatabase.Store, - { text, error = false }: { text: string; error?: boolean }, -) => { - t.resources.bannerText = text; - t.resources.bannerError = error; -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-connection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-connection.ts deleted file mode 100644 index 5822925e3..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-connection.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ConnectionState } from "../../../../data/connection-state/connection-state.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const setConnection = ( - t: CoreDatabase.Store, - { connection, sessionId }: { connection: ConnectionState; sessionId?: string | null }, -) => { - t.resources.connection = connection; - if (sessionId !== undefined) t.resources.sessionId = sessionId; -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-game-db.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-game-db.ts deleted file mode 100644 index 4cfc67d1e..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-game-db.ts +++ /dev/null @@ -1,15 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; - -/** - * Store the constructed (non-serializable) game database handle and transition - * to the live game. The visible phase / connection change is exactly the pure - * `State.enterGame` transform — see `set-game-db.test.ts`, which conforms this - * transaction to it by passing `gameDb: null` (the handle is invisible to the - * spec's serializable `State`). - */ -export const setGameDb = (t: CoreDatabase.Store, { gameDb }: { gameDb: unknown }) => { - t.resources.gameDb = gameDb; - t.resources.phase = "game"; - t.resources.connection = "connected"; -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-host-answer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-host-answer-input.ts deleted file mode 100644 index 3ad8004fd..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-host-answer-input.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const setHostAnswerInput = ( - t: CoreDatabase.Store, - { value }: { value: string }, -) => { - t.resources.hostAnswerInput = value; -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-joiner-offer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-joiner-offer-input.ts deleted file mode 100644 index 060e118e6..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-joiner-offer-input.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const setJoinerOfferInput = ( - t: CoreDatabase.Store, - { value }: { value: string }, -) => { - t.resources.joinerOfferInput = value; -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-offer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-offer-code.ts deleted file mode 100644 index 17ff29a3e..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-offer-code.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const setOfferCode = (t: CoreDatabase.Store, { code }: { code: string }) => { - t.resources.offerCode = code; - t.resources.bannerText = ""; -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-host-signaling.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-host-signaling.ts deleted file mode 100644 index 475727121..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-host-signaling.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const startHostSignaling = (t: CoreDatabase.Store) => { - t.resources.phase = "host-signaling"; - t.resources.role = "host"; - t.resources.connection = "connecting"; - t.resources.bannerText = "Generating invite code — please wait…"; - t.resources.bannerError = false; -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-join-signaling.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-join-signaling.ts deleted file mode 100644 index fc85fd537..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-join-signaling.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const startJoinSignaling = (t: CoreDatabase.Store) => { - t.resources.phase = "join-signaling"; - t.resources.role = "joiner"; - t.resources.connection = "connecting"; - t.resources.bannerText = ""; - t.resources.bannerError = false; -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/create-fake.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/create-fake.ts new file mode 100644 index 000000000..08bd48c82 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/create-fake.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { SignalingService } from "./signaling-service.js"; + +/** + * Fake {@link SignalingService}: no WebRTC I/O, never fires `onConnected`. The + * code-exchange methods resolve fixed placeholder codes; `void` methods are inert. + */ +export const createFake = (): SignalingService => ({ + serviceName: "signaling", + createHostInvite: () => Promise.resolve("fake-invite-code"), + acceptHostAnswer: () => Promise.resolve(), + createJoinAnswer: () => Promise.resolve("fake-answer-code"), + reset: () => {}, + dispose: () => {}, +}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/create.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/create.ts index 1f321812e..fee73b97e 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/create.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/create.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { startHostSignaling, startJoinerSignaling } from "./internal/signaling.js"; import { createRenegotiator } from "./internal/renegotiator.js"; -import type { Role } from "../../data/role/role.js"; +import type { Role } from "../../data/values/role/role.js"; import type { SignalingService } from "./signaling-service.js"; import type { Handlers, PeerLink } from "./types.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/public.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/public.ts index 826f850ee..3e5a7d568 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/public.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/public.ts @@ -1,3 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export type * from "./types.js"; export { create } from "./create.js"; +export { createFake } from "./create-fake.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/signaling.fake.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/signaling.fake.ts deleted file mode 100644 index 6e3baebb5..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/signaling.fake.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { SignalingService } from "./signaling-service.js"; - -/** - * Shape-only recording template for {@link SignalingService} — a TEST-TIER double, - * NOT re-exported through `public.ts`, so it can never be reached as - * `SignalingService.createFake` from runtime code. Every method is present: `void` - * methods are inert, and the value-returning code-exchange methods hand back fixed - * placeholder codes (it performs no WebRTC I/O and never fires `onConnected`). It - * takes no handlers/response parameters. Imported directly by the negotiation - * main-service tests (and the connection-service unit test) and nowhere else. - */ -export const createFake = (): SignalingService => ({ - serviceName: "signaling", - createHostInvite: () => Promise.resolve("fake-invite-code"), - acceptHostAnswer: () => Promise.resolve(), - createJoinAnswer: () => Promise.resolve("fake-answer-code"), - reset: () => {}, - dispose: () => {}, -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/create.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/create.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/create.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/create.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/spec/enter-game.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/enter-game.cases.ts new file mode 100644 index 000000000..0cb829489 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/enter-game.cases.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { enterGame } from "./enter-game.js"; + +// Inert cases for `enterGame`, run against the pure spec and the `enterGame` action. A +// no-arg transition. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "moves to the game phase, connected", + before: { phase: "host-signaling", connection: "connecting", role: "host" }, + after: { phase: "game", connection: "connected" }, + }, + ], +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/enter-game.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/enter-game.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/spec/public.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/public.ts new file mode 100644 index 000000000..69dfc9c89 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/public.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { create } from "./create.js"; +export { samples } from "./samples.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/samples.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/samples.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/samples.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/samples.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-answer-code.cases.ts similarity index 83% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.cases.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/set-answer-code.cases.ts index c2107ee35..8e90ecf4a 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.cases.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-answer-code.cases.ts @@ -3,8 +3,8 @@ import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { setAnswerCode } from "./set-answer-code.js"; -// Inert, spec-owned cases for `setAnswerCode`, shared with the ecs `setAnswerCode` -// transaction and action. +// Inert cases for `setAnswerCode`, run against the pure spec and the `setAnswerCode` +// action. export const cases: Conformance.SpecCases = { cases: [ { diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-answer-code.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/set-answer-code.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-banner.cases.ts similarity index 87% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.cases.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/set-banner.cases.ts index cc93dc972..c20a71d66 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.cases.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-banner.cases.ts @@ -3,8 +3,7 @@ import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { setBanner } from "./set-banner.js"; -// Inert, spec-owned cases for `setBanner`, shared with the ecs `setBanner` -// transaction and action. +// Inert cases for `setBanner`, run against the pure spec and the `setBanner` action. export const cases: Conformance.SpecCases = { cases: [ { diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-banner.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/set-banner.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-connection.cases.ts similarity index 82% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.cases.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/set-connection.cases.ts index 51e48ef83..959938fb6 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.cases.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-connection.cases.ts @@ -3,8 +3,8 @@ import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { setConnection } from "./set-connection.js"; -// Inert, spec-owned cases for `setConnection`, shared with the ecs `setConnection` -// transaction and action. Omitting `sessionId` leaves the existing value untouched. +// Inert cases for `setConnection`, run against the pure spec and the `setConnection` +// action. Omitting `sessionId` leaves the existing value untouched. export const cases: Conformance.SpecCases = { cases: [ { diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-connection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-connection.ts new file mode 100644 index 000000000..17c330fe1 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-connection.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ConnectionState } from "../data/values/connection-state/connection-state.js"; +import type { State } from "./state.js"; + +/** + * Update the connection lifecycle, optionally recording the sync session id. + * Omitting `sessionId` leaves the existing value untouched. + */ +export const setConnection = ( + state: T, + { connection, sessionId }: { connection: ConnectionState; sessionId?: string | null }, +): T => ({ + ...state, + connection, + sessionId: sessionId !== undefined ? sessionId : state.sessionId, +}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-host-answer-input.cases.ts similarity index 80% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.cases.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/set-host-answer-input.cases.ts index c6660f5e8..b1193b78e 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.cases.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-host-answer-input.cases.ts @@ -3,8 +3,8 @@ import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { setHostAnswerInput } from "./set-host-answer-input.js"; -// Inert, spec-owned cases for `setHostAnswerInput`, shared with the ecs -// `setHostAnswerInput` transaction and action. +// Inert cases for `setHostAnswerInput`, run against the pure spec and the +// `setHostAnswerInput` action. export const cases: Conformance.SpecCases = { cases: [ { diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-host-answer-input.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/set-host-answer-input.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-joiner-offer-input.cases.ts similarity index 79% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.cases.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/set-joiner-offer-input.cases.ts index 99903f98f..e4b762e42 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.cases.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-joiner-offer-input.cases.ts @@ -3,8 +3,8 @@ import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { setJoinerOfferInput } from "./set-joiner-offer-input.js"; -// Inert, spec-owned cases for `setJoinerOfferInput`, shared with the ecs -// `setJoinerOfferInput` transaction and action. +// Inert cases for `setJoinerOfferInput`, run against the pure spec and the +// `setJoinerOfferInput` action. export const cases: Conformance.SpecCases = { cases: [ { diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-joiner-offer-input.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/set-joiner-offer-input.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-offer-code.cases.ts similarity index 82% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.cases.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/set-offer-code.cases.ts index 5a5a6dcc0..3df86fcd5 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.cases.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-offer-code.cases.ts @@ -3,8 +3,8 @@ import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { setOfferCode } from "./set-offer-code.js"; -// Inert, spec-owned cases for `setOfferCode`, shared with the ecs `setOfferCode` -// transaction and action. +// Inert cases for `setOfferCode`, run against the pure spec and the `setOfferCode` +// action. export const cases: Conformance.SpecCases = { cases: [ { diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-offer-code.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/set-offer-code.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/spec/spec.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/spec.test.ts new file mode 100644 index 000000000..bdab1adf7 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/spec.test.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "./spec.js"; + +// Runs every case against the pure spec functions — no ECS involved. +Conformance.checkSpec(spec); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/spec/spec.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/spec.ts new file mode 100644 index 000000000..9d523f64e --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/spec.ts @@ -0,0 +1,33 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; + +import { cases as startHostSignaling } from "./start-host-signaling.cases.js"; +import { cases as startJoinSignaling } from "./start-join-signaling.cases.js"; +import { cases as setOfferCode } from "./set-offer-code.cases.js"; +import { cases as setAnswerCode } from "./set-answer-code.cases.js"; +import { cases as setBanner } from "./set-banner.cases.js"; +import { cases as setConnection } from "./set-connection.cases.js"; +import { cases as setHostAnswerInput } from "./set-host-answer-input.cases.js"; +import { cases as setJoinerOfferInput } from "./set-joiner-offer-input.cases.js"; +import { cases as enterGame } from "./enter-game.cases.js"; + +// The negotiation spec: every transition, one inert cases module each. No injected +// services (signaling is driven by the `connection` service, outside the spec), no +// derivations, no entity references. `ecs/conformance/` pairs it with the ECS build. +export const spec = Conformance.spec({ + state: State, + fns: transforms, + cases: { + startHostSignaling, + startJoinSignaling, + setOfferCode, + setAnswerCode, + setBanner, + setConnection, + setHostAnswerInput, + setJoinerOfferInput, + enterGame, + }, +}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/spec/start-host-signaling.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/start-host-signaling.cases.ts new file mode 100644 index 000000000..f5626f8d7 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/start-host-signaling.cases.ts @@ -0,0 +1,22 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { startHostSignaling } from "./start-host-signaling.js"; + +// Inert cases for `startHostSignaling`, run against the pure spec and the +// `startHostSignaling` action. A no-arg transition; `before` is a delta over +// `State.create()`, `after` the writes patch. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "enters host-signaling as host, connecting, with a waiting banner", + before: {}, + after: { + phase: "host-signaling", + role: "host", + connection: "connecting", + bannerText: "Generating invite code — please wait…", + }, + }, + ], +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/start-host-signaling.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/start-host-signaling.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/start-join-signaling.cases.ts similarity index 77% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.cases.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/start-join-signaling.cases.ts index 42a428afe..572cf91a7 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.cases.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/start-join-signaling.cases.ts @@ -3,9 +3,9 @@ import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { startJoinSignaling } from "./start-join-signaling.js"; -// Inert, spec-owned cases for `startJoinSignaling`, shared with the ecs -// `startJoinSignaling` transaction and action. A no-arg transition; it clears any -// stale banner on entry. +// Inert cases for `startJoinSignaling`, run against the pure spec and the +// `startJoinSignaling` action. A no-arg transition; it clears any stale banner on +// entry. export const cases: Conformance.SpecCases = { cases: [ { diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/start-join-signaling.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/start-join-signaling.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/spec/state.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/state.ts new file mode 100644 index 000000000..d9d617210 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/state.ts @@ -0,0 +1,31 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Phase } from "../data/values/phase/phase.js"; +import type { ConnectionState } from "../data/values/connection-state/connection-state.js"; +import type { Role } from "../data/values/role/role.js"; +import type { Assert } from "@adobe/data/types"; +import type { Conformance } from "@adobe/data-testing"; +import type { resources } from "../data/resources/index.js"; + +/** + * The serializable negotiation (signaling) state as one immutable object — the + * spec the ECS main-service is verified against. The non-serializable game + * database handle is deliberately *not* modelled here: it is session-only ECS + * state, invisible to this pure spec. + */ +export type State = { + readonly phase: Phase; + readonly connection: ConnectionState; + readonly role: Role | null; + readonly sessionId: string | null; + readonly offerCode: string; + readonly answerCode: string; + readonly bannerText: string; + readonly bannerError: boolean; + readonly hostAnswerInput: string; + readonly joinerOfferInput: string; +}; + +// Every key names a resource (negotiation has no components). +type _Pin = Assert>; + +export * as State from "./public.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/spec/transforms.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/transforms.ts new file mode 100644 index 000000000..0d34673ab --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/transforms.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The conformed functions of this feature — every spec transition, and nothing else. +// Its keys are the conformance surface: the spec's coverage guard requires exactly +// one `*.cases.ts` module per key. +export { startHostSignaling } from "./start-host-signaling.js"; +export { startJoinSignaling } from "./start-join-signaling.js"; +export { setOfferCode } from "./set-offer-code.js"; +export { setAnswerCode } from "./set-answer-code.js"; +export { setBanner } from "./set-banner.js"; +export { setConnection } from "./set-connection.js"; +export { setHostAnswerInput } from "./set-host-answer-input.js"; +export { setJoinerOfferInput } from "./set-joiner-offer-input.js"; +export { enterGame } from "./enter-game.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-app/p2p-app.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-app/p2p-app.ts deleted file mode 100644 index 186cd87d5..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-app/p2p-app.ts +++ /dev/null @@ -1,25 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// Pure composition wrapper for the serverless P2P Tic-Tac-Toe shell. No custom -// element here — the bootstrap is just data: combine the game plugin with the -// presence peer plugin, decide who is X / O, and pass lazy renderers for the game -// and presence overlay to the generic Negotiation container. - -import type { TemplateResult } from "lit"; -import { Database } from "@adobe/data/ecs"; -import { Tictactoe, TictactoeGameDatabase, PlayerMark } from "data-lit-tictactoe"; -import { MainService as PresenceMainService } from "../../../presence/services/main-service/main-service.js"; -import { PresenceOverlay } from "../../../presence/ui/p2p-presence-overlay/p2p-presence-overlay.js"; -import { Negotiation } from "../p2p-negotiation/p2p-negotiation.js"; - -const gamePlugin = Database.Plugin.combine(TictactoeGameDatabase.plugin, PresenceMainService.plugin); - -const assignUserId = (role: "host" | "joiner"): PlayerMark => - PlayerMark.values[role === "host" ? 0 : 1]; - -export const P2pApp = (): TemplateResult => Negotiation({ - gamePlugin, - assignUserId, - renderGame: ({ service }) => Tictactoe({ service }), - renderPresence: ({ service, children }) => PresenceOverlay({ service, children }), -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-negotiation/p2p-negotiation-element.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-negotiation/p2p-negotiation-element.ts index c94b543e2..f4da61d00 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-negotiation/p2p-negotiation-element.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-negotiation/p2p-negotiation-element.ts @@ -2,14 +2,12 @@ // // Container for serverless P2P play. The negotiation main-service's `connection` // service is the imperative signaling machine; this element renders purely from -// observable state and forwards user intent through `service.actions.*`. It -// supplies the game-specific config once after mount and tears the service down -// on unmount. No business logic, no full-database access. +// observable state and forwards user intent through `service.actions.*`, and tears +// the connection down on unmount. No business logic, no full-database access. import { customElement, property } from "lit/decorators.js"; -import { Database } from "@adobe/data/ecs"; import { DatabaseElement, useObservableValues, useEffect } from "@adobe/data-lit"; -import { MainService } from "../../services/main-service/main-service.js"; +import { MainService } from "../../ecs/main-service.js"; import { copyText } from "../copy-text.js"; import { styles } from "./p2p-negotiation.css.js"; import * as presentation from "./p2p-negotiation-presentation.js"; @@ -23,22 +21,10 @@ declare global { } } -type GamePlugin = Database.Plugin; -type AssignUserId = (role: "host" | "joiner") => string; - @customElement(tagName) export class P2pNegotiationElement extends DatabaseElement { static styles = styles; - // Game-specific config: which game to negotiate and how to assign peer ids. - // Supplied to the service via `configure` after mount (props do not exist - // when the database is created during connectedCallback). - @property({ attribute: false }) - gamePlugin!: GamePlugin; - - @property({ attribute: false }) - assignUserId!: AssignUserId; - @property({ attribute: false }) renderGame!: RenderGame; @@ -50,7 +36,7 @@ export class P2pNegotiationElement extends DatabaseElement ({ phase: observe.resources.phase, @@ -64,12 +50,8 @@ export class P2pNegotiationElement extends DatabaseElement { - actions.configure({ gamePlugin: this.gamePlugin, assignUserId: this.assignUserId }); - return () => actions.dispose(); - }, []); + // Tear down the WebRTC / sync machinery on unmount. + useEffect(() => () => actions.dispose(), []); if (!values) return undefined; @@ -82,8 +64,8 @@ export class P2pNegotiationElement extends DatabaseElement actions.submitAnswer(), generateAnswer: () => actions.generateAnswer(), reconnect: () => actions.reconnect(), - setHostAnswerInput: (value) => transactions.setHostAnswerInput({ value }), - setJoinerOfferInput: (value) => transactions.setJoinerOfferInput({ value }), + setHostAnswerInput: (value) => actions.setHostAnswerInput({ value }), + setJoinerOfferInput: (value) => actions.setJoinerOfferInput({ value }), copyText, }); } diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-negotiation/p2p-negotiation-presentation.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-negotiation/p2p-negotiation-presentation.ts index 5be84da1d..84186d629 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-negotiation/p2p-negotiation-presentation.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-negotiation/p2p-negotiation-presentation.ts @@ -1,8 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { html, nothing, type TemplateResult } from "lit"; -import type { Phase } from "../../data/phase/phase.js"; -import type { ConnectionState } from "../../data/connection-state/connection-state.js"; +import type { Phase } from "../../data/values/phase/phase.js"; +import type { ConnectionState } from "../../data/values/connection-state/connection-state.js"; export type RenderGame = (args: { service: unknown }) => TemplateResult; export type RenderPresence = (args: { service: unknown; children: TemplateResult }) => TemplateResult; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-negotiation/p2p-negotiation.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-negotiation/p2p-negotiation.ts index d82afd4ef..bd05ed09d 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-negotiation/p2p-negotiation.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-negotiation/p2p-negotiation.ts @@ -1,26 +1,31 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { html, type TemplateResult } from "lit"; -import type { Database } from "@adobe/data/ecs"; +import { Database } from "@adobe/data/ecs"; +import { MainService } from "../../ecs/main-service.js"; +import type { GameService } from "../../services/game-service/game-service.js"; import type { RenderGame, RenderPresence } from "./p2p-negotiation-presentation.js"; /** - * Generic over `P` (the game plugin) so callers' `renderGame` and - * `renderPresence` callbacks receive a fully-typed `service` argument matching - * the plugin they passed in. The element internally stores the game database as - * `unknown` (the negotiation plugin is monomorphic), so we adapt the typed - * callbacks at this boundary. The cast is justified: the connection service - * constructs the game database from `args.gamePlugin`, so by construction the - * value stored in the `gameDb` resource is a `Database.Plugin.ToDatabase

`. + * Mounts the negotiation for `game`: builds the negotiation database with `game` + * injected and binds it to the element. Render it once per session — each call + * builds a fresh database. + * + * Generic over the game plugin `P` so `renderGame` / `renderPresence` receive a + * fully typed `service`. The element stores the game database as `unknown` (the + * negotiation plugin is game-agnostic), so the typed callbacks are adapted here. + * The cast holds by construction: the `connection` service builds the game + * database from `game.plugin`, which is this `P`. */ export const Negotiation =

(args: { - gamePlugin: P; - assignUserId: (role: "host" | "joiner") => string; + game: GameService

; renderGame: (args: { service: Database.Plugin.ToDatabase

}) => TemplateResult; renderPresence?: (args: { service: Database.Plugin.ToDatabase

; children: TemplateResult }) => TemplateResult; }): TemplateResult => { void import("./p2p-negotiation-element.js"); + const service = Database.create(MainService.plugin, { services: { game: args.game } }); + type GameDb = Database.Plugin.ToDatabase

; const renderGame: RenderGame = ({ service }) => args.renderGame({ service: service as GameDb }); @@ -30,8 +35,7 @@ export const Negotiation =

(args: { return html` diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/cursors/public.ts b/packages/data-p2p-tictactoe/src/features/presence/data/cursors/public.ts deleted file mode 100644 index 1e5eef992..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/data/cursors/public.ts +++ /dev/null @@ -1,2 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export * from "./set.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/resources/cursors.ts b/packages/data-p2p-tictactoe/src/features/presence/data/resources/cursors.ts new file mode 100644 index 000000000..98aec28e4 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/data/resources/cursors.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ResourceSchema } from "@adobe/data/ecs"; +import type { Cursors } from "../values/cursors/cursors.js"; + +// Peer cursor positions, written only through transient (never-committed) envelopes. +// Default-only (no `type`): a typed object of `Vec2`s would be struct-packed, and a +// struct column needs every field while each mark's cursor is optional. +// Document scope, not `Scope.presence`: sync never replicates a `nonPersistent` +// resource, and these must reach the peer. +export const cursors = { default: {} as Cursors } satisfies ResourceSchema; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/resources/index.ts b/packages/data-p2p-tictactoe/src/features/presence/data/resources/index.ts new file mode 100644 index 000000000..06d9b28ab --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/data/resources/index.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { cursors } from "./cursors.js"; + +export const resources = { cursors }; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.ts deleted file mode 100644 index ec9fbc588..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { PlayerMark } from "data-lit-tictactoe"; -import { Cursors } from "../cursors/cursors.js"; -import type { State } from "./state.js"; - -/** - * Update the given peer's cursor position — a `{ cursors }` patch. `mark` identifies - * the peer; in the ecs implementation it is injected from the transaction's `userId` - * (the peer's assigned mark), so each peer only ever writes its own entry. - */ -export const movePresence = ( - state: Pick, - { mark, x, y }: { mark: PlayerMark; x: number; y: number }, -): Pick => ({ - cursors: Cursors.set(state.cursors, mark, x, y), -}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/public.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/public.ts deleted file mode 100644 index 437afe363..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/public.ts +++ /dev/null @@ -1,4 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { create } from "./create.js"; -export { samples } from "./samples.js"; -export { movePresence } from "./move-presence.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts deleted file mode 100644 index e627bf405..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "./spec.js"; - -// The pure-spec test for every transform in this folder — runs the manifest's inert -// cases against the pure functions. Presence cursor positions are `Vec2` tuples -// (compared in order); `cursors` compares by key. -Conformance.checkSpec(spec); diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.ts deleted file mode 100644 index 60221ff6e..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import * as transforms from "./transforms.js"; -import { MainService } from "../../services/main-service/main-service.js"; -import { projection } from "../../services/main-service/conformance/projection.js"; -import { cases as movePresence } from "./move-presence.cases.js"; - -// The presence conformance manifest. No services (the peer `mark` is ambient `userId`, -// not an injected service), no derivations (no `computedPlugin`/`hydrate`). The pure -// side runs through `checkSpec`; the ecs side needs per-surface `seedContext` + a custom -// concurrency, so it drives the lower runners via `Conformance.adaptCases(spec, …)` -// (see the conformance/ escape-hatch tests) rather than `checkFeature`. -export const spec = Conformance.feature({ - state: State, - fns: transforms, - plugin: MainService.plugin, - projection, - cases: { movePresence }, -}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/state.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/state.ts deleted file mode 100644 index 38b9115bc..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/state.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Cursors } from "../cursors/cursors.js"; - -/** - * The presence state as one immutable object: the peer cursor positions. The - * spec the ECS presence main-service is verified against. - */ -export type State = { - readonly cursors: Cursors; -}; -export * as State from "./public.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/cursors/cursors.ts b/packages/data-p2p-tictactoe/src/features/presence/data/values/cursors/cursors.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/presence/data/cursors/cursors.ts rename to packages/data-p2p-tictactoe/src/features/presence/data/values/cursors/cursors.ts diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/values/cursors/public.ts b/packages/data-p2p-tictactoe/src/features/presence/data/values/cursors/public.ts new file mode 100644 index 000000000..e90d09c06 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/data/values/cursors/public.ts @@ -0,0 +1,2 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { set } from "./set.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/cursors/set.ts b/packages/data-p2p-tictactoe/src/features/presence/data/values/cursors/set.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/presence/data/cursors/set.ts rename to packages/data-p2p-tictactoe/src/features/presence/data/values/cursors/set.ts diff --git a/packages/data-p2p-tictactoe/src/features/presence/ecs/actions/action-database.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/actions/action-database.ts new file mode 100644 index 000000000..e0cd18c68 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/actions/action-database.ts @@ -0,0 +1,24 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; +import * as actions from "./index.js"; + +/** + * The assembled presence plugin: state surface + the presence actions. + * Combined onto the synced game database at the app shell (see `app/schema.ts` + * composition) — presence is a P2P-specific concern, so it lives here rather than + * in the standalone game library. + */ +const actionDatabasePlugin = Database.Plugin.create({ + extends: TransactionDatabase.plugin, + actions, +}); + +export type ActionDatabase = Database.Plugin.ToDatabase< + typeof actionDatabasePlugin +>; + +export namespace ActionDatabase { + export const plugin = actionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-p2p-tictactoe/src/features/presence/ecs/actions/index.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/actions/index.ts new file mode 100644 index 000000000..04758fdc5 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/actions/index.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// `trackPresence` streams the UI's pointer positions; `movePresence` is the discrete +// action for the spec's `movePresence` transition. +export * from "./track-presence.js"; +export * from "./move-presence.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/ecs/actions/move-presence.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/actions/move-presence.ts new file mode 100644 index 000000000..39c73d9c5 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/actions/move-presence.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../transactions/transaction-database.js"; + +// Commit one cursor move for the calling peer. The peer identity is the +// transaction's `userId`, so this takes only the `{ x, y }` payload. +export const movePresence = (db: TransactionDatabase, { x, y }: { x: number; y: number }) => { + db.transactions.movePresence({ x, y }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/track-presence.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/actions/track-presence.ts similarity index 85% rename from packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/track-presence.ts rename to packages/data-p2p-tictactoe/src/features/presence/ecs/actions/track-presence.ts index ab2f718a2..7518f044a 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/track-presence.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/actions/track-presence.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; +import type { TransactionDatabase } from "../transactions/transaction-database.js"; /** * Pump a stream of normalised cursor positions into the synced presence diff --git a/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/conformance.test.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/conformance.test.ts new file mode 100644 index 000000000..66a983cd5 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/conformance.test.ts @@ -0,0 +1,54 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it } from "vitest"; +import { Database, Store } from "@adobe/data/ecs"; +import type { ConcurrencyStrategyFactory } from "@adobe/data/ecs"; +import { Conformance, Match } from "@adobe/data-testing"; +import { MainService } from "../main-service.js"; +import { implementation } from "./implementation.js"; + +// `checkFeature` can't seed the peer identity, so this drives the lower-level +// runners from the same manifest. `movePresence` reads the transaction `userId`, +// which the db's concurrency stamps at dispatch: this test-only concurrency reads it +// from a closure that `seedContext` primes with the case's `mark`, and otherwise +// commits immediately. +const { spec, projection } = implementation; + +let peerUserId: string | undefined; +const peerConcurrency: ConcurrencyStrategyFactory = (execute, getTransaction) => ({ + deferredCommit: false, + apply: (envelope) => { + if (envelope.time === 0) return undefined; + const transaction = getTransaction(envelope.name); + if (!transaction) throw new Error(`Unknown transaction: ${envelope.name}`); + return execute((t) => transaction(t, envelope.args), { + intermediate: envelope.time < 0, + userId: peerUserId, + }); + }, + cancel: () => {}, + onReset: () => {}, +}); + +Conformance.runActions({ + makeDb: () => Database.toSystemDatabase(Database.create(MainService.plugin, { concurrency: peerConcurrency })), + store: (db) => db.store, + fromState: projection.fromState, + toState: projection.toState, + initial: spec.state.create(), + transitions: Conformance.adaptCases(spec.fns, spec.cases, spec.services, true), + actions: MainService.plugin.actions, + seedContext: (_db, _before, args) => { + // Runtime invariant: presence cases carry a `mark` (see move-presence.cases.ts). + peerUserId = (args as { mark: string }).mark; + }, +}); + +describe("projection round-trips (toState ∘ fromState ≡ identity)", () => { + (spec.state.samples ?? []).forEach((sample, i) => { + it(`sample ${i}`, () => { + const store = Store.create(MainService.plugin); + projection.fromState(store, sample); + Match.assert(projection.toState(store), sample); + }); + }); +}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/implementation.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/implementation.ts new file mode 100644 index 000000000..05c3709b4 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/implementation.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "../../spec/spec.js"; +import { MainService } from "../main-service.js"; +import { projection } from "./projection.js"; + +// The spec paired with its ECS build: `movePresence` against the same-named action. +// `conformance.test.ts` runs it through the lower-level runners, because the action +// needs the peer `userId` seeded per case. +export const implementation = Conformance.implementation(spec, { + plugin: MainService.plugin, + projection, +}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/projection.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/projection.ts new file mode 100644 index 000000000..e1284022f --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/projection.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { State } from "../../spec/state.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// The test-only store ⇄ `State` projection. Presence keeps its whole state in the +// `cursors` resource — there are no entity collections — so `fromState` seeds that one +// resource and returns an empty `id → entity` map (the runners' `resolve` is never +// used), and there is no per-entity `toData`. +const fromState = (store: CoreDatabase.Store, state: State): ReadonlyMap => { + store.resources.cursors = state.cursors; + return new Map(); +}; + +const toState = (store: CoreDatabase.Store): State => ({ cursors: store.resources.cursors }); + +export const projection = { fromState, toState }; diff --git a/packages/data-p2p-tictactoe/src/features/presence/ecs/core/core-database.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/core/core-database.ts new file mode 100644 index 000000000..ed07cc2f0 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/core/core-database.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { resources } from "../../data/resources/index.js"; + +const coreDatabasePlugin = Database.Plugin.create({ + resources, +}); + +export type CoreDatabase = Database.Plugin.ToDatabase; + +export namespace CoreDatabase { + export const plugin = coreDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-p2p-tictactoe/src/features/presence/ecs/main-service.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/main-service.ts new file mode 100644 index 000000000..78c573d99 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/main-service.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// The assembled presence feature database. Its topmost layer is `action-database` +// (adds the presence actions). Every consumer references `MainService`, +// never the topmost layer. Combined onto the game database at the app shell. +export { ActionDatabase as MainService } from "./actions/action-database.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transactions/index.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/transactions/index.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transactions/index.ts rename to packages/data-p2p-tictactoe/src/features/presence/ecs/transactions/index.ts diff --git a/packages/data-p2p-tictactoe/src/features/presence/ecs/transactions/move-presence.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/transactions/move-presence.ts new file mode 100644 index 000000000..a04866472 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/transactions/move-presence.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { PlayerMark } from "data-lit-tictactoe"; +import { Cursors } from "../../data/values/cursors/cursors.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +/** + * Update the calling peer's cursor position. Driven as a never-ending + * async-generator transaction by the `trackPresence` action — each yield applies + * as a transient (never committed) envelope. Reads `t.userId` as a `PlayerMark` + * so each peer only writes its own cursor entry; foreign or unset `userId`s are + * ignored. + */ +export const movePresence = (t: CoreDatabase.Store, { x, y }: { x: number; y: number }) => { + if (!PlayerMark.is(t.userId)) return; + t.resources.cursors = Cursors.set(t.resources.cursors, t.userId, x, y); +}; diff --git a/packages/data-p2p-tictactoe/src/features/presence/ecs/transactions/transaction-database.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/transactions/transaction-database.ts new file mode 100644 index 000000000..c9bc5f895 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/transactions/transaction-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { CoreDatabase } from "../core/core-database.js"; +import * as transactions from "./index.js"; + +const transactionDatabasePlugin = Database.Plugin.create({ + extends: CoreDatabase.plugin, + transactions, +}); + +export type TransactionDatabase = Database.Plugin.ToDatabase< + typeof transactionDatabasePlugin +>; + +export namespace TransactionDatabase { + export const plugin = transactionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/action-database.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/action-database.ts deleted file mode 100644 index 0bdd4b728..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/action-database.ts +++ /dev/null @@ -1,24 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { TransactionDatabase } from "../transaction-database/transaction-database.js"; -import * as actions from "./actions/index.js"; - -/** - * The assembled presence plugin: state surface + the `trackPresence` action. - * Combined onto the synced game database at the app shell (see the `p2p-app` - * composition) — presence is a P2P-specific concern, so it lives here rather than - * in the standalone game library. - */ -const actionDatabasePlugin = Database.Plugin.create({ - extends: TransactionDatabase.plugin, - actions, -}); - -export type ActionDatabase = Database.Plugin.ToDatabase< - typeof actionDatabasePlugin ->; - -export namespace ActionDatabase { - export const plugin = actionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/index.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/index.ts deleted file mode 100644 index 1737d13ae..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// The `actions` plugin facet. Only `trackPresence` (the UI-facing streaming pump) -// is registered. The same-named per-transition action `move-presence.ts` (the -// discrete realization of `State.movePresence`) is deliberately NOT re-exported -// into the facet: it is conformance-only and the UI never dispatches it (it uses -// the streaming `trackPresence`). It lives beside this barrel as a plain function -// exercised directly by `conformance/actions.test.ts` — mirroring how the -// reference runner imports and calls action functions, never through `db.actions`. -export * from "./track-presence.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/move-presence.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/move-presence.ts deleted file mode 100644 index 419f518e2..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/move-presence.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; - -// App-facing realization of `State.movePresence`: commit one cursor move for the -// calling peer through a single (non-streaming) transaction dispatch. The peer -// identity (`mark`) is read by the transaction from the store's `userId`, so this -// takes only the plain `{ x, y }` payload. The live UI drives cursor updates as a -// stream via the `trackPresence` action; this discrete action is the same-named -// transition realization the conformance runner exercises. -export const movePresence = (db: TransactionDatabase, { x, y }: { x: number; y: number }) => { - db.transactions.movePresence({ x, y }); -}; diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts deleted file mode 100644 index 9561ccb63..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Database } from "@adobe/data/ecs"; -import type { ConcurrencyStrategyFactory } from "@adobe/data/ecs"; -import { Conformance } from "@adobe/data-testing"; -import { spec } from "../../../data/state/spec.js"; -import { MainService } from "../main-service.js"; -import { projection } from "./projection.js"; - -// The escape hatch (see conformance.md): `movePresence`'s peer identity is the -// transaction `userId`, stamped by the db's concurrency at dispatch. A test-only -// concurrency reads it from a closure that `seedContext` primes with the case's `mark` -// just before the action runs; it otherwise commits immediately. The per-transition -// `movePresence` action isn't in the facet barrel (the UI streams via `trackPresence`), -// so it's discovered via the actions glob. Cases come from the shared manifest. -let peerUserId: string | undefined; -const peerConcurrency: ConcurrencyStrategyFactory = (execute, getTransaction) => ({ - deferredCommit: false, - apply: (envelope) => { - if (envelope.time === 0) return undefined; - const transaction = getTransaction(envelope.name); - if (!transaction) throw new Error(`Unknown transaction: ${envelope.name}`); - return execute((t) => transaction(t, envelope.args), { - intermediate: envelope.time < 0, - userId: peerUserId, - }); - }, - cancel: () => {}, - onReset: () => {}, -}); - -Conformance.runActions({ - makeDb: () => - Database.toSystemDatabase(Database.create(MainService.plugin, { concurrency: peerConcurrency })), - store: (db) => db.store, - fromState: projection.fromState, - toState: projection.toState, - transitions: Conformance.adaptCases(spec.fns, spec.cases, spec.services, true), - actions: import.meta.glob( - ["../action-database/actions/*.ts", "!../action-database/actions/index.ts"], - { eager: true }, - ), - seedContext: (_db, _before, args) => { - // Runtime invariant: presence cases carry a `mark` (see move-presence.cases.ts). - peerUserId = (args as { mark: string }).mark; - }, -}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/create-store.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/create-store.ts deleted file mode 100644 index eba77b11d..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/create-store.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Store } from "@adobe/data/ecs"; -import { CoreDatabase } from "../core-database/core-database.js"; - -// A fresh writable store carrying the presence schema. `CoreDatabase` is the only -// schema layer. Test-only. -export const createStore = (): CoreDatabase.Store => Store.create(CoreDatabase.plugin); diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.test.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.test.ts deleted file mode 100644 index 86b31d853..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// Guards the presence projection: `toState(fromState(s)) ≡ s` over `State.samples`. -// Presence conforms its ecs via the lower-level runners (see conformance.md), so — unlike -// a `checkFeature` feature, which folds this round-trip into the one call — its projection -// round-trip is a standalone test over the same shared samples. -import { describe, it } from "vitest"; -import { Match } from "@adobe/data-testing"; -import { State } from "../../../data/state/state.js"; -import { createStore } from "./create-store.js"; -import { projection } from "./projection.js"; - -describe("presence conformance projection round-trips (toState ∘ fromState ≡ identity)", () => { - State.samples.forEach((state, i) => { - it(`sample ${i}`, () => { - const store = createStore(); - projection.fromState(store, state); - Match.assert(projection.toState(store), state); - }); - }); -}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.ts deleted file mode 100644 index dd2ef1f97..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { State } from "../../../data/state/state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// The test-only store ⇄ `State` projection. Presence keeps its whole state in the -// `cursors` resource — there are no entity collections — so `fromState` seeds that one -// resource and returns an empty `id → entity` map (the runners' `resolve` is never -// used), and there is no per-entity `toData`. Shared by the manifest (`data/state/spec.ts`) -// and, because presence conforms its ecs via the lower-level runners, by the escape-hatch -// tests and the projection round-trip test. -const fromState = (store: CoreDatabase.Store, state: State): ReadonlyMap => { - store.resources.cursors = state.cursors; - return new Map(); -}; - -const toState = (store: CoreDatabase.Store): State => ({ cursors: store.resources.cursors }); - -export const projection = { fromState, toState }; diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/seed-user-id.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/seed-user-id.ts deleted file mode 100644 index 24b4f3a11..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/seed-user-id.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../core-database/core-database.js"; - -/** - * Seed the transaction `userId` on a test store — impersonating the peer identity - * a live rebase-replay concurrency stamps at runtime (it is otherwise read-only, - * set only when the game database is created). Test-only. - */ -export const seedUserId = (store: CoreDatabase.Store, userId: string): void => { - Object.assign(store, { userId }); -}; diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts deleted file mode 100644 index 2cbde9c25..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "../../../data/state/spec.js"; -import * as transactions from "../transaction-database/transactions/index.js"; -import { createStore } from "./create-store.js"; -import { projection } from "./projection.js"; -import { seedUserId } from "./seed-user-id.js"; - -// The escape hatch (see conformance.md): `movePresence` reads the peer identity from the -// transaction `userId` (the peer's mark) — ambient context not derivable from the case — -// so `seedContext` seeds it from the case's `mark` before the raw transaction runs. The -// cases come from the shared manifest via `adaptCases` (services off — a transaction -// never receives services). -Conformance.runTransactions({ - createStore, - fromState: projection.fromState, - toState: projection.toState, - transitions: Conformance.adaptCases(spec.fns, spec.cases, spec.services, false), - transactions, - seedContext: (store, _before, args) => - // Runtime invariant: presence cases carry a `mark` (see move-presence.cases.ts). - seedUserId(store, (args as { mark: string }).mark), -}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/core-database/core-database.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/core-database/core-database.ts deleted file mode 100644 index 22f057543..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/core-database/core-database.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { resources } from "./resources.js"; - -const coreDatabasePlugin = Database.Plugin.create({ - resources, -}); - -export type CoreDatabase = Database.Plugin.ToDatabase; - -export namespace CoreDatabase { - export const plugin = coreDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/core-database/resources.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/core-database/resources.ts deleted file mode 100644 index 7e02b2d5e..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/core-database/resources.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Cursors } from "../../../data/cursors/cursors.js"; - -/** - * Presence state surface: the synced peer cursor positions. Written only through - * transient (never-committed) envelopes by `movePresence`, so it needs no schema - * flags — an absent entry simply means that peer hasn't moved yet. - */ -export const resources = { - cursors: { default: {} as Cursors }, -}; diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/main-service.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/main-service.ts deleted file mode 100644 index 979daeaf7..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/main-service.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// The assembled presence feature database. Its topmost layer is `action-database` -// (adds the `trackPresence` action). Every consumer references `MainService`, -// never the topmost layer. Combined onto the game database at the app shell. -export { ActionDatabase as MainService } from "./action-database/action-database.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transaction-database.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transaction-database.ts deleted file mode 100644 index 451e077c6..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transaction-database.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { CoreDatabase } from "../core-database/core-database.js"; -import * as transactions from "./transactions/index.js"; - -const transactionDatabasePlugin = Database.Plugin.create({ - extends: CoreDatabase.plugin, - transactions, -}); - -export type TransactionDatabase = Database.Plugin.ToDatabase< - typeof transactionDatabasePlugin ->; - -export namespace TransactionDatabase { - export const plugin = transactionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transactions/move-presence.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transactions/move-presence.ts deleted file mode 100644 index eb8ddad5d..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transactions/move-presence.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { PlayerMark } from "data-lit-tictactoe"; -import { State } from "../../../../data/state/state.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -/** - * Update the calling peer's cursor position. Driven as a never-ending - * async-generator transaction by the `trackPresence` action — each yield applies - * as a transient (never committed) envelope. Reads `t.userId` as a `PlayerMark` - * so each peer only writes its own cursor entry; foreign or unset `userId`s are - * ignored. The write itself is delegated to the pure `State.movePresence` spec. - */ -export const movePresence = (t: CoreDatabase.Store, { x, y }: { x: number; y: number }) => { - if (!PlayerMark.is(t.userId)) return; - t.resources.cursors = State.movePresence( - { cursors: t.resources.cursors }, - { mark: t.userId, x, y }, - ).cursors; -}; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/create.ts b/packages/data-p2p-tictactoe/src/features/presence/spec/create.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/presence/data/state/create.ts rename to packages/data-p2p-tictactoe/src/features/presence/spec/create.ts diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.cases.ts b/packages/data-p2p-tictactoe/src/features/presence/spec/move-presence.cases.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.cases.ts rename to packages/data-p2p-tictactoe/src/features/presence/spec/move-presence.cases.ts diff --git a/packages/data-p2p-tictactoe/src/features/presence/spec/move-presence.ts b/packages/data-p2p-tictactoe/src/features/presence/spec/move-presence.ts new file mode 100644 index 000000000..0f3b4ae04 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/spec/move-presence.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { PlayerMark } from "data-lit-tictactoe"; +import { Cursors } from "../data/values/cursors/cursors.js"; +import type { State } from "./state.js"; + +/** + * Update the given peer's cursor position — a `{ cursors }` patch. `mark` identifies + * the peer; in the ecs implementation it is injected from the transaction's `userId` + * (the peer's assigned mark), so each peer only ever writes its own entry. + */ +export const movePresence = ( + state: Pick, + { mark, x, y }: { mark: PlayerMark; x: number; y: number }, +): Pick => ({ + cursors: Cursors.set(state.cursors, mark, x, y), +}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/spec/public.ts b/packages/data-p2p-tictactoe/src/features/presence/spec/public.ts new file mode 100644 index 000000000..69dfc9c89 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/spec/public.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { create } from "./create.js"; +export { samples } from "./samples.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/samples.ts b/packages/data-p2p-tictactoe/src/features/presence/spec/samples.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/presence/data/state/samples.ts rename to packages/data-p2p-tictactoe/src/features/presence/spec/samples.ts diff --git a/packages/data-p2p-tictactoe/src/features/presence/spec/spec.test.ts b/packages/data-p2p-tictactoe/src/features/presence/spec/spec.test.ts new file mode 100644 index 000000000..bdab1adf7 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/spec/spec.test.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "./spec.js"; + +// Runs every case against the pure spec functions — no ECS involved. +Conformance.checkSpec(spec); diff --git a/packages/data-p2p-tictactoe/src/features/presence/spec/spec.ts b/packages/data-p2p-tictactoe/src/features/presence/spec/spec.ts new file mode 100644 index 000000000..a8e7a1e99 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/spec/spec.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import { cases as movePresence } from "./move-presence.cases.js"; + +// The presence spec. No services (the peer `mark` is the ambient transaction +// `userId`, not an injected service), no derivations, no entity references. +export const spec = Conformance.spec({ + state: State, + fns: transforms, + cases: { movePresence }, +}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/spec/state.ts b/packages/data-p2p-tictactoe/src/features/presence/spec/state.ts new file mode 100644 index 000000000..944949b13 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/spec/state.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert } from "@adobe/data/types"; +import type { Conformance } from "@adobe/data-testing"; +import type { Cursors } from "../data/values/cursors/cursors.js"; +import type { resources } from "../data/resources/index.js"; + +/** + * The presence state as one immutable object: the peer cursor positions. The + * spec the ECS presence main-service is verified against. + */ +export type State = { + readonly cursors: Cursors; +}; + +// Every key names a resource (presence has no components). +type _Pin = Assert>; + +export * as State from "./public.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/transforms.ts b/packages/data-p2p-tictactoe/src/features/presence/spec/transforms.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/presence/data/state/transforms.ts rename to packages/data-p2p-tictactoe/src/features/presence/spec/transforms.ts diff --git a/packages/data-p2p-tictactoe/src/features/presence/ui/p2p-presence-overlay/p2p-presence-overlay-element.ts b/packages/data-p2p-tictactoe/src/features/presence/ui/p2p-presence-overlay/p2p-presence-overlay-element.ts index 101d334a1..68870a505 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/ui/p2p-presence-overlay/p2p-presence-overlay-element.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/ui/p2p-presence-overlay/p2p-presence-overlay-element.ts @@ -9,7 +9,7 @@ import { customElement } from "lit/decorators.js"; import { DatabaseElement, useObservableValues, usePointerObserve, useEffect, useElement } from "@adobe/data-lit"; import { Observe } from "@adobe/data/observe"; -import { MainService } from "../../services/main-service/main-service.js"; +import { MainService } from "../../ecs/main-service.js"; import { styles } from "./p2p-presence-overlay.css.js"; import * as presentation from "./p2p-presence-overlay-presentation.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/ui/p2p-presence-overlay/p2p-presence-overlay-presentation.ts b/packages/data-p2p-tictactoe/src/features/presence/ui/p2p-presence-overlay/p2p-presence-overlay-presentation.ts index 109d3c6b4..46c354151 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/ui/p2p-presence-overlay/p2p-presence-overlay-presentation.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/ui/p2p-presence-overlay/p2p-presence-overlay-presentation.ts @@ -1,14 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { html, type TemplateResult } from "lit"; -import type { Vec2 } from "@adobe/data/math"; import { PlayerMark } from "data-lit-tictactoe"; +import type { Cursors } from "../../data/values/cursors/cursors.js"; export function render({ cursors, localMark, }: { - cursors: Partial> | undefined; + cursors: Cursors | undefined; localMark: number | string | undefined; }): TemplateResult { return html` diff --git a/packages/data-p2p-tictactoe/src/features/presence/ui/p2p-presence-overlay/p2p-presence-overlay.ts b/packages/data-p2p-tictactoe/src/features/presence/ui/p2p-presence-overlay/p2p-presence-overlay.ts index a7bc80fd8..b0cac4328 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/ui/p2p-presence-overlay/p2p-presence-overlay.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/ui/p2p-presence-overlay/p2p-presence-overlay.ts @@ -2,7 +2,7 @@ import { html, type TemplateResult } from "lit"; import type { Database } from "@adobe/data/ecs"; -import type { MainService } from "../../services/main-service/main-service.js"; +import type { MainService } from "../../ecs/main-service.js"; type PresenceService = Database.Plugin.ToDatabase; diff --git a/packages/data-p2p-tictactoe/src/main.ts b/packages/data-p2p-tictactoe/src/main.ts deleted file mode 100644 index 0c8c7d3ab..000000000 --- a/packages/data-p2p-tictactoe/src/main.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -import { render } from "lit"; -import { P2pApp } from "./features/negotiation/ui/p2p-app/p2p-app.js"; - -const app = document.getElementById("app"); -if (app) { - render(P2pApp(), app); -} diff --git a/packages/data-p2p-tictactoe/tsconfig.build.json b/packages/data-p2p-tictactoe/tsconfig.build.json deleted file mode 100644 index 1395b61d5..000000000 --- a/packages/data-p2p-tictactoe/tsconfig.build.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - // The RUNTIME wall (dependency isolation). It compiles the shippable data + services - // tiers as a `composite` project, so a data/ or services/ runtime file that imports a - // test-tier file (`*.cases.ts`, `*.fake.ts`, `spec.ts`, anything under `conformance/`) - // is a COMPILE ERROR (TS6307: not in this project's file list). `ui/` and the app-shell - // (`src/*.ts`) live in the test project instead — they import test-tier only in their own - // `*.test.ts`, and keeping their (often very large) inferred element/plugin types out of - // this project avoids forcing them into serialized declarations. Emits declarations only, - // to a throwaway dir, purely to satisfy the composite contract `tsconfig.test.json` refs. - "extends": "./tsconfig.json", - "compilerOptions": { - "composite": true, - "noEmit": false, - "declaration": true, - "emitDeclarationOnly": true, - "outDir": "./.tsbuild", - "rootDir": "src" - }, - "include": ["src/features/**/*"], - "exclude": [ - "**/ui/**", - "**/*.test.ts", - "**/*.type-test.ts", - "**/*.cases.ts", - "**/*.fake.ts", - "**/spec.ts", - "**/conformance/**" - ] -} diff --git a/packages/data-p2p-tictactoe/tsconfig.data.json b/packages/data-p2p-tictactoe/tsconfig.data.json new file mode 100644 index 000000000..ed8fdef71 --- /dev/null +++ b/packages/data-p2p-tictactoe/tsconfig.data.json @@ -0,0 +1,16 @@ +{ + // Layer 1+2 runtime: pure data declarations (data/) and service namespaces (services/). + // Composite so higher layers reference it; a data/services file importing spec/, ecs/ + // or any test file is a compile error (TS6307: not in this project). + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/data", + "rootDir": "src" + }, + "include": ["src/features/*/data/**/*", "src/features/*/services/**/*"], + "exclude": ["**/*.test.ts", "**/*.type-test.ts"] +} diff --git a/packages/data-p2p-tictactoe/tsconfig.ecs.json b/packages/data-p2p-tictactoe/tsconfig.ecs.json new file mode 100644 index 000000000..78227f085 --- /dev/null +++ b/packages/data-p2p-tictactoe/tsconfig.ecs.json @@ -0,0 +1,16 @@ +{ + // Runtime: the ECS implementation (ecs/, minus conformance). References only data + + // services, so an ecs file importing spec/ is a compile error. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/ecs", + "rootDir": "src" + }, + "include": ["src/features/*/ecs/**/*"], + "exclude": ["**/conformance/**", "**/*.test.ts", "**/*.type-test.ts"], + "references": [{ "path": "./tsconfig.data.json" }] +} diff --git a/packages/data-p2p-tictactoe/tsconfig.spec.json b/packages/data-p2p-tictactoe/tsconfig.spec.json new file mode 100644 index 000000000..e4dc0a8ff --- /dev/null +++ b/packages/data-p2p-tictactoe/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + // Test tier: the pure spec (spec/). References only data + services, so a spec file + // importing ecs/ is a compile error. May use @adobe/data-testing. No runtime file + // imports it (runtime projects never reference it). + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/spec", + "rootDir": "src" + }, + "include": ["src/features/*/spec/**/*"], + "exclude": ["**/*.test.ts", "**/*.type-test.ts"], + "references": [{ "path": "./tsconfig.data.json" }] +} diff --git a/packages/data-p2p-tictactoe/tsconfig.test.json b/packages/data-p2p-tictactoe/tsconfig.test.json index f328ac324..0f21c5b66 100644 --- a/packages/data-p2p-tictactoe/tsconfig.test.json +++ b/packages/data-p2p-tictactoe/tsconfig.test.json @@ -1,11 +1,7 @@ { - // The NON-EMITTING tier: everything the runtime wall (`tsconfig.build.json`) doesn't own — - // the test-tier (conformance manifest + inert cases, service fakes, `*.test.ts`, - // `conformance/`), plus `ui/` and the app-shell (`src/*.ts`). It `references` the runtime - // project, so its imports of data/services resolve through that project's declarations, - // and it alone may see `@adobe/data-testing`. Nothing here emits, so large element/plugin - // types are type-checked without being serialized. `tsc -b tsconfig.test.json` builds the - // wall first, then type-checks this tier — together covering all of `src`. + // Everything else, non-emitting: ui/, app/, ecs/conformance/, and every *.test.ts. + // References the ecs and spec projects, so it sees both. `tsc -b tsconfig.test.json` + // builds the layer projects first, then type-checks this tier — together covering src. "extends": "./tsconfig.json", "compilerOptions": { "composite": false, @@ -13,14 +9,12 @@ }, "include": [ "src/*.ts", - "src/**/ui/**/*", + "src/app/**/*", + "src/features/*/ui/**/*", + "src/features/*/ecs/conformance/**/*", "src/**/*.test.ts", - "src/**/*.type-test.ts", - "src/**/*.cases.ts", - "src/**/*.fake.ts", - "src/**/spec.ts", - "src/**/conformance/**/*" + "src/**/*.type-test.ts" ], "exclude": [], - "references": [{ "path": "./tsconfig.build.json" }] + "references": [{ "path": "./tsconfig.ecs.json" }, { "path": "./tsconfig.spec.json" }] } diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json index cd5b484c9..00a90a335 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.10.21", + "version": "0.10.22", "description": "Worker-based incremental persistence layer for @adobe/data ECS over OPFS (browser) and node:fs (server).", "type": "module", "sideEffects": false, diff --git a/packages/data-react-pixie/index.html b/packages/data-react-pixie/index.html index 847be8c23..925fee3dc 100644 --- a/packages/data-react-pixie/index.html +++ b/packages/data-react-pixie/index.html @@ -13,6 +13,6 @@

data-react-pixie

ECS sprites (bunny, fox) rendered with React Pixi.

- + diff --git a/packages/data-react-pixie/src/app/main.tsx b/packages/data-react-pixie/src/app/main.tsx new file mode 100644 index 000000000..521d8ca8a --- /dev/null +++ b/packages/data-react-pixie/src/app/main.tsx @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { createRoot } from "react-dom/client"; +import { Container, Graphics, Sprite } from "pixi.js"; +import { extend } from "@pixi/react"; +import { App } from "../features/main/ui/app.js"; + +// Register PixiJS components for @pixi/react (pixiSprite, pixiContainer) +extend({ Container, Graphics, Sprite }); + +const root = document.getElementById("root"); +if (root) { + createRoot(root).render(); +} diff --git a/packages/data-react-pixie/src/features/main/data/components/active.ts b/packages/data-react-pixie/src/features/main/data/components/active.ts new file mode 100644 index 000000000..299c059bd --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/components/active.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Boolean } from "@adobe/data/schema"; + +// Sprite was clicked on (toggled). +export const active = Boolean.schema; diff --git a/packages/data-react-pixie/src/features/main/data/components/hovered.ts b/packages/data-react-pixie/src/features/main/data/components/hovered.ts new file mode 100644 index 000000000..33e443d5c --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/components/hovered.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Boolean } from "@adobe/data/schema"; + +// Pointer is over the sprite. +export const hovered = Boolean.schema; diff --git a/packages/data-react-pixie/src/features/main/data/components/index.ts b/packages/data-react-pixie/src/features/main/data/components/index.ts new file mode 100644 index 000000000..a0838e8f6 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/components/index.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { position } from "./position.js"; +import { rotation } from "./rotation.js"; +import { kind } from "./kind.js"; +import { hovered } from "./hovered.js"; +import { active } from "./active.js"; + +export const components = { position, rotation, kind, hovered, active }; diff --git a/packages/data-react-pixie/src/features/main/data/components/kind.ts b/packages/data-react-pixie/src/features/main/data/components/kind.ts new file mode 100644 index 000000000..d6c1d0df9 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/components/kind.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { SpriteKind } from "../values/sprite-kind/sprite-kind.js"; + +export const kind = SpriteKind.schema; diff --git a/packages/data-react-pixie/src/features/main/data/components/position.ts b/packages/data-react-pixie/src/features/main/data/components/position.ts new file mode 100644 index 000000000..b94ea23a2 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/components/position.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Vec2 } from "@adobe/data/math"; + +// Scene-space centre of a sprite, in pixels. +export const position = Vec2.schema; diff --git a/packages/data-react-pixie/src/features/main/data/components/rotation.ts b/packages/data-react-pixie/src/features/main/data/components/rotation.ts new file mode 100644 index 000000000..f43be0fce --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/components/rotation.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { F32 } from "@adobe/data/math"; + +// Rotation in radians. +export const rotation = F32.schema; diff --git a/packages/data-react-pixie/src/features/main/data/entities/index.ts b/packages/data-react-pixie/src/features/main/data/entities/index.ts new file mode 100644 index 000000000..cfb663a43 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/entities/index.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Sprite } from "./sprite.js"; + +export const entities = { Sprite }; diff --git a/packages/data-react-pixie/src/features/main/data/entities/sprite.ts b/packages/data-react-pixie/src/features/main/data/entities/sprite.ts new file mode 100644 index 000000000..c8458c3e8 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/entities/sprite.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Database } from "@adobe/data/ecs"; +import type { components } from "../components/index.js"; + +// A sprite: plain readonly data with no id (identity is the entity key). +export const Sprite = ["position", "rotation", "kind", "hovered", "active"] as const satisfies Database.EntityComponents; +export type Sprite = Database.EntityType; diff --git a/packages/data-react-pixie/src/features/main/data/resources/filter.ts b/packages/data-react-pixie/src/features/main/data/resources/filter.ts new file mode 100644 index 000000000..024601990 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/resources/filter.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/ecs"; +import type { ResourceSchema } from "@adobe/data/ecs"; +import { FilterKind } from "../values/filter-kind/filter-kind.js"; + +// Scene-wide colour filter: settings scope (durable, not shared). The schema's +// default is "none". +export const filter = { ...FilterKind.schema, ...Scope.settings } satisfies ResourceSchema; diff --git a/packages/data-react-pixie/src/features/main/data/resources/index.ts b/packages/data-react-pixie/src/features/main/data/resources/index.ts new file mode 100644 index 000000000..c3759f937 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/resources/index.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { filter } from "./filter.js"; + +export const resources = { filter }; diff --git a/packages/data-react-pixie/src/features/main/data/sprite-kind/public.ts b/packages/data-react-pixie/src/features/main/data/sprite-kind/public.ts deleted file mode 100644 index 363fd32b4..000000000 --- a/packages/data-react-pixie/src/features/main/data/sprite-kind/public.ts +++ /dev/null @@ -1,2 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { schema } from "./schema.js"; diff --git a/packages/data-react-pixie/src/features/main/data/sprite/is.ts b/packages/data-react-pixie/src/features/main/data/sprite/is.ts deleted file mode 100644 index 83d420ab2..000000000 --- a/packages/data-react-pixie/src/features/main/data/sprite/is.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Sprite } from "./sprite.js"; - -// A structural type guard, re-exported so it reads as `Sprite.is`. A value is a -// Sprite when it carries the sprite component shape (a superset match, mirroring -// the ECS archetype query). -export const is = (v: unknown): v is Sprite => - typeof v === "object" && - v !== null && - "position" in v && - Array.isArray((v as Sprite).position) && - (v as Sprite).position.length === 2 && - "rotation" in v && - typeof (v as Sprite).rotation === "number" && - "kind" in v && - typeof (v as Sprite).kind === "string" && - "hovered" in v && - typeof (v as Sprite).hovered === "boolean" && - "active" in v && - typeof (v as Sprite).active === "boolean"; diff --git a/packages/data-react-pixie/src/features/main/data/sprite/public.ts b/packages/data-react-pixie/src/features/main/data/sprite/public.ts deleted file mode 100644 index f0c37fea2..000000000 --- a/packages/data-react-pixie/src/features/main/data/sprite/public.ts +++ /dev/null @@ -1,2 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { is } from "./is.js"; diff --git a/packages/data-react-pixie/src/features/main/data/sprite/sprite.ts b/packages/data-react-pixie/src/features/main/data/sprite/sprite.ts deleted file mode 100644 index 68b229d17..000000000 --- a/packages/data-react-pixie/src/features/main/data/sprite/sprite.ts +++ /dev/null @@ -1,15 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Vec2, F32 } from "@adobe/data/math"; -import type { SpriteKind } from "../sprite-kind/sprite-kind.js"; - -// One sprite entity value in the logical application State — plain readonly -// data with NO id. Identity lives in the `State.entities` map key, never in the -// value; the ECS materialises each sprite as an entity keyed by that id. -export type Sprite = { - readonly position: Vec2; - readonly rotation: F32; - readonly kind: SpriteKind; - readonly hovered: boolean; - readonly active: boolean; -}; -export * as Sprite from "./public.js"; diff --git a/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts b/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts deleted file mode 100644 index c4f0f60c4..000000000 --- a/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts +++ /dev/null @@ -1,26 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Vec2 } from "@adobe/data/math"; -import type { SpriteKind } from "../sprite-kind/sprite-kind.js"; -import type { State } from "./state.js"; - -// Append a sprite to the scene. The spec mints the id (the map key); the value -// carries none. Returns only the field it writes (`entities`). -export const createSprite = ( - state: Pick, - input: { - readonly position: Vec2; - readonly rotation?: number; - readonly kind: SpriteKind; - }, -): Pick => { - const id = Math.max(0, ...state.entities.keys()) + 1; - return { - entities: new Map(state.entities).set(id, { - position: input.position, - rotation: input.rotation ?? 0, - kind: input.kind, - hovered: false, - active: false, - }), - }; -}; diff --git a/packages/data-react-pixie/src/features/main/data/state/public.ts b/packages/data-react-pixie/src/features/main/data/state/public.ts deleted file mode 100644 index b53ae5d7e..000000000 --- a/packages/data-react-pixie/src/features/main/data/state/public.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { create } from "./create.js"; -export { samples } from "./samples.js"; -export { createSprite } from "./create-sprite.js"; -export { setSpriteHovered } from "./set-sprite-hovered.js"; -export { setSpriteActive } from "./set-sprite-active.js"; -export { toggleSpriteActive } from "./toggle-sprite-active.js"; -export { tick } from "./tick.js"; -export { setFilter } from "./set-filter.js"; diff --git a/packages/data-react-pixie/src/features/main/data/state/set-filter.ts b/packages/data-react-pixie/src/features/main/data/state/set-filter.ts deleted file mode 100644 index 9e51e95e0..000000000 --- a/packages/data-react-pixie/src/features/main/data/state/set-filter.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { FilterKind } from "../filter-kind/filter-kind.js"; -import type { State } from "./state.js"; - -// Replace the scene-wide filter. Writes only `filter`; sprites are untouched. -export const setFilter = ( - _state: Pick, - input: { readonly filter: FilterKind }, -): Pick => ({ filter: input.filter }); diff --git a/packages/data-react-pixie/src/features/main/data/state/spec.test.ts b/packages/data-react-pixie/src/features/main/data/state/spec.test.ts deleted file mode 100644 index 26a256253..000000000 --- a/packages/data-react-pixie/src/features/main/data/state/spec.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "./spec.js"; - -// The single pure-spec test for every transform in this folder. It runs the -// manifest's inert cases against the pure functions and asserts `after` (up to an -// id-bijection). -Conformance.checkSpec(spec); diff --git a/packages/data-react-pixie/src/features/main/data/state/spec.ts b/packages/data-react-pixie/src/features/main/data/state/spec.ts deleted file mode 100644 index eecdc1744..000000000 --- a/packages/data-react-pixie/src/features/main/data/state/spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import * as transforms from "./transforms.js"; -import { MainService } from "../../services/main-service/main-service.js"; -import { projection } from "../../services/main-service/conformance/projection.js"; - -import { cases as createSprite } from "./create-sprite.cases.js"; -import { cases as setSpriteHovered } from "./set-sprite-hovered.cases.js"; -import { cases as setSpriteActive } from "./set-sprite-active.cases.js"; -import { cases as toggleSpriteActive } from "./toggle-sprite-active.cases.js"; -import { cases as tick } from "./tick.cases.js"; -import { cases as setFilter } from "./set-filter.cases.js"; - -// The feature's conformance manifest — the single object `spec.test.ts` (pure) and -// `conformance.test.ts` (ecs) both import. It wires each conformed fn to its inert cases module explicitly: `cases` names one module per conformed fn, -// checked at COMPILE TIME against `transforms` (a fn without cases, or cases without -// a fn, won't type). This feature has no capability services and no derivations, so -// `services`, `computedPlugin`, and `hydrate` are all omitted. -// -// This is a test-tier module (excluded from the runtime program) — the ONE place the -// feature touches `@adobe/data-testing`. No transform or `*.cases.ts` file does. -export const spec = Conformance.feature({ - state: State, - fns: transforms, - plugin: MainService.plugin, - projection, - cases: { - createSprite, - setSpriteHovered, - setSpriteActive, - toggleSpriteActive, - tick, - setFilter, - }, -}); diff --git a/packages/data-react-pixie/src/features/main/data/state/state.ts b/packages/data-react-pixie/src/features/main/data/state/state.ts deleted file mode 100644 index 4f02181dd..000000000 --- a/packages/data-react-pixie/src/features/main/data/state/state.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Sprite } from "../sprite/sprite.js"; -import type { FilterKind } from "../filter-kind/filter-kind.js"; - -// The full application state as one immutable object — the specification the -// ECS implementation is verified against. `filter` is the scene-wide colour -// filter (a singleton → an ECS resource); `entities` holds every sprite keyed -// by a numeric id, the value carrying no id (identity is the key). -export type State = { - readonly filter: FilterKind; - readonly entities: ReadonlyMap; -}; -export * as State from "./public.js"; diff --git a/packages/data-react-pixie/src/features/main/data/state/tick.ts b/packages/data-react-pixie/src/features/main/data/state/tick.ts deleted file mode 100644 index 2a0ea1a5c..000000000 --- a/packages/data-react-pixie/src/features/main/data/state/tick.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Sprite } from "../sprite/sprite.js"; -import type { State } from "./state.js"; - -// Advance one animation frame: every sprite rotates by `delta * 0.1` radians. -// `delta` is the frame time step, supplied by the caller (the render loop). -// Writes only `entities`. -export const tick = ( - state: Pick, - input: { readonly delta: number }, -): Pick => ({ - entities: new Map( - [...state.entities].map(([id, sprite]): [number, Sprite] => [ - id, - { ...sprite, rotation: sprite.rotation + input.delta * 0.1 }, - ]), - ), -}); diff --git a/packages/data-react-pixie/src/features/main/data/state/transforms.ts b/packages/data-react-pixie/src/features/main/data/state/transforms.ts deleted file mode 100644 index 12f856883..000000000 --- a/packages/data-react-pixie/src/features/main/data/state/transforms.ts +++ /dev/null @@ -1,15 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -// The conformed functions of this feature — every pure transform the spec verifies, -// and nothing else (helpers like `create` and `samples` are deliberately absent). -// Its keys ARE the conformance surface: the manifest's coverage guard -// (`Conformance.feature`) requires exactly one `*.cases.ts` module per key here, so -// adding a transform without cases — or cases without a transform — fails to compile. -// This is a per-feature, local barrel (not a cross-feature registry), authored where -// the transforms live. -export { createSprite } from "./create-sprite.js"; -export { setSpriteHovered } from "./set-sprite-hovered.js"; -export { setSpriteActive } from "./set-sprite-active.js"; -export { toggleSpriteActive } from "./toggle-sprite-active.js"; -export { tick } from "./tick.js"; -export { setFilter } from "./set-filter.js"; diff --git a/packages/data-react-pixie/src/features/main/data/filter-kind/filter-kind.ts b/packages/data-react-pixie/src/features/main/data/values/filter-kind/filter-kind.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/data/filter-kind/filter-kind.ts rename to packages/data-react-pixie/src/features/main/data/values/filter-kind/filter-kind.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/phase/public.ts b/packages/data-react-pixie/src/features/main/data/values/filter-kind/public.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/phase/public.ts rename to packages/data-react-pixie/src/features/main/data/values/filter-kind/public.ts diff --git a/packages/data-react-pixie/src/features/main/data/filter-kind/schema.ts b/packages/data-react-pixie/src/features/main/data/values/filter-kind/schema.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/data/filter-kind/schema.ts rename to packages/data-react-pixie/src/features/main/data/values/filter-kind/schema.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/role/public.ts b/packages/data-react-pixie/src/features/main/data/values/sprite-kind/public.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/role/public.ts rename to packages/data-react-pixie/src/features/main/data/values/sprite-kind/public.ts diff --git a/packages/data-react-pixie/src/features/main/data/sprite-kind/schema.ts b/packages/data-react-pixie/src/features/main/data/values/sprite-kind/schema.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/data/sprite-kind/schema.ts rename to packages/data-react-pixie/src/features/main/data/values/sprite-kind/schema.ts diff --git a/packages/data-react-pixie/src/features/main/data/sprite-kind/sprite-kind.ts b/packages/data-react-pixie/src/features/main/data/values/sprite-kind/sprite-kind.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/data/sprite-kind/sprite-kind.ts rename to packages/data-react-pixie/src/features/main/data/values/sprite-kind/sprite-kind.ts diff --git a/packages/data-react-pixie/src/features/main/ecs/actions/action-database.ts b/packages/data-react-pixie/src/features/main/ecs/actions/action-database.ts new file mode 100644 index 000000000..9aec4ca66 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/actions/action-database.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; +import * as actions from "./index.js"; + +// The action layer: the async, app-facing realizations of the state transitions. +// This feature injects no capability services, so actions extend the transaction +// layer directly (the lowest layer exposing `db.transactions`). +const actionDatabasePlugin = Database.Plugin.create({ + extends: TransactionDatabase.plugin, + actions, +}); + +export type ActionDatabase = Database.Plugin.ToDatabase; + +export namespace ActionDatabase { + export const plugin = actionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-react-pixie/src/features/main/ecs/actions/create-sprite.ts b/packages/data-react-pixie/src/features/main/ecs/actions/create-sprite.ts new file mode 100644 index 000000000..f336df5d9 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/actions/create-sprite.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Vec2 } from "@adobe/data/math"; +import type { SpriteKind } from "../../data/values/sprite-kind/sprite-kind.js"; +import type { TransactionDatabase } from "../transactions/transaction-database.js"; + +// The app-facing realization of the spec's `createSprite`. This transition injects no +// services, so the action just commits through the same-named transaction. +export const createSprite = ( + db: TransactionDatabase, + input: { readonly position: Vec2; readonly rotation?: number; readonly kind: SpriteKind }, +) => { + db.transactions.createSprite(input); +}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/index.ts b/packages/data-react-pixie/src/features/main/ecs/actions/index.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/index.ts rename to packages/data-react-pixie/src/features/main/ecs/actions/index.ts diff --git a/packages/data-react-pixie/src/features/main/ecs/actions/set-filter.ts b/packages/data-react-pixie/src/features/main/ecs/actions/set-filter.ts new file mode 100644 index 000000000..c09bf18c6 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/actions/set-filter.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { FilterKind } from "../../data/values/filter-kind/filter-kind.js"; +import type { TransactionDatabase } from "../transactions/transaction-database.js"; + +// The app-facing realization of the spec's `setFilter`. +export const setFilter = (db: TransactionDatabase, input: { readonly filter: FilterKind }) => { + db.transactions.setFilter(input); +}; diff --git a/packages/data-react-pixie/src/features/main/ecs/actions/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/ecs/actions/set-sprite-active.ts new file mode 100644 index 000000000..bcb181c3e --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/actions/set-sprite-active.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { TransactionDatabase } from "../transactions/transaction-database.js"; + +// The app-facing realization of the spec's `setSpriteActive`. The ui supplies the +// entity directly; the action commits through the same-named transaction. +export const setSpriteActive = ( + db: TransactionDatabase, + input: { readonly id: Entity; readonly active: boolean }, +) => { + db.transactions.setSpriteActive(input); +}; diff --git a/packages/data-react-pixie/src/features/main/ecs/actions/set-sprite-hovered.ts b/packages/data-react-pixie/src/features/main/ecs/actions/set-sprite-hovered.ts new file mode 100644 index 000000000..c4b195262 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/actions/set-sprite-hovered.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { TransactionDatabase } from "../transactions/transaction-database.js"; + +// The app-facing realization of the spec's `setSpriteHovered`. +export const setSpriteHovered = ( + db: TransactionDatabase, + input: { readonly id: Entity; readonly hovered: boolean }, +) => { + db.transactions.setSpriteHovered(input); +}; diff --git a/packages/data-react-pixie/src/features/main/ecs/actions/tick.ts b/packages/data-react-pixie/src/features/main/ecs/actions/tick.ts new file mode 100644 index 000000000..2c5e98cc5 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/actions/tick.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../transactions/transaction-database.js"; + +// The app-facing realization of the spec's `tick`, driven per-frame by the render loop. +export const tick = (db: TransactionDatabase, input: { readonly delta: number }) => { + db.transactions.tick(input); +}; diff --git a/packages/data-react-pixie/src/features/main/ecs/actions/toggle-sprite-active.ts b/packages/data-react-pixie/src/features/main/ecs/actions/toggle-sprite-active.ts new file mode 100644 index 000000000..e90ecb685 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/actions/toggle-sprite-active.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { TransactionDatabase } from "../transactions/transaction-database.js"; + +// The app-facing realization of the spec's `toggleSpriteActive`. +export const toggleSpriteActive = ( + db: TransactionDatabase, + { id }: { readonly id: Entity }, +) => { + db.transactions.toggleSpriteActive({ id }); +}; diff --git a/packages/data-react-pixie/src/features/main/ecs/conformance/conformance.test.ts b/packages/data-react-pixie/src/features/main/ecs/conformance/conformance.test.ts new file mode 100644 index 000000000..e22629e75 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/conformance/conformance.test.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { implementation } from "./implementation.js"; + +// Replays every spec case against the same-named action, and round-trips +// `State.samples` through the projection. +Conformance.checkFeature(implementation); diff --git a/packages/data-react-pixie/src/features/main/ecs/conformance/implementation.ts b/packages/data-react-pixie/src/features/main/ecs/conformance/implementation.ts new file mode 100644 index 000000000..7c56462c6 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/conformance/implementation.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "../../spec/spec.js"; +import { MainService } from "../main-service.js"; +import { projection } from "./projection.js"; + +// The spec paired with its ECS build: actions from `MainService` and the projection +// between the store and `State`. +export const implementation = Conformance.implementation(spec, { + plugin: MainService.plugin, + projection, +}); diff --git a/packages/data-react-pixie/src/features/main/ecs/conformance/projection.ts b/packages/data-react-pixie/src/features/main/ecs/conformance/projection.ts new file mode 100644 index 000000000..938ba529b --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/conformance/projection.ts @@ -0,0 +1,63 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { Sprite } from "../../data/entities/sprite.js"; +import type { State } from "../../spec/state.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// Read one entity back into its id-less `data/` value — the per-entity projection +// `toState` folds over, and the single place the ecs↔data mapping for a sprite +// lives. Identity is the `State.entities` key (the entity itself), never a field. +const toData = (store: CoreDatabase.Store, entity: Entity): Sprite => { + const row = store.read(entity, store.archetypes.Sprite); + if (row === null) + throw new Error("conformance projection: expected a sprite entity"); + return { + position: row.position, + rotation: row.rotation, + kind: row.kind, + hovered: row.hovered, + active: row.active, + }; +}; + +// The test-only ecs↔`State` projection, carried on the manifest and used by `Conformance.checkFeature`. +// `fromState` seeds a store to a `State` (clear every sprite, set `filter`, then +// insert the sprites) and returns the `spec id → seeded entity` map so the +// runners resolve id-addressed operations generically; `toState` reads it back +// into the identity-keyed `entities` map; `toData` reads one entity. +export const projection = { + fromState: ( + store: CoreDatabase.Store, + state: State, + ): ReadonlyMap => { + for (const arch of store.queryArchetypes( + store.archetypes.Sprite.components, + )) { + for (let row = arch.rowCount - 1; row >= 0; row--) { + store.delete(arch.columns.id.get(row)); + } + } + store.resources.filter = state.filter; + return new Map( + [...state.entities].map(([id, sprite]): [number, Entity] => [ + id, + store.archetypes.Sprite.insert({ + position: sprite.position, + rotation: sprite.rotation, + kind: sprite.kind, + hovered: sprite.hovered, + active: sprite.active, + }), + ]), + ); + }, + toState: (store: CoreDatabase.Store): State => ({ + filter: store.resources.filter, + entities: new Map( + [...store.select(store.archetypes.Sprite.components)].map( + (entity): [number, Sprite] => [entity, toData(store, entity)], + ), + ), + }), + toData, +}; diff --git a/packages/data-react-pixie/src/features/main/ecs/core/archetypes.ts b/packages/data-react-pixie/src/features/main/ecs/core/archetypes.ts new file mode 100644 index 000000000..b3921c478 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/core/archetypes.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { components } from "../../data/components/index.js"; +import { Sprite } from "../../data/entities/sprite.js"; + +export const archetypes = Database.archetypes(components, { + Sprite, +}); diff --git a/packages/data-react-pixie/src/features/main/ecs/core/core-database.ts b/packages/data-react-pixie/src/features/main/ecs/core/core-database.ts new file mode 100644 index 000000000..d9fee9075 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/core/core-database.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { components } from "../../data/components/index.js"; +import { resources } from "../../data/resources/index.js"; +import { archetypes } from "./archetypes.js"; + +// The core schema: the feature's data/ components and resources, packed into archetypes. +const coreDatabasePlugin = Database.Plugin.create({ + components, + resources, + archetypes, +}); + +export type CoreDatabase = Database.Plugin.ToDatabase; + +export namespace CoreDatabase { + export const plugin = coreDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-react-pixie/src/features/main/ecs/main-service.ts b/packages/data-react-pixie/src/features/main/ecs/main-service.ts new file mode 100644 index 000000000..cad587970 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/main-service.ts @@ -0,0 +1,2 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { SystemDatabase as MainService } from "./systems/system-database.js"; diff --git a/packages/data-react-pixie/src/features/main/ecs/systems/system-database.ts b/packages/data-react-pixie/src/features/main/ecs/systems/system-database.ts new file mode 100644 index 000000000..17889a5e5 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/systems/system-database.ts @@ -0,0 +1,28 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { ActionDatabase } from "../actions/action-database.js"; + +// Systems sit atop the action layer, declared inline so each `create`'s `db` is +// inferred. Per-frame rotation is driven from the ui via the `tick` action, so this +// feature has only an init-only system. +const systemDatabasePlugin = Database.Plugin.create({ + extends: ActionDatabase.plugin, + systems: { + // Init-only: runs once at construction to populate the initial scene. + seedSprites: { + create: (db) => { + db.store.archetypes.Sprite.insert({ position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }); + db.store.archetypes.Sprite.insert({ position: [200, 150], rotation: 0.5, kind: "bunny", hovered: false, active: false }); + db.store.archetypes.Sprite.insert({ position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false }); + db.store.archetypes.Sprite.insert({ position: [150, 250], rotation: 0.2, kind: "fox", hovered: false, active: false }); + }, + }, + }, +}); + +export type SystemDatabase = Database.Plugin.ToDatabase; + +export namespace SystemDatabase { + export const plugin = systemDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-react-pixie/src/features/main/ecs/transactions/create-sprite.ts b/packages/data-react-pixie/src/features/main/ecs/transactions/create-sprite.ts new file mode 100644 index 000000000..05d0e2750 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/transactions/create-sprite.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Vec2 } from "@adobe/data/math"; +import type { SpriteKind } from "../../data/values/sprite-kind/sprite-kind.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +export const createSprite = ( + t: CoreDatabase.Store, + input: { readonly position: Vec2; readonly rotation?: number; readonly kind: SpriteKind }, +) => + t.archetypes.Sprite.insert({ + position: input.position, + rotation: input.rotation ?? 0, + kind: input.kind, + hovered: false, + active: false, + }); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/index.ts b/packages/data-react-pixie/src/features/main/ecs/transactions/index.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/index.ts rename to packages/data-react-pixie/src/features/main/ecs/transactions/index.ts diff --git a/packages/data-react-pixie/src/features/main/ecs/transactions/set-filter.ts b/packages/data-react-pixie/src/features/main/ecs/transactions/set-filter.ts new file mode 100644 index 000000000..a8b4a52f3 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/transactions/set-filter.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { FilterKind } from "../../data/values/filter-kind/filter-kind.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +export const setFilter = (t: CoreDatabase.Store, args: { readonly filter: FilterKind }) => { + t.resources.filter = args.filter; +}; diff --git a/packages/data-react-pixie/src/features/main/ecs/transactions/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/ecs/transactions/set-sprite-active.ts new file mode 100644 index 000000000..e0e519b9a --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/transactions/set-sprite-active.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { CoreDatabase } from "../core/core-database.js"; + +export const setSpriteActive = ( + t: CoreDatabase.Store, + args: { readonly id: Entity; readonly active: boolean }, +) => { + if (t.read(args.id)) { + t.update(args.id, { active: args.active }); + } +}; diff --git a/packages/data-react-pixie/src/features/main/ecs/transactions/set-sprite-hovered.ts b/packages/data-react-pixie/src/features/main/ecs/transactions/set-sprite-hovered.ts new file mode 100644 index 000000000..87c08de2d --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/transactions/set-sprite-hovered.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { CoreDatabase } from "../core/core-database.js"; + +export const setSpriteHovered = ( + t: CoreDatabase.Store, + args: { readonly id: Entity; readonly hovered: boolean }, +) => { + if (t.read(args.id)) { + t.update(args.id, { hovered: args.hovered }); + } +}; diff --git a/packages/data-react-pixie/src/features/main/ecs/transactions/tick.ts b/packages/data-react-pixie/src/features/main/ecs/transactions/tick.ts new file mode 100644 index 000000000..2275ad6da --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/transactions/tick.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +// Advance one animation frame: every sprite rotates by `delta * 0.1`. +export const tick = (t: CoreDatabase.Store, args: { readonly delta: number }) => { + for (const entity of t.select(t.archetypes.Sprite.components)) { + const sprite = t.read(entity); + if (sprite?.rotation !== undefined) { + t.update(entity, { rotation: sprite.rotation + args.delta * 0.1 }); + } + } +}; diff --git a/packages/data-react-pixie/src/features/main/ecs/transactions/toggle-sprite-active.ts b/packages/data-react-pixie/src/features/main/ecs/transactions/toggle-sprite-active.ts new file mode 100644 index 000000000..4711faa5f --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/transactions/toggle-sprite-active.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { CoreDatabase } from "../core/core-database.js"; + +export const toggleSpriteActive = ( + t: CoreDatabase.Store, + args: { readonly id: Entity }, +) => { + const sprite = t.read(args.id); + if (sprite && sprite.active !== undefined) { + t.update(args.id, { active: !sprite.active }); + } +}; diff --git a/packages/data-react-pixie/src/features/main/ecs/transactions/transaction-database.ts b/packages/data-react-pixie/src/features/main/ecs/transactions/transaction-database.ts new file mode 100644 index 000000000..bf625d540 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/transactions/transaction-database.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { CoreDatabase } from "../core/core-database.js"; +import * as transactions from "./index.js"; + +const transactionDatabasePlugin = Database.Plugin.create({ + extends: CoreDatabase.plugin, + transactions, +}); + +export type TransactionDatabase = Database.Plugin.ToDatabase; + +export namespace TransactionDatabase { + export const plugin = transactionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/action-database.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/action-database.ts deleted file mode 100644 index ce5e0f99a..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/action-database/action-database.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { TransactionDatabase } from "../transaction-database/transaction-database.js"; -import * as actions from "./actions/index.js"; - -// The action layer: the async, app-facing realizations of the state transitions. -// This feature injects no capability services, so actions extend the transaction -// layer directly (the lowest layer exposing `db.transactions`). -const actionDatabasePlugin = Database.Plugin.create({ - extends: TransactionDatabase.plugin, - actions, -}); - -export type ActionDatabase = Database.Plugin.ToDatabase; - -export namespace ActionDatabase { - export const plugin = actionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/create-sprite.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/create-sprite.ts deleted file mode 100644 index 582fafbe5..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/create-sprite.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Vec2 } from "@adobe/data/math"; -import type { SpriteKind } from "../../../../data/sprite-kind/sprite-kind.js"; -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; - -// The app-facing realization of `State.createSprite`. This transition injects no -// services, so the action just commits through the same-named transaction. -export const createSprite = ( - db: TransactionDatabase, - input: { readonly position: Vec2; readonly rotation?: number; readonly kind: SpriteKind }, -) => { - db.transactions.createSprite(input); -}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-filter.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-filter.ts deleted file mode 100644 index e11330633..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-filter.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { FilterKind } from "../../../../data/filter-kind/filter-kind.js"; -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; - -// The app-facing realization of `State.setFilter`. -export const setFilter = (db: TransactionDatabase, input: { readonly filter: FilterKind }) => { - db.transactions.setFilter(input); -}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-active.ts deleted file mode 100644 index 64339b293..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-active.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; - -// The app-facing realization of `State.setSpriteActive`. The ui supplies the -// entity directly; the action commits through the same-named transaction. -export const setSpriteActive = ( - db: TransactionDatabase, - input: { readonly id: Entity; readonly active: boolean }, -) => { - db.transactions.setSpriteActive(input); -}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-hovered.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-hovered.ts deleted file mode 100644 index 01c206ed8..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-hovered.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; - -// The app-facing realization of `State.setSpriteHovered`. -export const setSpriteHovered = ( - db: TransactionDatabase, - input: { readonly id: Entity; readonly hovered: boolean }, -) => { - db.transactions.setSpriteHovered(input); -}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/tick.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/tick.ts deleted file mode 100644 index 28a83a9e0..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/tick.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; - -// The app-facing realization of `State.tick`, driven per-frame by the render loop. -export const tick = (db: TransactionDatabase, input: { readonly delta: number }) => { - db.transactions.tick(input); -}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/toggle-sprite-active.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/toggle-sprite-active.ts deleted file mode 100644 index a12f6786e..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/toggle-sprite-active.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; - -// The app-facing realization of `State.toggleSpriteActive`. -export const toggleSpriteActive = ( - db: TransactionDatabase, - { id }: { readonly id: Entity }, -) => { - db.transactions.toggleSpriteActive({ id }); -}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/conformance.test.ts deleted file mode 100644 index 48e046088..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/conformance.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "../../../data/state/spec.js"; - -// The whole ecs conformance for this feature in one call: `checkFeature` replays the -// manifest's shared cases against the transactions/actions on `MainService.plugin`, -// seeds each `before` over `State.create()`, and round-trips `State.samples` through -// the `projection`. It shares `spec` with `spec.test.ts` — same cases, substituted -// implementation. -Conformance.checkFeature(spec); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.ts deleted file mode 100644 index 6b3f92e64..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.ts +++ /dev/null @@ -1,63 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { Sprite } from "../../../data/sprite/sprite.js"; -import type { State } from "../../../data/state/state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Read one entity back into its id-less `data/` value — the per-entity projection -// `toState` folds over, and the single place the ecs↔data mapping for a sprite -// lives. Identity is the `State.entities` key (the entity itself), never a field. -const toData = (store: CoreDatabase.Store, entity: Entity): Sprite => { - const row = store.read(entity, store.archetypes.Sprite); - if (row === null) - throw new Error("conformance projection: expected a sprite entity"); - return { - position: row.position, - rotation: row.rotation, - kind: row.kind, - hovered: row.hovered, - active: row.active, - }; -}; - -// The test-only ecs↔`State` projection, carried on the manifest and used by `Conformance.checkFeature`. -// `fromState` seeds a store to a `State` (clear every sprite, set `filter`, then -// insert the sprites) and returns the `spec id → seeded entity` map so the -// runners resolve id-addressed operations generically; `toState` reads it back -// into the identity-keyed `entities` map; `toData` reads one entity. -export const projection = { - fromState: ( - store: CoreDatabase.Store, - state: State, - ): ReadonlyMap => { - for (const arch of store.queryArchetypes( - store.archetypes.Sprite.components, - )) { - for (let row = arch.rowCount - 1; row >= 0; row--) { - store.delete(arch.columns.id.get(row)); - } - } - store.resources.filter = state.filter; - return new Map( - [...state.entities].map(([id, sprite]): [number, Entity] => [ - id, - store.archetypes.Sprite.insert({ - position: sprite.position, - rotation: sprite.rotation, - kind: sprite.kind, - hovered: sprite.hovered, - active: sprite.active, - }), - ]), - ); - }, - toState: (store: CoreDatabase.Store): State => ({ - filter: store.resources.filter, - entities: new Map( - [...store.select(store.archetypes.Sprite.components)].map( - (entity): [number, Sprite] => [entity, toData(store, entity)], - ), - ), - }), - toData, -}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/core-database/archetypes.ts b/packages/data-react-pixie/src/features/main/services/main-service/core-database/archetypes.ts deleted file mode 100644 index 9b2f2260f..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/core-database/archetypes.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { components } from "./components.js"; - -export const archetypes = Database.archetypes(components, { - Sprite: ["position", "rotation", "kind", "hovered", "active"], -}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/core-database/components.ts b/packages/data-react-pixie/src/features/main/services/main-service/core-database/components.ts deleted file mode 100644 index 9d988dadd..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/core-database/components.ts +++ /dev/null @@ -1,15 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { Boolean } from "@adobe/data/schema"; -import { Vec2, F32 } from "@adobe/data/math"; -import { SpriteKind } from "../../../data/sprite-kind/sprite-kind.js"; - -export const components = Database.components({ - document: { - position: Vec2.schema, - rotation: F32.schema, - kind: SpriteKind.schema, - hovered: Boolean.schema, - active: Boolean.schema, - }, -}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/core-database/core-database.ts b/packages/data-react-pixie/src/features/main/services/main-service/core-database/core-database.ts deleted file mode 100644 index 6f1face27..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/core-database/core-database.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { components } from "./components.js"; -import { resources } from "./resources.js"; -import { archetypes } from "./archetypes.js"; - -// The feature's whole schema, grouped by scope in components.ts / resources.ts. -const coreDatabasePlugin = Database.Plugin.create({ - components, - resources, - archetypes, -}); - -export type CoreDatabase = Database.Plugin.ToDatabase; - -export namespace CoreDatabase { - export const plugin = coreDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-react-pixie/src/features/main/services/main-service/core-database/resources.ts b/packages/data-react-pixie/src/features/main/services/main-service/core-database/resources.ts deleted file mode 100644 index dc030619a..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/core-database/resources.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { FilterKind } from "../../../data/filter-kind/filter-kind.js"; - -export const resources = Database.resources({ - settings: { - filter: FilterKind.schema, // scene-wide view filter; the schema carries a "none" default - }, -}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/main-service.ts b/packages/data-react-pixie/src/features/main/services/main-service/main-service.ts deleted file mode 100644 index 4652af7f1..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/main-service.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// The assembled feature database — the sole entrypoint the ui binds to. Aliased -// from the topmost layer so consumers reference `MainService` (`.plugin` / -// `.Store`) and never name that layer; add or drop a layer → change only this line. -export { SystemDatabase as MainService } from "./system-database/system-database.js"; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/system-database/system-database.ts b/packages/data-react-pixie/src/features/main/services/main-service/system-database/system-database.ts deleted file mode 100644 index 24e2b2b29..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/system-database/system-database.ts +++ /dev/null @@ -1,29 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { ActionDatabase } from "../action-database/action-database.js"; - -// Systems come last in the pipeline, atop the feature's current top layer -// (actions). `seedSprites` is init-only: its `create` runs ONCE at database -// construction to populate the initial scene and returns `void` (no per-frame -// work). Per-frame rotation is driven from the ui via the `tick` transaction, so -// this feature needs no scheduler. -const systemDatabasePlugin = Database.Plugin.create({ - extends: ActionDatabase.plugin, - systems: { - seedSprites: { - create: (db) => { - db.store.archetypes.Sprite.insert({ position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }); - db.store.archetypes.Sprite.insert({ position: [200, 150], rotation: 0.5, kind: "bunny", hovered: false, active: false }); - db.store.archetypes.Sprite.insert({ position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false }); - db.store.archetypes.Sprite.insert({ position: [150, 250], rotation: 0.2, kind: "fox", hovered: false, active: false }); - }, - }, - }, -}); - -export type SystemDatabase = Database.Plugin.ToDatabase; - -export namespace SystemDatabase { - export const plugin = systemDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transaction-database.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transaction-database.ts deleted file mode 100644 index 02a036846..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transaction-database.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { CoreDatabase } from "../core-database/core-database.js"; -import * as transactions from "./transactions/index.js"; - -const transactionDatabasePlugin = Database.Plugin.create({ - extends: CoreDatabase.plugin, - transactions, -}); - -export type TransactionDatabase = Database.Plugin.ToDatabase; - -export namespace TransactionDatabase { - export const plugin = transactionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/create-sprite.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/create-sprite.ts deleted file mode 100644 index 98621c907..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/create-sprite.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Vec2 } from "@adobe/data/math"; -import type { SpriteKind } from "../../../../data/sprite-kind/sprite-kind.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const createSprite = ( - t: CoreDatabase.Store, - input: { readonly position: Vec2; readonly rotation?: number; readonly kind: SpriteKind }, -) => - t.archetypes.Sprite.insert({ - position: input.position, - rotation: input.rotation ?? 0, - kind: input.kind, - hovered: false, - active: false, - }); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-filter.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-filter.ts deleted file mode 100644 index 5fa1704cf..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-filter.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { FilterKind } from "../../../../data/filter-kind/filter-kind.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const setFilter = (t: CoreDatabase.Store, args: { readonly filter: FilterKind }) => { - t.resources.filter = args.filter; -}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-active.ts deleted file mode 100644 index d47581b12..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-active.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const setSpriteActive = ( - t: CoreDatabase.Store, - args: { readonly id: Entity; readonly active: boolean }, -) => { - if (t.read(args.id)) { - t.update(args.id, { active: args.active }); - } -}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-hovered.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-hovered.ts deleted file mode 100644 index de162ff51..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-hovered.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const setSpriteHovered = ( - t: CoreDatabase.Store, - args: { readonly id: Entity; readonly hovered: boolean }, -) => { - if (t.read(args.id)) { - t.update(args.id, { hovered: args.hovered }); - } -}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/tick.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/tick.ts deleted file mode 100644 index c3099ec6f..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/tick.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// Advance one animation frame: every sprite rotates by `delta * 0.1`. Mirrors -// the pure `State.tick` transform (conformed in tick.test.ts). -export const tick = (t: CoreDatabase.Store, args: { readonly delta: number }) => { - for (const entity of t.select(t.archetypes.Sprite.components)) { - const sprite = t.read(entity); - if (sprite?.rotation !== undefined) { - t.update(entity, { rotation: sprite.rotation + args.delta * 0.1 }); - } - } -}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/toggle-sprite-active.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/toggle-sprite-active.ts deleted file mode 100644 index c0c89d7a2..000000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/toggle-sprite-active.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const toggleSpriteActive = ( - t: CoreDatabase.Store, - args: { readonly id: Entity }, -) => { - const sprite = t.read(args.id); - if (sprite && sprite.active !== undefined) { - t.update(args.id, { active: !sprite.active }); - } -}; diff --git a/packages/data-react-pixie/src/features/main/data/state/create-sprite.cases.ts b/packages/data-react-pixie/src/features/main/spec/create-sprite.cases.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/data/state/create-sprite.cases.ts rename to packages/data-react-pixie/src/features/main/spec/create-sprite.cases.ts diff --git a/packages/data-react-pixie/src/features/main/spec/create-sprite.ts b/packages/data-react-pixie/src/features/main/spec/create-sprite.ts new file mode 100644 index 000000000..1022dceae --- /dev/null +++ b/packages/data-react-pixie/src/features/main/spec/create-sprite.ts @@ -0,0 +1,26 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Vec2 } from "@adobe/data/math"; +import type { SpriteKind } from "../data/values/sprite-kind/sprite-kind.js"; +import type { State } from "./state.js"; + +// Append a sprite to the scene. The spec mints the id (the map key); the value +// carries none. Returns only the field it writes (`entities`). +export const createSprite = ( + state: Pick, + input: { + readonly position: Vec2; + readonly rotation?: number; + readonly kind: SpriteKind; + }, +): Pick => { + const id = Math.max(0, ...state.entities.keys()) + 1; + return { + entities: new Map(state.entities).set(id, { + position: input.position, + rotation: input.rotation ?? 0, + kind: input.kind, + hovered: false, + active: false, + }), + }; +}; diff --git a/packages/data-react-pixie/src/features/main/data/state/create.ts b/packages/data-react-pixie/src/features/main/spec/create.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/data/state/create.ts rename to packages/data-react-pixie/src/features/main/spec/create.ts diff --git a/packages/data-react-pixie/src/features/main/spec/public.ts b/packages/data-react-pixie/src/features/main/spec/public.ts new file mode 100644 index 000000000..69dfc9c89 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/spec/public.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { create } from "./create.js"; +export { samples } from "./samples.js"; diff --git a/packages/data-react-pixie/src/features/main/data/state/samples.ts b/packages/data-react-pixie/src/features/main/spec/samples.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/data/state/samples.ts rename to packages/data-react-pixie/src/features/main/spec/samples.ts diff --git a/packages/data-react-pixie/src/features/main/data/state/set-filter.cases.ts b/packages/data-react-pixie/src/features/main/spec/set-filter.cases.ts similarity index 97% rename from packages/data-react-pixie/src/features/main/data/state/set-filter.cases.ts rename to packages/data-react-pixie/src/features/main/spec/set-filter.cases.ts index d96f0d1c4..eca2144dd 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-filter.cases.ts +++ b/packages/data-react-pixie/src/features/main/spec/set-filter.cases.ts @@ -3,7 +3,7 @@ import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { setFilter } from "./set-filter.js"; -// Inert, spec-owned cases for `setFilter`, shared with the ecs transaction. The +// Inert, spec-owned cases for `setFilter`, shared with the ecs action. The // scene-wide filter is replaced; sprites are untouched. export const cases: Conformance.SpecCases = { cases: [ diff --git a/packages/data-react-pixie/src/features/main/spec/set-filter.ts b/packages/data-react-pixie/src/features/main/spec/set-filter.ts new file mode 100644 index 000000000..1ed3d1976 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/spec/set-filter.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { FilterKind } from "../data/values/filter-kind/filter-kind.js"; +import type { State } from "./state.js"; + +// Replace the scene-wide filter. Writes only `filter`; sprites are untouched. +export const setFilter = ( + _state: Pick, + input: { readonly filter: FilterKind }, +): Pick => ({ filter: input.filter }); diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.cases.ts b/packages/data-react-pixie/src/features/main/spec/set-sprite-active.cases.ts similarity index 95% rename from packages/data-react-pixie/src/features/main/data/state/set-sprite-active.cases.ts rename to packages/data-react-pixie/src/features/main/spec/set-sprite-active.cases.ts index 2be4671e7..206c44ddc 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.cases.ts +++ b/packages/data-react-pixie/src/features/main/spec/set-sprite-active.cases.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Entity } from "@adobe/data/ecs"; import type { Conformance } from "@adobe/data-testing"; -import type { Sprite } from "../sprite/sprite.js"; +import type { Sprite } from "../data/entities/sprite.js"; import type { State } from "./state.js"; import type { setSpriteActive } from "./set-sprite-active.js"; @@ -12,7 +12,7 @@ const fox: Sprite = { position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, }; -// Inert, spec-owned cases for `setSpriteActive`, shared with the ecs transaction. +// Inert, spec-owned cases for `setSpriteActive`, shared with the ecs action. // The `args` schema marks `id` as an entity reference so the ecs side resolves the // spec-id to its seeded entity. `before`/`after` keys are plain spec-ids (the ecs // mints its own; compared up to an id-bijection). diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/spec/set-sprite-active.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts rename to packages/data-react-pixie/src/features/main/spec/set-sprite-active.ts diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.cases.ts b/packages/data-react-pixie/src/features/main/spec/set-sprite-hovered.cases.ts similarity index 95% rename from packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.cases.ts rename to packages/data-react-pixie/src/features/main/spec/set-sprite-hovered.cases.ts index f84bd0415..0904a0179 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.cases.ts +++ b/packages/data-react-pixie/src/features/main/spec/set-sprite-hovered.cases.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Entity } from "@adobe/data/ecs"; import type { Conformance } from "@adobe/data-testing"; -import type { Sprite } from "../sprite/sprite.js"; +import type { Sprite } from "../data/entities/sprite.js"; import type { State } from "./state.js"; import type { setSpriteHovered } from "./set-sprite-hovered.js"; @@ -12,7 +12,7 @@ const fox: Sprite = { position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, }; -// Inert, spec-owned cases for `setSpriteHovered`, shared with the ecs transaction. +// Inert, spec-owned cases for `setSpriteHovered`, shared with the ecs action. // The `args` schema marks `id` as an entity reference so the ecs side resolves the // spec-id to its seeded entity. `before`/`after` keys are plain spec-ids (the ecs // mints its own; compared up to an id-bijection). diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts b/packages/data-react-pixie/src/features/main/spec/set-sprite-hovered.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts rename to packages/data-react-pixie/src/features/main/spec/set-sprite-hovered.ts diff --git a/packages/data-react-pixie/src/features/main/spec/spec.test.ts b/packages/data-react-pixie/src/features/main/spec/spec.test.ts new file mode 100644 index 000000000..bdab1adf7 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/spec/spec.test.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "./spec.js"; + +// Runs every case against the pure spec functions — no ECS involved. +Conformance.checkSpec(spec); diff --git a/packages/data-react-pixie/src/features/main/spec/spec.ts b/packages/data-react-pixie/src/features/main/spec/spec.ts new file mode 100644 index 000000000..03cf9af46 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/spec/spec.ts @@ -0,0 +1,28 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; + +import { cases as createSprite } from "./create-sprite.cases.js"; +import { cases as setSpriteHovered } from "./set-sprite-hovered.cases.js"; +import { cases as setSpriteActive } from "./set-sprite-active.cases.js"; +import { cases as toggleSpriteActive } from "./toggle-sprite-active.cases.js"; +import { cases as tick } from "./tick.cases.js"; +import { cases as setFilter } from "./set-filter.cases.js"; + +// The feature's spec: every action, one inert cases module each (coverage checked at +// compile time against `transforms`). State holds no entity references and the +// actions inject no services, so `schemas` and `services` are omitted. +// `ecs/conformance/` pairs this with the ECS build. +export const spec = Conformance.spec({ + state: State, + fns: transforms, + cases: { + createSprite, + setSpriteHovered, + setSpriteActive, + toggleSpriteActive, + tick, + setFilter, + }, +}); diff --git a/packages/data-react-pixie/src/features/main/spec/state.ts b/packages/data-react-pixie/src/features/main/spec/state.ts new file mode 100644 index 000000000..dcf28e839 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/spec/state.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert } from "@adobe/data/types"; +import type { Conformance } from "@adobe/data-testing"; +import type { Sprite } from "../data/entities/sprite.js"; +import type { FilterKind } from "../data/values/filter-kind/filter-kind.js"; +import type { components } from "../data/components/index.js"; +import type { resources } from "../data/resources/index.js"; + +// The full application state as one immutable object — the specification the +// ECS implementation is verified against. `filter` is the scene-wide colour +// filter (a singleton → an ECS resource); `entities` holds every sprite keyed +// by a numeric id, the value carrying no id (identity is the key). +export type State = { + readonly filter: FilterKind; + readonly entities: ReadonlyMap; +}; + +// Singleton keys name resources; entity value keys name components. +type _Pin = Assert>; + +export * as State from "./public.js"; diff --git a/packages/data-react-pixie/src/features/main/data/state/tick.cases.ts b/packages/data-react-pixie/src/features/main/spec/tick.cases.ts similarity index 89% rename from packages/data-react-pixie/src/features/main/data/state/tick.cases.ts rename to packages/data-react-pixie/src/features/main/spec/tick.cases.ts index 4536a43df..cc6effbc1 100644 --- a/packages/data-react-pixie/src/features/main/data/state/tick.cases.ts +++ b/packages/data-react-pixie/src/features/main/spec/tick.cases.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Conformance } from "@adobe/data-testing"; -import type { Sprite } from "../sprite/sprite.js"; +import type { Sprite } from "../data/entities/sprite.js"; import type { State } from "./state.js"; import type { tick } from "./tick.js"; @@ -11,7 +11,7 @@ const fox: Sprite = { position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, }; -// Inert, spec-owned cases for `tick`, shared with the ecs transaction. Every +// Inert, spec-owned cases for `tick`, shared with the ecs action. Every // sprite's rotation advances by delta * 0.1; `after` keys are plain spec-ids // (compared up to an id-bijection). export const cases: Conformance.SpecCases = { diff --git a/packages/data-react-pixie/src/features/main/spec/tick.ts b/packages/data-react-pixie/src/features/main/spec/tick.ts new file mode 100644 index 000000000..78e889618 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/spec/tick.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Sprite } from "../data/entities/sprite.js"; +import type { State } from "./state.js"; + +// Advance one animation frame: every sprite rotates by `delta * 0.1` radians. +// `delta` is the frame time step, supplied by the caller (the render loop). +// Writes only `entities`. +export const tick = ( + state: Pick, + input: { readonly delta: number }, +): Pick => ({ + entities: new Map( + [...state.entities].map(([id, sprite]): [number, Sprite] => [ + id, + { ...sprite, rotation: sprite.rotation + input.delta * 0.1 }, + ]), + ), +}); diff --git a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.cases.ts b/packages/data-react-pixie/src/features/main/spec/toggle-sprite-active.cases.ts similarity index 95% rename from packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.cases.ts rename to packages/data-react-pixie/src/features/main/spec/toggle-sprite-active.cases.ts index bf0e2cd6e..4e0227a47 100644 --- a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.cases.ts +++ b/packages/data-react-pixie/src/features/main/spec/toggle-sprite-active.cases.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Entity } from "@adobe/data/ecs"; import type { Conformance } from "@adobe/data-testing"; -import type { Sprite } from "../sprite/sprite.js"; +import type { Sprite } from "../data/entities/sprite.js"; import type { State } from "./state.js"; import type { toggleSpriteActive } from "./toggle-sprite-active.js"; @@ -12,7 +12,7 @@ const activeFox: Sprite = { position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: true, }; -// Inert, spec-owned cases for `toggleSpriteActive`, shared with the ecs transaction. +// Inert, spec-owned cases for `toggleSpriteActive`, shared with the ecs action. // The `args` schema marks `id` as an entity reference so the ecs side resolves the // spec-id to its seeded entity. `before`/`after` keys are plain spec-ids (the ecs // mints its own; compared up to an id-bijection). diff --git a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts b/packages/data-react-pixie/src/features/main/spec/toggle-sprite-active.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts rename to packages/data-react-pixie/src/features/main/spec/toggle-sprite-active.ts diff --git a/packages/data-react-pixie/src/features/main/spec/transforms.ts b/packages/data-react-pixie/src/features/main/spec/transforms.ts new file mode 100644 index 000000000..52555dfa9 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/spec/transforms.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The conformed functions of this feature — every spec action, and nothing else +// (`create` and `samples` are deliberately absent). Its keys are the conformance +// surface: `Conformance.spec` requires exactly one `*.cases.ts` module per key. +export { createSprite } from "./create-sprite.js"; +export { setSpriteHovered } from "./set-sprite-hovered.js"; +export { setSpriteActive } from "./set-sprite-active.js"; +export { toggleSpriteActive } from "./toggle-sprite-active.js"; +export { tick } from "./tick.js"; +export { setFilter } from "./set-filter.js"; diff --git a/packages/data-react-pixie/src/features/main/ui/app.tsx b/packages/data-react-pixie/src/features/main/ui/app.tsx index 3bd84c07b..c0aad95b8 100644 --- a/packages/data-react-pixie/src/features/main/ui/app.tsx +++ b/packages/data-react-pixie/src/features/main/ui/app.tsx @@ -2,7 +2,7 @@ import { Application } from "@pixi/react"; import { DatabaseProvider } from "@adobe/data-react"; -import { MainService } from "../services/main-service/main-service.js"; +import { MainService } from "../ecs/main-service.js"; import { FilterSelector } from "./filter-selector/filter-selector.js"; import { PixieTick } from "./pixie-scene/pixie-tick.js"; import { PixieScene } from "./pixie-scene/pixie-scene.js"; diff --git a/packages/data-react-pixie/src/features/main/ui/filter-selector/filter-selector-presentation.tsx b/packages/data-react-pixie/src/features/main/ui/filter-selector/filter-selector-presentation.tsx index 05e9162fd..f650bcfd2 100644 --- a/packages/data-react-pixie/src/features/main/ui/filter-selector/filter-selector-presentation.tsx +++ b/packages/data-react-pixie/src/features/main/ui/filter-selector/filter-selector-presentation.tsx @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { FilterKind } from "../../data/filter-kind/filter-kind.js"; +import type { FilterKind } from "../../data/values/filter-kind/filter-kind.js"; const filterLabels: Record = { none: "None", diff --git a/packages/data-react-pixie/src/features/main/ui/filter-selector/filter-selector.tsx b/packages/data-react-pixie/src/features/main/ui/filter-selector/filter-selector.tsx index 983fb82eb..1529a94e0 100644 --- a/packages/data-react-pixie/src/features/main/ui/filter-selector/filter-selector.tsx +++ b/packages/data-react-pixie/src/features/main/ui/filter-selector/filter-selector.tsx @@ -17,6 +17,6 @@ export function FilterSelector() { return presentation.render({ currentFilter, - setFilter: (filter) => db.transactions.setFilter({ filter }), + setFilter: (filter) => db.actions.setFilter({ filter }), }); } diff --git a/packages/data-react-pixie/src/features/main/ui/pixie-scene/pixie-filters.ts b/packages/data-react-pixie/src/features/main/ui/pixie-scene/pixie-filters.ts index d2be9c800..8284f431e 100644 --- a/packages/data-react-pixie/src/features/main/ui/pixie-scene/pixie-filters.ts +++ b/packages/data-react-pixie/src/features/main/ui/pixie-scene/pixie-filters.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { BlurFilter, ColorMatrixFilter, type Filter } from "pixi.js"; -import type { FilterKind } from "../../data/filter-kind/filter-kind.js"; +import type { FilterKind } from "../../data/values/filter-kind/filter-kind.js"; const blurFilter = new BlurFilter({ strength: 2, quality: 2 }); const sepiaFilter = new ColorMatrixFilter(); diff --git a/packages/data-react-pixie/src/features/main/ui/pixie-scene/pixie-tick.tsx b/packages/data-react-pixie/src/features/main/ui/pixie-scene/pixie-tick.tsx index 74603ec37..3dbdb393f 100644 --- a/packages/data-react-pixie/src/features/main/ui/pixie-scene/pixie-tick.tsx +++ b/packages/data-react-pixie/src/features/main/ui/pixie-scene/pixie-tick.tsx @@ -6,7 +6,7 @@ import { useMainService } from "../use-main-service.js"; export function PixieTick() { const db = useMainService(); useTick((ticker) => { - db.transactions.tick({ delta: ticker.deltaTime }); + db.actions.tick({ delta: ticker.deltaTime }); }); return null; } diff --git a/packages/data-react-pixie/src/features/main/ui/sprite/sprite-images.ts b/packages/data-react-pixie/src/features/main/ui/sprite/sprite-images.ts index d25336b73..1f83409ad 100644 --- a/packages/data-react-pixie/src/features/main/ui/sprite/sprite-images.ts +++ b/packages/data-react-pixie/src/features/main/ui/sprite/sprite-images.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { SpriteKind } from "../../data/sprite-kind/sprite-kind.js"; +import type { SpriteKind } from "../../data/values/sprite-kind/sprite-kind.js"; import bunnyUrl from "./bunny.png"; import foxUrl from "./fox.png"; diff --git a/packages/data-react-pixie/src/features/main/ui/sprite/sprite.tsx b/packages/data-react-pixie/src/features/main/ui/sprite/sprite.tsx index 675f63200..bfbf33474 100644 --- a/packages/data-react-pixie/src/features/main/ui/sprite/sprite.tsx +++ b/packages/data-react-pixie/src/features/main/ui/sprite/sprite.tsx @@ -31,10 +31,10 @@ export function Sprite({ entity }: { entity: Entity }) { rotation: sprite.rotation, scale, toggleSpriteActive: () => - db.transactions.toggleSpriteActive({ id: entity }), + db.actions.toggleSpriteActive({ id: entity }), setSpriteHoveredTrue: () => - db.transactions.setSpriteHovered({ id: entity, hovered: true }), + db.actions.setSpriteHovered({ id: entity, hovered: true }), setSpriteHoveredFalse: () => - db.transactions.setSpriteHovered({ id: entity, hovered: false }), + db.actions.setSpriteHovered({ id: entity, hovered: false }), }); } diff --git a/packages/data-react-pixie/src/features/main/ui/use-main-service.ts b/packages/data-react-pixie/src/features/main/ui/use-main-service.ts index c7b9128dc..ec18a814c 100644 --- a/packages/data-react-pixie/src/features/main/ui/use-main-service.ts +++ b/packages/data-react-pixie/src/features/main/ui/use-main-service.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { useDatabase } from "@adobe/data-react"; -import { MainService } from "../services/main-service/main-service.js"; +import { MainService } from "../ecs/main-service.js"; // The single main-service context every binding component reads. Wraps // `useDatabase(MainService.plugin)` so the plugin identity is named once. diff --git a/packages/data-react-pixie/src/main.tsx b/packages/data-react-pixie/src/main.tsx deleted file mode 100644 index 3640814b2..000000000 --- a/packages/data-react-pixie/src/main.tsx +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -import { createRoot } from "react-dom/client"; -import { Container, Graphics, Sprite } from "pixi.js"; -import { extend } from "@pixi/react"; -import { App } from "./features/main/ui/app.js"; - -// Register PixiJS components for @pixi/react (pixiSprite, pixiContainer) -extend({ Container, Graphics, Sprite }); - -const root = document.getElementById("root"); -if (root) { - createRoot(root).render(); -} diff --git a/packages/data-react-pixie/tsconfig.build.json b/packages/data-react-pixie/tsconfig.build.json deleted file mode 100644 index 1395b61d5..000000000 --- a/packages/data-react-pixie/tsconfig.build.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - // The RUNTIME wall (dependency isolation). It compiles the shippable data + services - // tiers as a `composite` project, so a data/ or services/ runtime file that imports a - // test-tier file (`*.cases.ts`, `*.fake.ts`, `spec.ts`, anything under `conformance/`) - // is a COMPILE ERROR (TS6307: not in this project's file list). `ui/` and the app-shell - // (`src/*.ts`) live in the test project instead — they import test-tier only in their own - // `*.test.ts`, and keeping their (often very large) inferred element/plugin types out of - // this project avoids forcing them into serialized declarations. Emits declarations only, - // to a throwaway dir, purely to satisfy the composite contract `tsconfig.test.json` refs. - "extends": "./tsconfig.json", - "compilerOptions": { - "composite": true, - "noEmit": false, - "declaration": true, - "emitDeclarationOnly": true, - "outDir": "./.tsbuild", - "rootDir": "src" - }, - "include": ["src/features/**/*"], - "exclude": [ - "**/ui/**", - "**/*.test.ts", - "**/*.type-test.ts", - "**/*.cases.ts", - "**/*.fake.ts", - "**/spec.ts", - "**/conformance/**" - ] -} diff --git a/packages/data-react-pixie/tsconfig.data.json b/packages/data-react-pixie/tsconfig.data.json new file mode 100644 index 000000000..513d7b78a --- /dev/null +++ b/packages/data-react-pixie/tsconfig.data.json @@ -0,0 +1,16 @@ +{ + // Runtime: pure data declarations (data/). Composite so higher layers reference it; + // a data file importing spec/, ecs/ or any test file is a compile error (TS6307: not + // in this project). + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/data", + "rootDir": "src" + }, + "include": ["src/features/*/data/**/*", "src/features/*/services/**/*"], + "exclude": ["**/*.test.ts", "**/*.type-test.ts"] +} diff --git a/packages/data-react-pixie/tsconfig.ecs.json b/packages/data-react-pixie/tsconfig.ecs.json new file mode 100644 index 000000000..78227f085 --- /dev/null +++ b/packages/data-react-pixie/tsconfig.ecs.json @@ -0,0 +1,16 @@ +{ + // Runtime: the ECS implementation (ecs/, minus conformance). References only data + + // services, so an ecs file importing spec/ is a compile error. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/ecs", + "rootDir": "src" + }, + "include": ["src/features/*/ecs/**/*"], + "exclude": ["**/conformance/**", "**/*.test.ts", "**/*.type-test.ts"], + "references": [{ "path": "./tsconfig.data.json" }] +} diff --git a/packages/data-react-pixie/tsconfig.spec.json b/packages/data-react-pixie/tsconfig.spec.json new file mode 100644 index 000000000..e4dc0a8ff --- /dev/null +++ b/packages/data-react-pixie/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + // Test tier: the pure spec (spec/). References only data + services, so a spec file + // importing ecs/ is a compile error. May use @adobe/data-testing. No runtime file + // imports it (runtime projects never reference it). + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/spec", + "rootDir": "src" + }, + "include": ["src/features/*/spec/**/*"], + "exclude": ["**/*.test.ts", "**/*.type-test.ts"], + "references": [{ "path": "./tsconfig.data.json" }] +} diff --git a/packages/data-react-pixie/tsconfig.test.json b/packages/data-react-pixie/tsconfig.test.json index f328ac324..0f21c5b66 100644 --- a/packages/data-react-pixie/tsconfig.test.json +++ b/packages/data-react-pixie/tsconfig.test.json @@ -1,11 +1,7 @@ { - // The NON-EMITTING tier: everything the runtime wall (`tsconfig.build.json`) doesn't own — - // the test-tier (conformance manifest + inert cases, service fakes, `*.test.ts`, - // `conformance/`), plus `ui/` and the app-shell (`src/*.ts`). It `references` the runtime - // project, so its imports of data/services resolve through that project's declarations, - // and it alone may see `@adobe/data-testing`. Nothing here emits, so large element/plugin - // types are type-checked without being serialized. `tsc -b tsconfig.test.json` builds the - // wall first, then type-checks this tier — together covering all of `src`. + // Everything else, non-emitting: ui/, app/, ecs/conformance/, and every *.test.ts. + // References the ecs and spec projects, so it sees both. `tsc -b tsconfig.test.json` + // builds the layer projects first, then type-checks this tier — together covering src. "extends": "./tsconfig.json", "compilerOptions": { "composite": false, @@ -13,14 +9,12 @@ }, "include": [ "src/*.ts", - "src/**/ui/**/*", + "src/app/**/*", + "src/features/*/ui/**/*", + "src/features/*/ecs/conformance/**/*", "src/**/*.test.ts", - "src/**/*.type-test.ts", - "src/**/*.cases.ts", - "src/**/*.fake.ts", - "src/**/spec.ts", - "src/**/conformance/**/*" + "src/**/*.type-test.ts" ], "exclude": [], - "references": [{ "path": "./tsconfig.build.json" }] + "references": [{ "path": "./tsconfig.ecs.json" }, { "path": "./tsconfig.spec.json" }] } diff --git a/packages/data-react/package.json b/packages/data-react/package.json index db37a3d9d..b71215e8f 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.10.21", + "version": "0.10.22", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-rpc/package.json b/packages/data-rpc/package.json index 6e9aeaede..d474a72e4 100644 --- a/packages/data-rpc/package.json +++ b/packages/data-rpc/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-rpc", - "version": "0.10.21", + "version": "0.10.22", "description": "Schema-driven, bidirectional projection of @adobe/data async data services across a boundary (iframe / MessagePort / Worker). Only Data crosses the wire.", "type": "module", "sideEffects": false, diff --git a/packages/data-solid-dashboard/README.md b/packages/data-solid-dashboard/README.md index 85d705ab6..85d011fe9 100644 --- a/packages/data-solid-dashboard/README.md +++ b/packages/data-solid-dashboard/README.md @@ -17,7 +17,7 @@ pnpm dev # starts Vite on http://localhost:3004 - **Fine-grained reactivity** — each component observes only the slices it needs (`count`, `log`, `userName`). Updating one resource does not re-render components that don't depend on it. -- **Cross-component actions** — the control panel fires transactions +- **Cross-component actions** — the control panel fires actions (`increment`, `setUserName`, …) that are reflected in the counter display, activity log, and status bar. - **Presentation separation** — each component is split into a data-wiring @@ -27,31 +27,29 @@ pnpm dev # starts Vite on http://localhost:3004 ## Structure -Organized into the feature-folder architecture — layered by the *kind of type* -each folder holds (`data/` value types & pure transforms, `services/` the ECS -implementation, `ui/` presentation): +Organized into the layered feature layout (`data → spec | ecs → ui → app`): ``` src/ - main.tsx entry point + app/main.tsx entry point features/main/ - data/state/ the spec — one immutable State + pure transforms - state.ts create.ts public.ts - increment.ts / .test.ts decrement.ts / .test.ts reset.ts / .test.ts - set-user-name.ts / .test.ts clear-log.ts / .test.ts - conformance-case.ts expect-state-matches.ts - services/main-service/ the implementation — the sole entrypoint the ui/ binds to - core-database/ resources (count, userName, log) — no entities - transaction-database/transactions/ one mutation per file + conformance test - conformance/ fromState / toState / expectConforms (test-only) + data/ runtime: pure Data declarations + values/activity-log/ ActivityLog type + pinned schema + resources/ count, userName, log (one schema per file) + index.ts + spec/ test tier: State + one pure action per file + its cases + state.ts create.ts samples.ts transforms.ts spec.ts spec.test.ts + increment.ts decrement.ts reset.ts set-user-name.ts clear-log.ts (+ .cases.ts) + ecs/ runtime: the ECS implementation + core/ transactions/ actions/ one op per file + -database.ts main-service.ts aliases the top layer as MainService + conformance/ test tier: projection, implementation, conformance.test.ts ui/ Solid components (element + pure presentation) app/ status-bar/ control-panel/ counter-display/ activity-log/ ``` -Each transaction is proven equivalent to its pure `data/` transform via -`toState(apply(fromState(before), args)) ≡ State.transform(before, args)`, reusing -the spec-owned cases exported from each `data/state/.test.ts`. +Each spec action is replayed against the same-named `MainService` action via +`toState(action(fromState(before), args)) ≡ spec(before, args)`, using the inert +cases in `spec/.cases.ts`. ## Pattern summary diff --git a/packages/data-solid-dashboard/index.html b/packages/data-solid-dashboard/index.html index f57b62909..5e17a794e 100644 --- a/packages/data-solid-dashboard/index.html +++ b/packages/data-solid-dashboard/index.html @@ -11,6 +11,6 @@
- + diff --git a/packages/data-solid-dashboard/src/app/main.tsx b/packages/data-solid-dashboard/src/app/main.tsx new file mode 100644 index 000000000..801c43d27 --- /dev/null +++ b/packages/data-solid-dashboard/src/app/main.tsx @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { render } from "solid-js/web"; +import { App } from "../features/main/ui/app/app.jsx"; + +const root = document.getElementById("root"); +if (root) { + render(() => , root); +} diff --git a/packages/data-solid-dashboard/src/features/main/data/resources/count.ts b/packages/data-solid-dashboard/src/features/main/data/resources/count.ts new file mode 100644 index 000000000..9a0f2ac5f --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/resources/count.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ResourceSchema } from "@adobe/data/ecs"; + +// The counter value. Document scope. +export const count = { type: "number", default: 0 } as const satisfies ResourceSchema; diff --git a/packages/data-solid-dashboard/src/features/main/data/resources/index.ts b/packages/data-solid-dashboard/src/features/main/data/resources/index.ts new file mode 100644 index 000000000..e01f7a1d1 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/resources/index.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { count } from "./count.js"; +import { userName } from "./user-name.js"; +import { log } from "./log.js"; + +export const resources = { count, userName, log }; diff --git a/packages/data-solid-dashboard/src/features/main/data/resources/log.ts b/packages/data-solid-dashboard/src/features/main/data/resources/log.ts new file mode 100644 index 000000000..343a39bfd --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/resources/log.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ResourceSchema } from "@adobe/data/ecs"; +import { ActivityLog } from "../values/activity-log/activity-log.js"; + +// The activity trail. Document scope. +export const log = { ...ActivityLog.schema, default: [] } satisfies ResourceSchema; diff --git a/packages/data-solid-dashboard/src/features/main/data/resources/user-name.ts b/packages/data-solid-dashboard/src/features/main/data/resources/user-name.ts new file mode 100644 index 000000000..aa8432c2e --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/resources/user-name.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ResourceSchema } from "@adobe/data/ecs"; + +// The active user's display name. Document scope. +export const userName = { type: "string", default: "Guest" } as const satisfies ResourceSchema; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/public.ts b/packages/data-solid-dashboard/src/features/main/data/state/public.ts deleted file mode 100644 index 1b91e9481..000000000 --- a/packages/data-solid-dashboard/src/features/main/data/state/public.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { create } from "./create.js"; -export { samples } from "./samples.js"; -export { increment } from "./increment.js"; -export { decrement } from "./decrement.js"; -export { reset } from "./reset.js"; -export { setUserName } from "./set-user-name.js"; -export { clearLog } from "./clear-log.js"; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts b/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts deleted file mode 100644 index 316c33081..000000000 --- a/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "./spec.js"; - -// The single pure-spec test for every transform in this folder. It runs the -// manifest's inert cases against the pure functions and asserts `after`. -Conformance.checkSpec(spec); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/spec.ts b/packages/data-solid-dashboard/src/features/main/data/state/spec.ts deleted file mode 100644 index a82d90d5a..000000000 --- a/packages/data-solid-dashboard/src/features/main/data/state/spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import * as transforms from "./transforms.js"; -import { MainService } from "../../services/main-service/main-service.js"; -import { projection } from "../../services/main-service/conformance/projection.js"; - -import { cases as increment } from "./increment.cases.js"; -import { cases as decrement } from "./decrement.cases.js"; -import { cases as reset } from "./reset.cases.js"; -import { cases as setUserName } from "./set-user-name.cases.js"; -import { cases as clearLog } from "./clear-log.cases.js"; - -// The feature's conformance manifest — the single object `spec.test.ts` (pure) and -// `conformance.test.ts` (ecs) both import. `cases` names one module per conformed fn, -// checked at COMPILE TIME against `transforms`. This feature is all scalar -// singletons — no entities, no derivations, no capability services — so it needs no -// `computedPlugin`, `hydrate`, or `services`. -// -// This is a test-tier module (excluded from the runtime program) — the ONE place the -// feature touches `@adobe/data-testing`. No transform or `*.cases.ts` file does. -export const spec = Conformance.feature({ - state: State, - fns: transforms, - plugin: MainService.plugin, - projection, - cases: { - increment, - decrement, - reset, - setUserName, - clearLog, - }, -}); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/state.ts b/packages/data-solid-dashboard/src/features/main/data/state/state.ts deleted file mode 100644 index c3a9c9143..000000000 --- a/packages/data-solid-dashboard/src/features/main/data/state/state.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -// The whole dashboard modelled as one immutable value — the specification the -// ECS implementation is verified against. Every field is a scalar (no entity -// collections); `log` is an append-only activity trail in chronological order. -export type State = { - readonly count: number; - readonly log: readonly string[]; - readonly userName: string; -}; -export * as State from "./public.js"; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/transforms.ts b/packages/data-solid-dashboard/src/features/main/data/state/transforms.ts deleted file mode 100644 index 72504dac7..000000000 --- a/packages/data-solid-dashboard/src/features/main/data/state/transforms.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -// The conformed functions of this feature — every pure transform the spec verifies, -// and nothing else (helpers like `create` and `samples` are deliberately absent). Its -// keys ARE the conformance surface: the manifest's coverage guard -// (`Conformance.feature`) requires exactly one `*.cases.ts` module per key here, so -// adding a transform without cases — or cases without a transform — fails to compile. -export { increment } from "./increment.js"; -export { decrement } from "./decrement.js"; -export { reset } from "./reset.js"; -export { setUserName } from "./set-user-name.js"; -export { clearLog } from "./clear-log.js"; diff --git a/packages/data-solid-dashboard/src/features/main/data/values/activity-log/activity-log.ts b/packages/data-solid-dashboard/src/features/main/data/values/activity-log/activity-log.ts new file mode 100644 index 000000000..e322ad78a --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/values/activity-log/activity-log.ts @@ -0,0 +1,5 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// An append-only activity trail, oldest entry first. +export type ActivityLog = readonly string[]; +export * as ActivityLog from "./public.js"; diff --git a/packages/data-react-pixie/src/features/main/data/filter-kind/public.ts b/packages/data-solid-dashboard/src/features/main/data/values/activity-log/public.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/data/filter-kind/public.ts rename to packages/data-solid-dashboard/src/features/main/data/values/activity-log/public.ts diff --git a/packages/data-solid-dashboard/src/features/main/data/values/activity-log/schema.ts b/packages/data-solid-dashboard/src/features/main/data/values/activity-log/schema.ts new file mode 100644 index 000000000..4973b65fd --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/values/activity-log/schema.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "@adobe/data/types"; +import type { ActivityLog } from "./activity-log.js"; +import { Schema } from "@adobe/data/schema"; + +export const schema = { type: "array", items: { type: "string" } } as const satisfies Schema; + +type _Pin = Assert, ActivityLog>>; diff --git a/packages/data-solid-dashboard/src/features/main/ecs/actions/action-database.ts b/packages/data-solid-dashboard/src/features/main/ecs/actions/action-database.ts new file mode 100644 index 000000000..a03974dca --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/actions/action-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; +import * as actions from "./index.js"; + +// Extends the transaction layer directly: this feature has no computed / service +// layers between them (no derivations, no external capability services). +const actionDatabasePlugin = Database.Plugin.create({ + extends: TransactionDatabase.plugin, + actions, +}); + +export type ActionDatabase = Database.Plugin.ToDatabase; + +export namespace ActionDatabase { + export const plugin = actionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-solid-dashboard/src/features/main/ecs/actions/clear-log.ts b/packages/data-solid-dashboard/src/features/main/ecs/actions/clear-log.ts new file mode 100644 index 000000000..ef24be5d3 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/actions/clear-log.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../transactions/transaction-database.js"; + +// Commits the `clearLog` transaction. +export const clearLog = (db: TransactionDatabase) => { + db.transactions.clearLog(); +}; diff --git a/packages/data-solid-dashboard/src/features/main/ecs/actions/decrement.ts b/packages/data-solid-dashboard/src/features/main/ecs/actions/decrement.ts new file mode 100644 index 000000000..a84d1000c --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/actions/decrement.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../transactions/transaction-database.js"; + +// Commits the `decrement` transaction. +export const decrement = (db: TransactionDatabase) => { + db.transactions.decrement(); +}; diff --git a/packages/data-solid-dashboard/src/features/main/ecs/actions/increment.ts b/packages/data-solid-dashboard/src/features/main/ecs/actions/increment.ts new file mode 100644 index 000000000..86104d2f3 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/actions/increment.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../transactions/transaction-database.js"; + +// Commits the `increment` transaction. +export const increment = (db: TransactionDatabase) => { + db.transactions.increment(); +}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/index.ts b/packages/data-solid-dashboard/src/features/main/ecs/actions/index.ts similarity index 100% rename from packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/index.ts rename to packages/data-solid-dashboard/src/features/main/ecs/actions/index.ts diff --git a/packages/data-solid-dashboard/src/features/main/ecs/actions/reset.ts b/packages/data-solid-dashboard/src/features/main/ecs/actions/reset.ts new file mode 100644 index 000000000..7c681ef6f --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/actions/reset.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../transactions/transaction-database.js"; + +// Commits the `reset` transaction. +export const reset = (db: TransactionDatabase) => { + db.transactions.reset(); +}; diff --git a/packages/data-solid-dashboard/src/features/main/ecs/actions/set-user-name.ts b/packages/data-solid-dashboard/src/features/main/ecs/actions/set-user-name.ts new file mode 100644 index 000000000..b50b269b1 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/actions/set-user-name.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../transactions/transaction-database.js"; + +// Commits the `setUserName` transaction. +export const setUserName = (db: TransactionDatabase, input: { readonly name: string }) => { + db.transactions.setUserName(input); +}; diff --git a/packages/data-solid-dashboard/src/features/main/ecs/conformance/conformance.test.ts b/packages/data-solid-dashboard/src/features/main/ecs/conformance/conformance.test.ts new file mode 100644 index 000000000..e22629e75 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/conformance/conformance.test.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { implementation } from "./implementation.js"; + +// Replays every spec case against the same-named action, and round-trips +// `State.samples` through the projection. +Conformance.checkFeature(implementation); diff --git a/packages/data-solid-dashboard/src/features/main/ecs/conformance/implementation.ts b/packages/data-solid-dashboard/src/features/main/ecs/conformance/implementation.ts new file mode 100644 index 000000000..76cc4d5bc --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/conformance/implementation.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "../../spec/spec.js"; +import { MainService } from "../main-service.js"; +import { projection } from "./projection.js"; + +// The spec paired with its ECS build: actions from `MainService` and the projection +// between the store and `State`. No derivations, so no `computedPlugin`. +export const implementation = Conformance.implementation(spec, { + plugin: MainService.plugin, + projection, +}); diff --git a/packages/data-solid-dashboard/src/features/main/ecs/conformance/projection.ts b/packages/data-solid-dashboard/src/features/main/ecs/conformance/projection.ts new file mode 100644 index 000000000..83c531977 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/conformance/projection.ts @@ -0,0 +1,25 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { State } from "../../spec/state.js"; +import type { CoreDatabase } from "../core/core-database.js"; + +// The test-only ecs↔`State` projection, carried on the manifest for `checkFeature`. +// `fromState` seeds a store to a `State`; the whole state is scalar resources, so +// seeding is three assignments, it mints no ids and returns an empty map. +// `toState` reads it back — the inverse. No entities, so no `toData`. +export const projection = { + fromState: ( + store: CoreDatabase.Store, + state: State, + ): ReadonlyMap => { + store.resources.count = state.count; + store.resources.log = state.log; + store.resources.userName = state.userName; + return new Map(); + }, + toState: (store: CoreDatabase.Store): State => ({ + count: store.resources.count, + log: store.resources.log, + userName: store.resources.userName, + }), +}; diff --git a/packages/data-solid-dashboard/src/features/main/ecs/core/core-database.ts b/packages/data-solid-dashboard/src/features/main/ecs/core/core-database.ts new file mode 100644 index 000000000..29377a24f --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/core/core-database.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { resources } from "../../data/resources/index.js"; + +// The core schema: the feature's data/ resources. No entities, so no components or archetypes. +const coreDatabasePlugin = Database.Plugin.create({ + resources, +}); + +export type CoreDatabase = Database.Plugin.ToDatabase; + +export namespace CoreDatabase { + export const plugin = coreDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-solid-dashboard/src/features/main/ecs/main-service.ts b/packages/data-solid-dashboard/src/features/main/ecs/main-service.ts new file mode 100644 index 000000000..bbacf9c54 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/main-service.ts @@ -0,0 +1,2 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { ActionDatabase as MainService } from "./actions/action-database.js"; diff --git a/packages/data-solid-dashboard/src/features/main/ecs/transactions/clear-log.ts b/packages/data-solid-dashboard/src/features/main/ecs/transactions/clear-log.ts new file mode 100644 index 000000000..782bba397 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/transactions/clear-log.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +// Empty the activity log. +export const clearLog = (t: CoreDatabase.Store) => { + t.resources.log = []; +}; diff --git a/packages/data-solid-dashboard/src/features/main/ecs/transactions/decrement.ts b/packages/data-solid-dashboard/src/features/main/ecs/transactions/decrement.ts new file mode 100644 index 000000000..c29c893f8 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/transactions/decrement.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +// Lower the counter by one and log the new value; a no-op at zero. +export const decrement = (t: CoreDatabase.Store) => { + if (t.resources.count <= 0) return; + const count = t.resources.count - 1; + t.resources.count = count; + t.resources.log = [...t.resources.log, `Decremented to ${count}`]; +}; diff --git a/packages/data-solid-dashboard/src/features/main/ecs/transactions/increment.ts b/packages/data-solid-dashboard/src/features/main/ecs/transactions/increment.ts new file mode 100644 index 000000000..94d522029 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/transactions/increment.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +// Raise the counter by one and log the new value. +export const increment = (t: CoreDatabase.Store) => { + const count = t.resources.count + 1; + t.resources.count = count; + t.resources.log = [...t.resources.log, `Incremented to ${count}`]; +}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/index.ts b/packages/data-solid-dashboard/src/features/main/ecs/transactions/index.ts similarity index 100% rename from packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/index.ts rename to packages/data-solid-dashboard/src/features/main/ecs/transactions/index.ts diff --git a/packages/data-solid-dashboard/src/features/main/ecs/transactions/reset.ts b/packages/data-solid-dashboard/src/features/main/ecs/transactions/reset.ts new file mode 100644 index 000000000..b8cd49775 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/transactions/reset.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +// Return the counter to zero and log the reset. +export const reset = (t: CoreDatabase.Store) => { + t.resources.count = 0; + t.resources.log = [...t.resources.log, "Reset to 0"]; +}; diff --git a/packages/data-solid-dashboard/src/features/main/ecs/transactions/set-user-name.ts b/packages/data-solid-dashboard/src/features/main/ecs/transactions/set-user-name.ts new file mode 100644 index 000000000..20f5c72ee --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/transactions/set-user-name.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { CoreDatabase } from "../core/core-database.js"; + +// Change the active user's name and log the change. +export const setUserName = (t: CoreDatabase.Store, { name }: { readonly name: string }) => { + t.resources.userName = name; + t.resources.log = [...t.resources.log, `Name changed to ${name}`]; +}; diff --git a/packages/data-solid-dashboard/src/features/main/ecs/transactions/transaction-database.ts b/packages/data-solid-dashboard/src/features/main/ecs/transactions/transaction-database.ts new file mode 100644 index 000000000..bf625d540 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/ecs/transactions/transaction-database.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { CoreDatabase } from "../core/core-database.js"; +import * as transactions from "./index.js"; + +const transactionDatabasePlugin = Database.Plugin.create({ + extends: CoreDatabase.plugin, + transactions, +}); + +export type TransactionDatabase = Database.Plugin.ToDatabase; + +export namespace TransactionDatabase { + export const plugin = transactionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/action-database.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/action-database.ts deleted file mode 100644 index cc2572a23..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/action-database.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { TransactionDatabase } from "../transaction-database/transaction-database.js"; -import * as actions from "./actions/index.js"; - -// Extends the transaction layer directly: this feature has no computed / service -// layers between them (no derivations, no external capability services). -const actionDatabasePlugin = Database.Plugin.create({ - extends: TransactionDatabase.plugin, - actions, -}); - -export type ActionDatabase = Database.Plugin.ToDatabase; - -export namespace ActionDatabase { - export const plugin = actionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/clear-log.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/clear-log.ts deleted file mode 100644 index 299420071..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/clear-log.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; - -// The app-facing realization of `State.clearLog` — commits the transaction. -export const clearLog = (db: TransactionDatabase) => { - db.transactions.clearLog(); -}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/decrement.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/decrement.ts deleted file mode 100644 index a751cfafb..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/decrement.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; - -// The app-facing realization of `State.decrement` — commits the transaction. -export const decrement = (db: TransactionDatabase) => { - db.transactions.decrement(); -}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/increment.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/increment.ts deleted file mode 100644 index 3b4f3d2fb..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/increment.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; - -// The app-facing realization of `State.increment`. This feature has no external -// services to await, so the action just commits the transaction (exactly one). -export const increment = (db: TransactionDatabase) => { - db.transactions.increment(); -}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/reset.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/reset.ts deleted file mode 100644 index 9c152c66d..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/reset.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; - -// The app-facing realization of `State.reset` — commits the transaction. -export const reset = (db: TransactionDatabase) => { - db.transactions.reset(); -}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/set-user-name.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/set-user-name.ts deleted file mode 100644 index 85f1e50f1..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/set-user-name.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; - -// The app-facing realization of `State.setUserName` — commits the transaction. -export const setUserName = (db: TransactionDatabase, input: { readonly name: string }) => { - db.transactions.setUserName(input); -}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/conformance.test.ts deleted file mode 100644 index 48e046088..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/conformance.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { spec } from "../../../data/state/spec.js"; - -// The whole ecs conformance for this feature in one call: `checkFeature` replays the -// manifest's shared cases against the transactions/actions on `MainService.plugin`, -// seeds each `before` over `State.create()`, and round-trips `State.samples` through -// the `projection`. It shares `spec` with `spec.test.ts` — same cases, substituted -// implementation. -Conformance.checkFeature(spec); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.ts deleted file mode 100644 index 17b674a5f..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.ts +++ /dev/null @@ -1,25 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { State } from "../../../data/state/state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// The test-only ecs↔`State` projection, carried on the manifest for `checkFeature`. -// `fromState` seeds a store to a `State`; the whole state is scalar resources, so -// seeding is three assignments, it mints no ids and returns an empty map. -// `toState` reads it back — the inverse. No entities, so no `toData`. -export const projection = { - fromState: ( - store: CoreDatabase.Store, - state: State, - ): ReadonlyMap => { - store.resources.count = state.count; - store.resources.log = state.log; - store.resources.userName = state.userName; - return new Map(); - }, - toState: (store: CoreDatabase.Store): State => ({ - count: store.resources.count, - log: store.resources.log, - userName: store.resources.userName, - }), -}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/core-database/core-database.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/core-database/core-database.ts deleted file mode 100644 index e296d0e18..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/core-database/core-database.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { resources } from "./resources.js"; - -// The core schema. This feature has no entities, so it declares only resources — -// no `components` / `archetypes` boilerplate (all facets are optional). -const coreDatabasePlugin = Database.Plugin.create({ - resources, -}); - -export type CoreDatabase = Database.Plugin.ToDatabase; - -export namespace CoreDatabase { - export const plugin = coreDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/core-database/resources.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/core-database/resources.ts deleted file mode 100644 index 5c5b43a1c..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/core-database/resources.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; - -// The whole dashboard is scalar state — three singleton resources, no entities. -// All three are `document` scope (shared + durable, no scope flags), the default -// home for feature state. Each carries the same default as `State.create()`. -export const resources = Database.resources({ - document: { - count: { type: "number", default: 0 }, - userName: { type: "string", default: "Guest" }, - log: { type: "array", items: { type: "string" }, default: [] }, - }, -}); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/main-service.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/main-service.ts deleted file mode 100644 index 14f15fb41..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/main-service.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -// The assembled feature database — the sole entrypoint the `ui/` binds to. -// Actions are the topmost layer this feature builds (no computed / service / -// system layers), so `ActionDatabase` is `MainService`. Adding or dropping a -// layer changes only this one line; consumers name `MainService`. -export { ActionDatabase as MainService } from "./action-database/action-database.js"; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transaction-database.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transaction-database.ts deleted file mode 100644 index 02a036846..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transaction-database.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Database } from "@adobe/data/ecs"; -import { CoreDatabase } from "../core-database/core-database.js"; -import * as transactions from "./transactions/index.js"; - -const transactionDatabasePlugin = Database.Plugin.create({ - extends: CoreDatabase.plugin, - transactions, -}); - -export type TransactionDatabase = Database.Plugin.ToDatabase; - -export namespace TransactionDatabase { - export const plugin = transactionDatabasePlugin; - export type Store = Database.Plugin.ToStore; -} diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/clear-log.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/clear-log.ts deleted file mode 100644 index 83d5f929d..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/clear-log.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "../../../../data/state/state.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const clearLog = (t: CoreDatabase.Store) => { - const next = State.clearLog({ log: t.resources.log }); - t.resources.log = next.log; -}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/decrement.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/decrement.ts deleted file mode 100644 index e084b8d20..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/decrement.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "../../../../data/state/state.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const decrement = (t: CoreDatabase.Store) => { - const next = State.decrement({ count: t.resources.count, log: t.resources.log }); - t.resources.count = next.count; - t.resources.log = next.log; -}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/increment.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/increment.ts deleted file mode 100644 index 61581a040..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/increment.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "../../../../data/state/state.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -// Applies `State.increment` to the resource slice it touches — the decision is -// the pure transform's, this only writes the diff back to the store. -export const increment = (t: CoreDatabase.Store) => { - const next = State.increment({ count: t.resources.count, log: t.resources.log }); - t.resources.count = next.count; - t.resources.log = next.log; -}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/reset.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/reset.ts deleted file mode 100644 index 178614517..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/reset.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "../../../../data/state/state.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const reset = (t: CoreDatabase.Store) => { - const next = State.reset({ count: t.resources.count, log: t.resources.log }); - t.resources.count = next.count; - t.resources.log = next.log; -}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/set-user-name.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/set-user-name.ts deleted file mode 100644 index f87ade0bd..000000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/set-user-name.ts +++ /dev/null @@ -1,12 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "../../../../data/state/state.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; - -export const setUserName = (t: CoreDatabase.Store, { name }: { name: string }) => { - const next = State.setUserName( - { userName: t.resources.userName, log: t.resources.log }, - { name }, - ); - t.resources.userName = next.userName; - t.resources.log = next.log; -}; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/clear-log.cases.ts b/packages/data-solid-dashboard/src/features/main/spec/clear-log.cases.ts similarity index 82% rename from packages/data-solid-dashboard/src/features/main/data/state/clear-log.cases.ts rename to packages/data-solid-dashboard/src/features/main/spec/clear-log.cases.ts index 1554566ac..e444c3af8 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/clear-log.cases.ts +++ b/packages/data-solid-dashboard/src/features/main/spec/clear-log.cases.ts @@ -3,8 +3,8 @@ import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { clearLog } from "./clear-log.js"; -// Inert, spec-owned cases for `clearLog` — data only, shared with the ecs -// `clearLog` transaction and action. +// Inert, spec-owned cases for `clearLog` — data only, replayed against the +// `clearLog` action by conformance. export const cases: Conformance.SpecCases = { cases: [ { diff --git a/packages/data-solid-dashboard/src/features/main/data/state/clear-log.ts b/packages/data-solid-dashboard/src/features/main/spec/clear-log.ts similarity index 100% rename from packages/data-solid-dashboard/src/features/main/data/state/clear-log.ts rename to packages/data-solid-dashboard/src/features/main/spec/clear-log.ts diff --git a/packages/data-solid-dashboard/src/features/main/data/state/create.ts b/packages/data-solid-dashboard/src/features/main/spec/create.ts similarity index 100% rename from packages/data-solid-dashboard/src/features/main/data/state/create.ts rename to packages/data-solid-dashboard/src/features/main/spec/create.ts diff --git a/packages/data-solid-dashboard/src/features/main/data/state/decrement.cases.ts b/packages/data-solid-dashboard/src/features/main/spec/decrement.cases.ts similarity index 83% rename from packages/data-solid-dashboard/src/features/main/data/state/decrement.cases.ts rename to packages/data-solid-dashboard/src/features/main/spec/decrement.cases.ts index d843650e5..91695d040 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/decrement.cases.ts +++ b/packages/data-solid-dashboard/src/features/main/spec/decrement.cases.ts @@ -3,8 +3,8 @@ import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { decrement } from "./decrement.js"; -// Inert, spec-owned cases for `decrement` — data only, shared with the ecs -// `decrement` transaction and action. +// Inert, spec-owned cases for `decrement` — data only, replayed against the +// `decrement` action by conformance. export const cases: Conformance.SpecCases = { cases: [ { diff --git a/packages/data-solid-dashboard/src/features/main/data/state/decrement.ts b/packages/data-solid-dashboard/src/features/main/spec/decrement.ts similarity index 100% rename from packages/data-solid-dashboard/src/features/main/data/state/decrement.ts rename to packages/data-solid-dashboard/src/features/main/spec/decrement.ts diff --git a/packages/data-solid-dashboard/src/features/main/data/state/increment.cases.ts b/packages/data-solid-dashboard/src/features/main/spec/increment.cases.ts similarity index 80% rename from packages/data-solid-dashboard/src/features/main/data/state/increment.cases.ts rename to packages/data-solid-dashboard/src/features/main/spec/increment.cases.ts index 3c2c63d83..397bfd059 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/increment.cases.ts +++ b/packages/data-solid-dashboard/src/features/main/spec/increment.cases.ts @@ -3,8 +3,8 @@ import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { increment } from "./increment.js"; -// Inert, spec-owned cases for `increment` — data only, shared with the ecs -// `increment` transaction and action. `before` is a delta over `State.create()`; +// Inert, spec-owned cases for `increment` — data only, replayed against the +// `increment` action by conformance. `before` is a delta over `State.create()`; // `after` is the writes patch. export const cases: Conformance.SpecCases = { cases: [ diff --git a/packages/data-solid-dashboard/src/features/main/data/state/increment.ts b/packages/data-solid-dashboard/src/features/main/spec/increment.ts similarity index 100% rename from packages/data-solid-dashboard/src/features/main/data/state/increment.ts rename to packages/data-solid-dashboard/src/features/main/spec/increment.ts diff --git a/packages/data-solid-dashboard/src/features/main/spec/public.ts b/packages/data-solid-dashboard/src/features/main/spec/public.ts new file mode 100644 index 000000000..69dfc9c89 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/spec/public.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { create } from "./create.js"; +export { samples } from "./samples.js"; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/reset.cases.ts b/packages/data-solid-dashboard/src/features/main/spec/reset.cases.ts similarity index 84% rename from packages/data-solid-dashboard/src/features/main/data/state/reset.cases.ts rename to packages/data-solid-dashboard/src/features/main/spec/reset.cases.ts index 42f1879e3..a9a0a622e 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/reset.cases.ts +++ b/packages/data-solid-dashboard/src/features/main/spec/reset.cases.ts @@ -3,8 +3,8 @@ import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { reset } from "./reset.js"; -// Inert, spec-owned cases for `reset` — data only, shared with the ecs `reset` -// transaction and action. +// Inert, spec-owned cases for `reset` — data only, replayed against the `reset` +// action by conformance. export const cases: Conformance.SpecCases = { cases: [ { diff --git a/packages/data-solid-dashboard/src/features/main/data/state/reset.ts b/packages/data-solid-dashboard/src/features/main/spec/reset.ts similarity index 100% rename from packages/data-solid-dashboard/src/features/main/data/state/reset.ts rename to packages/data-solid-dashboard/src/features/main/spec/reset.ts diff --git a/packages/data-solid-dashboard/src/features/main/data/state/samples.ts b/packages/data-solid-dashboard/src/features/main/spec/samples.ts similarity index 100% rename from packages/data-solid-dashboard/src/features/main/data/state/samples.ts rename to packages/data-solid-dashboard/src/features/main/spec/samples.ts diff --git a/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.cases.ts b/packages/data-solid-dashboard/src/features/main/spec/set-user-name.cases.ts similarity index 91% rename from packages/data-solid-dashboard/src/features/main/data/state/set-user-name.cases.ts rename to packages/data-solid-dashboard/src/features/main/spec/set-user-name.cases.ts index e744051af..c1f814abf 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.cases.ts +++ b/packages/data-solid-dashboard/src/features/main/spec/set-user-name.cases.ts @@ -3,8 +3,8 @@ import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { setUserName } from "./set-user-name.js"; -// Inert, spec-owned cases for `setUserName` — data-only `args` ({ name }), shared -// with the ecs `setUserName` transaction and action. +// Inert, spec-owned cases for `setUserName` — data-only `args` ({ name }), replayed +// against the `setUserName` action by conformance. export const cases: Conformance.SpecCases = { cases: [ { diff --git a/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.ts b/packages/data-solid-dashboard/src/features/main/spec/set-user-name.ts similarity index 100% rename from packages/data-solid-dashboard/src/features/main/data/state/set-user-name.ts rename to packages/data-solid-dashboard/src/features/main/spec/set-user-name.ts diff --git a/packages/data-solid-dashboard/src/features/main/spec/spec.test.ts b/packages/data-solid-dashboard/src/features/main/spec/spec.test.ts new file mode 100644 index 000000000..bdab1adf7 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/spec/spec.test.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { spec } from "./spec.js"; + +// Runs every case against the pure spec functions — no ECS involved. +Conformance.checkSpec(spec); diff --git a/packages/data-solid-dashboard/src/features/main/spec/spec.ts b/packages/data-solid-dashboard/src/features/main/spec/spec.ts new file mode 100644 index 000000000..502dabfaf --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/spec/spec.ts @@ -0,0 +1,25 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; + +import { cases as increment } from "./increment.cases.js"; +import { cases as decrement } from "./decrement.cases.js"; +import { cases as reset } from "./reset.cases.js"; +import { cases as setUserName } from "./set-user-name.cases.js"; +import { cases as clearLog } from "./clear-log.cases.js"; + +// The feature's spec: every action, one inert cases module each (coverage checked at +// compile time against `transforms`). State holds no entity references and no action +// injects services, so it needs no `schemas` or `services`. +export const spec = Conformance.spec({ + state: State, + fns: transforms, + cases: { + increment, + decrement, + reset, + setUserName, + clearLog, + }, +}); diff --git a/packages/data-solid-dashboard/src/features/main/spec/state.ts b/packages/data-solid-dashboard/src/features/main/spec/state.ts new file mode 100644 index 000000000..c37a9b572 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/spec/state.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert } from "@adobe/data/types"; +import type { Conformance } from "@adobe/data-testing"; +import type { ActivityLog } from "../data/values/activity-log/activity-log.js"; +import type { resources } from "../data/resources/index.js"; + +// The whole dashboard as one immutable value: the specification the ECS implementation +// is verified against. Every field is a singleton; there are no entity collections. +export type State = { + readonly count: number; + readonly log: ActivityLog; + readonly userName: string; +}; + +// Singleton keys name resources. The feature declares no components. +type _Pin = Assert>; + +export * as State from "./public.js"; diff --git a/packages/data-solid-dashboard/src/features/main/spec/transforms.ts b/packages/data-solid-dashboard/src/features/main/spec/transforms.ts new file mode 100644 index 000000000..bba0d2b85 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/spec/transforms.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The conformed functions: every spec action. Its keys are the conformance surface; +// `Conformance.spec` requires exactly one `*.cases.ts` module per key. +export { increment } from "./increment.js"; +export { decrement } from "./decrement.js"; +export { reset } from "./reset.js"; +export { setUserName } from "./set-user-name.js"; +export { clearLog } from "./clear-log.js"; diff --git a/packages/data-solid-dashboard/src/features/main/ui/activity-log/activity-log.tsx b/packages/data-solid-dashboard/src/features/main/ui/activity-log/activity-log.tsx index b46126f09..0ae438c5c 100644 --- a/packages/data-solid-dashboard/src/features/main/ui/activity-log/activity-log.tsx +++ b/packages/data-solid-dashboard/src/features/main/ui/activity-log/activity-log.tsx @@ -1,13 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { fromObserve, useDatabase } from "@adobe/data-solid"; -import { MainService } from "../../services/main-service/main-service.js"; +import { MainService } from "../../ecs/main-service.js"; import * as presentation from "./activity-log.presentation.jsx"; export function ActivityLog() { const db = useDatabase(MainService.plugin); const log = fromObserve(db.observe.resources.log, []); - const { clearLog } = db.transactions; + const { clearLog } = db.actions; return presentation.render({ log, clearLog }); } diff --git a/packages/data-solid-dashboard/src/features/main/ui/app/app.tsx b/packages/data-solid-dashboard/src/features/main/ui/app/app.tsx index c9df0e908..9db2d166e 100644 --- a/packages/data-solid-dashboard/src/features/main/ui/app/app.tsx +++ b/packages/data-solid-dashboard/src/features/main/ui/app/app.tsx @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { DatabaseProvider } from "@adobe/data-solid"; -import { MainService } from "../../services/main-service/main-service.js"; +import { MainService } from "../../ecs/main-service.js"; import { StatusBar } from "../status-bar/status-bar.jsx"; import { ControlPanel } from "../control-panel/control-panel.jsx"; import { CounterDisplay } from "../counter-display/counter-display.jsx"; diff --git a/packages/data-solid-dashboard/src/features/main/ui/control-panel/control-panel.tsx b/packages/data-solid-dashboard/src/features/main/ui/control-panel/control-panel.tsx index d482eaa72..bb66e7200 100644 --- a/packages/data-solid-dashboard/src/features/main/ui/control-panel/control-panel.tsx +++ b/packages/data-solid-dashboard/src/features/main/ui/control-panel/control-panel.tsx @@ -1,13 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { fromObserve, useDatabase } from "@adobe/data-solid"; -import { MainService } from "../../services/main-service/main-service.js"; +import { MainService } from "../../ecs/main-service.js"; import * as presentation from "./control-panel.presentation.jsx"; export function ControlPanel() { const db = useDatabase(MainService.plugin); const count = fromObserve(db.observe.resources.count, 0); - const { increment, decrement, reset, setUserName } = db.transactions; + const { increment, decrement, reset, setUserName } = db.actions; const setName = (name: string) => setUserName({ name }); return presentation.render({ count, increment, decrement, reset, setUserName: setName }); diff --git a/packages/data-solid-dashboard/src/features/main/ui/counter-display/counter-display.tsx b/packages/data-solid-dashboard/src/features/main/ui/counter-display/counter-display.tsx index 2f71e8b36..b1651b4d0 100644 --- a/packages/data-solid-dashboard/src/features/main/ui/counter-display/counter-display.tsx +++ b/packages/data-solid-dashboard/src/features/main/ui/counter-display/counter-display.tsx @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { fromObserve, useDatabase } from "@adobe/data-solid"; -import { MainService } from "../../services/main-service/main-service.js"; +import { MainService } from "../../ecs/main-service.js"; import * as presentation from "./counter-display.presentation.jsx"; export function CounterDisplay() { diff --git a/packages/data-solid-dashboard/src/features/main/ui/status-bar/status-bar.tsx b/packages/data-solid-dashboard/src/features/main/ui/status-bar/status-bar.tsx index 5f2cfe243..e2a5ce2c1 100644 --- a/packages/data-solid-dashboard/src/features/main/ui/status-bar/status-bar.tsx +++ b/packages/data-solid-dashboard/src/features/main/ui/status-bar/status-bar.tsx @@ -2,7 +2,7 @@ import { createMemo } from "solid-js"; import { fromObserve, useDatabase } from "@adobe/data-solid"; -import { MainService } from "../../services/main-service/main-service.js"; +import { MainService } from "../../ecs/main-service.js"; import * as presentation from "./status-bar.presentation.jsx"; export function StatusBar() { diff --git a/packages/data-solid-dashboard/src/main.tsx b/packages/data-solid-dashboard/src/main.tsx deleted file mode 100644 index 8fbc4fd47..000000000 --- a/packages/data-solid-dashboard/src/main.tsx +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -import { render } from "solid-js/web"; -import { App } from "./features/main/ui/app/app.jsx"; - -const root = document.getElementById("root"); -if (root) { - render(() => , root); -} diff --git a/packages/data-solid-dashboard/tsconfig.build.json b/packages/data-solid-dashboard/tsconfig.build.json deleted file mode 100644 index 1395b61d5..000000000 --- a/packages/data-solid-dashboard/tsconfig.build.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - // The RUNTIME wall (dependency isolation). It compiles the shippable data + services - // tiers as a `composite` project, so a data/ or services/ runtime file that imports a - // test-tier file (`*.cases.ts`, `*.fake.ts`, `spec.ts`, anything under `conformance/`) - // is a COMPILE ERROR (TS6307: not in this project's file list). `ui/` and the app-shell - // (`src/*.ts`) live in the test project instead — they import test-tier only in their own - // `*.test.ts`, and keeping their (often very large) inferred element/plugin types out of - // this project avoids forcing them into serialized declarations. Emits declarations only, - // to a throwaway dir, purely to satisfy the composite contract `tsconfig.test.json` refs. - "extends": "./tsconfig.json", - "compilerOptions": { - "composite": true, - "noEmit": false, - "declaration": true, - "emitDeclarationOnly": true, - "outDir": "./.tsbuild", - "rootDir": "src" - }, - "include": ["src/features/**/*"], - "exclude": [ - "**/ui/**", - "**/*.test.ts", - "**/*.type-test.ts", - "**/*.cases.ts", - "**/*.fake.ts", - "**/spec.ts", - "**/conformance/**" - ] -} diff --git a/packages/data-solid-dashboard/tsconfig.data.json b/packages/data-solid-dashboard/tsconfig.data.json new file mode 100644 index 000000000..ed8fdef71 --- /dev/null +++ b/packages/data-solid-dashboard/tsconfig.data.json @@ -0,0 +1,16 @@ +{ + // Layer 1+2 runtime: pure data declarations (data/) and service namespaces (services/). + // Composite so higher layers reference it; a data/services file importing spec/, ecs/ + // or any test file is a compile error (TS6307: not in this project). + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/data", + "rootDir": "src" + }, + "include": ["src/features/*/data/**/*", "src/features/*/services/**/*"], + "exclude": ["**/*.test.ts", "**/*.type-test.ts"] +} diff --git a/packages/data-solid-dashboard/tsconfig.ecs.json b/packages/data-solid-dashboard/tsconfig.ecs.json new file mode 100644 index 000000000..78227f085 --- /dev/null +++ b/packages/data-solid-dashboard/tsconfig.ecs.json @@ -0,0 +1,16 @@ +{ + // Runtime: the ECS implementation (ecs/, minus conformance). References only data + + // services, so an ecs file importing spec/ is a compile error. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/ecs", + "rootDir": "src" + }, + "include": ["src/features/*/ecs/**/*"], + "exclude": ["**/conformance/**", "**/*.test.ts", "**/*.type-test.ts"], + "references": [{ "path": "./tsconfig.data.json" }] +} diff --git a/packages/data-solid-dashboard/tsconfig.spec.json b/packages/data-solid-dashboard/tsconfig.spec.json new file mode 100644 index 000000000..e4dc0a8ff --- /dev/null +++ b/packages/data-solid-dashboard/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + // Test tier: the pure spec (spec/). References only data + services, so a spec file + // importing ecs/ is a compile error. May use @adobe/data-testing. No runtime file + // imports it (runtime projects never reference it). + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild/spec", + "rootDir": "src" + }, + "include": ["src/features/*/spec/**/*"], + "exclude": ["**/*.test.ts", "**/*.type-test.ts"], + "references": [{ "path": "./tsconfig.data.json" }] +} diff --git a/packages/data-solid-dashboard/tsconfig.test.json b/packages/data-solid-dashboard/tsconfig.test.json index f328ac324..ecee32459 100644 --- a/packages/data-solid-dashboard/tsconfig.test.json +++ b/packages/data-solid-dashboard/tsconfig.test.json @@ -1,26 +1,19 @@ { - // The NON-EMITTING tier: everything the runtime wall (`tsconfig.build.json`) doesn't own — - // the test-tier (conformance manifest + inert cases, service fakes, `*.test.ts`, - // `conformance/`), plus `ui/` and the app-shell (`src/*.ts`). It `references` the runtime - // project, so its imports of data/services resolve through that project's declarations, - // and it alone may see `@adobe/data-testing`. Nothing here emits, so large element/plugin - // types are type-checked without being serialized. `tsc -b tsconfig.test.json` builds the - // wall first, then type-checks this tier — together covering all of `src`. + // Everything else, non-emitting: ui/, app/, ecs/conformance/, and every *.test.ts. + // References the ecs and spec projects, so it sees both. `tsc -b tsconfig.test.json` + // builds the layer projects first, then type-checks this tier — together covering src. "extends": "./tsconfig.json", "compilerOptions": { "composite": false, "noEmit": true }, "include": [ - "src/*.ts", - "src/**/ui/**/*", + "src/app/**/*", + "src/features/*/ui/**/*", + "src/features/*/ecs/conformance/**/*", "src/**/*.test.ts", - "src/**/*.type-test.ts", - "src/**/*.cases.ts", - "src/**/*.fake.ts", - "src/**/spec.ts", - "src/**/conformance/**/*" + "src/**/*.type-test.ts" ], "exclude": [], - "references": [{ "path": "./tsconfig.build.json" }] + "references": [{ "path": "./tsconfig.ecs.json" }, { "path": "./tsconfig.spec.json" }] } diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index f94707fc0..c5dda014a 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.10.21", + "version": "0.10.22", "description": "Adobe data SolidJS bindings — context and provider for ECS database", "type": "module", "private": false, diff --git a/packages/data-sync/package.json b/packages/data-sync/package.json index ea41c628a..865fd1727 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.10.21", + "version": "0.10.22", "description": "Multi-user real-time synchronisation for @adobe/data ECS — server, client, and in-process loopback.", "type": "module", "sideEffects": false, diff --git a/packages/data-testing/README.md b/packages/data-testing/README.md index 97db9b7ad..d149f1a3d 100644 --- a/packages/data-testing/README.md +++ b/packages/data-testing/README.md @@ -1,9 +1,8 @@ # @adobe/data-testing -Conformance-testing utilities for `@adobe/data` ECS features — the `Match` and -`Conformance` namespaces used by the co-located conformance pattern (see -`@adobe/data-ai`'s `features/data/state.md` and -`features/services/main-service/conformance.md`). +Conformance-testing utilities for `@adobe/data` ECS features: the `Match` and +`Conformance` namespaces behind the spec ⇄ ECS conformance pattern (see +`@adobe/data-ai`'s `features/spec/index.md` and `features/ecs/conformance.md`). This package exists separately from `@adobe/data` so that installing `@adobe/data` never pulls in a `vitest` peer dependency — only projects that @@ -19,26 +18,44 @@ pnpm add -D @adobe/data-testing vitest ## Usage ```ts -// data/state/conformance-case.ts -import { Conformance as ConformanceApi } from "@adobe/data-testing"; -import type { State } from "./state.js"; - -export type Conformance unknown> = ConformanceApi.Cases; -export const entity = ConformanceApi.entity; -``` - -```ts -// data/state/spec.test.ts -import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import { transitions } from "./transitions.js"; - -Conformance.runSpec({ state: State, transitions }); +// spec/spec.ts — the pure spec: State, its actions and derivations, their cases +export const spec = Conformance.spec({ + state: State, + fns: transforms, + schemas: { ...components, ...resources }, + services: { analytics: AnalyticsService.createFake }, + cases: { createTodo, toggleComplete, visibleTodos }, +}); + +// spec/spec.test.ts +Conformance.checkSpec(spec); + +// ecs/conformance/implementation.ts — the spec paired with its ECS build +export const implementation = Conformance.implementation(spec, { + plugin: MainService.plugin, + computedPlugin: ComputedDatabase.plugin, + projection, + hydrate: ["visibleTodos"], +}); + +// ecs/conformance/conformance.test.ts +Conformance.checkFeature(implementation); ``` -`Match` provides tolerant, matcher-aware value comparison (`matches`, `ref`, -`anyNumber`, `anyString`) used by the `after` side of a conformance case. -`Conformance` provides the case types (`Cases`, `DerivationCases`, `Effects`) -and the runner drivers (`runSpec`, `runTransactions`, `runActions`, -`runComputeds`, `runFeature`) that drive vitest's `describe`/`it` against -spec-owned cases. +- **`checkSpec`** runs every case against the pure spec functions. No ECS is built. +- **`checkFeature`** runs every case against the ECS: + - each spec action against the same-named action; + - each derivation against its computed; + - each ECS system against the same-named spec system function, running just that + system; + - each frame case by running every modelled system once, in the order the systems' + `schedule`s declare (the `frame` option lists arg writers and unmodelled systems). + + It also round-trips `State.samples` through the projection. A spec op with no + implementation fails by name. +- **`Conformance.StateMatches`** pins a + `State`'s keys to the feature's resource and component names. +- **`Match`** provides tolerant, matcher-aware comparison (`matches`, `ref`, + `anyNumber`, `anyString`). +- The lower-level runners (`runActions`, `runComputeds`, `runTransactions`, + `adaptCases`) remain for a feature that needs per-case ambient context. diff --git a/packages/data-testing/package.json b/packages/data-testing/package.json index eccb21696..e3d102195 100644 --- a/packages/data-testing/package.json +++ b/packages/data-testing/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-testing", - "version": "0.10.21", + "version": "0.10.22", "description": "Conformance-testing utilities (Match + Conformance runners) for @adobe/data ECS features", "type": "module", "sideEffects": false, diff --git a/packages/data-testing/src/conformance/adapt-spec.ts b/packages/data-testing/src/conformance/adapt-spec.ts index 770f22b1f..1c32e2dad 100644 --- a/packages/data-testing/src/conformance/adapt-spec.ts +++ b/packages/data-testing/src/conformance/adapt-spec.ts @@ -39,13 +39,15 @@ const injectDoubles = ( // transaction is a pure store mutation and never receives services). export const adaptCases = ( fns: Readonly>, - cases: Readonly>, + cases: object, services: Readonly object>> | undefined, injectServices: boolean, ): Record> => { const out: Record> = {}; for (const [name, fn] of Object.entries(fns)) { - const module = cases[name]!; + // Runtime invariant: a manifest's `cases` holds one case module per fn (the + // `Conformance.spec` coverage guard). + const module = (cases as Readonly>)[name]!; const list = module.cases; const first = list[0] as Record | undefined; if (first !== undefined && "value" in first) { diff --git a/packages/data-testing/src/conformance/build-double.test.ts b/packages/data-testing/src/conformance/build-double.test.ts new file mode 100644 index 000000000..8c84fd40a --- /dev/null +++ b/packages/data-testing/src/conformance/build-double.test.ts @@ -0,0 +1,35 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { buildDouble } from "./build-double.js"; + +const template = () => ({ + serviceName: "rng", + next: () => 0, + fetch: () => Promise.resolve(""), + log: () => {}, +}); +type Rng = ReturnType; + +describe("buildDouble", () => { + it("returns scheduled responses in order", () => { + const double = buildDouble(template, { next: [1, 2] }) as Rng; + expect([double.next(), double.next()]).toEqual([1, 2]); + }); + + it("throws on a value method with no response scheduled", () => { + const double = buildDouble(template, undefined) as Rng; + expect(() => double.next()).toThrow('no response scheduled for "rng.next"'); + expect(() => double.fetch()).toThrow('no response scheduled for "rng.fetch"'); + }); + + it("throws once a schedule is exhausted", () => { + const double = buildDouble(template, { next: [1] }) as Rng; + double.next(); + expect(() => double.next()).toThrow('response schedule exhausted for "rng.next"'); + }); + + it("lets a void method run unscheduled", () => { + const double = buildDouble(template, undefined) as Rng; + expect(double.log()).toBeUndefined(); + }); +}); diff --git a/packages/data-testing/src/conformance/build-double.ts b/packages/data-testing/src/conformance/build-double.ts index 3cd6e7d20..b0faa85b9 100644 --- a/packages/data-testing/src/conformance/build-double.ts +++ b/packages/data-testing/src/conformance/build-double.ts @@ -5,36 +5,37 @@ export type Schedule = Readonly>; // Build a recording double for one service from a SHAPE-ONLY template and a case's -// response schedule. The template — the feature's registered fake — is called once -// purely to enumerate the service's members (types are erased at runtime, and Proxy -// is banned on these paths, so the method names must come from a real value); its -// own behavior is never used. Every value-returning call drains that method's FIFO; -// a `void` method (or any method with no schedule) returns `undefined`. Draining an -// EXHAUSTED schedule throws, so a transform that calls a method more times than the -// case scheduled is a hard failure — the determinism a conformance oracle needs -// (the old fakes cycled, silently masking an over-call). +// response schedule. The template — the feature's registered fake — supplies the +// method names (types are erased at runtime, and Proxy is banned on these paths), and +// one probe call per method tells a value method (returns something) from a void one +// (returns `undefined`); fakes are inert, so probing is safe. Every value-returning +// call drains that method's FIFO. Calling a value method with no response scheduled, +// or more times than scheduled, throws: a case must own every value its code reads. // -// The returned double still gets wrapped by `recordCalls` (call recording) on its -// way into a case's args, exactly like the live fakes it replaces — so effect -// assertion is unchanged; only the RETURNS now come from the case, not the fake. +// The returned double still gets wrapped by `recordCalls` on its way into a case's +// args, so effect assertion is unchanged; only the RETURNS come from the case. export const buildDouble = (template: () => object, responses: Schedule | undefined): object => { const shape = template(); + const service = "serviceName" in shape ? String(shape.serviceName) : "service"; const queues = new Map( Object.entries(responses ?? {}).map(([method, schedule]) => [method, [...schedule]]), ); return Object.fromEntries( - Object.entries(shape).map(([key, value]) => - typeof value !== "function" - ? [key, value] // serviceName and other non-method members pass through - : [ - key, - (..._args: unknown[]): unknown => { - const queue = queues.get(key); - if (!queue) return undefined; // void method, or an unscheduled call - if (queue.length === 0) throw new Error(`response schedule exhausted for "${key}"`); - return queue.shift(); - }, - ], - ), + Object.entries(shape).map(([key, value]) => { + if (typeof value !== "function") return [key, value]; // serviceName and other non-method members pass through + const returnsValue = (value as () => unknown)() !== undefined; + return [ + key, + (..._args: unknown[]): unknown => { + const queue = queues.get(key); + if (queue === undefined) { + if (!returnsValue) return undefined; // a void method + throw new Error(`no response scheduled for "${service}.${key}"; add it to the case's responses`); + } + if (queue.length === 0) throw new Error(`response schedule exhausted for "${service}.${key}"`); + return queue.shift(); + }, + ]; + }), ); }; diff --git a/packages/data-testing/src/conformance/check-feature.ts b/packages/data-testing/src/conformance/check-feature.ts new file mode 100644 index 000000000..170070200 --- /dev/null +++ b/packages/data-testing/src/conformance/check-feature.ts @@ -0,0 +1,60 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { runFeature } from "./run-feature.js"; +import { runSystems, type SystemCase, type SystemDatabase } from "./run-systems.js"; +import { adaptCases } from "./adapt-spec.js"; +import { withNoOp } from "./with-no-op.js"; +import type { SchemaSource } from "./refify.js"; +import type { Implementation } from "./implementation.js"; + +type AnyFn = (...args: never[]) => unknown; + +// Frame cases with recording doubles injected into their `args`, as `adaptCases` does +// for an op's cases. +const frameCasesOf = (frame: object | undefined, services: Readonly object>> | undefined): readonly SystemCase[] => { + if (frame === undefined) return []; + const adapted = adaptCases({ frame: () => undefined }, { frame }, services, true)["./frame"]!; + // Runtime invariant: `adaptCases` keeps a transition envelope's `{ cases }` list. + return (adapted["cases"] as { readonly cases: readonly SystemCase[] }).cases; +}; + +// The ecs conformance suite: the spec's cases drive the implementation's actions and +// computeds (fresh doubles again, so the pure and ecs runs never share FIFO state). +// A spec op with no same-named implementation is a named failure. +export const checkFeature = , StoreT extends SchemaSource>( + impl: Implementation, +): void => { + const { spec } = impl; + runFeature({ + state: spec.state, + transitions: adaptCases(spec.fns, spec.cases, spec.services, true), + plugin: impl.plugin, + computedPlugin: impl.computedPlugin, + projection: impl.projection, + hydrate: impl.hydrate, + services: impl.services, + match: spec.match, + ops: impl.ops, + }); + if (spec.systems && impl.frame) { + const baseServices = (): Record => + Object.fromEntries(Object.entries(impl.services ?? {}).map(([name, create]) => [name, create()])); + runSystems({ + // Runtime invariant: a plugin builds a database whose system surface and store + // are the projection's (`implementation` types `plugin` and `projection` together). + makeDb: (services) => + Database.toSystemDatabase( + Database.create(impl.plugin as never, { services: { ...baseServices(), ...services } }), + ) as unknown as SystemDatabase, + fromState: impl.projection.fromState, + toState: impl.projection.toState, + initial: spec.state.create(), + systems: adaptCases(spec.systems.fns, withNoOp(spec.systems.cases, spec.systems.noOp), spec.services, true), + frameCases: () => frameCasesOf(impl.frame?.cases, spec.services), + // Runtime invariant: `implementation` types each writer against this db and arg. + args: impl.frame.args as never, + unmodelled: impl.frame.unmodelled ?? {}, + match: spec.match, + }); + } +}; diff --git a/packages/data-testing/src/conformance/check-spec.ts b/packages/data-testing/src/conformance/check-spec.ts new file mode 100644 index 000000000..50e566c8a --- /dev/null +++ b/packages/data-testing/src/conformance/check-spec.ts @@ -0,0 +1,25 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { runSpec } from "./run-spec.js"; +import { adaptCases } from "./adapt-spec.js"; +import { withNoOp } from "./with-no-op.js"; +import type { Spec } from "./spec.js"; + +type AnyFn = (...args: never[]) => unknown; + +// The pure-spec suite: run every case against its spec fn, with fresh recording +// doubles built from the `services` templates + each case's `responses`. Reference +// fields are found from `spec.schemas`; no store or plugin is built. Per-system cases +// run here too; whole-frame cases need the ECS's system order, so `checkFeature` runs them. +export const checkSpec = , C extends object, Sys extends Record>( + spec: Spec, +): void => { + runSpec({ + state: spec.state, + transitions: { + ...adaptCases(spec.fns, spec.cases, spec.services, true), + ...(spec.systems ? adaptCases(spec.systems.fns, withNoOp(spec.systems.cases, spec.systems.noOp), spec.services, true) : {}), + }, + schemas: spec.schemas, + match: spec.match, + }); +}; diff --git a/packages/data-testing/src/conformance/feature-spec.ts b/packages/data-testing/src/conformance/feature-spec.ts deleted file mode 100644 index 7e85fa680..000000000 --- a/packages/data-testing/src/conformance/feature-spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Database } from "@adobe/data/ecs"; -import type { MatchOptions } from "../match/match.js"; -import type { - CaseModule, - HasDerivations, - HydrateExact, - HydrationKeys, - ProjectionShape, - ProjectionStore, - ProjectionValue, - RequiredServices, -} from "./feature-types.js"; -import type { Projection } from "./run-feature.js"; -import type { SchemaSource } from "./refify.js"; -import { runSpec } from "./run-spec.js"; -import { runFeature } from "./run-feature.js"; -import { adaptCases } from "./adapt-spec.js"; - -type AnyFn = (...args: never[]) => unknown; - -// One feature's whole conformance declaration — the single object both -// `spec.test.ts` (pure) and `conformance.test.ts` (ecs) import. It replaces the -// per-file `import.meta.glob` discovery with an explicit, local manifest: adding a -// transform means one line here, checked at COMPILE TIME (see `feature`). -export interface FeatureSpec, StoreT> { - // The `State` namespace: `create()` is the default each case's `before`/`input` - // deltas over; `samples` are full states for the projection round-trip. - readonly state: { create(): State; readonly samples?: readonly State[] }; - // The conformed functions — exactly the transforms and derivations under test, - // authored as an `import * as transforms` barrel. Its keys drive the guard below. - readonly fns: Fns; - // The corpus, one inert `*.cases.ts` module per fn. The mapped type over `keyof - // Fns` is the coverage guard: a fn with no cases, or a case with no fn, is a - // compile error — the guarantee the glob shape could never give. - readonly cases: { readonly [K in keyof Fns]: CaseModule }; - // Shape-only recording-double templates, keyed by injected-service arg name. The - // runner calls each once to enumerate the service's methods (no Proxy), then - // synthesizes returns from each case's `responses`. Omit for a service-free feature. - readonly services?: Readonly object>>; - readonly plugin: Database.Plugin; - readonly computedPlugin?: Database.Plugin; - readonly projection: Projection; - readonly hydrate?: readonly string[]; - readonly match?: MatchOptions; - readonly ops?: { - readonly transactions?: Record; - readonly actions?: Record; - readonly computeds?: Record; - }; -} - -// Author a feature's conformance manifest. Purely a typed identity — it exists so the -// generics infer from the argument and arm the compile-time guards. `P` (projection) -// and `C` (the concrete case modules) are captured, not widened, so their real types -// feed the checks: -// • `cases` coverage — `C extends { [K in keyof Fns]: CaseModule }` requires a module -// for every fn (missing → error); the `NoExtraCases` factor rejects a module whose -// key is not a fn (extra → error). -// • `hydrate` exactness — must be EXACTLY the entity-list derivations (`HydrateExact`). -// • `services` requiredness — exactly the injected-service keys the fns declare, or -// absent when none (`RequiredServices`); a missing template is a compile error, not -// a runtime crash. -// • `computedPlugin` requiredness — required when any authored case module is a -// derivation (`HasDerivations`), so a feature with derivations can't silently skip -// its computed conformance by omitting the plugin. -export const feature = < - State extends object, - Fns extends Record, - P extends ProjectionShape, - C extends { readonly [K in keyof Fns]: CaseModule }, - const H extends readonly Extract[] = readonly [], ->( - spec: Omit>, "projection" | "cases" | "hydrate" | "services" | "computedPlugin"> & { - readonly projection: P; - readonly cases: C; - readonly hydrate?: H; - } & RequiredServices & - (HasDerivations extends true ? { readonly computedPlugin: Database.Plugin } : { readonly computedPlugin?: Database.Plugin }) & - ([Exclude] extends [never] - ? unknown - : { readonly __casesError: "a case module names something that is not a conformed fn"; readonly extra: Exclude }) & - HydrateExact>>, -): FeatureSpec> => - // Runtime invariant the checker can't see: the constrained param IS this FeatureSpec - // (`P` widens to the stored `Projection`, `C` to the `cases` mapped type, `H` to - // `readonly string[]`, the fake factories to `() => object`). The several requiredness - // factors are compile-only phantoms with no runtime shape, and together they defeat - // TS's structural-overlap check for a direct `as`, so this goes through `unknown`. - spec as unknown as FeatureSpec>; - -// The pure-spec suite for a manifest: adapt the inert cases into the discovery map -// `runSpec` consumes — WITH freshly-built recording doubles merged into each case's -// args from the `services` templates + `responses` — and run it unchanged. This -// test file's doubles are its own, independent of the ecs side's. -export const checkSpec = , StoreT>( - spec: FeatureSpec, -): void => { - runSpec({ - state: spec.state, - transitions: adaptCases(spec.fns, spec.cases, spec.services, true), - plugin: spec.plugin, - match: spec.match, - }); -}; - -// The ecs conformance suite for a manifest: the same adapted, doubles-injected map -// drives `runFeature` unchanged (transactions ignore the injected services; only the -// actions consume each case's response schedule, exactly once). Fresh doubles again, -// so the pure and ecs runs never share FIFO state. -export const checkFeature = , StoreT extends SchemaSource>( - spec: FeatureSpec, -): void => { - runFeature({ - state: spec.state, - transitions: adaptCases(spec.fns, spec.cases, spec.services, true), - plugin: spec.plugin, - computedPlugin: spec.computedPlugin, - projection: spec.projection, - hydrate: spec.hydrate, - match: spec.match, - ops: spec.ops, - }); -}; diff --git a/packages/data-testing/src/conformance/feature-spec.type-test.ts b/packages/data-testing/src/conformance/feature-spec.type-test.ts deleted file mode 100644 index 7a280403f..000000000 --- a/packages/data-testing/src/conformance/feature-spec.type-test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// Type-level checks for the `feature` manifest's two compile-time guards: the `cases` -// coverage guard (exactly one case module per conformed fn) and the `hydrate` -// exactness guard (exactly the entity-list derivations, no more, no fewer). Each guard -// gets a POSITIVE case (must compile) and NEGATIVE cases (`@ts-expect-error`, must NOT -// compile) — a negative that stops failing turns the directive into an "unused -// directive" error, so the guard can't silently rot. -import type { Database, Entity } from "@adobe/data/ecs"; -import { feature } from "./feature-spec.js"; -import type { CaseModule, SpecCases, SpecDerivations } from "./feature-types.js"; - -type Foo = { readonly name: string }; -type State = { readonly entities: ReadonlyMap; readonly count: number }; - -// A transition (2 params) and an entity-list derivation (returns `readonly Foo[]`, so -// its ecs computed emits ids → it MUST be hydrated). -const bump = (s: Pick, args: { readonly by: number }): Pick => ({ count: s.count + args.by }); -const listFoos = (s: Pick): readonly Foo[] => [...s.entities.values()]; -const fns = { bump, listFoos }; - -interface Store { - readonly componentSchemas: Record; -} -declare const plugin: Database.Plugin; -declare const computedPlugin: Database.Plugin; -declare const state: { create(): State; readonly samples?: readonly State[] }; - -// A concrete projection whose `toData` returns the entity value `Foo` — that return -// type is what tells the guard `listFoos` is an entity-list derivation. -const projection = { - fromState: (_store: Store, _s: State): void => {}, - toState: (_store: Store): State => ({ entities: new Map(), count: 0 }), - toData: (_store: Store, _e: Entity): Foo => ({ name: "" }), -}; - -const bumpCases: SpecCases = { - cases: [{ name: "bumps", before: {}, args: { by: 1 }, after: { count: 1 } }], -}; -const listFoosCases: SpecDerivations = { - cases: [{ name: "lists", input: { entities: new Map() }, value: [] }], -}; - -// POSITIVE — every fn has a case, and `hydrate` is exactly the entity-list derivation. -export const ok = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos"], cases: { bump: bumpCases, listFoos: listFoosCases } }); - -// NEGATIVE (cases coverage) — a conformed fn with no case module. -// @ts-expect-error — `cases` is missing `listFoos` -export const missingCase = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos"], cases: { bump: bumpCases } }); - -// NEGATIVE (cases coverage) — a case module for a name that is not a conformed fn. -// @ts-expect-error — `cases` has `bogus`, which is not in `fns` -export const extraCase = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos"], cases: { bump: bumpCases, listFoos: listFoosCases, bogus: bumpCases } }); - -// NEGATIVE (hydrate exactness) — an entity-list derivation left out of `hydrate`. -// @ts-expect-error — `hydrate` is missing the entity-list derivation `listFoos` -export const missingHydrate = feature({ state, fns, plugin, computedPlugin, projection, hydrate: [], cases: { bump: bumpCases, listFoos: listFoosCases } }); - -// NEGATIVE (hydrate exactness) — a non-entity-list fn listed in `hydrate`. -// @ts-expect-error — `hydrate` lists `bump`, which does not need hydration -export const extraHydrate = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos", "bump"], cases: { bump: bumpCases, listFoos: listFoosCases } }); - -// ── `SpecCases` case-shape guards (tested directly, without the manifest) ────────── -// A service-injected transition: `log` is fire-and-forget (void), `next` returns a value. -interface Logger { - log: (msg: string) => void; - next: () => Promise; -} -const record = (s: Pick, args: { readonly by: number; readonly logger: Logger }): Pick => ({ - count: s.count + args.by + (args.logger.log("x"), 0), -}); - -// POSITIVE — data-only `args` (the `logger` service is stripped), a scheduled return for -// the value method `next`, and the void `log` asserted in `effects`. -export const recOk: SpecCases = { - cases: [{ name: "ok", before: {}, args: { by: 1 }, responses: { logger: { next: [5] } }, after: { count: 6 }, effects: { logger: [["log", "x"]] } }], -}; - -// NEGATIVE — a live service instance in `args` (args are DATA only). -// @ts-expect-error — `logger` is a service; it must not appear in `args` -export const recBadArgsService: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1, logger: {} }, after: { count: 1 } }] }; - -// NEGATIVE — a required data arg omitted. -// @ts-expect-error — `args` is missing the required `by` -export const recMissingArg: SpecCases = { cases: [{ name: "x", before: {}, args: {}, after: { count: 1 } }] }; - -// NEGATIVE — scheduling a response for a `void` method (only value methods take one). -// @ts-expect-error — `log` is void; it cannot be scheduled in `responses` -export const recVoidResponse: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, responses: { logger: { log: ["y"] } }, after: { count: 1 } }] }; - -// NEGATIVE — a scheduled response of the wrong payload type (`next` yields `number`). -// @ts-expect-error — `next` yields `number`, not `string` -export const recWrongResponse: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, responses: { logger: { next: ["nope"] } }, after: { count: 1 } }] }; - -// POSITIVE — a `throws` case (no `after`/`effects`/`responses`). -export const recThrows: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, throws: "boom" }] }; - -// NEGATIVE — `after` and `throws` are mutually exclusive. -// @ts-expect-error — a case is either an `after` expectation or a `throws`, never both -export const recBoth: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, after: { count: 1 }, throws: true }] }; - -// ── `CaseModule` transition/derivation discrimination ────────────────────────────── -// The KIND is the authored `SpecCases`/`SpecDerivations` annotation, NOT the fn's -// signature — arity and return-type structure are both unsound discriminators, so -// `CaseModule` is a bare union. These pin the cases the union must (and must not) admit. - -// POSITIVE — a no-arg, service-free transition `(state) => patch`. This is the exact -// regression the old arity-based `CaseModule` caused (one param → misread as derivation). -declare const reset: (s: Pick) => Pick; -declare const resetCases: SpecCases; -export const resetOk: CaseModule = resetCases; - -// POSITIVE — an async transition `(state, args) => Promise` (return is a Promise, -// so a return-type-`extends Partial` check would have misclassified it). -declare const saveAsync: (s: Pick, a: { readonly id: number }) => Promise>; -declare const asyncCases: SpecCases; -export const asyncOk: CaseModule = asyncCases; - -// POSITIVE — derivations returning an array (also a hydration key), a set, and a scalar. -export const arrOk: CaseModule = listFoosCases; -declare const idsOf: (s: State) => ReadonlySet; -declare const idsCases: SpecDerivations; -export const setOk: CaseModule = idsCases; -declare const total: (s: State) => number; -declare const totalCases: SpecDerivations; -export const scalarOk: CaseModule = totalCases; - -// NEGATIVE — wrong-arm annotation, caught by the envelope's content (not its arity). -// @ts-expect-error — a transition envelope cannot hold derivation-shaped `{ input, value }` cases -export const wrongTransition: SpecCases = { cases: [{ name: "x", input: { count: 0 }, value: 0 }] }; -// @ts-expect-error — a derivation envelope cannot hold transition-shaped `{ before, after }` cases -export const wrongDerivation: SpecDerivations = { cases: [{ name: "x", before: {}, after: { count: 1 } }] }; - -// NEGATIVE — mis-pairing an array-derivation module into a transition fn's slot, caught -// by the `ReturnType`/args mismatch inside the envelope generics. -// @ts-expect-error — a derivation module for `listFoos` is not a valid case module for the transition `bump` -export const mispair: CaseModule = listFoosCases; - -// RESIDUAL GAP (documented, deliberately compiles — NOT a `@ts-expect-error`): a -// transition-shaped module accepted for a `(state) => value` derivation, because args -// align (`ArgsOf` is void) and a state patch is a valid `after`. Unreachable from the -// signature; caught by the conformance run when it name-pairs against a computed. -declare const count: (s: State) => number; -export const residualGap: CaseModule = resetCases; - -// ── `computedPlugin` requiredness (needed iff the manifest has a derivation) ───────── -// `fns` above has `listFoos` (a derivation), so `ok` (which includes `computedPlugin`) -// is the POSITIVE. NEGATIVE — omit it with a derivation present: -// @ts-expect-error — `computedPlugin` is required because `listFoos` is a derivation -export const missingComputedPlugin = feature({ state, fns, plugin, projection, hydrate: ["listFoos"], cases: { bump: bumpCases, listFoos: listFoosCases } }); - -// POSITIVE — a transition-only feature may omit `computedPlugin` (no derivations). -const txFns = { bump }; -declare const txCases: SpecCases; -export const noComputedOk = feature({ state, fns: txFns, plugin, projection, cases: { bump: txCases } }); - -// ── `services` requiredness (needed iff a fn injects a service) ────────────────────── -declare const logger: Logger; -const svcFns = { record }; -declare const recordCases: SpecCases; - -// POSITIVE — the injected service `logger` has a template. -export const servicesOk = feature({ state, fns: svcFns, plugin, projection, services: { logger: () => logger }, cases: { record: recordCases } }); - -// NEGATIVE — a fn injects `logger`, but `services` is omitted (today: a runtime crash). -// @ts-expect-error — `services` is required because `record` injects `logger` -export const missingServices = feature({ state, fns: svcFns, plugin, projection, cases: { record: recordCases } }); - -// NEGATIVE — `services` omits the required `logger` template. -// @ts-expect-error — `services` is missing the `logger` template -export const incompleteServices = feature({ state, fns: svcFns, plugin, projection, services: {}, cases: { record: recordCases } }); - -// NEGATIVE — a service-free feature must not carry dead templates. -// @ts-expect-error — `bump` injects no service, so `services` must be absent -export const extraServices = feature({ state, fns: txFns, plugin, projection, services: { logger: () => logger }, cases: { bump: txCases } }); diff --git a/packages/data-testing/src/conformance/feature-types.ts b/packages/data-testing/src/conformance/feature-types.ts index cdc3451ef..3e4abb958 100644 --- a/packages/data-testing/src/conformance/feature-types.ts +++ b/packages/data-testing/src/conformance/feature-types.ts @@ -16,7 +16,7 @@ type IsService = T extends readonly unknown[] : false; // A transform's args type — its second parameter, or `void` when it takes none. -type ArgsOf = F extends (...a: never[]) => unknown +export type ArgsOf = F extends (...a: never[]) => unknown ? Parameters extends [unknown, infer A, ...unknown[]] ? A : void @@ -51,7 +51,7 @@ export type Responses = { // The case's authored `args`, with injected-service fields removed — a case carries // only DATA; the recording doubles are synthesized by the runner from the feature's // `services` templates plus this case's `responses`. -type DataArgKeys = { [K in keyof Args]-?: IsService extends true ? never : K }[keyof Args]; +export type DataArgKeys = { [K in keyof Args]-?: IsService extends true ? never : K }[keyof Args]; type DataArgs = Pick>; // One inert transition case: `before`/`after` state deltas, DATA-only `args`, and diff --git a/packages/data-testing/src/conformance/implementation.ts b/packages/data-testing/src/conformance/implementation.ts new file mode 100644 index 000000000..b8261e725 --- /dev/null +++ b/packages/data-testing/src/conformance/implementation.ts @@ -0,0 +1,86 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Database } from "@adobe/data/ecs"; +import type { + HasDerivations, + HydrateExact, + HydrationKeys, + ProjectionShape, + ProjectionStore, + ProjectionValue, +} from "./feature-types.js"; +import type { Projection } from "./run-feature.js"; +import type { Spec } from "./spec.js"; +import type { FrameArgWriters, FrameCases, SystemKeys, SystemsExact } from "./systems-types.js"; + +type AnyFn = (...args: never[]) => unknown; + +// A feature's ECS implementation, paired with its pure spec — the manifest a feature's +// `ecs/conformance/` authors. `checkFeature` conforms every spec action against the +// plugin's same-named action and every derivation against its computed. +export interface Implementation, StoreT> { + readonly spec: Spec>; + // The assembled feature plugin (`MainService.plugin`): its actions are the ops under test. + readonly plugin: Database.Plugin; + // A real-time feature's systems (required when the spec has `systems`). `args` writes + // each frame arg into the store (`dt` → `frameDelta`); `unmodelled` names the systems + // the spec does not model (wasm physics, init-only seeding), each with its reason. + // Every spec system conforms by running just that system. Each of `cases` — whole + // frames — folds the spec systems and runs one ECS frame, both in `db.system.order`, + // the order the `schedule`s declare; they live here because that order is the ECS's. + readonly frame?: { + readonly args: Readonly void>>; + readonly unmodelled?: Readonly>; + readonly cases?: object; + }; + // The `ComputedDatabase` layer plugin, built alone so a `withCache` value from a + // layer above can't go stale across the seed. Required when the spec has derivations. + readonly computedPlugin?: Database.Plugin; + readonly projection: Projection; + // Names of computeds that emit an entity-id list (hydrated through `toData`). + readonly hydrate?: readonly string[]; + // Base services injected into every db the runner builds — fakes for services whose + // factory throws to require injection. Per-case recording doubles override them. + readonly services?: Readonly object>>; + // Override the ops read off the plugin facets. + readonly ops?: { + readonly actions?: Record; + readonly computeds?: Record; + }; +} + +// Pair a spec with its ECS implementation. A typed identity that arms the guards: +// • `hydrate` exactness — EXACTLY the entity-list derivations (`HydrateExact`). +// • `computedPlugin` requiredness — required when the spec has a derivation. +// • `frame` requiredness and exactness — required when the spec has systems; one arg +// writer per frame data arg; every ECS system modelled xor unmodelled. +export const implementation = < + State extends object, + Fns extends Record, + C extends object, + P extends ProjectionShape, + const H extends readonly Extract[] = readonly [], + PL extends Database.Plugin = Database.Plugin, + Sys extends Record = {}, + U extends string = never, +>( + spec: Spec, + impl: Omit>, "spec" | "plugin" | "projection" | "hydrate" | "computedPlugin" | "frame"> & { + readonly plugin: PL; + readonly projection: P; + readonly hydrate?: H; + } & (HasDerivations extends true ? { readonly computedPlugin: Database.Plugin } : { readonly computedPlugin?: Database.Plugin }) & + HydrateExact>> & + ([keyof Sys] extends [never] + ? { readonly frame?: undefined } + : { + readonly frame: { + readonly args: FrameArgWriters; + readonly unmodelled?: { readonly [K in U]: string }; + readonly cases?: FrameCases; + } & SystemsExact, U, SystemKeys>; + }), +): Implementation> => + // Runtime invariant the checker can't see: `impl` plus the spec IS this + // Implementation (`P` widens to the stored `Projection`, `H` to `readonly string[]`; + // the requiredness factors are compile-only phantoms). + ({ ...impl, spec }) as unknown as Implementation>; diff --git a/packages/data-testing/src/conformance/manifest.type-test.ts b/packages/data-testing/src/conformance/manifest.type-test.ts new file mode 100644 index 000000000..3f11c7e08 --- /dev/null +++ b/packages/data-testing/src/conformance/manifest.type-test.ts @@ -0,0 +1,186 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// Type-level checks for the `spec` / `implementation` manifests' compile-time guards: the +// `cases` coverage guard (exactly one case module per conformed fn) and the `hydrate` +// exactness guard (exactly the entity-list derivations, no more, no fewer). Each guard +// gets a POSITIVE case (must compile) and NEGATIVE cases (`@ts-expect-error`, must NOT +// compile) — a negative that stops failing turns the directive into an "unused +// directive" error, so the guard can't silently rot. +import type { Database, Entity } from "@adobe/data/ecs"; +import { spec as specOf } from "./spec.js"; +import { implementation } from "./implementation.js"; +import type { CaseModule, SpecCases, SpecDerivations } from "./feature-types.js"; + +type Foo = { readonly name: string }; +type State = { readonly entities: ReadonlyMap; readonly count: number }; + +// A transition (2 params) and an entity-list derivation (returns `readonly Foo[]`, so +// its ecs computed emits ids → it MUST be hydrated). +const bump = (s: Pick, args: { readonly by: number }): Pick => ({ count: s.count + args.by }); +const listFoos = (s: Pick): readonly Foo[] => [...s.entities.values()]; +const fns = { bump, listFoos }; + +interface Store { + readonly componentSchemas: Record; +} +declare const plugin: Database.Plugin; +declare const computedPlugin: Database.Plugin; +declare const state: { create(): State; readonly samples?: readonly State[] }; + +// A concrete projection whose `toData` returns the entity value `Foo` — that return +// type is what tells the guard `listFoos` is an entity-list derivation. +const projection = { + fromState: (_store: Store, _s: State): void => {}, + toState: (_store: Store): State => ({ entities: new Map(), count: 0 }), + toData: (_store: Store, _e: Entity): Foo => ({ name: "" }), +}; + +const bumpCases: SpecCases = { + cases: [{ name: "bumps", before: {}, args: { by: 1 }, after: { count: 1 } }], +}; +const listFoosCases: SpecDerivations = { + cases: [{ name: "lists", input: { entities: new Map() }, value: [] }], +}; + +// POSITIVE — every fn has a case, and `hydrate` is exactly the entity-list derivation. +export const ok = implementation(specOf({ state, fns, cases: { bump: bumpCases, listFoos: listFoosCases } }), { plugin, computedPlugin, projection, hydrate: ["listFoos"] }); + +// NEGATIVE (cases coverage) — a conformed fn with no case module. +// @ts-expect-error — `cases` is missing `listFoos` +export const missingCase = implementation(specOf({ state, fns, cases: { bump: bumpCases } }), { plugin, computedPlugin, projection, hydrate: ["listFoos"] }); + +// NEGATIVE (cases coverage) — a case module for a name that is not a conformed fn. +// @ts-expect-error — `cases` has `bogus`, which is not in `fns` +export const extraCase = implementation(specOf({ state, fns, cases: { bump: bumpCases, listFoos: listFoosCases, bogus: bumpCases } }), { plugin, computedPlugin, projection, hydrate: ["listFoos"] }); + +// NEGATIVE (hydrate exactness) — an entity-list derivation left out of `hydrate`. +// @ts-expect-error — `hydrate` is missing the entity-list derivation `listFoos` +export const missingHydrate = implementation(specOf({ state, fns, cases: { bump: bumpCases, listFoos: listFoosCases } }), { plugin, computedPlugin, projection, hydrate: [] }); + +// NEGATIVE (hydrate exactness) — a non-entity-list fn listed in `hydrate`. +// @ts-expect-error — `hydrate` lists `bump`, which does not need hydration +export const extraHydrate = implementation(specOf({ state, fns, cases: { bump: bumpCases, listFoos: listFoosCases } }), { plugin, computedPlugin, projection, hydrate: ["listFoos", "bump"] }); + +// ── `SpecCases` case-shape guards (tested directly, without the manifest) ────────── +// A service-injected transition: `log` is fire-and-forget (void), `next` returns a value. +interface Logger { + log: (msg: string) => void; + next: () => Promise; +} +const record = (s: Pick, args: { readonly by: number; readonly logger: Logger }): Pick => ({ + count: s.count + args.by + (args.logger.log("x"), 0), +}); + +// POSITIVE — data-only `args` (the `logger` service is stripped), a scheduled return for +// the value method `next`, and the void `log` asserted in `effects`. +export const recOk: SpecCases = { + cases: [{ name: "ok", before: {}, args: { by: 1 }, responses: { logger: { next: [5] } }, after: { count: 6 }, effects: { logger: [["log", "x"]] } }], +}; + +// NEGATIVE — a live service instance in `args` (args are DATA only). +// @ts-expect-error — `logger` is a service; it must not appear in `args` +export const recBadArgsService: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1, logger: {} }, after: { count: 1 } }] }; + +// NEGATIVE — a required data arg omitted. +// @ts-expect-error — `args` is missing the required `by` +export const recMissingArg: SpecCases = { cases: [{ name: "x", before: {}, args: {}, after: { count: 1 } }] }; + +// NEGATIVE — scheduling a response for a `void` method (only value methods take one). +// @ts-expect-error — `log` is void; it cannot be scheduled in `responses` +export const recVoidResponse: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, responses: { logger: { log: ["y"] } }, after: { count: 1 } }] }; + +// NEGATIVE — a scheduled response of the wrong payload type (`next` yields `number`). +// @ts-expect-error — `next` yields `number`, not `string` +export const recWrongResponse: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, responses: { logger: { next: ["nope"] } }, after: { count: 1 } }] }; + +// POSITIVE — a `throws` case (no `after`/`effects`/`responses`). +export const recThrows: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, throws: "boom" }] }; + +// NEGATIVE — `after` and `throws` are mutually exclusive. +// @ts-expect-error — a case is either an `after` expectation or a `throws`, never both +export const recBoth: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, after: { count: 1 }, throws: true }] }; + +// ── `CaseModule` transition/derivation discrimination ────────────────────────────── +// The KIND is the authored `SpecCases`/`SpecDerivations` annotation, NOT the fn's +// signature — arity and return-type structure are both unsound discriminators, so +// `CaseModule` is a bare union. These pin the cases the union must (and must not) admit. + +// POSITIVE — a no-arg, service-free transition `(state) => patch`. This is the exact +// regression the old arity-based `CaseModule` caused (one param → misread as derivation). +declare const reset: (s: Pick) => Pick; +declare const resetCases: SpecCases; +export const resetOk: CaseModule = resetCases; + +// POSITIVE — an async transition `(state, args) => Promise` (return is a Promise, +// so a return-type-`extends Partial` check would have misclassified it). +declare const saveAsync: (s: Pick, a: { readonly id: number }) => Promise>; +declare const asyncCases: SpecCases; +export const asyncOk: CaseModule = asyncCases; + +// POSITIVE — derivations returning an array (also a hydration key), a set, and a scalar. +export const arrOk: CaseModule = listFoosCases; +declare const idsOf: (s: State) => ReadonlySet; +declare const idsCases: SpecDerivations; +export const setOk: CaseModule = idsCases; +declare const total: (s: State) => number; +declare const totalCases: SpecDerivations; +export const scalarOk: CaseModule = totalCases; + +// NEGATIVE — wrong-arm annotation, caught by the envelope's content (not its arity). +// @ts-expect-error — a transition envelope cannot hold derivation-shaped `{ input, value }` cases +export const wrongTransition: SpecCases = { cases: [{ name: "x", input: { count: 0 }, value: 0 }] }; +// @ts-expect-error — a derivation envelope cannot hold transition-shaped `{ before, after }` cases +export const wrongDerivation: SpecDerivations = { cases: [{ name: "x", before: {}, after: { count: 1 } }] }; + +// NEGATIVE — mis-pairing an array-derivation module into a transition fn's slot, caught +// by the `ReturnType`/args mismatch inside the envelope generics. +// @ts-expect-error — a derivation module for `listFoos` is not a valid case module for the transition `bump` +export const mispair: CaseModule = listFoosCases; + +// RESIDUAL GAP (documented, deliberately compiles — NOT a `@ts-expect-error`): a +// transition-shaped module accepted for a `(state) => value` derivation, because args +// align (`ArgsOf` is void) and a state patch is a valid `after`. Unreachable from the +// signature; caught by the conformance run when it name-pairs against a computed. +declare const count: (s: State) => number; +export const residualGap: CaseModule = resetCases; + +// ── `computedPlugin` requiredness (needed iff the manifest has a derivation) ───────── +// `fns` above has `listFoos` (a derivation), so `ok` (which includes `computedPlugin`) +// is the POSITIVE. NEGATIVE — omit it with a derivation present: +// @ts-expect-error — `computedPlugin` is required because `listFoos` is a derivation +export const missingComputedPlugin = implementation(specOf({ state, fns, cases: { bump: bumpCases, listFoos: listFoosCases } }), { plugin, projection, hydrate: ["listFoos"] }); + +// POSITIVE — a transition-only feature may omit `computedPlugin` (no derivations). +const txFns = { bump }; +declare const txCases: SpecCases; +export const noComputedOk = implementation(specOf({ state, fns: txFns, cases: { bump: txCases } }), { plugin, projection }); + +// ── `services` requiredness (needed iff a fn injects a service) ────────────────────── +declare const logger: Logger; +const svcFns = { record }; +declare const recordCases: SpecCases; + +// POSITIVE — the injected service `logger` has a template. +export const servicesOk = implementation(specOf({ state, fns: svcFns, services: { logger: () => logger }, cases: { record: recordCases } }), { plugin, projection }); + +// NEGATIVE — a fn injects `logger`, but `services` is omitted (today: a runtime crash). +// @ts-expect-error — `services` is required because `record` injects `logger` +export const missingServices = implementation(specOf({ state, fns: svcFns, cases: { record: recordCases } }), { plugin, projection }); + +// NEGATIVE — `services` omits the required `logger` template. +// @ts-expect-error — `services` is missing the `logger` template +export const incompleteServices = implementation(specOf({ state, fns: svcFns, services: {}, cases: { record: recordCases } }), { plugin, projection }); + +// NEGATIVE — a service-free feature must not carry dead templates. +// @ts-expect-error — `bump` injects no service, so `services` must be absent +export const extraServices = implementation(specOf({ state, fns: txFns, services: { logger: () => logger }, cases: { bump: txCases } }), { plugin, projection }); + +// ── `StateMatches` — State keys name resources (singletons) and components (entity values) ── +import type { StateMatches } from "./state-matches.js"; +type Assert = T; +type Resources = { readonly count: unknown }; +type Components = { readonly name: unknown }; +export type _stateOk = Assert>; +// @ts-expect-error — `count` is not a resource +export type _badSingleton = Assert>; +// @ts-expect-error — `name` is not a component +export type _badEntity = Assert>; diff --git a/packages/data-testing/src/conformance/public.ts b/packages/data-testing/src/conformance/public.ts index ccea6a910..55a63866a 100644 --- a/packages/data-testing/src/conformance/public.ts +++ b/packages/data-testing/src/conformance/public.ts @@ -1,8 +1,15 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -// The conformance manifest API — the standard surface. `feature` builds a feature's -// manifest; `checkSpec` runs the pure spec suite, `checkFeature` the ecs suite. -export { feature, checkSpec, checkFeature, type FeatureSpec } from "./feature-spec.js"; +// The conformance manifest API — the standard surface. `spec` builds a feature's pure +// spec manifest and `checkSpec` runs it; `implementation` pairs the spec with its ECS +// build and `checkFeature` runs that. `StateMatches` pins State to the data schemas. +export { spec, type Spec } from "./spec.js"; +export { implementation, type Implementation } from "./implementation.js"; +export { checkSpec } from "./check-spec.js"; +export { checkFeature } from "./check-feature.js"; +export type { StateMatches } from "./state-matches.js"; +export type { FrameCases, SystemCases } from "./systems-types.js"; +export { runSystems, type SystemRunConfig } from "./run-systems.js"; export type { Responses, SpecCase, diff --git a/packages/data-testing/src/conformance/run-actions.ts b/packages/data-testing/src/conformance/run-actions.ts index c970c2fef..42d1d7af2 100644 --- a/packages/data-testing/src/conformance/run-actions.ts +++ b/packages/data-testing/src/conformance/run-actions.ts @@ -30,6 +30,8 @@ export interface ActionRunConfig { // (e.g. the acting peer's `userId`) — the one seam not derivable from cases. readonly seedContext?: (db: Db, before: State, args: unknown) => void; readonly match?: MatchOptions; + // The describe label's op kind (default "action"). + readonly kind?: string; } // The single conformance test for every ecs action: each transition's cases run @@ -39,7 +41,7 @@ export function runActions(config: Action for (const [name, action] of discoverOps(config.actions)) { const paired = transitions.get(name); if (!paired) continue; // action with no transition (e.g. a streaming port) — not conformed here - describe(`${name} action conforms`, () => { + describe(`${name} ${config.kind ?? "action"} conforms`, () => { for (const testCase of paired.cases) { it(testCase.name as string, async () => { const { services, input, calls } = splitAndRecordServices(testCase.args); diff --git a/packages/data-testing/src/conformance/run-feature.ts b/packages/data-testing/src/conformance/run-feature.ts index 0b43d5b84..63e0e4b4c 100644 --- a/packages/data-testing/src/conformance/run-feature.ts +++ b/packages/data-testing/src/conformance/run-feature.ts @@ -3,10 +3,10 @@ import { describe, it } from "vitest"; import { Database, Store } from "@adobe/data/ecs"; import type { Entity } from "@adobe/data/ecs"; import type { MatchOptions } from "../match/match.js"; -import { runTransactions } from "./run-transactions.js"; import { runActions } from "./run-actions.js"; import { runComputeds } from "./run-computeds.js"; import { expectAfter } from "./expect-after.js"; +import { discoverTransitions, discoverDerivations, discoverOps } from "./discover.js"; import type { SchemaSource } from "./refify.js"; // The feature's ecs↔`State` projection — the one genuinely feature-specific piece. @@ -17,25 +17,23 @@ export interface Projection { } // One call conforms a whole feature. The runner pulls the ops off the plugin's -// registered facets (`plugin.transactions` / `plugin.actions` / -// `computedPlugin.computed`) and constructs the stores/dbs itself, so a feature -// supplies only its `State` namespace (default + representative samples), the -// `data/state` glob (the `{ fn, cases }` source), the plugin(s), and its -// projection. It runs the transaction, action, and computed conformance plus a -// projection round-trip (`toState ∘ fromState ≡ identity`) over `State.samples`. +// registered facets (`plugin.actions` / `computedPlugin.computed`) and constructs the +// dbs itself. Each spec transition conforms against the plugin's same-named ACTION — +// the spec describes actions, and transactions are an implementation detail they +// cover. A spec op with no implementation is a named failure. It also round-trips +// `State.samples` through the projection. Systems conform separately (`runSystems`). // -// A feature whose ops aren't registered in the facet (a conformance-only action), -// or that needs ambient per-case context (a user-scoped `userId`), uses the +// A feature that needs ambient per-case context (a user-scoped `userId`) uses the // lower-level `runTransactions`/`runActions`/`runComputeds` directly instead. export interface FeatureRunConfig { // The `State` namespace: `create()` is the default seed each case's `before` // deltas over; `samples` (optional) are representative full states for the // projection round-trip. readonly state: { create(): State; readonly samples?: readonly State[] }; - // `import.meta.glob(["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], { eager: true })`. + // The adapted `{ fn, cases }` map (see `adaptCases`). readonly transitions: Record>; - // The assembled feature plugin (`MainService.plugin`) — its `.transactions` and - // `.actions` facets are the ops, and it builds the transaction store + action db. + // The assembled feature plugin (`MainService.plugin`) — its `.actions` facet is the + // ops, and it builds the action db and the projection store. readonly plugin: Database.Plugin; // The `ComputedDatabase` layer plugin — its `.computed` facet is the ops, built // from this layer for seed-freshness. Omit when the feature has no derivations. @@ -43,11 +41,12 @@ export interface FeatureRunConfig { readonly projection: Projection; // Names of computeds that emit an entity-id list (hydrated through `toData`). readonly hydrate?: readonly string[]; + // Base service factories injected into every db (fakes for services whose factory + // throws to require injection); per-case recording doubles override them. + readonly services?: Readonly object>>; readonly match?: MatchOptions; - // Override the ops discovered from the plugin when they aren't registered in a - // facet (e.g. per-transition actions kept out of the plugin to bound its type). + // Override the ops discovered from the plugin facets. readonly ops?: { - readonly transactions?: Record; readonly actions?: Record; readonly computeds?: Record; }; @@ -55,7 +54,10 @@ export interface FeatureRunConfig { // Runtime invariant: a plugin object carries its registered facet maps (see // `create-plugin.ts`), so this reads the ops directly off it. -type PluginFacets = { transactions: Record; actions: Record; computed: Record }; +type PluginFacets = { actions: Record; computed: Record }; + +const pick = (source: ReadonlyMap, names: ReadonlySet): Record => + Object.fromEntries([...source].filter(([name]) => names.has(name))); export function runFeature( config: FeatureRunConfig, @@ -63,41 +65,60 @@ export function runFeature Store.create(config.plugin as never) as unknown as StoreT; + const baseServices = (): Record => + Object.fromEntries(Object.entries(config.services ?? {}).map(([name, create]) => [name, create()])); - runTransactions({ - createStore: makeStore, - fromState, - toState, - initial, - transitions: config.transitions, - transactions: config.ops?.transactions ?? facets.transactions, - match: config.match, - }); + const transitions = discoverTransitions(config.transitions); + const derivations = discoverDerivations(config.transitions); + const actions = discoverOps(config.ops?.actions ?? facets.actions); + const computeds = discoverOps(config.ops?.computeds ?? computedFacets?.computed ?? {}); + + const byAction = new Set([...transitions.keys()].filter((name) => actions.has(name))); + const missing = [ + ...[...transitions.keys()].filter((name) => !byAction.has(name)), + ...[...derivations.keys()].filter((name) => !computeds.has(name)), + ]; + + if (missing.length > 0) { + describe("every spec op has an implementation", () => { + for (const name of missing) { + it(name, () => { + throw new Error(`spec op "${name}" has no same-named ${derivations.has(name) ? "computed" : "action"}`); + }); + } + }); + } runActions({ - makeDb: (services) => Database.toSystemDatabase(Database.create(config.plugin as never, { services })) as unknown as Db, + makeDb: (services) => + Database.toSystemDatabase( + Database.create(config.plugin as never, { services: { ...baseServices(), ...services } }), + ) as unknown as Db, store: (db) => db.store, fromState, toState, initial, transitions: config.transitions, - actions: config.ops?.actions ?? facets.actions, + actions: pick(actions, byAction), match: config.match, }); if (config.computedPlugin) { - const computedFacets = config.computedPlugin as unknown as PluginFacets; runComputeds({ - makeDb: () => Database.toSystemDatabase(Database.create(config.computedPlugin as never)) as unknown as Db, + makeDb: () => + Database.toSystemDatabase( + Database.create(config.computedPlugin as never, { services: baseServices() }), + ) as unknown as Db, store: (db) => db.store, fromState, toData, initial, derivations: config.transitions, - computeds: config.ops?.computeds ?? computedFacets.computed, + computeds: Object.fromEntries(computeds), hydrate: config.hydrate, match: config.match, }); diff --git a/packages/data-testing/src/conformance/run-spec.ts b/packages/data-testing/src/conformance/run-spec.ts index 369ab5cb4..62d86847a 100644 --- a/packages/data-testing/src/conformance/run-spec.ts +++ b/packages/data-testing/src/conformance/run-spec.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, it } from "vitest"; -import { Database, Store } from "@adobe/data/ecs"; +import type { Schema } from "@adobe/data/schema"; import { assert } from "../match/assert.js"; import type { MatchOptions } from "../match/match.js"; import { expectAfter } from "./expect-after.js"; @@ -23,14 +23,12 @@ export interface SpecRunConfig { // Passed through to `matches` (float tolerance). Ordered vs. unordered is now // carried by the value's type — `Array` positional, `Set`/`Map` order-independent. readonly match?: MatchOptions; - // The feature plugin, ONLY needed when entity VALUES (or singletons) carry - // reference fields (an `asset`/`parent`/`selected` id): the pure transform mints - // its own spec-ids, so a reference to a minted entity must compare up to the same - // id-bijection as the ecs side. Given it, `runSpec` reads the plugin's component + - // resource schemas (via a throwaway store) to find those reference fields — exactly - // as `runFeature` does from its store. Omit it for a feature of self-contained - // values: entity map KEYS refify with no schema (an id by construction). - readonly plugin?: Database.Plugin; + // The feature's component + resource schemas, ONLY needed when entity VALUES (or + // singletons) carry reference fields (an `asset`/`parent`/`selected` id): the pure + // transform mints its own spec-ids, so a reference to a minted entity must compare + // up to the same id-bijection as the ecs side. Omit it for a feature of + // self-contained values: entity map KEYS refify with no schema (an id by construction). + readonly schemas?: Readonly>; // Override the `describe` label per module (default `State.`). readonly label?: (path: string, fnName: string | undefined) => string; } @@ -64,12 +62,9 @@ type Suite = InvalidSuite | CasesSuite; // "No test suite found" — so we register a single no-op so empty discovery is a // valid outcome, not an error. export const runSpec = (config: SpecRunConfig): void => { - // Reference fields are found from schemas; without a plugin only map keys (an id - // by construction) refify. A throwaway store surfaces the plugin's component + - // resource schemas, exactly the source `runFeature`'s runners read. - const schemaSource: SchemaSource = config.plugin - ? { componentSchemas: Store.create(config.plugin as never).componentSchemas } - : { componentSchemas: {} }; + // Reference fields are found from schemas; without them only map keys (an id by + // construction) refify. + const schemaSource: SchemaSource = { componentSchemas: config.schemas ?? {} }; const suites: Suite[] = []; for (const [path, module] of Object.entries(config.transitions)) { const exportNames = Object.keys(module); @@ -146,8 +141,8 @@ export const runSpec = (config: SpecRunConfig): void => { } const result = (await suite.fn(before, args)) as Record; // The pure transform mints its own spec-ids, so compare up to an - // id-bijection too (entity map keys always; reference fields when a - // `plugin` gave the schemas). + // id-bijection too (entity map keys always; reference fields when + // `schemas` are given). expectAfter({ ...before, ...result }, before, tc.after as Record, schemaSource, config.match); expectEffects(calls, tc.effects); }); diff --git a/packages/data-testing/src/conformance/run-systems.ts b/packages/data-testing/src/conformance/run-systems.ts new file mode 100644 index 000000000..bd1235f87 --- /dev/null +++ b/packages/data-testing/src/conformance/run-systems.ts @@ -0,0 +1,183 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it } from "vitest"; +import type { Entity } from "@adobe/data/ecs"; +import type { MatchOptions } from "../match/match.js"; +import { matches } from "../match/match.js"; +import { discoverTransitions } from "./discover.js"; +import { expectAfter } from "./expect-after.js"; +import { expectThrows } from "./expect-throws.js"; +import type { SchemaSource } from "./refify.js"; +import { resolveArgs } from "./resolve-args.js"; +import { resolver } from "./resolve.js"; +import { recordArgServices, splitAndRecordServices, expectEffects } from "./record-effects.js"; +import type { Effects } from "./types.js"; + +type SystemFn = () => void | Promise; +type SpecFn = (state: object, args: unknown) => object | Promise; + +// The slice of a system database the runner drives. +export type SystemDatabase = { + readonly store: StoreT; + readonly system: { + readonly order: readonly (readonly string[])[]; + readonly functions: Readonly>; + }; +}; + +export type SystemCase = { + readonly name: string; + readonly before: object; + readonly args?: unknown; + readonly after?: object; + readonly effects?: Effects>; + readonly throws?: true | string; +}; + +export interface SystemRunConfig { + // A fresh system database with these services injected (per-case recording doubles). + readonly makeDb: (services: Record) => SystemDatabase; + readonly fromState: (store: StoreT, state: State) => ReadonlyMap | void; + readonly toState: (store: StoreT) => State; + readonly initial: State; + // The adapted per-system `{ fn, cases }` map (see `adaptCases`), keyed by system name. + readonly systems: Record>; + // Whole-frame cases, adapted the same way. A factory, so every run gets fresh + // recording doubles and one run never drains another's response schedule. + readonly frameCases: () => readonly SystemCase[]; + // One writer per frame data arg: how a case's arg reaches the store. + readonly args: Readonly, value: unknown) => void>>; + // System name → why the spec does not model it. + readonly unmodelled: Readonly>; + readonly match?: MatchOptions; +} + +const SCHEDULER = "schedulerSystem"; + +// Write the case's data args into the store; services already arrived as doubles. +const writeArgs = ( + db: SystemDatabase, + input: Record, + writers: SystemRunConfig["args"], +): void => { + for (const [key, value] of Object.entries(input)) writers[key]?.(db, value); +}; + +// Conform a real-time feature's systems. Each spec system function conforms by running +// just the same-named ECS system on a seeded store. Each frame case conforms twice: +// the paired spec functions folded in `db.system.order` must give `after`, and so must +// one real frame. The order comes only from the systems' `schedule` declarations. +// Unmodelled systems are skipped on both sides and listed as skipped tests. +export function runSystems(config: SystemRunConfig): void { + const systems = discoverTransitions(config.systems); + const unmodelled = new Set(Object.keys(config.unmodelled)); + const order = config.makeDb({}).system.order; + const orderNames = order.flat().filter((name) => name !== SCHEDULER); + + const unaccounted = orderNames.filter((name) => !systems.has(name) && !unmodelled.has(name)); + const unknown = [...systems.keys()].filter((name) => !orderNames.includes(name)); + if (unaccounted.length > 0 || unknown.length > 0) { + describe("every system is modelled or declared unmodelled", () => { + for (const name of unaccounted) { + it(name, () => { + throw new Error(`system "${name}" has no spec function and is not declared unmodelled`); + }); + } + for (const name of unknown) { + it(name, () => { + throw new Error(`spec system "${name}" names no ECS system`); + }); + } + }); + } + + for (const [name, reason] of Object.entries(config.unmodelled)) { + describe(`${name} system`, () => { + it.skip(`unmodelled — ${reason}`, () => {}); + }); + } + + const seed = (testCase: SystemCase) => { + const { services, input, calls } = splitAndRecordServices(testCase.args); + const before = { ...(config.initial as object), ...testCase.before } as State; + const db = config.makeDb(services); + return { db, before, input, calls, resolve: resolver(config.fromState(db.store, before)) }; + }; + + const expectCase = (db: SystemDatabase, before: State, testCase: SystemCase, calls: Parameters[0]) => { + expectAfter(config.toState(db.store), before as object, testCase.after ?? {}, db.store, config.match); + expectEffects(calls, testCase.effects); + }; + + for (const [name, paired] of systems) { + describe(`${name} system conforms`, () => { + for (const raw of paired.cases) { + const testCase = raw as SystemCase; + it(testCase.name, async () => { + const { db, before, input, calls, resolve } = seed(testCase); + writeArgs(db, resolveArgs(input, paired.argsSchema, resolve) as Record, config.args); + const fn = db.system.functions[name]; + if (typeof fn !== "function") throw new Error(`system "${name}" is init-only; declare it unmodelled`); + if (testCase.throws !== undefined) { + await expectThrows(() => fn(), testCase.throws); + return; + } + await fn(); + expectCase(db, before, testCase, calls); + }); + } + }); + } + + const frameCases = config.frameCases(); + if (frameCases.length === 0) return; + + // The modelled systems of each tier, in the order the scheduler runs them. + const tiers = order.map((tier) => tier.filter((name) => name !== SCHEDULER && systems.has(name))); + + // Fold the paired spec functions over a tier's systems in the given order. + const foldTier = async (state: object, names: readonly string[], args: unknown): Promise => { + let next = state; + for (const name of names) next = { ...next, ...(await (systems.get(name)!.fn as SpecFn)(next, args)) }; + return next; + }; + + describe("frame conforms", () => { + frameCases.forEach((named, index) => { + const fresh = (): SystemCase => config.frameCases()[index]!; + it(`${named.name} (spec systems in schedule order)`, async () => { + const testCase = fresh(); + const { args, calls } = recordArgServices(testCase.args); + const before = { ...(config.initial as object), ...testCase.before }; + let state: object = before; + for (const tier of tiers) { + const forward = await foldTier(state, tier, args); + if (tier.length > 1) { + // Systems sharing a tier have no declared order, so they must commute. + const reverse = await foldTier(state, [...tier].reverse(), recordArgServices(fresh().args).args); + if (!matches(forward, reverse, config.match)) { + throw new Error(`systems ${tier.join(", ")} share a tier but don't commute; declare before/after`); + } + } + state = forward; + } + const store = config.makeDb({}).store; + expectAfter(state, before, testCase.after ?? {}, store, config.match); + expectEffects(calls, testCase.effects); + }); + + it(`${named.name} (one ECS frame)`, async () => { + const testCase = fresh(); + const { db, before, input, calls, resolve } = seed(testCase); + writeArgs(db, resolveArgs(input, undefined, resolve) as Record, config.args); + for (const tier of db.system.order) { + await Promise.all( + tier + .filter((name) => name !== SCHEDULER && !unmodelled.has(name)) + .map((name) => db.system.functions[name]?.()), + ); + } + expectCase(db, before, testCase, calls); + }); + }); + }); +} diff --git a/packages/data-testing/src/conformance/spec.ts b/packages/data-testing/src/conformance/spec.ts new file mode 100644 index 000000000..63900917e --- /dev/null +++ b/packages/data-testing/src/conformance/spec.ts @@ -0,0 +1,54 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "@adobe/data/schema"; +import type { MatchOptions } from "../match/match.js"; +import type { CaseModule, RequiredServices } from "./feature-types.js"; +import type { SpecSystems } from "./systems-types.js"; + +type AnyFn = (...args: never[]) => unknown; + +// A feature's pure spec — the manifest a feature's `spec/spec.ts` authors. It names +// only spec-tier things: no plugin, store or projection, so the spec never depends on +// the ECS implementation. `Conformance.implementation` pairs it with an ECS build. +export interface Spec, Cases extends object, Sys = {}> { + // The `State` namespace: `create()` is the default each case's `before`/`input` + // deltas over; `samples` are full states for the projection round-trip. + readonly state: { create(): State; readonly samples?: readonly State[] }; + // The conformed functions — every spec action and derivation, authored as an + // `import * as transforms` barrel. Its keys drive the coverage guard. + readonly fns: Fns; + // One inert `*.cases.ts` module per fn (coverage checked at compile time). + readonly cases: Cases; + // Shape-only templates, keyed by injected-service arg name. The runner calls each + // once to enumerate the service's methods; returns come from each case's `responses`. + readonly services?: Readonly object>>; + // The feature's component + resource schemas (`{ ...components, ...resources }`), + // keyed by State field name. Needed only when State holds entity references: an + // `Entity.schema` field marks a reference, compared up to the same id-bijection as + // entity map keys. Omit for a feature of self-contained values. + readonly schemas?: Readonly>; + readonly match?: MatchOptions; + // A real-time feature's systems: pure functions named after the ECS systems they + // specify, one case module each, and `noOp` states every system leaves unchanged. + // Whole-frame cases depend on the ECS's schedule, so they live on the implementation. + readonly systems?: SpecSystems; +} + +// Author a feature's spec manifest. A typed identity that arms the compile-time guards: +// • `cases` coverage — a module for every fn (missing → error), none for a non-fn. +// • `services` requiredness — exactly the injected-service keys the fns and systems declare. +// • `systems.cases` coverage — a module for every system function. +export const spec = < + State extends object, + Fns extends Record, + C extends { readonly [K in keyof Fns]: CaseModule }, + Sys extends Record = {}, +>( + manifest: Omit, "services"> & + RequiredServices & + ([Exclude] extends [never] + ? unknown + : { readonly __casesError: "a case module names something that is not a conformed fn"; readonly extra: Exclude }), +): Spec => + // Runtime invariant the checker can't see: the constrained param IS this Spec (the + // requiredness factors are compile-only phantoms with no runtime shape). + manifest as unknown as Spec; diff --git a/packages/data-testing/src/conformance/state-matches.ts b/packages/data-testing/src/conformance/state-matches.ts new file mode 100644 index 000000000..a75bf4c1d --- /dev/null +++ b/packages/data-testing/src/conformance/state-matches.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +type SingletonKeys = { [K in keyof S]: S[K] extends ReadonlyMap ? never : K }[keyof S]; +type EntityValueKeys = { [K in keyof S]: S[K] extends ReadonlyMap ? keyof V : never }[keyof S]; + +// The naming contract between a `State` and the feature's `data/` declarations, as a +// pin: every singleton key names a resource, and every entity value key names a +// component (a `ReadonlyMap` field is an entity collection). Conformance reads schemas +// by those names, so a mismatch would silently skip reference comparison. +// +// type _ = Assert>; +export type StateMatches = [Exclude, keyof Resources>] extends [never] + ? [Exclude, keyof Components>] extends [never] + ? true + : { readonly __stateError: "an entity value key is not a component"; readonly keys: Exclude, keyof Components> } + : { readonly __stateError: "a singleton key is not a resource"; readonly keys: Exclude, keyof Resources> }; diff --git a/packages/data-testing/src/conformance/systems-types.ts b/packages/data-testing/src/conformance/systems-types.ts new file mode 100644 index 000000000..cc6e84465 --- /dev/null +++ b/packages/data-testing/src/conformance/systems-types.ts @@ -0,0 +1,61 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Database } from "@adobe/data/ecs"; +import type { UnionToIntersection } from "@adobe/data/types"; +import type { ArgsOf, DataArgKeys, SpecCases } from "./feature-types.js"; + +type AnyFn = (...args: never[]) => unknown; + +// The args one frame supplies: the intersection of every system function's args +// (`{ dt } & { input } & { random }`), so a frame case provides them all. +// A system that takes no args contributes nothing. +export type FrameArgs = UnionToIntersection<{ [K in keyof Sys]: [ArgsOf] extends [void] ? {} : ArgsOf }[keyof Sys]>; + +// A whole-frame case: seed `before`, run every modelled system once in schedule +// order, expect `after`. +export type FrameCases = SpecCases) => Partial>; + +// The per-system cases: exactly one case module per system function. +export type SystemCases = { readonly [K in keyof Sys]: SpecCases }; + +// A state every modelled system must leave unchanged (e.g. game over), checked +// against each system on both the spec and the ECS side. `args` supplies the frame's +// data args; services arrive as doubles with no responses scheduled. +export type NoOpCase = { readonly name: string; readonly before: Partial } & ([ + DataArgKeys>, +] extends [never] + ? { readonly args?: undefined } + : { readonly args: Pick, DataArgKeys>> }); + +// The spec's systems group: pure functions named after the ECS systems they specify, +// one case module each, and the states every system leaves unchanged. +export type SpecSystems = { + readonly fns: Sys; + readonly cases: SystemCases; + readonly noOp?: readonly NoOpCase[]; +}; + +// The names of a plugin's systems, less the scheduler's own driver system. +export type SystemKeys = Exclude< + Extract["system"]["functions"], string>, + "schedulerSystem" +>; + +// One writer per data arg of the frame: how a case's arg reaches the store +// (`dt: (db, dt) => { db.store.resources.frameDelta = dt; }`). Services arrive as +// recording doubles, so they need no writer. +export type FrameArgWriters = { + readonly [K in DataArgKeys>]-?: ( + db: Database.Plugin.ToSystemDatabase, + value: FrameArgs[K], + ) => void; +}; + +// Every ECS system is either modelled (a spec function of the same name) or declared +// unmodelled, never both, and the spec names no system the ECS lacks. +export type SystemsExact = [Exclude] extends [never] + ? [Exclude] extends [never] + ? [Extract] extends [never] + ? unknown + : { readonly __systemsError: "a system is both modelled and unmodelled"; readonly both: Extract } + : { readonly __systemsError: "names a system the ECS does not have"; readonly unknown: Exclude } + : { readonly __systemsError: "a system is neither modelled nor unmodelled"; readonly missing: Exclude }; diff --git a/packages/data-testing/src/conformance/systems.test.ts b/packages/data-testing/src/conformance/systems.test.ts new file mode 100644 index 000000000..30bff1436 --- /dev/null +++ b/packages/data-testing/src/conformance/systems.test.ts @@ -0,0 +1,141 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// A real-time feature's systems conform one by one and as a frame. `integrate` is +// scheduled after `accelerate`, and the frame case only holds in that order, so it +// proves the order comes from `schedule`, not from the spec. `drift` reads a service, +// so its frame case also proves each run gets fresh recording doubles. `physics` +// stands in for a system the spec can't model (e.g. wasm) and `seed` is init-only; +// both are declared unmodelled and skipped. Every system guards on `frozen`, which +// the `noOp` case checks against each of them. +import { Database } from "@adobe/data/ecs"; +import { spec } from "./spec.js"; +import { implementation } from "./implementation.js"; +import { checkSpec } from "./check-spec.js"; +import { checkFeature } from "./check-feature.js"; +import type { SpecCases } from "./feature-types.js"; +import type { FrameCases } from "./systems-types.js"; + +type State = { readonly frozen: boolean; readonly position: number; readonly velocity: number; readonly offset: number }; + +interface Rng { + readonly serviceName: "rng"; + readonly next: () => number; +} + +const accelerate = (state: Pick, { dt }: { readonly dt: number }): Pick => ({ + velocity: state.frozen ? state.velocity : state.velocity + dt, +}); +const integrate = (state: State, { dt }: { readonly dt: number }): Pick => ({ + position: state.frozen ? state.position : state.position + state.velocity * dt, +}); +const drift = (state: Pick, { rng }: { readonly rng: Rng }): Pick => ({ + offset: state.frozen ? state.offset : state.offset + rng.next(), +}); +const systems = { accelerate, integrate, drift }; + +const plugin = Database.Plugin.create({ + services: { rng: (): Rng => ({ serviceName: "rng", next: () => 0 }) }, + resources: { + frozen: { default: false as boolean }, + position: { default: 0 as number }, + velocity: { default: 0 as number }, + offset: { default: 0 as number }, + jitter: { default: 0 as number }, + frameDelta: { default: 0 as number }, + }, + systems: { + integrate: { + schedule: { after: ["accelerate"] }, + create: (db) => () => { + const r = db.store.resources; + if (!r.frozen) r.position += r.velocity * r.frameDelta; + }, + }, + accelerate: { + create: (db) => () => { + const r = db.store.resources; + if (!r.frozen) r.velocity += r.frameDelta; + }, + }, + drift: { + create: (db) => () => { + const r = db.store.resources; + if (!r.frozen) r.offset += db.services.rng.next(); + }, + }, + physics: { + create: (db) => () => { + db.store.resources.jitter += 1; + }, + }, + seed: { + create: (db) => { + db.store.resources.jitter = 0; + }, + }, + }, +}); + +type Store = Database.Plugin.ToStore; + +const accelerateCases: SpecCases = { + cases: [{ name: "adds dt to velocity", before: { velocity: 1 }, args: { dt: 2 }, after: { velocity: 3 } }], +}; +const integrateCases: SpecCases = { + cases: [{ name: "moves by velocity × dt", before: { position: 1, velocity: 2 }, args: { dt: 3 }, after: { position: 7 } }], +}; +const driftCases: SpecCases = { + cases: [{ name: "adds the next random offset", before: { offset: 1 }, responses: { rng: { next: [4] } }, after: { offset: 5 } }], +}; + +const motion = spec({ + state: { create: (): State => ({ frozen: false, position: 0, velocity: 0, offset: 0 }) }, + fns: {}, + cases: {}, + services: { rng: (): Rng => ({ serviceName: "rng", next: () => 0 }) }, + systems: { + fns: systems, + cases: { accelerate: accelerateCases, integrate: integrateCases, drift: driftCases }, + noOp: [{ name: "frozen", before: { frozen: true, position: 1, velocity: 2, offset: 3 }, args: { dt: 5 } }], + }, +}); + +// Whole frames: accelerate first gives velocity 3, then position 0 + 3 × 2 = 6 (the +// other order gives 2). +const frame: FrameCases = { + cases: [ + { + name: "accelerates, then integrates", + before: { velocity: 1 }, + args: { dt: 2 }, + responses: { rng: { next: [5] } }, + after: { velocity: 3, position: 6, offset: 5 }, + }, + ], +}; + +checkSpec(motion); + +checkFeature( + implementation(motion, { + plugin, + projection: { + fromState: (store: Store, state: State): void => { + store.resources.frozen = state.frozen; + store.resources.position = state.position; + store.resources.velocity = state.velocity; + store.resources.offset = state.offset; + }, + toState: (store: Store): State => ({ + frozen: store.resources.frozen, + position: store.resources.position, + velocity: store.resources.velocity, + offset: store.resources.offset, + }), + }, + frame: { + args: { dt: (db, dt) => { db.store.resources.frameDelta = dt; } }, + unmodelled: { physics: "stands in for a wasm system", seed: "init-only" }, + cases: frame, + }, + }), +); diff --git a/packages/data-testing/src/conformance/systems.type-test.ts b/packages/data-testing/src/conformance/systems.type-test.ts new file mode 100644 index 000000000..6e4118c41 --- /dev/null +++ b/packages/data-testing/src/conformance/systems.type-test.ts @@ -0,0 +1,44 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// Compile-time guards of the systems manifest: every ECS system is modelled xor +// unmodelled, and every frame data arg has a writer. +import { Database } from "@adobe/data/ecs"; +import { spec } from "./spec.js"; +import { implementation } from "./implementation.js"; +import type { SpecCases } from "./feature-types.js"; + +type State = { readonly velocity: number }; +const accelerate = (state: State, { dt }: { readonly dt: number }): State => ({ velocity: state.velocity + dt }); +declare const accelerateCases: SpecCases; + +const plugin = Database.Plugin.create({ + resources: { velocity: { default: 0 as number }, frameDelta: { default: 0 as number } }, + systems: { + accelerate: { create: (db) => () => { db.store.resources.velocity += db.store.resources.frameDelta; } }, + physics: { create: () => () => {} }, + }, +}); +type Store = Database.Plugin.ToStore; +const projection = { + fromState: (store: Store, state: State): void => { store.resources.velocity = state.velocity; }, + toState: (store: Store): State => ({ velocity: store.resources.velocity }), +}; +const motion = spec({ state: { create: (): State => ({ velocity: 0 }) }, fns: {}, cases: {}, systems: { fns: { accelerate }, cases: { accelerate: accelerateCases } } }); +const dt = (db: Database.Plugin.ToSystemDatabase, value: number) => { db.store.resources.frameDelta = value; }; + +// POSITIVE — `accelerate` modelled, `physics` unmodelled, `dt` written. +export const ok = implementation(motion, { plugin, projection, frame: { args: { dt }, unmodelled: { physics: "wasm" } } }); + +// @ts-expect-error — `physics` is neither modelled nor declared unmodelled +export const missing = implementation(motion, { plugin, projection, frame: { args: { dt } } }); + +// @ts-expect-error — `gravity` is not a system of this plugin +export const unknown = implementation(motion, { plugin, projection, frame: { args: { dt }, unmodelled: { physics: "wasm", gravity: "x" } } }); + +// @ts-expect-error — `accelerate` is modelled, so it can't also be unmodelled +export const both = implementation(motion, { plugin, projection, frame: { args: { dt }, unmodelled: { physics: "wasm", accelerate: "x" } } }); + +// @ts-expect-error — the frame arg `dt` has no writer +export const noWriter = implementation(motion, { plugin, projection, frame: { args: {}, unmodelled: { physics: "wasm" } } }); + +// @ts-expect-error — a spec with systems requires `frame` +export const noFrame = implementation(motion, { plugin, projection }); diff --git a/packages/data-testing/src/conformance/with-no-op.ts b/packages/data-testing/src/conformance/with-no-op.ts new file mode 100644 index 000000000..326afec5e --- /dev/null +++ b/packages/data-testing/src/conformance/with-no-op.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +type Module = { readonly args?: unknown; readonly cases: readonly object[] }; + +// Append each no-op state to every system's cases as an ordinary case expecting no +// change, so both runners check it without a separate path. +export const withNoOp = ( + cases: object, + noOp: readonly { readonly name: string; readonly before: object; readonly args?: unknown }[] | undefined, +): object => { + if (noOp === undefined || noOp.length === 0) return cases; + // Runtime invariant: a systems manifest's `cases` holds one case module per system. + const modules = cases as Readonly>; + return Object.fromEntries( + Object.entries(modules).map(([name, module]) => [ + name, + { ...module, cases: [...module.cases, ...noOp.map((c) => ({ ...c, name: `${c.name} (no-op)`, after: {} }))] }, + ]), + ); +}; diff --git a/packages/data-testing/src/index.ts b/packages/data-testing/src/index.ts index 10dd00133..62285ba58 100644 --- a/packages/data-testing/src/index.ts +++ b/packages/data-testing/src/index.ts @@ -2,9 +2,9 @@ // Shared test-only utilities for the spec↔ecs conformance pattern. Two namespaces: // Match — tolerant, matcher-aware value comparison (framework-agnostic). -// Conformance — the manifest API (`feature` / `checkSpec` / `checkFeature`), the case +// Conformance — the manifest API (`spec` / `implementation` / `checkSpec` / `checkFeature`), the case // types, and the lower-level runner escape hatch. -// Import only from a feature's test tier (its `*.cases.ts`, `spec.ts`, `*.fake.ts`, and +// Import only from a feature's test tier (its `spec/` folder, `ecs/conformance/`, and // `*.test.ts`), which the package's project-reference wall keeps out of the runtime build. export * as Match from "./match/public.js"; export * as Conformance from "./conformance/public.js"; @@ -22,7 +22,11 @@ export type { SpecDerivations, Responses, CaseModule, - FeatureSpec, + Spec, + Implementation, + StateMatches, + FrameCases, + SystemCases, Effects, ServiceCall, } from "./conformance/public.js"; diff --git a/packages/data/package.json b/packages/data/package.json index c1ef0fc75..89851ac23 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.10.21", + "version": "0.10.22", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/packages/data/scripts/copy-references.js b/packages/data/scripts/copy-references.js index 7a7c09a61..59469c264 100644 --- a/packages/data/scripts/copy-references.js +++ b/packages/data/scripts/copy-references.js @@ -12,7 +12,9 @@ const refsDir = path.join(packageRoot, "references"); const packagesDir = path.join(packageRoot, ".."); const PACKAGES = [ + "data-gpu-hopper", "data-lit", + "data-lit-space-rock-game", "data-lit-tictactoe", "data-lit-todo", "data-react", @@ -20,7 +22,17 @@ const PACKAGES = [ "data-react-pixie", ]; -const COPY_ENTRIES = ["src", "package.json", "tsconfig.json", "README.md"]; +const COPY_ENTRIES = [ + "src", + "package.json", + "README.md", + "tsconfig.json", + // The feature layer projects the rules point consumers to. + "tsconfig.data.json", + "tsconfig.spec.json", + "tsconfig.ecs.json", + "tsconfig.test.json", +]; function copyRecursive(src, dest) { const stat = fs.statSync(src); diff --git a/packages/data/src/ecs/database/database.ts b/packages/data/src/ecs/database/database.ts index 0e1668d5f..071b6c16f 100644 --- a/packages/data/src/ecs/database/database.ts +++ b/packages/data/src/ecs/database/database.ts @@ -41,6 +41,8 @@ import { combinePlugins } from "./combine-plugins.js"; import { components as componentsFacet } from "./facets/components.js"; import { resources as resourcesFacet } from "./facets/resources.js"; import { archetypes as archetypesFacet } from "./facets/archetypes.js"; +import type { EntityComponents as EntityComponentsFacet } from "./facets/entity-components.js"; +import type { EntityType as EntityTypeFacet } from "./facets/entity-type.js"; export type SystemFunction = () => void | Promise; export type SystemDeclaration = { @@ -349,6 +351,10 @@ export namespace Database { export const components = componentsFacet; export const resources = resourcesFacet; export const archetypes = archetypesFacet; + /** An entity's declaring component tuple (see `facets/entity-components.ts`). */ + export type EntityComponents = EntityComponentsFacet; + /** An entity's value type: the aggregate of its component types (see `facets/entity-type.ts`). */ + export type EntityType> = EntityTypeFacet; /** * Converts a Plugin type to its corresponding Database type. diff --git a/packages/data/src/ecs/database/facets/archetypes.ts b/packages/data/src/ecs/database/facets/archetypes.ts index 565fd1120..c3f20ecf7 100644 --- a/packages/data/src/ecs/database/facets/archetypes.ts +++ b/packages/data/src/ecs/database/facets/archetypes.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { ComponentSchemas } from "../../component-schemas.js"; +import type { EntityComponents } from "./entity-components.js"; /** * Declare a feature's archetypes, validating every key against `components` and @@ -16,7 +17,7 @@ import type { ComponentSchemas } from "../../component-schemas.js"; */ export function archetypes< C extends ComponentSchemas, - const A extends { readonly [name: string]: readonly (keyof C & string)[] }, + const A extends { readonly [name: string]: EntityComponents }, >(_components: C, map: A): A { // `_components` carries the component type C for key validation; unused at runtime. return map; diff --git a/packages/data/src/ecs/database/facets/entity-components.ts b/packages/data/src/ecs/database/facets/entity-components.ts new file mode 100644 index 000000000..84ec44057 --- /dev/null +++ b/packages/data/src/ecs/database/facets/entity-components.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ComponentSchemas } from "../../component-schemas.js"; +import type { OptionalComponents } from "../../optional-components.js"; +import type { StringKeyof } from "../../../types/types.js"; + +/** + * The component-name tuple that declares an entity: any of the feature's + * components, plus the built-in `nonPersistent` / `nonShared` marker components + * that place the entity in a scope quadrant. + * + * ```ts + * export const Todo = ["name", "complete", "order"] as const satisfies Database.EntityComponents; + * ``` + */ +export type EntityComponents = readonly (StringKeyof | StringKeyof)[]; diff --git a/packages/data/src/ecs/database/facets/entity-type.ts b/packages/data/src/ecs/database/facets/entity-type.ts new file mode 100644 index 000000000..54f337fa2 --- /dev/null +++ b/packages/data/src/ecs/database/facets/entity-type.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ComponentSchemas } from "../../component-schemas.js"; +import type { OptionalComponents } from "../../optional-components.js"; +import type { ToType } from "../../../schema/to-type.js"; +import type { StringKeyof } from "../../../types/types.js"; +import type { EntityComponents } from "./entity-components.js"; + +/** + * The value type of an entity declared by component tuple `A`: the aggregate of its + * component types. The built-in marker components are omitted (they carry no + * data); tags (`True.schema` components) are kept. + */ +export type EntityType> = { + readonly [K in Exclude>]: ToType; +}; diff --git a/packages/data/src/ecs/database/facets/entity-type.type-test.ts b/packages/data/src/ecs/database/facets/entity-type.type-test.ts new file mode 100644 index 000000000..7ee9c15d5 --- /dev/null +++ b/packages/data/src/ecs/database/facets/entity-type.type-test.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Assert, Equal } from "../../../types/index.js"; +import { Boolean, F32, True } from "../../../schema/index.js"; +import { Database } from "../database.js"; + +const components = { todo: True.schema, name: { type: "string" }, complete: Boolean.schema, order: F32.schema } as const; + +// An entity tuple may name built-in marker components; its value type omits them and keeps tags. +const Cursor = ["todo", "name", "nonPersistent", "nonShared"] as const satisfies Database.EntityComponents; +type _Cursor = Assert, { readonly todo: true; readonly name: string }>>; + +const Todo = ["name", "complete", "order"] as const satisfies Database.EntityComponents; +type _Todo = Assert, { readonly name: string; readonly complete: boolean; readonly order: number }>>; + +// Archetypes accept the marker components too. +export const archetypes = Database.archetypes(components, { Cursor, Session: ["name", "nonPersistent", "nonShared"] }); + +// @ts-expect-error — an unknown component name is rejected +export const bad = ["nope"] as const satisfies Database.EntityComponents; diff --git a/packages/data/src/ecs/index.ts b/packages/data/src/ecs/index.ts index c332cad8b..c6f3ac531 100644 --- a/packages/data/src/ecs/index.ts +++ b/packages/data/src/ecs/index.ts @@ -12,6 +12,7 @@ export { type Components } from "./store/components.js"; export { type ResourceComponents } from "./store/resource-components.js"; export * from "./component-schemas.js"; export * from "./resource-schemas.js"; +export { Scope } from "./scope.js"; export * from "./undo-redo-service/index.js"; export * from "./persistence-service/index.js"; export { applyOperations } from "./database/transactional-store/apply-operations.js"; diff --git a/packages/data/src/ecs/scope.ts b/packages/data/src/ecs/scope.ts new file mode 100644 index 000000000..801112917 --- /dev/null +++ b/packages/data/src/ecs/scope.ts @@ -0,0 +1,25 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +/** + * The schema flag pairs for the four scopes, spread into a component or resource + * schema where it is declared, so each schema object is stamped once and shared by + * identity: + * + * ```ts + * export const displayCompleted = { ...Boolean.schema, ...Scope.settings } satisfies ResourceSchema; + * ``` + * + * - **document** — shared + durable: no flags (the default). + * - **settings** — local + durable: `nonShared`. + * - **presence** — shared + ephemeral: `nonPersistent`. + * - **session** — local + ephemeral: both. + * + * On a component these flags scope the column. An entity's own quadrant comes from + * the built-in `nonPersistent` / `nonShared` marker components in its archetype. + */ +export const Scope = { + document: {}, + settings: { nonShared: true }, + presence: { nonPersistent: true }, + session: { nonPersistent: true, nonShared: true }, +} as const;