From 1994443dd40c4979b38857752fa77c44b2b58145 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 17:44:16 -0700 Subject: [PATCH 01/24] feat(conformance): split manifest into Conformance.spec + Conformance.implementation; add Scope, entity types - Conformance.spec (pure, reads reference marks from schemas; no plugin) - Conformance.implementation pairs a spec with its ECS build; checkFeature conforms spec ops against same-named actions (transaction fallback for system-dispatched ops) and fails on a spec op with no implementation - base services injected into every conformance db - Conformance.StateMatches pin type - @adobe/data: Scope flag constants, Database.EntityComponents/EntityType, Database.archetypes accepts the built-in marker components Co-Authored-By: Claude Opus 5.5 --- .../src/conformance/adapt-spec.ts | 6 +- .../src/conformance/check-feature.ts | 27 ++++ .../src/conformance/check-spec.ts | 18 +++ .../src/conformance/feature-spec.ts | 124 ------------------ .../src/conformance/implementation.ts | 61 +++++++++ ...pec.type-test.ts => manifest.type-test.ts} | 40 ++++-- .../data-testing/src/conformance/public.ts | 11 +- .../src/conformance/run-feature.ts | 75 ++++++++--- .../data-testing/src/conformance/run-spec.ts | 29 ++-- packages/data-testing/src/conformance/spec.ts | 47 +++++++ .../src/conformance/state-matches.ts | 16 +++ packages/data-testing/src/index.ts | 8 +- packages/data/src/ecs/database/database.ts | 6 + .../src/ecs/database/facets/archetypes.ts | 3 +- .../ecs/database/facets/entity-components.ts | 15 +++ .../src/ecs/database/facets/entity-type.ts | 15 +++ .../database/facets/entity-type.type-test.ts | 19 +++ packages/data/src/ecs/index.ts | 1 + packages/data/src/ecs/scope.ts | 25 ++++ 19 files changed, 363 insertions(+), 183 deletions(-) create mode 100644 packages/data-testing/src/conformance/check-feature.ts create mode 100644 packages/data-testing/src/conformance/check-spec.ts delete mode 100644 packages/data-testing/src/conformance/feature-spec.ts create mode 100644 packages/data-testing/src/conformance/implementation.ts rename packages/data-testing/src/conformance/{feature-spec.type-test.ts => manifest.type-test.ts} (77%) create mode 100644 packages/data-testing/src/conformance/spec.ts create mode 100644 packages/data-testing/src/conformance/state-matches.ts create mode 100644 packages/data/src/ecs/database/facets/entity-components.ts create mode 100644 packages/data/src/ecs/database/facets/entity-type.ts create mode 100644 packages/data/src/ecs/database/facets/entity-type.type-test.ts create mode 100644 packages/data/src/ecs/scope.ts 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/check-feature.ts b/packages/data-testing/src/conformance/check-feature.ts new file mode 100644 index 000000000..4448c468e --- /dev/null +++ b/packages/data-testing/src/conformance/check-feature.ts @@ -0,0 +1,27 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { runFeature } from "./run-feature.js"; +import { adaptCases } from "./adapt-spec.js"; +import type { SchemaSource } from "./refify.js"; +import type { Implementation } from "./implementation.js"; + +type AnyFn = (...args: never[]) => unknown; + +// 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, + }); +}; 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..1e71a0919 --- /dev/null +++ b/packages/data-testing/src/conformance/check-spec.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { runSpec } from "./run-spec.js"; +import { adaptCases } from "./adapt-spec.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. +export const checkSpec = , C extends object>(spec: Spec): void => { + runSpec({ + state: spec.state, + transitions: adaptCases(spec.fns, spec.cases, 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/implementation.ts b/packages/data-testing/src/conformance/implementation.ts new file mode 100644 index 000000000..f70cda814 --- /dev/null +++ b/packages/data-testing/src/conformance/implementation.ts @@ -0,0 +1,61 @@ +// © 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"; + +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 (and, for a spec + // op with no action, its transactions) are the ops under test. + readonly plugin: Database.Plugin; + // 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 transactions?: Record; + 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. +export const implementation = < + State extends object, + Fns extends Record, + C extends object, + P extends ProjectionShape, + const H extends readonly Extract[] = readonly [], +>( + spec: Spec, + impl: Omit>, "spec" | "projection" | "hydrate" | "computedPlugin"> & { + readonly projection: P; + readonly hydrate?: H; + } & (HasDerivations extends true ? { readonly computedPlugin: Database.Plugin } : { readonly computedPlugin?: Database.Plugin }) & + HydrateExact>>, +): 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/feature-spec.type-test.ts b/packages/data-testing/src/conformance/manifest.type-test.ts similarity index 77% rename from packages/data-testing/src/conformance/feature-spec.type-test.ts rename to packages/data-testing/src/conformance/manifest.type-test.ts index 7a280403f..3f11c7e08 100644 --- a/packages/data-testing/src/conformance/feature-spec.type-test.ts +++ b/packages/data-testing/src/conformance/manifest.type-test.ts @@ -1,12 +1,13 @@ // © 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` +// 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 { feature } from "./feature-spec.js"; +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 }; @@ -41,23 +42,23 @@ const listFoosCases: SpecDerivations = { }; // 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 } }); +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 = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos"], cases: { bump: bumpCases } }); +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 = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos"], cases: { bump: bumpCases, listFoos: listFoosCases, bogus: bumpCases } }); +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 = feature({ state, fns, plugin, computedPlugin, projection, hydrate: [], cases: { bump: bumpCases, listFoos: listFoosCases } }); +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 = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos", "bump"], cases: { bump: bumpCases, listFoos: listFoosCases } }); +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. @@ -146,12 +147,12 @@ export const residualGap: CaseModule = resetCases; // `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 } }); +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 = feature({ state, fns: txFns, plugin, projection, cases: { bump: txCases } }); +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; @@ -159,16 +160,27 @@ 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 } }); +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 = feature({ state, fns: svcFns, plugin, projection, cases: { record: recordCases } }); +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 = feature({ state, fns: svcFns, plugin, projection, services: {}, cases: { record: recordCases } }); +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 = feature({ state, fns: txFns, plugin, projection, services: { logger: () => logger }, cases: { bump: txCases } }); +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..0f64f27fc 100644 --- a/packages/data-testing/src/conformance/public.ts +++ b/packages/data-testing/src/conformance/public.ts @@ -1,8 +1,13 @@ // © 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 { Responses, SpecCase, diff --git a/packages/data-testing/src/conformance/run-feature.ts b/packages/data-testing/src/conformance/run-feature.ts index 0b43d5b84..915d02a20 100644 --- a/packages/data-testing/src/conformance/run-feature.ts +++ b/packages/data-testing/src/conformance/run-feature.ts @@ -7,6 +7,7 @@ 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 +18,25 @@ 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` / `plugin.transactions` / +// `computedPlugin.computed`) and constructs the stores/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 transition +// with no action but a same-named transaction (a system-dispatched step) conforms +// against that transaction instead. A spec op with no implementation at all is a +// named failure. It also round-trips `State.samples` through the projection. // -// 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` and + // `.transactions` facets are the ops, and it builds the transaction store + action db. 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,9 +44,11 @@ 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; @@ -57,15 +60,44 @@ export interface FeatureRunConfig { // `create-plugin.ts`), so this reads the ops directly off it. type PluginFacets = { transactions: Record; actions: Record; computed: Record }; +const pick = (source: ReadonlyMap, names: ReadonlySet): Record => + Object.fromEntries([...source].filter(([name]) => names.has(name))); + export function runFeature( config: FeatureRunConfig, ): void { const initial = config.state.create(); const { fromState, toState, toData } = config.projection; const facets = config.plugin as unknown as PluginFacets; + const computedFacets = config.computedPlugin as unknown as PluginFacets | undefined; // A plugin carries the schema facets, so `Store.create` / `Database.create` // accept it; the resulting store/db is the projection's `StoreT`/`Db`. const makeStore = (): StoreT => Store.create(config.plugin as never) as unknown as StoreT; + const baseServices = (): Record => + Object.fromEntries(Object.entries(config.services ?? {}).map(([name, create]) => [name, create()])); + + const transitions = discoverTransitions(config.transitions); + const derivations = discoverDerivations(config.transitions); + const actions = discoverOps(config.ops?.actions ?? facets.actions); + const transactions = discoverOps(config.ops?.transactions ?? facets.transactions); + const computeds = discoverOps(config.ops?.computeds ?? computedFacets?.computed ?? {}); + + const byAction = new Set([...transitions.keys()].filter((name) => actions.has(name))); + const byTransaction = new Set([...transitions.keys()].filter((name) => !actions.has(name) && transactions.has(name))); + const missing = [ + ...[...transitions.keys()].filter((name) => !byAction.has(name) && !byTransaction.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 action, transaction or computed`); + }); + } + }); + } runTransactions({ createStore: makeStore, @@ -73,31 +105,36 @@ export function runFeature({ - 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/spec.ts b/packages/data-testing/src/conformance/spec.ts new file mode 100644 index 000000000..966cf3fbd --- /dev/null +++ b/packages/data-testing/src/conformance/spec.ts @@ -0,0 +1,47 @@ +// © 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"; + +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> { + // 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; +} + +// 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 declare. +export const spec = < + State extends object, + Fns extends Record, + C extends { readonly [K in keyof Fns]: CaseModule }, +>( + 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/index.ts b/packages/data-testing/src/index.ts index 10dd00133..581043604 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,9 @@ export type { SpecDerivations, Responses, CaseModule, - FeatureSpec, + Spec, + Implementation, + StateMatches, Effects, ServiceCall, } from "./conformance/public.js"; 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; From 17a06843db18d883ee60290978bcc288e65fafa3 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 17:52:55 -0700 Subject: [PATCH 02/24] refactor(data-lit-todo): port to the layered feature layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data/ (values, components, resources, entities) → services/ → spec/ (test tier) → ecs/ → ui/ → app/. Per-file components/resources with scope flags; entities declared as component tuples; State pinned with StateMatches; Conformance.spec + Conformance.implementation; app/ composes feature schemas and owns versioning. assign gains a spec, actions and conformance, owns its assignees column, and renders its own assignee chips. Layer tsconfigs: data, spec, ecs, test. data-testing: spec transitions pair with same-named actions; transactionOps opts a system-dispatched op into transaction pairing. Co-Authored-By: Claude Opus 5.5 --- packages/data-lit-todo/index.html | 2 +- packages/data-lit-todo/src/{ => app}/main.ts | 8 +-- packages/data-lit-todo/src/app/schema.ts | 17 +++++++ .../versioning/versions.test.ts | 4 +- .../versioning/versions.ts | 2 +- .../assign/data/components/assignees.ts | 5 ++ .../features/assign/data/components/index.ts | 11 ++++ .../features/assign/data/components/user.ts | 6 +++ .../assign/data/entities/assigned-todo.ts | 7 +++ .../features/assign/data/entities/index.ts | 5 ++ .../src/features/assign/data/entities/user.ts | 6 +++ .../src/features/assign/data/user/user.ts | 8 --- .../assign/data/values/assignees/assignees.ts | 4 ++ .../data/values}/assignees/public.ts | 0 .../data/values}/assignees/schema.ts | 4 ++ .../{ => values}/assignment/assign.test.ts | 0 .../data/{ => values}/assignment/assign.ts | 0 .../{ => values}/assignment/assignment.ts | 0 .../data/{ => values}/assignment/public.ts | 0 .../{ => values}/assignment/unassign.test.ts | 0 .../data/{ => values}/assignment/unassign.ts | 0 .../assign/ecs/actions/action-database.ts | 18 +++++++ .../features/assign/ecs/actions/add-user.ts | 6 +++ .../assign/ecs/actions/assign-user.ts | 7 +++ .../src/features/assign/ecs/actions/index.ts | 4 ++ .../assign/ecs/actions/unassign-user.ts | 7 +++ .../main-service => ecs}/assign.test.ts | 8 +-- .../ecs/computed}/computed-database.ts | 4 +- .../computed/index.ts | 0 .../computed/tasks-by-user.ts | 11 ++-- .../computed/users.ts | 6 +-- .../ecs/conformance/conformance.test.ts | 6 +++ .../assign/ecs/conformance/implementation.ts | 13 +++++ .../assign/ecs/conformance/projection.ts | 38 ++++++++++++++ .../features/assign/ecs/core/archetypes.ts | 12 +++++ .../core}/core-database.ts | 10 ++-- .../indexes}/index-database.ts | 4 +- .../index-database => ecs}/indexes/index.ts | 0 .../indexes/todos-by-assignee.ts | 0 .../indexes/users-by-name.ts | 0 .../src/features/assign/ecs/main-service.ts | 2 + .../transactions/add-user.ts | 2 +- .../transactions/assign-user.ts | 9 ++-- .../transactions/index.ts | 0 .../transactions}/transaction-database.ts | 4 +- .../assign/ecs/transactions/unassign-user.ts | 14 +++++ .../main-service/core-database/archetypes.ts | 7 --- .../main-service/core-database/components.ts | 14 ----- .../transactions/unassign-user.ts | 13 ----- .../features/assign/spec/add-user.cases.ts | 33 ++++++++++++ .../src/features/assign/spec/add-user.ts | 15 ++++++ .../features/assign/spec/assign-user.cases.ts | 36 +++++++++++++ .../src/features/assign/spec/assign-user.ts | 13 +++++ .../src/features/assign/spec/create.ts | 5 ++ .../src/features/assign/spec/public.ts | 3 ++ .../src/features/assign/spec/samples.ts | 17 +++++++ .../src/features/assign/spec/spec.test.ts | 6 +++ .../src/features/assign/spec/spec.ts | 19 +++++++ .../src/features/assign/spec/state.ts | 19 +++++++ .../assign/spec/tasks-by-user.cases.ts | 31 +++++++++++ .../src/features/assign/spec/tasks-by-user.ts | 19 +++++++ .../src/features/assign/spec/transforms.ts | 8 +++ .../assign/spec/unassign-user.cases.ts | 36 +++++++++++++ .../src/features/assign/spec/unassign-user.ts | 13 +++++ .../src/features/assign/spec/users.cases.ts | 22 ++++++++ .../src/features/assign/spec/users.ts | 8 +++ .../src/features/assign/ui/assign-element.ts | 8 +-- .../assignee-chips/assignee-chips-element.ts | 31 +++++++++++ .../assignee-chips-presentation.test.ts | 16 ++++++ .../assignee-chips-presentation.ts | 10 ++++ .../ui/assignee-chips/assignee-chips.css.ts | 18 +++++++ .../ui/assignee-chips/assignee-chips.ts | 10 ++++ .../assignee-dropdown-element.ts | 4 +- .../assign/ui/users-tab/users-tab-element.ts | 2 +- .../features/main/data/assignees/assignees.ts | 6 --- .../features/main/data/components/complete.ts | 4 ++ .../main/data/components/drag-position.ts | 7 +++ .../features/main/data/components/index.ts | 8 +++ .../src/features/main/data/components/name.ts | 4 ++ .../features/main/data/components/order.ts | 5 ++ .../src/features/main/data/components/todo.ts | 6 +++ .../main/data/drag-position/drag-position.ts | 6 --- .../src/features/main/data/entities/index.ts | 4 ++ .../src/features/main/data/entities/todo.ts | 7 +++ .../src/features/main/data/name/name.ts | 6 --- .../main/data/resources/display-completed.ts | 7 +++ .../src/features/main/data/resources/index.ts | 5 ++ .../main/data/resources/selected-todo.ts | 7 +++ .../src/features/main/data/state/public.ts | 12 ----- .../src/features/main/data/state/spec.test.ts | 9 ---- .../src/features/main/data/state/spec.ts | 51 ------------------- .../src/features/main/data/todo/is.ts | 15 ------ .../src/features/main/data/todo/public.ts | 2 - .../src/features/main/data/todo/todo.ts | 12 ----- .../values/drag-position/drag-position.ts | 4 ++ .../data/{ => values}/drag-position/public.ts | 0 .../data/{ => values}/drag-position/schema.ts | 4 ++ .../features/main/data/values/name/name.ts | 4 ++ .../main/data/{ => values}/name/public.ts | 0 .../main/data/{ => values}/name/schema.ts | 4 ++ .../actions}/action-database.ts | 4 +- .../actions/create-bulk-todos.ts | 2 +- .../actions/create-random-todo.ts | 2 +- .../actions/create-todo.ts | 2 +- .../actions/delete-all-todos.ts | 2 +- .../actions/delete-todo.ts | 2 +- .../actions/drag-todo.test.ts | 2 +- .../actions/drag-todo.ts | 2 +- .../action-database => ecs}/actions/index.ts | 1 + .../actions/reorder-todo.ts | 2 +- .../features/main/ecs/actions/select-todo.ts | 8 +++ .../actions/toggle-complete.ts | 2 +- .../actions/toggle-display-completed.ts | 2 +- .../computed/all-todos.ts | 2 +- .../computed/complete-todos.ts | 2 +- .../ecs/computed}/computed-database.ts | 7 +-- .../computed/incomplete-todos.ts | 2 +- .../computed/index.ts | 1 - .../computed/visible-todos.ts | 2 +- .../main/ecs/conformance/conformance.test.ts | 7 +++ .../main/ecs/conformance/implementation.ts | 16 ++++++ .../conformance/projection.ts | 10 ++-- .../src/features/main/ecs/core/archetypes.ts | 10 ++++ .../core}/core-database.ts | 11 ++-- .../indexes/by-complete.ts | 2 +- .../indexes}/index-database.ts | 4 +- .../index-database => ecs}/indexes/index.ts | 0 .../src/features/main/ecs/main-service.ts | 2 + .../services}/service-database.ts | 8 +-- .../transactions/create-bulk-todos.ts | 2 +- .../transactions/create-todo.ts | 3 +- .../transactions/delete-all-todos.ts | 2 +- .../transactions/delete-todo.ts | 2 +- .../transactions/drag-todo.ts | 2 +- .../transactions/index.ts | 0 .../transactions/order/index.ts | 0 .../transactions/order/next-order.ts | 2 +- .../transactions/order/normalize-order.ts | 2 +- .../order/select-ordered-todos.ts | 2 +- .../transactions/select-todo.ts | 2 +- .../transactions/toggle-complete.ts | 2 +- .../transactions/toggle-display-completed.ts | 2 +- .../transactions}/transaction-database.ts | 4 +- .../analytics-service/analytics-service.ts | 3 +- .../analytics-service/analytics.fake.ts | 23 --------- .../services/analytics-service/create-fake.ts | 19 +++++++ .../main/services/analytics-service/public.ts | 1 + .../computed-database/computed/state.ts | 33 ------------ .../conformance/conformance.test.ts | 11 ---- .../main-service/core-database/archetypes.ts | 7 --- .../main-service/core-database/components.ts | 19 ------- .../main-service/core-database/resources.ts | 17 ------- .../services/main-service/main-service.ts | 2 - .../name-generator-service/create-fake.ts | 12 +++++ .../name-generator.fake.ts | 14 ----- .../services/name-generator-service/public.ts | 1 + .../main/{data/state => spec}/append-todo.ts | 0 .../state => spec}/create-bulk-todos.cases.ts | 0 .../{data/state => spec}/create-bulk-todos.ts | 2 +- .../create-random-todo.cases.ts | 0 .../state => spec}/create-random-todo.ts | 2 +- .../{data/state => spec}/create-todo.cases.ts | 0 .../main/{data/state => spec}/create-todo.ts | 2 +- .../main/{data/state => spec}/create.ts | 2 +- .../state => spec}/delete-all-todos.cases.ts | 0 .../{data/state => spec}/delete-all-todos.ts | 4 +- .../{data/state => spec}/delete-todo.cases.ts | 0 .../main/{data/state => spec}/delete-todo.ts | 2 +- .../src/features/main/spec/public.ts | 3 ++ .../state => spec}/reorder-todo.cases.ts | 0 .../main/{data/state => spec}/reorder-todo.ts | 0 .../main/{data/state => spec}/samples.ts | 0 .../{data/state => spec}/select-todo.cases.ts | 0 .../main/{data/state => spec}/select-todo.ts | 0 .../src/features/main/spec/spec.test.ts | 6 +++ .../src/features/main/spec/spec.ts | 42 +++++++++++++++ .../main/{data/state => spec}/state.ts | 14 +++-- .../state => spec}/toggle-complete.cases.ts | 0 .../{data/state => spec}/toggle-complete.ts | 2 +- .../toggle-display-completed.cases.ts | 0 .../toggle-display-completed.ts | 2 +- .../main/{data/state => spec}/transforms.ts | 0 .../state => spec}/visible-todos.cases.ts | 0 .../{data/state => spec}/visible-todos.ts | 2 +- .../src/features/main/ui/todo-app/todo-app.ts | 2 +- .../src/features/main/ui/todo-element.ts | 2 +- .../main/ui/todo-row/todo-row-element.ts | 1 - .../ui/todo-row/todo-row-presentation.test.ts | 10 ++-- .../main/ui/todo-row/todo-row-presentation.ts | 10 ++-- .../features/main/ui/todo-row/todo-row.css.ts | 9 ---- packages/data-lit-todo/src/index.ts | 4 +- packages/data-lit-todo/tsconfig.build.json | 29 ----------- packages/data-lit-todo/tsconfig.data.json | 16 ++++++ packages/data-lit-todo/tsconfig.ecs.json | 16 ++++++ packages/data-lit-todo/tsconfig.spec.json | 17 +++++++ packages/data-lit-todo/tsconfig.test.json | 22 +++----- .../src/conformance/check-feature.ts | 1 + .../src/conformance/implementation.ts | 10 ++-- .../src/conformance/run-feature.ts | 18 ++++--- 199 files changed, 991 insertions(+), 478 deletions(-) rename packages/data-lit-todo/src/{ => app}/main.ts (89%) create mode 100644 packages/data-lit-todo/src/app/schema.ts rename packages/data-lit-todo/src/{features/main/services/main-service => app}/versioning/versions.test.ts (86%) rename packages/data-lit-todo/src/{features/main/services/main-service => app}/versioning/versions.ts (94%) create mode 100644 packages/data-lit-todo/src/features/assign/data/components/assignees.ts create mode 100644 packages/data-lit-todo/src/features/assign/data/components/index.ts create mode 100644 packages/data-lit-todo/src/features/assign/data/components/user.ts create mode 100644 packages/data-lit-todo/src/features/assign/data/entities/assigned-todo.ts create mode 100644 packages/data-lit-todo/src/features/assign/data/entities/index.ts create mode 100644 packages/data-lit-todo/src/features/assign/data/entities/user.ts delete mode 100644 packages/data-lit-todo/src/features/assign/data/user/user.ts create mode 100644 packages/data-lit-todo/src/features/assign/data/values/assignees/assignees.ts rename packages/data-lit-todo/src/features/{main/data => assign/data/values}/assignees/public.ts (100%) rename packages/data-lit-todo/src/features/{main/data => assign/data/values}/assignees/schema.ts (62%) rename packages/data-lit-todo/src/features/assign/data/{ => values}/assignment/assign.test.ts (100%) rename packages/data-lit-todo/src/features/assign/data/{ => values}/assignment/assign.ts (100%) rename packages/data-lit-todo/src/features/assign/data/{ => values}/assignment/assignment.ts (100%) rename packages/data-lit-todo/src/features/assign/data/{ => values}/assignment/public.ts (100%) rename packages/data-lit-todo/src/features/assign/data/{ => values}/assignment/unassign.test.ts (100%) rename packages/data-lit-todo/src/features/assign/data/{ => values}/assignment/unassign.ts (100%) create mode 100644 packages/data-lit-todo/src/features/assign/ecs/actions/action-database.ts create mode 100644 packages/data-lit-todo/src/features/assign/ecs/actions/add-user.ts create mode 100644 packages/data-lit-todo/src/features/assign/ecs/actions/assign-user.ts create mode 100644 packages/data-lit-todo/src/features/assign/ecs/actions/index.ts create mode 100644 packages/data-lit-todo/src/features/assign/ecs/actions/unassign-user.ts rename packages/data-lit-todo/src/features/assign/{services/main-service => ecs}/assign.test.ts (91%) rename packages/data-lit-todo/src/features/{main/services/main-service/computed-database => assign/ecs/computed}/computed-database.ts (77%) rename packages/data-lit-todo/src/features/assign/{services/main-service/computed-database => ecs}/computed/index.ts (100%) rename packages/data-lit-todo/src/features/assign/{services/main-service/computed-database => ecs}/computed/tasks-by-user.ts (68%) rename packages/data-lit-todo/src/features/assign/{services/main-service/computed-database => ecs}/computed/users.ts (80%) create mode 100644 packages/data-lit-todo/src/features/assign/ecs/conformance/conformance.test.ts create mode 100644 packages/data-lit-todo/src/features/assign/ecs/conformance/implementation.ts create mode 100644 packages/data-lit-todo/src/features/assign/ecs/conformance/projection.ts create mode 100644 packages/data-lit-todo/src/features/assign/ecs/core/archetypes.ts rename packages/data-lit-todo/src/features/assign/{services/main-service/core-database => ecs/core}/core-database.ts (62%) rename packages/data-lit-todo/src/features/assign/{services/main-service/index-database => ecs/indexes}/index-database.ts (79%) rename packages/data-lit-todo/src/features/assign/{services/main-service/index-database => ecs}/indexes/index.ts (100%) rename packages/data-lit-todo/src/features/assign/{services/main-service/index-database => ecs}/indexes/todos-by-assignee.ts (100%) rename packages/data-lit-todo/src/features/assign/{services/main-service/index-database => ecs}/indexes/users-by-name.ts (100%) create mode 100644 packages/data-lit-todo/src/features/assign/ecs/main-service.ts rename packages/data-lit-todo/src/features/assign/{services/main-service/transaction-database => ecs}/transactions/add-user.ts (88%) rename packages/data-lit-todo/src/features/assign/{services/main-service/transaction-database => ecs}/transactions/assign-user.ts (60%) rename packages/data-lit-todo/src/features/assign/{services/main-service/transaction-database => ecs}/transactions/index.ts (100%) rename packages/data-lit-todo/src/features/assign/{services/main-service/transaction-database => ecs/transactions}/transaction-database.ts (79%) create mode 100644 packages/data-lit-todo/src/features/assign/ecs/transactions/unassign-user.ts delete mode 100644 packages/data-lit-todo/src/features/assign/services/main-service/core-database/archetypes.ts delete mode 100644 packages/data-lit-todo/src/features/assign/services/main-service/core-database/components.ts delete mode 100644 packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transactions/unassign-user.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/add-user.cases.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/add-user.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/assign-user.cases.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/assign-user.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/create.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/public.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/samples.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/spec.test.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/spec.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/state.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/tasks-by-user.cases.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/tasks-by-user.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/transforms.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/unassign-user.cases.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/unassign-user.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/users.cases.ts create mode 100644 packages/data-lit-todo/src/features/assign/spec/users.ts create mode 100644 packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips-element.ts create mode 100644 packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips-presentation.test.ts create mode 100644 packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips-presentation.ts create mode 100644 packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips.css.ts create mode 100644 packages/data-lit-todo/src/features/assign/ui/assignee-chips/assignee-chips.ts delete mode 100644 packages/data-lit-todo/src/features/main/data/assignees/assignees.ts create mode 100644 packages/data-lit-todo/src/features/main/data/components/complete.ts create mode 100644 packages/data-lit-todo/src/features/main/data/components/drag-position.ts create mode 100644 packages/data-lit-todo/src/features/main/data/components/index.ts create mode 100644 packages/data-lit-todo/src/features/main/data/components/name.ts create mode 100644 packages/data-lit-todo/src/features/main/data/components/order.ts create mode 100644 packages/data-lit-todo/src/features/main/data/components/todo.ts delete mode 100644 packages/data-lit-todo/src/features/main/data/drag-position/drag-position.ts create mode 100644 packages/data-lit-todo/src/features/main/data/entities/index.ts create mode 100644 packages/data-lit-todo/src/features/main/data/entities/todo.ts delete mode 100644 packages/data-lit-todo/src/features/main/data/name/name.ts create mode 100644 packages/data-lit-todo/src/features/main/data/resources/display-completed.ts create mode 100644 packages/data-lit-todo/src/features/main/data/resources/index.ts create mode 100644 packages/data-lit-todo/src/features/main/data/resources/selected-todo.ts delete mode 100644 packages/data-lit-todo/src/features/main/data/state/public.ts delete mode 100644 packages/data-lit-todo/src/features/main/data/state/spec.test.ts delete mode 100644 packages/data-lit-todo/src/features/main/data/state/spec.ts delete mode 100644 packages/data-lit-todo/src/features/main/data/todo/is.ts delete mode 100644 packages/data-lit-todo/src/features/main/data/todo/public.ts delete mode 100644 packages/data-lit-todo/src/features/main/data/todo/todo.ts create mode 100644 packages/data-lit-todo/src/features/main/data/values/drag-position/drag-position.ts rename packages/data-lit-todo/src/features/main/data/{ => values}/drag-position/public.ts (100%) rename packages/data-lit-todo/src/features/main/data/{ => values}/drag-position/schema.ts (60%) create mode 100644 packages/data-lit-todo/src/features/main/data/values/name/name.ts rename packages/data-lit-todo/src/features/main/data/{ => values}/name/public.ts (100%) rename packages/data-lit-todo/src/features/main/data/{ => values}/name/schema.ts (51%) rename packages/data-lit-todo/src/features/main/{services/main-service/action-database => ecs/actions}/action-database.ts (78%) rename packages/data-lit-todo/src/features/main/{services/main-service/action-database => ecs}/actions/create-bulk-todos.ts (76%) rename packages/data-lit-todo/src/features/main/{services/main-service/action-database => ecs}/actions/create-random-todo.ts (92%) rename packages/data-lit-todo/src/features/main/{services/main-service/action-database => ecs}/actions/create-todo.ts (77%) rename packages/data-lit-todo/src/features/main/{services/main-service/action-database => ecs}/actions/delete-all-todos.ts (70%) rename packages/data-lit-todo/src/features/main/{services/main-service/action-database => ecs}/actions/delete-todo.ts (75%) rename packages/data-lit-todo/src/features/main/{services/main-service/action-database => ecs}/actions/drag-todo.test.ts (96%) rename packages/data-lit-todo/src/features/main/{services/main-service/action-database => ecs}/actions/drag-todo.ts (94%) rename packages/data-lit-todo/src/features/main/{services/main-service/action-database => ecs}/actions/index.ts (92%) rename packages/data-lit-todo/src/features/main/{services/main-service/action-database => ecs}/actions/reorder-todo.ts (89%) create mode 100644 packages/data-lit-todo/src/features/main/ecs/actions/select-todo.ts rename packages/data-lit-todo/src/features/main/{services/main-service/action-database => ecs}/actions/toggle-complete.ts (76%) rename packages/data-lit-todo/src/features/main/{services/main-service/action-database => ecs}/actions/toggle-display-completed.ts (72%) rename packages/data-lit-todo/src/features/main/{services/main-service/computed-database => ecs}/computed/all-todos.ts (75%) rename packages/data-lit-todo/src/features/main/{services/main-service/computed-database => ecs}/computed/complete-todos.ts (78%) rename packages/data-lit-todo/src/features/{assign/services/main-service/computed-database => main/ecs/computed}/computed-database.ts (56%) rename packages/data-lit-todo/src/features/main/{services/main-service/computed-database => ecs}/computed/incomplete-todos.ts (78%) rename packages/data-lit-todo/src/features/main/{services/main-service/computed-database => ecs}/computed/index.ts (87%) rename packages/data-lit-todo/src/features/main/{services/main-service/computed-database => ecs}/computed/visible-todos.ts (91%) create mode 100644 packages/data-lit-todo/src/features/main/ecs/conformance/conformance.test.ts create mode 100644 packages/data-lit-todo/src/features/main/ecs/conformance/implementation.ts rename packages/data-lit-todo/src/features/main/{services/main-service => ecs}/conformance/projection.ts (88%) create mode 100644 packages/data-lit-todo/src/features/main/ecs/core/archetypes.ts rename packages/data-lit-todo/src/features/main/{services/main-service/core-database => ecs/core}/core-database.ts (68%) rename packages/data-lit-todo/src/features/main/{services/main-service/index-database => ecs}/indexes/by-complete.ts (65%) rename packages/data-lit-todo/src/features/main/{services/main-service/index-database => ecs/indexes}/index-database.ts (79%) rename packages/data-lit-todo/src/features/main/{services/main-service/index-database => ecs}/indexes/index.ts (100%) create mode 100644 packages/data-lit-todo/src/features/main/ecs/main-service.ts rename packages/data-lit-todo/src/features/main/{services/main-service/service-database => ecs/services}/service-database.ts (77%) rename packages/data-lit-todo/src/features/main/{services/main-service/transaction-database => ecs}/transactions/create-bulk-todos.ts (85%) rename packages/data-lit-todo/src/features/main/{services/main-service/transaction-database => ecs}/transactions/create-todo.ts (80%) rename packages/data-lit-todo/src/features/main/{services/main-service/transaction-database => ecs}/transactions/delete-all-todos.ts (81%) rename packages/data-lit-todo/src/features/main/{services/main-service/transaction-database => ecs}/transactions/delete-todo.ts (77%) rename packages/data-lit-todo/src/features/main/{services/main-service/transaction-database => ecs}/transactions/drag-todo.ts (97%) rename packages/data-lit-todo/src/features/main/{services/main-service/transaction-database => ecs}/transactions/index.ts (100%) rename packages/data-lit-todo/src/features/main/{services/main-service/transaction-database => ecs}/transactions/order/index.ts (100%) rename packages/data-lit-todo/src/features/main/{services/main-service/transaction-database => ecs}/transactions/order/next-order.ts (85%) rename packages/data-lit-todo/src/features/main/{services/main-service/transaction-database => ecs}/transactions/order/normalize-order.ts (88%) rename packages/data-lit-todo/src/features/main/{services/main-service/transaction-database => ecs}/transactions/order/select-ordered-todos.ts (82%) rename packages/data-lit-todo/src/features/main/{services/main-service/transaction-database => ecs}/transactions/select-todo.ts (84%) rename packages/data-lit-todo/src/features/main/{services/main-service/transaction-database => ecs}/transactions/toggle-complete.ts (79%) rename packages/data-lit-todo/src/features/main/{services/main-service/transaction-database => ecs}/transactions/toggle-display-completed.ts (72%) rename packages/data-lit-todo/src/features/main/{services/main-service/transaction-database => ecs/transactions}/transaction-database.ts (79%) delete mode 100644 packages/data-lit-todo/src/features/main/services/analytics-service/analytics.fake.ts create mode 100644 packages/data-lit-todo/src/features/main/services/analytics-service/create-fake.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/state.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/conformance/conformance.test.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/core-database/archetypes.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/core-database/components.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/core-database/resources.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/main-service.ts create mode 100644 packages/data-lit-todo/src/features/main/services/name-generator-service/create-fake.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/name-generator-service/name-generator.fake.ts rename packages/data-lit-todo/src/features/main/{data/state => spec}/append-todo.ts (100%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/create-bulk-todos.cases.ts (100%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/create-bulk-todos.ts (92%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/create-random-todo.cases.ts (100%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/create-random-todo.ts (94%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/create-todo.cases.ts (100%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/create-todo.ts (91%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/create.ts (90%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/delete-all-todos.cases.ts (100%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/delete-all-todos.ts (81%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/delete-todo.cases.ts (100%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/delete-todo.ts (91%) create mode 100644 packages/data-lit-todo/src/features/main/spec/public.ts rename packages/data-lit-todo/src/features/main/{data/state => spec}/reorder-todo.cases.ts (100%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/reorder-todo.ts (100%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/samples.ts (100%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/select-todo.cases.ts (100%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/select-todo.ts (100%) create mode 100644 packages/data-lit-todo/src/features/main/spec/spec.test.ts create mode 100644 packages/data-lit-todo/src/features/main/spec/spec.ts rename packages/data-lit-todo/src/features/main/{data/state => spec}/state.ts (57%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/toggle-complete.cases.ts (100%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/toggle-complete.ts (91%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/toggle-display-completed.cases.ts (100%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/toggle-display-completed.ts (89%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/transforms.ts (100%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/visible-todos.cases.ts (100%) rename packages/data-lit-todo/src/features/main/{data/state => spec}/visible-todos.ts (91%) delete mode 100644 packages/data-lit-todo/tsconfig.build.json create mode 100644 packages/data-lit-todo/tsconfig.data.json create mode 100644 packages/data-lit-todo/tsconfig.ecs.json create mode 100644 packages/data-lit-todo/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/main.ts b/packages/data-lit-todo/src/app/main.ts similarity index 89% rename from packages/data-lit-todo/src/main.ts rename to packages/data-lit-todo/src/app/main.ts index 418cf7218..f2f6414f0 100644 --- a/packages/data-lit-todo/src/main.ts +++ b/packages/data-lit-todo/src/app/main.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { html, render } from "lit"; import { Database, createVersionUpgrader, createStoragePersistenceService } from "@adobe/data/ecs"; -import { MainService } from "./features/main/services/main-service/main-service.js"; -import { versions } from "./features/main/services/main-service/versioning/versions.js"; -import { TodoApp } from "./features/main/ui/todo-app/todo-app.js"; +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"; @@ -14,7 +14,7 @@ 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(MainService.plugin, { + const service = Database.create(AppSchema.plugin, { versioning: createVersionUpgrader(versions), }); 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-todo/src/features/main/data/assignees/public.ts b/packages/data-lit-todo/src/features/assign/data/values/assignees/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/assign/data/values/assignees/public.ts diff --git a/packages/data-lit-todo/src/features/main/data/assignees/schema.ts b/packages/data-lit-todo/src/features/assign/data/values/assignees/schema.ts similarity index 62% rename from packages/data-lit-todo/src/features/main/data/assignees/schema.ts rename to packages/data-lit-todo/src/features/assign/data/values/assignees/schema.ts index cb65bfee2..f11a63950 100644 --- a/packages/data-lit-todo/src/features/main/data/assignees/schema.ts +++ b/packages/data-lit-todo/src/features/assign/data/values/assignees/schema.ts @@ -1,5 +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/main/services/main-service/computed-database/computed-database.ts b/packages/data-lit-todo/src/features/assign/ecs/computed/computed-database.ts similarity index 77% rename from packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed-database.ts rename to packages/data-lit-todo/src/features/assign/ecs/computed/computed-database.ts index d66e1ad68..268bf3446 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed-database.ts +++ b/packages/data-lit-todo/src/features/assign/ecs/computed/computed-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; +import * as computed from "./index.js"; const computedDatabasePlugin = Database.Plugin.create({ extends: TransactionDatabase.plugin, 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/services/main-service/computed-database/computed/tasks-by-user.ts b/packages/data-lit-todo/src/features/assign/ecs/computed/tasks-by-user.ts similarity index 68% rename from packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed/tasks-by-user.ts rename to packages/data-lit-todo/src/features/assign/ecs/computed/tasks-by-user.ts index d5410456a..bc831b702 100644 --- 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/ecs/computed/tasks-by-user.ts @@ -2,9 +2,9 @@ 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"; +import type { IndexDatabase } from "../indexes/index-database.js"; -// Each user with the names of the tasks assigned to them — the user→todos +// 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. @@ -16,12 +16,13 @@ export const tasksByUser = cached((db: IndexDatabase) => for (const uid of read.select(db.archetypes.User.components)) { const u = read.read(uid); if (!u || u.name === undefined) continue; - const tasks: string[] = []; + 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) tasks.push(todo.name); + if (todo?.name !== undefined && todo.order !== undefined) tasks.push({ name: todo.name, order: todo.order }); } - result.push({ user: u.name, tasks }); + 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/services/main-service/computed-database/computed/users.ts b/packages/data-lit-todo/src/features/assign/ecs/computed/users.ts similarity index 80% rename from packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed/users.ts rename to packages/data-lit-todo/src/features/assign/ecs/computed/users.ts index e1ac35635..6fec261c7 100644 --- a/packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed/users.ts +++ b/packages/data-lit-todo/src/features/assign/ecs/computed/users.ts @@ -2,8 +2,8 @@ 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"; +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). @@ -15,7 +15,7 @@ export const users = cached((db: IndexDatabase) => 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 }); + 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..f95ae05eb --- /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 and `dragPosition` +// slot), 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, dragPosition: null })); + } + 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..7fac2861a --- /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 (main's tag and drag slot included), +// 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, "dragPosition"], +}); 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/ecs/core/core-database.ts similarity index 62% rename from packages/data-lit-todo/src/features/assign/services/main-service/core-database/core-database.ts rename to packages/data-lit-todo/src/features/assign/ecs/core/core-database.ts index 8fbe15f83..c9b38bf50 100644 --- a/packages/data-lit-todo/src/features/assign/services/main-service/core-database/core-database.ts +++ b/packages/data-lit-todo/src/features/assign/ecs/core/core-database.ts @@ -1,13 +1,15 @@ // © 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 { 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. `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. +// 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, }); 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/ecs/indexes/index-database.ts similarity index 79% rename from packages/data-lit-todo/src/features/assign/services/main-service/index-database/index-database.ts rename to packages/data-lit-todo/src/features/assign/ecs/indexes/index-database.ts index d23b43f05..3af2fc81d 100644 --- a/packages/data-lit-todo/src/features/assign/services/main-service/index-database/index-database.ts +++ b/packages/data-lit-todo/src/features/assign/ecs/indexes/index-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { CoreDatabase } from "../core/core-database.js"; +import * as indexes from "./index.js"; const indexDatabasePlugin = Database.Plugin.create({ extends: CoreDatabase.plugin, 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/services/main-service/transaction-database/transactions/add-user.ts b/packages/data-lit-todo/src/features/assign/ecs/transactions/add-user.ts similarity index 88% rename from packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transactions/add-user.ts rename to packages/data-lit-todo/src/features/assign/ecs/transactions/add-user.ts index d2cc32d2b..410b85307 100644 --- 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/ecs/transactions/add-user.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { IndexDatabase } from "../../index-database/index-database.js"; +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). 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/ecs/transactions/assign-user.ts similarity index 60% rename from packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transactions/assign-user.ts rename to packages/data-lit-todo/src/features/assign/ecs/transactions/assign-user.ts index dccc8eb31..2a1e7ffcf 100644 --- 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/ecs/transactions/assign-user.ts @@ -1,7 +1,7 @@ // © 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"; +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 @@ -10,6 +10,7 @@ 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) }); + 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/services/main-service/transaction-database/transaction-database.ts b/packages/data-lit-todo/src/features/assign/ecs/transactions/transaction-database.ts similarity index 79% rename from packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transaction-database.ts rename to packages/data-lit-todo/src/features/assign/ecs/transactions/transaction-database.ts index ce4267576..b81bb52ac 100644 --- a/packages/data-lit-todo/src/features/assign/services/main-service/transaction-database/transaction-database.ts +++ b/packages/data-lit-todo/src/features/assign/ecs/transactions/transaction-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { IndexDatabase } from "../indexes/index-database.js"; +import * as transactions from "./index.js"; const transactionDatabasePlugin = Database.Plugin.create({ extends: IndexDatabase.plugin, 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/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/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/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/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/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/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/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-todo/src/features/main/data/drag-position/public.ts b/packages/data-lit-todo/src/features/main/data/values/drag-position/public.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/drag-position/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/drag-position/schema.ts b/packages/data-lit-todo/src/features/main/data/values/drag-position/schema.ts similarity index 60% rename from packages/data-lit-todo/src/features/main/data/drag-position/schema.ts rename to packages/data-lit-todo/src/features/main/data/values/drag-position/schema.ts index 146183915..7df08b179 100644 --- a/packages/data-lit-todo/src/features/main/data/drag-position/schema.ts +++ b/packages/data-lit-todo/src/features/main/data/values/drag-position/schema.ts @@ -1,6 +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/name/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/name/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/name/schema.ts b/packages/data-lit-todo/src/features/main/data/values/name/schema.ts similarity index 51% rename from packages/data-lit-todo/src/features/main/data/name/schema.ts rename to packages/data-lit-todo/src/features/main/data/values/name/schema.ts index d385085a8..b1eb5fd9d 100644 --- a/packages/data-lit-todo/src/features/main/data/name/schema.ts +++ b/packages/data-lit-todo/src/features/main/data/values/name/schema.ts @@ -1,4 +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/services/main-service/action-database/action-database.ts b/packages/data-lit-todo/src/features/main/ecs/actions/action-database.ts similarity index 78% rename from packages/data-lit-todo/src/features/main/services/main-service/action-database/action-database.ts rename to packages/data-lit-todo/src/features/main/ecs/actions/action-database.ts index 84dc6b4ac..6d8034b33 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/action-database.ts +++ b/packages/data-lit-todo/src/features/main/ecs/actions/action-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { ServiceDatabase } from "../services/service-database.js"; +import * as actions from "./index.js"; const actionDatabasePlugin = Database.Plugin.create({ extends: ServiceDatabase.plugin, 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/ecs/actions/create-bulk-todos.ts similarity index 76% rename from packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/create-bulk-todos.ts rename to packages/data-lit-todo/src/features/main/ecs/actions/create-bulk-todos.ts index 1fe87f842..266735677 100644 --- 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/ecs/actions/create-bulk-todos.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; +import type { ServiceDatabase } from "../services/service-database.js"; export const createBulkTodos = ( db: ServiceDatabase, 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/ecs/actions/create-random-todo.ts similarity index 92% rename from packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/create-random-todo.ts rename to packages/data-lit-todo/src/features/main/ecs/actions/create-random-todo.ts index c5abe52ee..7fc46c9f9 100644 --- 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/ecs/actions/create-random-todo.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; +import type { ServiceDatabase } from "../services/service-database.js"; /** * Efficient ecs counterpart to the pure `State.createRandomTodo` spec (which 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/ecs/actions/create-todo.ts similarity index 77% rename from packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/create-todo.ts rename to packages/data-lit-todo/src/features/main/ecs/actions/create-todo.ts index 7d2ee364d..c98ec6347 100644 --- 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/ecs/actions/create-todo.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; +import type { ServiceDatabase } from "../services/service-database.js"; export const createTodo = ( db: ServiceDatabase, 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/ecs/actions/delete-all-todos.ts similarity index 70% rename from packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/delete-all-todos.ts rename to packages/data-lit-todo/src/features/main/ecs/actions/delete-all-todos.ts index 9c4c5afa8..a5739ad1b 100644 --- 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/ecs/actions/delete-all-todos.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; +import type { ServiceDatabase } from "../services/service-database.js"; export const deleteAllTodos = (db: ServiceDatabase) => { db.services.analytics.allTodosCleared(); 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/ecs/actions/delete-todo.ts similarity index 75% rename from packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/delete-todo.ts rename to packages/data-lit-todo/src/features/main/ecs/actions/delete-todo.ts index 07a4da8e8..b7063a707 100644 --- 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/ecs/actions/delete-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"; export const deleteTodo = (db: ServiceDatabase, { id }: { id: Entity }) => { db.services.analytics.todoDeleted(); 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/services/main-service/action-database/actions/drag-todo.ts b/packages/data-lit-todo/src/features/main/ecs/actions/drag-todo.ts similarity index 94% rename from packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/drag-todo.ts rename to packages/data-lit-todo/src/features/main/ecs/actions/drag-todo.ts index 0d47ab5d0..c85d5534c 100644 --- 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/ecs/actions/drag-todo.ts @@ -1,7 +1,7 @@ // © 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"; +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 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/ecs/actions/index.ts similarity index 92% rename from packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/index.ts rename to packages/data-lit-todo/src/features/main/ecs/actions/index.ts index e4787154e..678930fc5 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/index.ts +++ b/packages/data-lit-todo/src/features/main/ecs/actions/index.ts @@ -8,3 +8,4 @@ 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/services/main-service/action-database/actions/toggle-complete.ts b/packages/data-lit-todo/src/features/main/ecs/actions/toggle-complete.ts similarity index 76% rename from packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/toggle-complete.ts rename to packages/data-lit-todo/src/features/main/ecs/actions/toggle-complete.ts index 1c28b6240..da7514da5 100644 --- 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/ecs/actions/toggle-complete.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"; export const toggleComplete = (db: ServiceDatabase, { id }: { id: Entity }) => { db.services.analytics.todoToggled(); 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/ecs/actions/toggle-display-completed.ts similarity index 72% rename from packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/toggle-display-completed.ts rename to packages/data-lit-todo/src/features/main/ecs/actions/toggle-display-completed.ts index 2207e7060..42ec37566 100644 --- 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/ecs/actions/toggle-display-completed.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; +import type { ServiceDatabase } from "../services/service-database.js"; export const toggleDisplayCompleted = (db: ServiceDatabase) => { db.services.analytics.displayCompletedToggled(); 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/assign/services/main-service/computed-database/computed-database.ts b/packages/data-lit-todo/src/features/main/ecs/computed/computed-database.ts similarity index 56% rename from packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed-database.ts rename to packages/data-lit-todo/src/features/main/ecs/computed/computed-database.ts index a3200bac1..268bf3446 100644 --- a/packages/data-lit-todo/src/features/assign/services/main-service/computed-database/computed-database.ts +++ b/packages/data-lit-todo/src/features/main/ecs/computed/computed-database.ts @@ -1,11 +1,8 @@ // © 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"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; +import * as computed from "./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, 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/services/main-service/computed-database/computed/index.ts b/packages/data-lit-todo/src/features/main/ecs/computed/index.ts similarity index 87% rename from packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/index.ts rename to packages/data-lit-todo/src/features/main/ecs/computed/index.ts index 68865d203..41dbe9d68 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/index.ts +++ b/packages/data-lit-todo/src/features/main/ecs/computed/index.ts @@ -1,5 +1,4 @@ // © 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"; 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/ecs/computed/visible-todos.ts similarity index 91% rename from packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/visible-todos.ts rename to packages/data-lit-todo/src/features/main/ecs/computed/visible-todos.ts index cdc534db1..bd384d019 100644 --- 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/ecs/computed/visible-todos.ts @@ -1,7 +1,7 @@ // © 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 type { IndexDatabase } from "../indexes/index-database.js"; import { allTodos } from "./all-todos.js"; import { incompleteTodos } from "./incomplete-todos.js"; 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/services/main-service/conformance/projection.ts b/packages/data-lit-todo/src/features/main/ecs/conformance/projection.ts similarity index 88% rename from packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts rename to packages/data-lit-todo/src/features/main/ecs/conformance/projection.ts index 98e0b7163..6cfb10b6f 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts +++ b/packages/data-lit-todo/src/features/main/ecs/conformance/projection.ts @@ -1,8 +1,8 @@ // © 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"; +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 @@ -20,8 +20,7 @@ const toData = (store: CoreDatabase.Store, entity: Entity): Todo => { // 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 +// the implementation-only `dragPosition` slot 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 @@ -50,7 +49,6 @@ export const projection = { complete: todo.complete, order: todo.order, dragPosition: null, - assignees: [], }), ]), ); 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..0e76a4a44 --- /dev/null +++ b/packages/data-lit-todo/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 { Todo } from "../../data/entities/todo.js"; + +// Archetype packing: the spec `Todo` entity plus the implementation-only `todo` tag +// and live `dragPosition` slot. +export const archetypes = Database.archetypes(components, { + Todo: ["todo", ...Todo, "dragPosition"], +}); 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/ecs/core/core-database.ts similarity index 68% rename from packages/data-lit-todo/src/features/main/services/main-service/core-database/core-database.ts rename to packages/data-lit-todo/src/features/main/ecs/core/core-database.ts index d996f71fd..8103a653d 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/core-database/core-database.ts +++ b/packages/data-lit-todo/src/features/main/ecs/core/core-database.ts @@ -1,17 +1,12 @@ // © 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 { components } from "../../data/components/index.js"; +import { resources } from "../../data/resources/index.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. +// The core schema: the feature's data/ components and resources, packed into archetypes. const coreDatabasePlugin = Database.Plugin.create({ - imports: AssignSchema.plugin, components, resources, archetypes, 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/ecs/indexes/by-complete.ts similarity index 65% rename from packages/data-lit-todo/src/features/main/services/main-service/index-database/indexes/by-complete.ts rename to packages/data-lit-todo/src/features/main/ecs/indexes/by-complete.ts index ebdb24e52..dc4fd74b9 100644 --- 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/ecs/indexes/by-complete.ts @@ -1,3 +1,3 @@ // © 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"; export const byComplete = { key: "complete" } as const satisfies CoreDatabase.Index; 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/ecs/indexes/index-database.ts similarity index 79% rename from packages/data-lit-todo/src/features/main/services/main-service/index-database/index-database.ts rename to packages/data-lit-todo/src/features/main/ecs/indexes/index-database.ts index 04b996c61..3002e615c 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/index-database/index-database.ts +++ b/packages/data-lit-todo/src/features/main/ecs/indexes/index-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { CoreDatabase } from "../core/core-database.js"; +import * as indexes from "./index.js"; const indexDatabasePlugin = Database.Plugin.create({ extends: CoreDatabase.plugin, 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/services/main-service/service-database/service-database.ts b/packages/data-lit-todo/src/features/main/ecs/services/service-database.ts similarity index 77% rename from packages/data-lit-todo/src/features/main/services/main-service/service-database/service-database.ts rename to packages/data-lit-todo/src/features/main/ecs/services/service-database.ts index 9a5c03909..e620d8bb0 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/service-database/service-database.ts +++ b/packages/data-lit-todo/src/features/main/ecs/services/service-database.ts @@ -1,10 +1,10 @@ // © 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"; +import { ComputedDatabase } from "../computed/computed-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 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/ecs/transactions/create-bulk-todos.ts similarity index 85% rename from packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/create-bulk-todos.ts rename to packages/data-lit-todo/src/features/main/ecs/transactions/create-bulk-todos.ts index 91597a7e4..3d9ecea75 100644 --- 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/ecs/transactions/create-bulk-todos.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 { createTodo } from "./create-todo.js"; export const createBulkTodos = ( 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/ecs/transactions/create-todo.ts similarity index 80% rename from packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/create-todo.ts rename to packages/data-lit-todo/src/features/main/ecs/transactions/create-todo.ts index f83288652..e208b08ca 100644 --- 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/ecs/transactions/create-todo.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 { nextOrder } from "./order/index.js"; export const createTodo = ( @@ -12,5 +12,4 @@ export const createTodo = ( 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/ecs/transactions/delete-all-todos.ts similarity index 81% rename from packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/delete-all-todos.ts rename to packages/data-lit-todo/src/features/main/ecs/transactions/delete-all-todos.ts index 36fe34708..98686ae10 100644 --- 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/ecs/transactions/delete-all-todos.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"; export const deleteAllTodos = (t: CoreDatabase.Store) => { for (const archetype of t.queryArchetypes(t.archetypes.Todo.components)) { 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/ecs/transactions/delete-todo.ts similarity index 77% rename from packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/delete-todo.ts rename to packages/data-lit-todo/src/features/main/ecs/transactions/delete-todo.ts index ae375c230..fbb3e02a9 100644 --- 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/ecs/transactions/delete-todo.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"; export const deleteTodo = (t: CoreDatabase.Store, { id }: { id: Entity }) => { const todo = t.read(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/ecs/transactions/drag-todo.ts similarity index 97% rename from packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/drag-todo.ts rename to packages/data-lit-todo/src/features/main/ecs/transactions/drag-todo.ts index f2c4aafa6..8da229e2b 100644 --- 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/ecs/transactions/drag-todo.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"; import { normalizeOrder, selectOrderedTodos } from "./order/index.js"; // Declared, not exported (one public export per file — the transaction). A 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/services/main-service/transaction-database/transactions/select-todo.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/select-todo.ts similarity index 84% rename from packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/select-todo.ts rename to packages/data-lit-todo/src/features/main/ecs/transactions/select-todo.ts index bc17c21a9..d864bdcb3 100644 --- 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/ecs/transactions/select-todo.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"; // 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. 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/ecs/transactions/toggle-complete.ts similarity index 79% rename from packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/toggle-complete.ts rename to packages/data-lit-todo/src/features/main/ecs/transactions/toggle-complete.ts index 704bcbe0b..e3bc011a0 100644 --- 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/ecs/transactions/toggle-complete.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"; export const toggleComplete = (t: CoreDatabase.Store, { id }: { id: Entity }) => { const todo = t.read(id); 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/ecs/transactions/toggle-display-completed.ts similarity index 72% rename from packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/toggle-display-completed.ts rename to packages/data-lit-todo/src/features/main/ecs/transactions/toggle-display-completed.ts index de7859c88..f367e278a 100644 --- 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/ecs/transactions/toggle-display-completed.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"; export const toggleDisplayCompleted = (t: CoreDatabase.Store) => { t.resources.displayCompleted = !t.resources.displayCompleted; 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/ecs/transactions/transaction-database.ts similarity index 79% rename from packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transaction-database.ts rename to packages/data-lit-todo/src/features/main/ecs/transactions/transaction-database.ts index d3c8c0d6e..b9ce84715 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transaction-database.ts +++ b/packages/data-lit-todo/src/features/main/ecs/transactions/transaction-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { IndexDatabase } from "../indexes/index-database.js"; +import * as transactions from "./index.js"; const transactionDatabasePlugin = Database.Plugin.create({ extends: IndexDatabase.plugin, 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/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/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/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/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/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/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/data/state/create-bulk-todos.ts b/packages/data-lit-todo/src/features/main/spec/create-bulk-todos.ts similarity index 92% rename from packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts rename to packages/data-lit-todo/src/features/main/spec/create-bulk-todos.ts index a02f40b0a..1a72b554a 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts +++ b/packages/data-lit-todo/src/features/main/spec/create-bulk-todos.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Services } from "../../services/services.js"; +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 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/data/state/create-random-todo.ts b/packages/data-lit-todo/src/features/main/spec/create-random-todo.ts similarity index 94% rename from packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts rename to packages/data-lit-todo/src/features/main/spec/create-random-todo.ts index 357a7583c..844b7f54e 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts +++ b/packages/data-lit-todo/src/features/main/spec/create-random-todo.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Services } from "../../services/services.js"; +import type { Services } from "../services/services.js"; import type { State } from "./state.js"; import { appendTodo } from "./append-todo.js"; /** 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/data/state/create-todo.ts b/packages/data-lit-todo/src/features/main/spec/create-todo.ts similarity index 91% rename from packages/data-lit-todo/src/features/main/data/state/create-todo.ts rename to packages/data-lit-todo/src/features/main/spec/create-todo.ts index 4c7c698ff..ac4df34be 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create-todo.ts +++ b/packages/data-lit-todo/src/features/main/spec/create-todo.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Services } from "../../services/services.js"; +import type { Services } from "../services/services.js"; import type { State } from "./state.js"; import { appendTodo } from "./append-todo.js"; diff --git a/packages/data-lit-todo/src/features/main/data/state/create.ts b/packages/data-lit-todo/src/features/main/spec/create.ts similarity index 90% rename from packages/data-lit-todo/src/features/main/data/state/create.ts rename to packages/data-lit-todo/src/features/main/spec/create.ts index b9a4b82cc..fd9db40da 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create.ts +++ b/packages/data-lit-todo/src/features/main/spec/create.ts @@ -1,7 +1,7 @@ // © 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"; +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 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/data/state/delete-all-todos.ts b/packages/data-lit-todo/src/features/main/spec/delete-all-todos.ts similarity index 81% rename from packages/data-lit-todo/src/features/main/data/state/delete-all-todos.ts rename to packages/data-lit-todo/src/features/main/spec/delete-all-todos.ts index 6e20df485..ef37a33fb 100644 --- a/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.ts +++ b/packages/data-lit-todo/src/features/main/spec/delete-all-todos.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Services } from "../../services/services.js"; +import type { Services } from "../services/services.js"; import type { State } from "./state.js"; -import type { Todo } from "../todo/todo.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`. 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/data/state/delete-todo.ts b/packages/data-lit-todo/src/features/main/spec/delete-todo.ts similarity index 91% rename from packages/data-lit-todo/src/features/main/data/state/delete-todo.ts rename to packages/data-lit-todo/src/features/main/spec/delete-todo.ts index a13b175e6..1cbf6fa1f 100644 --- a/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts +++ b/packages/data-lit-todo/src/features/main/spec/delete-todo.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Services } from "../../services/services.js"; +import type { Services } from "../services/services.js"; import type { State } from "./state.js"; // Reads the entities, writes the entities — an `{ entities }` patch — dropping the 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/data/state/state.ts b/packages/data-lit-todo/src/features/main/spec/state.ts similarity index 57% rename from packages/data-lit-todo/src/features/main/data/state/state.ts rename to packages/data-lit-todo/src/features/main/spec/state.ts index b1c00c9d3..93cb46ed9 100644 --- a/packages/data-lit-todo/src/features/main/data/state/state.ts +++ b/packages/data-lit-todo/src/features/main/spec/state.ts @@ -1,9 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Entity } from "@adobe/data/ecs"; -import type { Todo } from "../todo/todo.js"; +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 full persistent application state as one immutable object — the -// specification the ECS implementation is verified against. `entities` holds every +// 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 @@ -16,4 +20,8 @@ export type State = { 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/data/state/toggle-complete.ts b/packages/data-lit-todo/src/features/main/spec/toggle-complete.ts similarity index 91% rename from packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts rename to packages/data-lit-todo/src/features/main/spec/toggle-complete.ts index 07c5d9e90..d9326532a 100644 --- a/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts +++ b/packages/data-lit-todo/src/features/main/spec/toggle-complete.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Services } from "../../services/services.js"; +import type { Services } from "../services/services.js"; import type { State } from "./state.js"; // Reads the entities, writes the entities — an `{ entities }` patch — flipping the 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/data/state/toggle-display-completed.ts b/packages/data-lit-todo/src/features/main/spec/toggle-display-completed.ts similarity index 89% rename from packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts rename to packages/data-lit-todo/src/features/main/spec/toggle-display-completed.ts index a670687c5..deb900670 100644 --- a/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts +++ b/packages/data-lit-todo/src/features/main/spec/toggle-display-completed.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Services } from "../../services/services.js"; +import type { Services } from "../services/services.js"; import type { State } from "./state.js"; // Reads `displayCompleted`, writes `displayCompleted` — a `{ displayCompleted }` diff --git a/packages/data-lit-todo/src/features/main/data/state/transforms.ts b/packages/data-lit-todo/src/features/main/spec/transforms.ts similarity index 100% rename from packages/data-lit-todo/src/features/main/data/state/transforms.ts rename to packages/data-lit-todo/src/features/main/spec/transforms.ts 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/data/state/visible-todos.ts b/packages/data-lit-todo/src/features/main/spec/visible-todos.ts similarity index 91% rename from packages/data-lit-todo/src/features/main/data/state/visible-todos.ts rename to packages/data-lit-todo/src/features/main/spec/visible-todos.ts index 7bf0c0029..b22c7950f 100644 --- a/packages/data-lit-todo/src/features/main/data/state/visible-todos.ts +++ b/packages/data-lit-todo/src/features/main/spec/visible-todos.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; -import type { Todo } from "../todo/todo.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. 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 })} unknown; // 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 (and, for a spec - // op with no action, its transactions) are the ops under test. + // The assembled feature plugin (`MainService.plugin`): its actions are the ops under test. readonly plugin: Database.Plugin; + // Spec transitions a system dispatches straight to a same-named transaction (a tick + // step, not a user action). These conform against the transaction; every other + // transition must have a same-named action. + readonly transactionOps?: readonly string[]; // 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; @@ -49,9 +52,10 @@ export const implementation = < const H extends readonly Extract[] = readonly [], >( spec: Spec, - impl: Omit>, "spec" | "projection" | "hydrate" | "computedPlugin"> & { + impl: Omit>, "spec" | "projection" | "hydrate" | "computedPlugin" | "transactionOps"> & { readonly projection: P; readonly hydrate?: H; + readonly transactionOps?: readonly Extract[]; } & (HasDerivations extends true ? { readonly computedPlugin: Database.Plugin } : { readonly computedPlugin?: Database.Plugin }) & HydrateExact>>, ): Implementation> => diff --git a/packages/data-testing/src/conformance/run-feature.ts b/packages/data-testing/src/conformance/run-feature.ts index 915d02a20..33767366d 100644 --- a/packages/data-testing/src/conformance/run-feature.ts +++ b/packages/data-testing/src/conformance/run-feature.ts @@ -22,9 +22,8 @@ export interface Projection { // `computedPlugin.computed`) and constructs the stores/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 transition -// with no action but a same-named transaction (a system-dispatched step) conforms -// against that transaction instead. A spec op with no implementation at all is a -// named failure. It also round-trips `State.samples` through the projection. +// named in `transactionOps` (a system-dispatched step) conforms against its +// same-named transaction instead. A spec op with no implementation is a named failure. It also round-trips `State.samples` through the projection. // // A feature that needs ambient per-case context (a user-scoped `userId`) uses the // lower-level `runTransactions`/`runActions`/`runComputeds` directly instead. @@ -44,6 +43,8 @@ export interface FeatureRunConfig { readonly projection: Projection; // Names of computeds that emit an entity-id list (hydrated through `toData`). readonly hydrate?: readonly string[]; + // Spec transitions that conform against a same-named transaction, not an action. + readonly transactionOps?: 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>>; @@ -82,8 +83,9 @@ export function runFeature actions.has(name))); - const byTransaction = new Set([...transitions.keys()].filter((name) => !actions.has(name) && transactions.has(name))); + const viaTransaction = new Set(config.transactionOps ?? []); + const byTransaction = new Set([...transitions.keys()].filter((name) => viaTransaction.has(name) && transactions.has(name))); + const byAction = new Set([...transitions.keys()].filter((name) => !viaTransaction.has(name) && actions.has(name))); const missing = [ ...[...transitions.keys()].filter((name) => !byAction.has(name) && !byTransaction.has(name)), ...[...derivations.keys()].filter((name) => !computeds.has(name)), @@ -93,7 +95,11 @@ export function runFeature { for (const name of missing) { it(name, () => { - throw new Error(`spec op "${name}" has no same-named action, transaction or computed`); + throw new Error( + viaTransaction.has(name) + ? `spec op "${name}" is listed in transactionOps but has no same-named transaction` + : `spec op "${name}" has no same-named ${derivations.has(name) ? "computed" : "action"}`, + ); }); } }); From 8199935a079c06587ff611c5515dd4e1c0a53cd1 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 17:57:18 -0700 Subject: [PATCH 03/24] =?UTF-8?q?fix(data-lit-todo):=20reloaded=20todos=20?= =?UTF-8?q?vanished=20=E2=80=94=20don't=20pack=20session=20dragPosition=20?= =?UTF-8?q?into=20Todo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A nonPersistent column with no real default is stripped on load, so every reloaded todo fell out of the Todo archetype (and out of every Todo query). The drag transaction now adds dragPosition on demand. Adds a persistence round-trip regression test. Co-Authored-By: Claude Opus 5.5 --- .../data-lit-todo/src/app/persistence.test.ts | 19 +++++++++++++++++++ .../assign/ecs/conformance/projection.ts | 6 +++--- .../features/assign/ecs/core/archetypes.ts | 4 ++-- .../main/ecs/conformance/projection.ts | 3 +-- .../src/features/main/ecs/core/archetypes.ts | 8 +++++--- .../main/ecs/transactions/create-todo.ts | 1 - 6 files changed, 30 insertions(+), 11 deletions(-) create mode 100644 packages/data-lit-todo/src/app/persistence.test.ts 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/features/assign/ecs/conformance/projection.ts b/packages/data-lit-todo/src/features/assign/ecs/conformance/projection.ts index f95ae05eb..ed1c676bc 100644 --- a/packages/data-lit-todo/src/features/assign/ecs/conformance/projection.ts +++ b/packages/data-lit-todo/src/features/assign/ecs/conformance/projection.ts @@ -6,8 +6,8 @@ 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 and `dragPosition` -// slot), returning the `spec id → seeded entity` map. `toState` reads them back. +// 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); @@ -16,7 +16,7 @@ export const projection = { 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, dragPosition: null })); + seeded.set(specId, store.archetypes.AssignedTodo.insert({ todo: true, ...todo })); } return seeded; }, 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 index 7fac2861a..a3d3c8c47 100644 --- a/packages/data-lit-todo/src/features/assign/ecs/core/archetypes.ts +++ b/packages/data-lit-todo/src/features/assign/ecs/core/archetypes.ts @@ -4,9 +4,9 @@ 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 (main's tag and drag slot included), +// `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, "dragPosition"], + AssignedTodo: ["todo", ...AssignedTodo], }); 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 index 6cfb10b6f..468e6c189 100644 --- a/packages/data-lit-todo/src/features/main/ecs/conformance/projection.ts +++ b/packages/data-lit-todo/src/features/main/ecs/conformance/projection.ts @@ -20,7 +20,7 @@ const toData = (store: CoreDatabase.Store, entity: Entity): Todo => { // 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 `dragPosition` slot and the `todo` tag are seeded here). Clearing iterates tail→head so each delete is from the tail +// 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 @@ -48,7 +48,6 @@ export const projection = { name: todo.name, complete: todo.complete, order: todo.order, - dragPosition: null, }), ]), ); 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 index 0e76a4a44..2a3045822 100644 --- a/packages/data-lit-todo/src/features/main/ecs/core/archetypes.ts +++ b/packages/data-lit-todo/src/features/main/ecs/core/archetypes.ts @@ -3,8 +3,10 @@ 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 -// and live `dragPosition` slot. +// 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, "dragPosition"], + Todo: ["todo", ...Todo], }); 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 index e208b08ca..d0e40b9f9 100644 --- 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 @@ -11,5 +11,4 @@ export const createTodo = ( name: input.name, complete: input.complete ?? false, order: nextOrder(t), - dragPosition: null, }); From c33a5a3e6cb7bbdfdc2ca70cfa61efa2296386ef Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 17:58:39 -0700 Subject: [PATCH 04/24] refactor(data-solid-dashboard): port to the layered feature layout Co-Authored-By: Claude Opus 5.5 --- packages/data-solid-dashboard/README.md | 32 ++++++++--------- packages/data-solid-dashboard/index.html | 2 +- .../src/{ => app}/main.tsx | 2 +- .../src/features/main/data/resources/count.ts | 5 +++ .../src/features/main/data/resources/index.ts | 6 ++++ .../src/features/main/data/resources/log.ts | 6 ++++ .../features/main/data/resources/user-name.ts | 5 +++ .../src/features/main/data/state/spec.ts | 34 ------------------- .../src/features/main/data/state/state.ts | 11 ------ .../features/main/data/state/transforms.ts | 12 ------- .../data/values/activity-log/activity-log.ts | 5 +++ .../main/data/values/activity-log/public.ts | 2 ++ .../main/data/values/activity-log/schema.ts | 8 +++++ .../actions}/action-database.ts | 4 +-- .../features/main/ecs/actions/clear-log.ts | 7 ++++ .../features/main/ecs/actions/decrement.ts | 7 ++++ .../features/main/ecs/actions/increment.ts | 7 ++++ .../action-database => ecs}/actions/index.ts | 0 .../src/features/main/ecs/actions/reset.ts | 7 ++++ .../actions/set-user-name.ts | 4 +-- .../main/ecs/conformance/conformance.test.ts | 7 ++++ .../main/ecs/conformance/implementation.ts | 12 +++++++ .../conformance/projection.ts | 4 +-- .../core}/core-database.ts | 5 ++- .../src/features/main/ecs/main-service.ts | 2 ++ .../main/ecs/transactions/clear-log.ts | 7 ++++ .../main/ecs/transactions/decrement.ts | 10 ++++++ .../main/ecs/transactions/increment.ts | 9 +++++ .../transactions/index.ts | 0 .../features/main/ecs/transactions/reset.ts | 8 +++++ .../main/ecs/transactions/set-user-name.ts | 8 +++++ .../transactions}/transaction-database.ts | 4 +-- .../action-database/actions/clear-log.ts | 7 ---- .../action-database/actions/decrement.ts | 7 ---- .../action-database/actions/increment.ts | 8 ----- .../action-database/actions/reset.ts | 7 ---- .../conformance/conformance.test.ts | 10 ------ .../main-service/core-database/resources.ts | 13 ------- .../services/main-service/main-service.ts | 7 ---- .../transactions/clear-log.ts | 8 ----- .../transactions/decrement.ts | 9 ----- .../transactions/increment.ts | 11 ------ .../transactions/reset.ts | 9 ----- .../transactions/set-user-name.ts | 12 ------- .../{data/state => spec}/clear-log.cases.ts | 4 +-- .../main/{data/state => spec}/clear-log.ts | 0 .../main/{data/state => spec}/create.ts | 0 .../{data/state => spec}/decrement.cases.ts | 4 +-- .../main/{data/state => spec}/decrement.ts | 0 .../{data/state => spec}/increment.cases.ts | 4 +-- .../main/{data/state => spec}/increment.ts | 0 .../src/features/main/spec/public.ts | 3 ++ .../main/{data/state => spec}/reset.cases.ts | 4 +-- .../main/{data/state => spec}/reset.ts | 0 .../main/{data/state => spec}/samples.ts | 0 .../state => spec}/set-user-name.cases.ts | 4 +-- .../{data/state => spec}/set-user-name.ts | 0 .../main/{data/state => spec}/spec.test.ts | 3 +- .../src/features/main/spec/spec.ts | 25 ++++++++++++++ .../src/features/main/spec/state.ts | 18 ++++++++++ .../state/public.ts => spec/transforms.ts} | 5 +-- .../main/ui/activity-log/activity-log.tsx | 4 +-- .../src/features/main/ui/app/app.tsx | 2 +- .../main/ui/control-panel/control-panel.tsx | 4 +-- .../ui/counter-display/counter-display.tsx | 2 +- .../main/ui/status-bar/status-bar.tsx | 2 +- .../data-solid-dashboard/tsconfig.build.json | 29 ---------------- .../data-solid-dashboard/tsconfig.data.json | 16 +++++++++ .../data-solid-dashboard/tsconfig.ecs.json | 16 +++++++++ .../data-solid-dashboard/tsconfig.spec.json | 17 ++++++++++ .../data-solid-dashboard/tsconfig.test.json | 23 +++++-------- 71 files changed, 279 insertions(+), 260 deletions(-) rename packages/data-solid-dashboard/src/{ => app}/main.tsx (77%) create mode 100644 packages/data-solid-dashboard/src/features/main/data/resources/count.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/resources/index.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/resources/log.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/resources/user-name.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/data/state/spec.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/data/state/state.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/data/state/transforms.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/values/activity-log/activity-log.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/values/activity-log/public.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/values/activity-log/schema.ts rename packages/data-solid-dashboard/src/features/main/{services/main-service/action-database => ecs/actions}/action-database.ts (82%) create mode 100644 packages/data-solid-dashboard/src/features/main/ecs/actions/clear-log.ts create mode 100644 packages/data-solid-dashboard/src/features/main/ecs/actions/decrement.ts create mode 100644 packages/data-solid-dashboard/src/features/main/ecs/actions/increment.ts rename packages/data-solid-dashboard/src/features/main/{services/main-service/action-database => ecs}/actions/index.ts (100%) create mode 100644 packages/data-solid-dashboard/src/features/main/ecs/actions/reset.ts rename packages/data-solid-dashboard/src/features/main/{services/main-service/action-database => ecs}/actions/set-user-name.ts (51%) create mode 100644 packages/data-solid-dashboard/src/features/main/ecs/conformance/conformance.test.ts create mode 100644 packages/data-solid-dashboard/src/features/main/ecs/conformance/implementation.ts rename packages/data-solid-dashboard/src/features/main/{services/main-service => ecs}/conformance/projection.ts (87%) rename packages/data-solid-dashboard/src/features/main/{services/main-service/core-database => ecs/core}/core-database.ts (66%) create mode 100644 packages/data-solid-dashboard/src/features/main/ecs/main-service.ts create mode 100644 packages/data-solid-dashboard/src/features/main/ecs/transactions/clear-log.ts create mode 100644 packages/data-solid-dashboard/src/features/main/ecs/transactions/decrement.ts create mode 100644 packages/data-solid-dashboard/src/features/main/ecs/transactions/increment.ts rename packages/data-solid-dashboard/src/features/main/{services/main-service/transaction-database => ecs}/transactions/index.ts (100%) create mode 100644 packages/data-solid-dashboard/src/features/main/ecs/transactions/reset.ts create mode 100644 packages/data-solid-dashboard/src/features/main/ecs/transactions/set-user-name.ts rename packages/data-solid-dashboard/src/features/main/{services/main-service/transaction-database => ecs/transactions}/transaction-database.ts (79%) delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/clear-log.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/decrement.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/increment.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/reset.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/conformance/conformance.test.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/core-database/resources.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/main-service.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/clear-log.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/decrement.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/increment.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/reset.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/set-user-name.ts rename packages/data-solid-dashboard/src/features/main/{data/state => spec}/clear-log.cases.ts (82%) rename packages/data-solid-dashboard/src/features/main/{data/state => spec}/clear-log.ts (100%) rename packages/data-solid-dashboard/src/features/main/{data/state => spec}/create.ts (100%) rename packages/data-solid-dashboard/src/features/main/{data/state => spec}/decrement.cases.ts (83%) rename packages/data-solid-dashboard/src/features/main/{data/state => spec}/decrement.ts (100%) rename packages/data-solid-dashboard/src/features/main/{data/state => spec}/increment.cases.ts (80%) rename packages/data-solid-dashboard/src/features/main/{data/state => spec}/increment.ts (100%) create mode 100644 packages/data-solid-dashboard/src/features/main/spec/public.ts rename packages/data-solid-dashboard/src/features/main/{data/state => spec}/reset.cases.ts (84%) rename packages/data-solid-dashboard/src/features/main/{data/state => spec}/reset.ts (100%) rename packages/data-solid-dashboard/src/features/main/{data/state => spec}/samples.ts (100%) rename packages/data-solid-dashboard/src/features/main/{data/state => spec}/set-user-name.cases.ts (91%) rename packages/data-solid-dashboard/src/features/main/{data/state => spec}/set-user-name.ts (100%) rename packages/data-solid-dashboard/src/features/main/{data/state => spec}/spec.test.ts (53%) create mode 100644 packages/data-solid-dashboard/src/features/main/spec/spec.ts create mode 100644 packages/data-solid-dashboard/src/features/main/spec/state.ts rename packages/data-solid-dashboard/src/features/main/{data/state/public.ts => spec/transforms.ts} (63%) delete mode 100644 packages/data-solid-dashboard/tsconfig.build.json create mode 100644 packages/data-solid-dashboard/tsconfig.data.json create mode 100644 packages/data-solid-dashboard/tsconfig.ecs.json create mode 100644 packages/data-solid-dashboard/tsconfig.spec.json 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/main.tsx b/packages/data-solid-dashboard/src/app/main.tsx similarity index 77% rename from packages/data-solid-dashboard/src/main.tsx rename to packages/data-solid-dashboard/src/app/main.tsx index 8fbc4fd47..801c43d27 100644 --- a/packages/data-solid-dashboard/src/main.tsx +++ b/packages/data-solid-dashboard/src/app/main.tsx @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { render } from "solid-js/web"; -import { App } from "./features/main/ui/app/app.jsx"; +import { App } from "../features/main/ui/app/app.jsx"; const root = document.getElementById("root"); if (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/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-solid-dashboard/src/features/main/data/values/activity-log/public.ts b/packages/data-solid-dashboard/src/features/main/data/values/activity-log/public.ts new file mode 100644 index 000000000..363fd32b4 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/values/activity-log/public.ts @@ -0,0 +1,2 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { schema } from "./schema.js"; 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/services/main-service/action-database/action-database.ts b/packages/data-solid-dashboard/src/features/main/ecs/actions/action-database.ts similarity index 82% rename from packages/data-solid-dashboard/src/features/main/services/main-service/action-database/action-database.ts rename to packages/data-solid-dashboard/src/features/main/ecs/actions/action-database.ts index cc2572a23..a03974dca 100644 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/action-database.ts +++ b/packages/data-solid-dashboard/src/features/main/ecs/actions/action-database.ts @@ -1,7 +1,7 @@ // © 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"; +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). 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/services/main-service/action-database/actions/set-user-name.ts b/packages/data-solid-dashboard/src/features/main/ecs/actions/set-user-name.ts similarity index 51% rename from packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/set-user-name.ts rename to packages/data-solid-dashboard/src/features/main/ecs/actions/set-user-name.ts index 85f1e50f1..b50b269b1 100644 --- 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/ecs/actions/set-user-name.ts @@ -1,7 +1,7 @@ // © 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"; -// The app-facing realization of `State.setUserName` — commits the transaction. +// 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/services/main-service/conformance/projection.ts b/packages/data-solid-dashboard/src/features/main/ecs/conformance/projection.ts similarity index 87% rename from packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.ts rename to packages/data-solid-dashboard/src/features/main/ecs/conformance/projection.ts index 17b674a5f..83c531977 100644 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.ts +++ b/packages/data-solid-dashboard/src/features/main/ecs/conformance/projection.ts @@ -1,7 +1,7 @@ // © 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"; +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 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/ecs/core/core-database.ts similarity index 66% rename from packages/data-solid-dashboard/src/features/main/services/main-service/core-database/core-database.ts rename to packages/data-solid-dashboard/src/features/main/ecs/core/core-database.ts index e296d0e18..29377a24f 100644 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/core-database/core-database.ts +++ b/packages/data-solid-dashboard/src/features/main/ecs/core/core-database.ts @@ -1,9 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { resources } from "./resources.js"; +import { resources } from "../../data/resources/index.js"; -// The core schema. This feature has no entities, so it declares only resources — -// no `components` / `archetypes` boilerplate (all facets are optional). +// The core schema: the feature's data/ resources. No entities, so no components or archetypes. const coreDatabasePlugin = Database.Plugin.create({ resources, }); 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/services/main-service/transaction-database/transaction-database.ts b/packages/data-solid-dashboard/src/features/main/ecs/transactions/transaction-database.ts similarity index 79% rename from packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transaction-database.ts rename to packages/data-solid-dashboard/src/features/main/ecs/transactions/transaction-database.ts index 02a036846..bf625d540 100644 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transaction-database.ts +++ b/packages/data-solid-dashboard/src/features/main/ecs/transactions/transaction-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { CoreDatabase } from "../core/core-database.js"; +import * as transactions from "./index.js"; const transactionDatabasePlugin = Database.Plugin.create({ extends: CoreDatabase.plugin, 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/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/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/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/data/state/spec.test.ts b/packages/data-solid-dashboard/src/features/main/spec/spec.test.ts similarity index 53% rename from packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts rename to packages/data-solid-dashboard/src/features/main/spec/spec.test.ts index 316c33081..bdab1adf7 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts +++ b/packages/data-solid-dashboard/src/features/main/spec/spec.test.ts @@ -2,6 +2,5 @@ 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`. +// 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/data/state/public.ts b/packages/data-solid-dashboard/src/features/main/spec/transforms.ts similarity index 63% rename from packages/data-solid-dashboard/src/features/main/data/state/public.ts rename to packages/data-solid-dashboard/src/features/main/spec/transforms.ts index 1b91e9481..bba0d2b85 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/public.ts +++ b/packages/data-solid-dashboard/src/features/main/spec/transforms.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export { create } from "./create.js"; -export { samples } from "./samples.js"; + +// 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"; 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/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" }] } From a8afc6379affc8dbdaf0a659c3cec250683213e7 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 17:59:22 -0700 Subject: [PATCH 05/24] refactor(data-react-pixie): port to the layered feature layout Co-Authored-By: Claude Opus 5.5 --- packages/data-react-pixie/index.html | 2 +- .../data-react-pixie/src/{ => app}/main.tsx | 2 +- .../features/main/data/components/active.ts | 5 +++ .../features/main/data/components/hovered.ts | 5 +++ .../features/main/data/components/index.ts | 8 +++++ .../src/features/main/data/components/kind.ts | 4 +++ .../features/main/data/components/position.ts | 5 +++ .../features/main/data/components/rotation.ts | 5 +++ .../src/features/main/data/entities/index.ts | 4 +++ .../src/features/main/data/entities/sprite.ts | 7 ++++ .../features/main/data/resources/filter.ts | 8 +++++ .../src/features/main/data/resources/index.ts | 4 +++ .../src/features/main/data/sprite/is.ts | 20 ----------- .../src/features/main/data/sprite/sprite.ts | 15 -------- .../src/features/main/data/state/spec.test.ts | 8 ----- .../src/features/main/data/state/spec.ts | 36 ------------------- .../src/features/main/data/state/state.ts | 13 ------- .../features/main/data/state/transforms.ts | 15 -------- .../{ => values}/filter-kind/filter-kind.ts | 0 .../data/{ => values}/filter-kind/public.ts | 0 .../data/{ => values}/filter-kind/schema.ts | 0 .../data/{ => values}/sprite-kind/public.ts | 0 .../data/{ => values}/sprite-kind/schema.ts | 0 .../{ => values}/sprite-kind/sprite-kind.ts | 0 .../actions}/action-database.ts | 4 +-- .../actions/create-sprite.ts | 6 ++-- .../action-database => ecs}/actions/index.ts | 0 .../features/main/ecs/actions/set-filter.ts | 8 +++++ .../actions/set-sprite-active.ts | 4 +-- .../actions/set-sprite-hovered.ts | 4 +-- .../src/features/main/ecs/actions/tick.ts | 7 ++++ .../actions/toggle-sprite-active.ts | 4 +-- .../main/ecs/conformance/conformance.test.ts | 7 ++++ .../main/ecs/conformance/implementation.ts | 12 +++++++ .../conformance/projection.ts | 6 ++-- .../core-database => ecs/core}/archetypes.ts | 5 +-- .../core}/core-database.ts | 6 ++-- .../src/features/main/ecs/main-service.ts | 2 ++ .../sprite/public.ts => ecs/systems/index.ts} | 2 +- .../features/main/ecs/systems/seed-sprites.ts | 14 ++++++++ .../main/ecs/systems/system-database.ts | 18 ++++++++++ .../transactions/create-sprite.ts | 4 +-- .../transactions/index.ts | 0 .../transactions/set-filter.ts | 4 +-- .../transactions/set-sprite-active.ts | 2 +- .../transactions/set-sprite-hovered.ts | 2 +- .../transactions/tick.ts | 5 ++- .../transactions/toggle-sprite-active.ts | 2 +- .../transactions}/transaction-database.ts | 4 +-- .../action-database/actions/set-filter.ts | 8 ----- .../action-database/actions/tick.ts | 7 ---- .../conformance/conformance.test.ts | 10 ------ .../main-service/core-database/components.ts | 15 -------- .../main-service/core-database/resources.ts | 9 ----- .../services/main-service/main-service.ts | 6 ---- .../system-database/system-database.ts | 29 --------------- .../state => spec}/create-sprite.cases.ts | 0 .../{data/state => spec}/create-sprite.ts | 2 +- .../main/{data/state => spec}/create.ts | 0 .../src/features/main/spec/public.ts | 3 ++ .../main/{data/state => spec}/samples.ts | 0 .../{data/state => spec}/set-filter.cases.ts | 2 +- .../main/{data/state => spec}/set-filter.ts | 2 +- .../state => spec}/set-sprite-active.cases.ts | 4 +-- .../{data/state => spec}/set-sprite-active.ts | 0 .../set-sprite-hovered.cases.ts | 4 +-- .../state => spec}/set-sprite-hovered.ts | 0 .../src/features/main/spec/spec.test.ts | 6 ++++ .../src/features/main/spec/spec.ts | 28 +++++++++++++++ .../src/features/main/spec/state.ts | 21 +++++++++++ .../main/{data/state => spec}/tick.cases.ts | 4 +-- .../main/{data/state => spec}/tick.ts | 2 +- .../toggle-sprite-active.cases.ts | 4 +-- .../state => spec}/toggle-sprite-active.ts | 0 .../state/public.ts => spec/transforms.ts} | 6 ++-- .../src/features/main/ui/app.tsx | 2 +- .../filter-selector-presentation.tsx | 2 +- .../ui/filter-selector/filter-selector.tsx | 2 +- .../main/ui/pixie-scene/pixie-filters.ts | 2 +- .../main/ui/pixie-scene/pixie-tick.tsx | 2 +- .../features/main/ui/sprite/sprite-images.ts | 2 +- .../src/features/main/ui/sprite/sprite.tsx | 6 ++-- .../src/features/main/ui/use-main-service.ts | 2 +- packages/data-react-pixie/tsconfig.build.json | 29 --------------- packages/data-react-pixie/tsconfig.data.json | 16 +++++++++ packages/data-react-pixie/tsconfig.ecs.json | 16 +++++++++ packages/data-react-pixie/tsconfig.spec.json | 17 +++++++++ packages/data-react-pixie/tsconfig.test.json | 22 +++++------- 88 files changed, 298 insertions(+), 292 deletions(-) rename packages/data-react-pixie/src/{ => app}/main.tsx (89%) create mode 100644 packages/data-react-pixie/src/features/main/data/components/active.ts create mode 100644 packages/data-react-pixie/src/features/main/data/components/hovered.ts create mode 100644 packages/data-react-pixie/src/features/main/data/components/index.ts create mode 100644 packages/data-react-pixie/src/features/main/data/components/kind.ts create mode 100644 packages/data-react-pixie/src/features/main/data/components/position.ts create mode 100644 packages/data-react-pixie/src/features/main/data/components/rotation.ts create mode 100644 packages/data-react-pixie/src/features/main/data/entities/index.ts create mode 100644 packages/data-react-pixie/src/features/main/data/entities/sprite.ts create mode 100644 packages/data-react-pixie/src/features/main/data/resources/filter.ts create mode 100644 packages/data-react-pixie/src/features/main/data/resources/index.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/sprite/is.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/sprite/sprite.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/state/spec.test.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/state/spec.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/state/state.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/state/transforms.ts rename packages/data-react-pixie/src/features/main/data/{ => values}/filter-kind/filter-kind.ts (100%) rename packages/data-react-pixie/src/features/main/data/{ => values}/filter-kind/public.ts (100%) rename packages/data-react-pixie/src/features/main/data/{ => values}/filter-kind/schema.ts (100%) rename packages/data-react-pixie/src/features/main/data/{ => values}/sprite-kind/public.ts (100%) rename packages/data-react-pixie/src/features/main/data/{ => values}/sprite-kind/schema.ts (100%) rename packages/data-react-pixie/src/features/main/data/{ => values}/sprite-kind/sprite-kind.ts (100%) rename packages/data-react-pixie/src/features/main/{services/main-service/action-database => ecs/actions}/action-database.ts (83%) rename packages/data-react-pixie/src/features/main/{services/main-service/action-database => ecs}/actions/create-sprite.ts (59%) rename packages/data-react-pixie/src/features/main/{services/main-service/action-database => ecs}/actions/index.ts (100%) create mode 100644 packages/data-react-pixie/src/features/main/ecs/actions/set-filter.ts rename packages/data-react-pixie/src/features/main/{services/main-service/action-database => ecs}/actions/set-sprite-active.ts (67%) rename packages/data-react-pixie/src/features/main/{services/main-service/action-database => ecs}/actions/set-sprite-hovered.ts (64%) create mode 100644 packages/data-react-pixie/src/features/main/ecs/actions/tick.ts rename packages/data-react-pixie/src/features/main/{services/main-service/action-database => ecs}/actions/toggle-sprite-active.ts (62%) create mode 100644 packages/data-react-pixie/src/features/main/ecs/conformance/conformance.test.ts create mode 100644 packages/data-react-pixie/src/features/main/ecs/conformance/implementation.ts rename packages/data-react-pixie/src/features/main/{services/main-service => ecs}/conformance/projection.ts (91%) rename packages/data-react-pixie/src/features/main/{services/main-service/core-database => ecs/core}/archetypes.ts (56%) rename packages/data-react-pixie/src/features/main/{services/main-service/core-database => ecs/core}/core-database.ts (69%) create mode 100644 packages/data-react-pixie/src/features/main/ecs/main-service.ts rename packages/data-react-pixie/src/features/main/{data/sprite/public.ts => ecs/systems/index.ts} (61%) create mode 100644 packages/data-react-pixie/src/features/main/ecs/systems/seed-sprites.ts create mode 100644 packages/data-react-pixie/src/features/main/ecs/systems/system-database.ts rename packages/data-react-pixie/src/features/main/{services/main-service/transaction-database => ecs}/transactions/create-sprite.ts (73%) rename packages/data-react-pixie/src/features/main/{services/main-service/transaction-database => ecs}/transactions/index.ts (100%) rename packages/data-react-pixie/src/features/main/{services/main-service/transaction-database => ecs}/transactions/set-filter.ts (55%) rename packages/data-react-pixie/src/features/main/{services/main-service/transaction-database => ecs}/transactions/set-sprite-active.ts (80%) rename packages/data-react-pixie/src/features/main/{services/main-service/transaction-database => ecs}/transactions/set-sprite-hovered.ts (80%) rename packages/data-react-pixie/src/features/main/{services/main-service/transaction-database => ecs}/transactions/tick.ts (74%) rename packages/data-react-pixie/src/features/main/{services/main-service/transaction-database => ecs}/transactions/toggle-sprite-active.ts (82%) rename packages/data-react-pixie/src/features/main/{services/main-service/transaction-database => ecs/transactions}/transaction-database.ts (79%) delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-filter.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/tick.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/conformance.test.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/core-database/components.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/core-database/resources.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/main-service.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/system-database/system-database.ts rename packages/data-react-pixie/src/features/main/{data/state => spec}/create-sprite.cases.ts (100%) rename packages/data-react-pixie/src/features/main/{data/state => spec}/create-sprite.ts (90%) rename packages/data-react-pixie/src/features/main/{data/state => spec}/create.ts (100%) create mode 100644 packages/data-react-pixie/src/features/main/spec/public.ts rename packages/data-react-pixie/src/features/main/{data/state => spec}/samples.ts (100%) rename packages/data-react-pixie/src/features/main/{data/state => spec}/set-filter.cases.ts (97%) rename packages/data-react-pixie/src/features/main/{data/state => spec}/set-filter.ts (81%) rename packages/data-react-pixie/src/features/main/{data/state => spec}/set-sprite-active.cases.ts (95%) rename packages/data-react-pixie/src/features/main/{data/state => spec}/set-sprite-active.ts (100%) rename packages/data-react-pixie/src/features/main/{data/state => spec}/set-sprite-hovered.cases.ts (95%) rename packages/data-react-pixie/src/features/main/{data/state => spec}/set-sprite-hovered.ts (100%) create mode 100644 packages/data-react-pixie/src/features/main/spec/spec.test.ts create mode 100644 packages/data-react-pixie/src/features/main/spec/spec.ts create mode 100644 packages/data-react-pixie/src/features/main/spec/state.ts rename packages/data-react-pixie/src/features/main/{data/state => spec}/tick.cases.ts (89%) rename packages/data-react-pixie/src/features/main/{data/state => spec}/tick.ts (91%) rename packages/data-react-pixie/src/features/main/{data/state => spec}/toggle-sprite-active.cases.ts (95%) rename packages/data-react-pixie/src/features/main/{data/state => spec}/toggle-sprite-active.ts (100%) rename packages/data-react-pixie/src/features/main/{data/state/public.ts => spec/transforms.ts} (59%) delete mode 100644 packages/data-react-pixie/tsconfig.build.json create mode 100644 packages/data-react-pixie/tsconfig.data.json create mode 100644 packages/data-react-pixie/tsconfig.ecs.json create mode 100644 packages/data-react-pixie/tsconfig.spec.json 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/main.tsx b/packages/data-react-pixie/src/app/main.tsx similarity index 89% rename from packages/data-react-pixie/src/main.tsx rename to packages/data-react-pixie/src/app/main.tsx index 3640814b2..521d8ca8a 100644 --- a/packages/data-react-pixie/src/main.tsx +++ b/packages/data-react-pixie/src/app/main.tsx @@ -3,7 +3,7 @@ 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"; +import { App } from "../features/main/ui/app.js"; // Register PixiJS components for @pixi/react (pixiSprite, pixiContainer) extend({ Container, Graphics, Sprite }); 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/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/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/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/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-react-pixie/src/features/main/data/filter-kind/public.ts b/packages/data-react-pixie/src/features/main/data/values/filter-kind/public.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/data/filter-kind/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-react-pixie/src/features/main/data/sprite-kind/public.ts b/packages/data-react-pixie/src/features/main/data/values/sprite-kind/public.ts similarity index 100% rename from packages/data-react-pixie/src/features/main/data/sprite-kind/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/services/main-service/action-database/action-database.ts b/packages/data-react-pixie/src/features/main/ecs/actions/action-database.ts similarity index 83% rename from packages/data-react-pixie/src/features/main/services/main-service/action-database/action-database.ts rename to packages/data-react-pixie/src/features/main/ecs/actions/action-database.ts index ce5e0f99a..9aec4ca66 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/action-database/action-database.ts +++ b/packages/data-react-pixie/src/features/main/ecs/actions/action-database.ts @@ -1,7 +1,7 @@ // © 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"; +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 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/ecs/actions/create-sprite.ts similarity index 59% rename from packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/create-sprite.ts rename to packages/data-react-pixie/src/features/main/ecs/actions/create-sprite.ts index 582fafbe5..f336df5d9 100644 --- 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/ecs/actions/create-sprite.ts @@ -1,9 +1,9 @@ // © 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"; +import type { SpriteKind } from "../../data/values/sprite-kind/sprite-kind.js"; +import type { TransactionDatabase } from "../transactions/transaction-database.js"; -// The app-facing realization of `State.createSprite`. This transition injects no +// 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, 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/services/main-service/action-database/actions/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/ecs/actions/set-sprite-active.ts similarity index 67% rename from packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-active.ts rename to packages/data-react-pixie/src/features/main/ecs/actions/set-sprite-active.ts index 64339b293..bcb181c3e 100644 --- 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/ecs/actions/set-sprite-active.ts @@ -1,8 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Entity } from "@adobe/data/ecs"; -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; +import type { TransactionDatabase } from "../transactions/transaction-database.js"; -// The app-facing realization of `State.setSpriteActive`. The ui supplies the +// 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, 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/ecs/actions/set-sprite-hovered.ts similarity index 64% rename from packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-hovered.ts rename to packages/data-react-pixie/src/features/main/ecs/actions/set-sprite-hovered.ts index 01c206ed8..c4b195262 100644 --- 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/ecs/actions/set-sprite-hovered.ts @@ -1,8 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Entity } from "@adobe/data/ecs"; -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; +import type { TransactionDatabase } from "../transactions/transaction-database.js"; -// The app-facing realization of `State.setSpriteHovered`. +// The app-facing realization of the spec's `setSpriteHovered`. export const setSpriteHovered = ( db: TransactionDatabase, input: { readonly id: Entity; readonly hovered: boolean }, 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/services/main-service/action-database/actions/toggle-sprite-active.ts b/packages/data-react-pixie/src/features/main/ecs/actions/toggle-sprite-active.ts similarity index 62% rename from packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/toggle-sprite-active.ts rename to packages/data-react-pixie/src/features/main/ecs/actions/toggle-sprite-active.ts index a12f6786e..e90ecb685 100644 --- 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/ecs/actions/toggle-sprite-active.ts @@ -1,8 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Entity } from "@adobe/data/ecs"; -import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; +import type { TransactionDatabase } from "../transactions/transaction-database.js"; -// The app-facing realization of `State.toggleSpriteActive`. +// The app-facing realization of the spec's `toggleSpriteActive`. export const toggleSpriteActive = ( db: TransactionDatabase, { id }: { readonly id: Entity }, 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/services/main-service/conformance/projection.ts b/packages/data-react-pixie/src/features/main/ecs/conformance/projection.ts similarity index 91% rename from packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.ts rename to packages/data-react-pixie/src/features/main/ecs/conformance/projection.ts index 6b3f92e64..938ba529b 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.ts +++ b/packages/data-react-pixie/src/features/main/ecs/conformance/projection.ts @@ -1,8 +1,8 @@ // © 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"; +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 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/ecs/core/archetypes.ts similarity index 56% rename from packages/data-react-pixie/src/features/main/services/main-service/core-database/archetypes.ts rename to packages/data-react-pixie/src/features/main/ecs/core/archetypes.ts index 9b2f2260f..b3921c478 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/core-database/archetypes.ts +++ b/packages/data-react-pixie/src/features/main/ecs/core/archetypes.ts @@ -1,7 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { components } from "./components.js"; +import { components } from "../../data/components/index.js"; +import { Sprite } from "../../data/entities/sprite.js"; export const archetypes = Database.archetypes(components, { - Sprite: ["position", "rotation", "kind", "hovered", "active"], + Sprite, }); 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/ecs/core/core-database.ts similarity index 69% rename from packages/data-react-pixie/src/features/main/services/main-service/core-database/core-database.ts rename to packages/data-react-pixie/src/features/main/ecs/core/core-database.ts index 6f1face27..d9fee9075 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/core-database/core-database.ts +++ b/packages/data-react-pixie/src/features/main/ecs/core/core-database.ts @@ -1,10 +1,10 @@ // © 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 { components } from "../../data/components/index.js"; +import { resources } from "../../data/resources/index.js"; import { archetypes } from "./archetypes.js"; -// The feature's whole schema, grouped by scope in components.ts / resources.ts. +// The core schema: the feature's data/ components and resources, packed into archetypes. const coreDatabasePlugin = Database.Plugin.create({ components, resources, 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/data/sprite/public.ts b/packages/data-react-pixie/src/features/main/ecs/systems/index.ts similarity index 61% rename from packages/data-react-pixie/src/features/main/data/sprite/public.ts rename to packages/data-react-pixie/src/features/main/ecs/systems/index.ts index f0c37fea2..9c9b8ca6d 100644 --- a/packages/data-react-pixie/src/features/main/data/sprite/public.ts +++ b/packages/data-react-pixie/src/features/main/ecs/systems/index.ts @@ -1,2 +1,2 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export { is } from "./is.js"; +export * from "./seed-sprites.js"; diff --git a/packages/data-react-pixie/src/features/main/ecs/systems/seed-sprites.ts b/packages/data-react-pixie/src/features/main/ecs/systems/seed-sprites.ts new file mode 100644 index 000000000..d4b86e5e9 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/systems/seed-sprites.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ActionDatabase } from "../actions/action-database.js"; + +// Init-only: `create` runs once at database construction to populate the initial +// scene and returns no per-frame function. System `create` receives the database +// plus its raw `store`. +export const seedSprites = { + create: (db: ActionDatabase & { readonly store: ActionDatabase.Store }) => { + 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 }); + }, +}; 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..e3dd6a269 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/ecs/systems/system-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { ActionDatabase } from "../actions/action-database.js"; +import * as systems from "./index.js"; + +// Systems sit atop the action layer. Per-frame rotation is driven from the ui via the +// `tick` action, so this feature needs no scheduled system. +const systemDatabasePlugin = Database.Plugin.create({ + extends: ActionDatabase.plugin, + systems, +}); + +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/transactions/create-sprite.ts b/packages/data-react-pixie/src/features/main/ecs/transactions/create-sprite.ts similarity index 73% rename from packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/create-sprite.ts rename to packages/data-react-pixie/src/features/main/ecs/transactions/create-sprite.ts index 98621c907..05d0e2750 100644 --- 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/ecs/transactions/create-sprite.ts @@ -1,7 +1,7 @@ // © 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"; +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, 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/services/main-service/transaction-database/transactions/set-filter.ts b/packages/data-react-pixie/src/features/main/ecs/transactions/set-filter.ts similarity index 55% rename from packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-filter.ts rename to packages/data-react-pixie/src/features/main/ecs/transactions/set-filter.ts index 5fa1704cf..a8b4a52f3 100644 --- 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/ecs/transactions/set-filter.ts @@ -1,6 +1,6 @@ // © 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"; +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/services/main-service/transaction-database/transactions/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/ecs/transactions/set-sprite-active.ts similarity index 80% rename from packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-active.ts rename to packages/data-react-pixie/src/features/main/ecs/transactions/set-sprite-active.ts index d47581b12..e0e519b9a 100644 --- 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/ecs/transactions/set-sprite-active.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"; export const setSpriteActive = ( t: CoreDatabase.Store, 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/ecs/transactions/set-sprite-hovered.ts similarity index 80% rename from packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-hovered.ts rename to packages/data-react-pixie/src/features/main/ecs/transactions/set-sprite-hovered.ts index de162ff51..87c08de2d 100644 --- 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/ecs/transactions/set-sprite-hovered.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"; export const setSpriteHovered = ( t: CoreDatabase.Store, 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/ecs/transactions/tick.ts similarity index 74% rename from packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/tick.ts rename to packages/data-react-pixie/src/features/main/ecs/transactions/tick.ts index c3099ec6f..2275ad6da 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/tick.ts +++ b/packages/data-react-pixie/src/features/main/ecs/transactions/tick.ts @@ -1,8 +1,7 @@ // © 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"; -// Advance one animation frame: every sprite rotates by `delta * 0.1`. Mirrors -// the pure `State.tick` transform (conformed in tick.test.ts). +// 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); 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/ecs/transactions/toggle-sprite-active.ts similarity index 82% rename from packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/toggle-sprite-active.ts rename to packages/data-react-pixie/src/features/main/ecs/transactions/toggle-sprite-active.ts index c0c89d7a2..4711faa5f 100644 --- 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/ecs/transactions/toggle-sprite-active.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"; export const toggleSpriteActive = ( t: CoreDatabase.Store, 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/ecs/transactions/transaction-database.ts similarity index 79% rename from packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transaction-database.ts rename to packages/data-react-pixie/src/features/main/ecs/transactions/transaction-database.ts index 02a036846..bf625d540 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transaction-database.ts +++ b/packages/data-react-pixie/src/features/main/ecs/transactions/transaction-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { CoreDatabase } from "../core/core-database.js"; +import * as transactions from "./index.js"; const transactionDatabasePlugin = Database.Plugin.create({ extends: CoreDatabase.plugin, 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/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/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/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/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/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/data/state/create-sprite.ts b/packages/data-react-pixie/src/features/main/spec/create-sprite.ts similarity index 90% rename from packages/data-react-pixie/src/features/main/data/state/create-sprite.ts rename to packages/data-react-pixie/src/features/main/spec/create-sprite.ts index c4f0f60c4..1022dceae 100644 --- a/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts +++ b/packages/data-react-pixie/src/features/main/spec/create-sprite.ts @@ -1,6 +1,6 @@ // © 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 { 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 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/data/state/set-filter.ts b/packages/data-react-pixie/src/features/main/spec/set-filter.ts similarity index 81% rename from packages/data-react-pixie/src/features/main/data/state/set-filter.ts rename to packages/data-react-pixie/src/features/main/spec/set-filter.ts index 9e51e95e0..1ed3d1976 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-filter.ts +++ b/packages/data-react-pixie/src/features/main/spec/set-filter.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { FilterKind } from "../filter-kind/filter-kind.js"; +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. 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/data/state/tick.ts b/packages/data-react-pixie/src/features/main/spec/tick.ts similarity index 91% rename from packages/data-react-pixie/src/features/main/data/state/tick.ts rename to packages/data-react-pixie/src/features/main/spec/tick.ts index 2a0ea1a5c..78e889618 100644 --- a/packages/data-react-pixie/src/features/main/data/state/tick.ts +++ b/packages/data-react-pixie/src/features/main/spec/tick.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Sprite } from "../sprite/sprite.js"; +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. 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/data/state/public.ts b/packages/data-react-pixie/src/features/main/spec/transforms.ts similarity index 59% rename from packages/data-react-pixie/src/features/main/data/state/public.ts rename to packages/data-react-pixie/src/features/main/spec/transforms.ts index b53ae5d7e..52555dfa9 100644 --- a/packages/data-react-pixie/src/features/main/data/state/public.ts +++ b/packages/data-react-pixie/src/features/main/spec/transforms.ts @@ -1,6 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export { create } from "./create.js"; -export { samples } from "./samples.js"; + +// 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"; 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/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" }] } From b7cbd50774cd4e8feed13451065caece09901278 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 18:09:26 -0700 Subject: [PATCH 06/24] refactor(data-lit-tictactoe): port to the layered feature layout State holds placed marks (entities) with the board as a derivation; fixes currentPlayer not reacting to firstPlayer after a restart. Co-Authored-By: Claude Opus 5.5 --- packages/data-lit-tictactoe/index.html | 2 +- .../data-lit-tictactoe/src/{ => app}/main.ts | 4 +- .../main/data/board-state/board-state.ts | 7 -- .../board-state/create-initial-board.test.ts | 10 -- .../data/board-state/create-initial-board.ts | 5 - .../main/data/board-state/from-marks.ts | 11 --- .../data/board-state/set-board-cell.test.ts | 23 ----- .../main/data/board-state/set-board-cell.ts | 13 --- .../main/data/cell-index/cell-index.ts | 6 -- .../main/data/components/cell-index.ts | 4 + .../features/main/data/components/index.ts | 5 + .../src/features/main/data/components/mark.ts | 4 + .../src/features/main/data/entities/index.ts | 4 + .../main/data/entities/placed-mark.ts | 7 ++ .../main/data/game-status/game-status.ts | 6 -- .../move-reject-reason/move-reject-reason.ts | 6 -- .../main/data/move-reject-reason/schema.ts | 7 -- .../main/data/placed-mark/placed-mark.ts | 6 -- .../features/main/data/placed-mark/schema.ts | 12 --- .../main/data/player-mark/player-mark.ts | 7 -- .../src/features/main/data/resources/draws.ts | 5 + .../main/data/resources/first-player.ts | 6 ++ .../src/features/main/data/resources/index.ts | 7 ++ .../features/main/data/resources/o-wins.ts | 5 + .../features/main/data/resources/x-wins.ts | 5 + .../src/features/main/data/score/public.ts | 2 - .../src/features/main/data/score/score.ts | 6 -- .../src/features/main/data/state/create.ts | 14 --- .../main/data/state/current-player.ts | 13 --- .../main/data/state/play-move.cases.ts | 52 ----------- .../src/features/main/data/state/play-move.ts | 26 ------ .../src/features/main/data/state/public.ts | 6 -- .../main/data/state/restart-game.cases.ts | 58 ------------ .../src/features/main/data/state/samples.ts | 9 -- .../src/features/main/data/state/spec.test.ts | 8 -- .../src/features/main/data/state/spec.ts | 33 ------- .../src/features/main/data/state/state.ts | 15 --- .../features/main/data/state/transforms.ts | 12 --- .../{ => values}/board-cell/blank.test.ts | 0 .../data/{ => values}/board-cell/blank.ts | 0 .../{ => values}/board-cell/board-cell.ts | 0 .../data/{ => values}/board-cell/public.ts | 0 .../data/values/board-state/board-state.ts | 4 + .../board-state/current-player.test.ts | 0 .../board-state/current-player.ts | 0 .../board-state/derive-status.test.ts | 0 .../{ => values}/board-state/derive-status.ts | 3 +- .../board-state/from-marks.test.ts | 6 +- .../data/values/board-state/from-marks.ts | 11 +++ .../board-state/get-move-count.test.ts | 0 .../board-state/get-move-count.ts | 0 .../board-state/get-winner.test.ts | 0 .../{ => values}/board-state/get-winner.ts | 5 +- .../board-state/get-winning-line.test.ts | 0 .../board-state/get-winning-line.ts | 0 .../board-state/is-board-full.test.ts | 0 .../{ => values}/board-state/is-board-full.ts | 0 .../board-state/is-game-over.test.ts | 0 .../{ => values}/board-state/is-game-over.ts | 0 .../data/{ => values}/board-state/public.ts | 2 - .../data/{ => values}/board-state/schema.ts | 4 + .../main/data/values/cell-index/cell-index.ts | 4 + .../data/{ => values}/cell-index/public.ts | 0 .../data/{ => values}/cell-index/schema.ts | 4 + .../data/values/game-status/game-status.ts | 4 + .../game-status/is-active.test.ts | 0 .../{ => values}/game-status/is-active.ts | 0 .../data/{ => values}/game-status/public.ts | 0 .../data/{ => values}/game-status/schema.ts | 4 + .../move-reject-reason/move-reject-reason.ts | 4 + .../{ => values}/move-reject-reason/public.ts | 0 .../data/values/move-reject-reason/schema.ts | 11 +++ .../play-move-args/can-play-move.test.ts | 0 .../play-move-args/can-play-move.ts | 0 .../play-move-args/play-move-args.ts | 0 .../{ => values}/play-move-args/public.ts | 0 .../data/{ => values}/player-mark/is.test.ts | 0 .../main/data/{ => values}/player-mark/is.ts | 0 .../player-mark/mark-color.test.ts | 0 .../{ => values}/player-mark/mark-color.ts | 0 .../{ => values}/player-mark/opponent.test.ts | 0 .../data/{ => values}/player-mark/opponent.ts | 0 .../data/values/player-mark/player-mark.ts | 4 + .../data/{ => values}/player-mark/public.ts | 0 .../data/{ => values}/player-mark/schema.ts | 4 + .../{ => values}/player-mark/values.test.ts | 0 .../data/{ => values}/player-mark/values.ts | 0 .../{placed-mark => values/score}/public.ts | 0 .../main/data/{ => values}/score/schema.ts | 4 + .../features/main/data/values/score/score.ts | 4 + .../{ => values}/winning-line/lines.test.ts | 0 .../data/{ => values}/winning-line/lines.ts | 0 .../data/{ => values}/winning-line/public.ts | 0 .../{ => values}/winning-line/winning-line.ts | 0 .../actions}/action-database.ts | 4 +- .../action-database => ecs}/actions/index.ts | 0 .../features/main/ecs/actions/play-move.ts | 8 ++ .../features/main/ecs/actions/restart-game.ts | 7 ++ .../src/features/main/ecs/computed/board.ts | 21 +++++ .../computed}/computed-database.ts | 4 +- .../main/ecs/computed/current-player.test.ts | 18 ++++ .../main/ecs/computed/current-player.ts | 14 +++ .../computed/index.ts | 2 - .../computed/is-game-over.ts | 4 +- .../computed/status.ts | 4 +- .../computed/winner.ts | 4 +- .../computed/winning-line.ts | 4 +- .../main/ecs/conformance/conformance.test.ts | 7 ++ .../main/ecs/conformance/implementation.ts | 14 +++ .../main/ecs/conformance/projection.ts | 42 +++++++++ .../src/features/main/ecs/core/archetypes.ts | 6 ++ .../features/main/ecs/core/core-database.ts | 28 ++++++ .../src/features/main/ecs/indexes/by-cell.ts | 9 ++ .../indexes}/index-database.ts | 4 +- .../index-database => ecs}/indexes/index.ts | 0 .../src/features/main/ecs/main-service.ts | 2 + .../main/ecs/services/create-agent-service.ts | 71 +++++++++++++++ .../ecs/services/create-root-agent-service.ts | 17 ++++ .../services/index.ts | 0 .../main/ecs/services/service-database.ts | 22 +++++ .../transactions/index.ts | 0 .../transactions/play-move.ts | 8 +- .../main/ecs/transactions/read-board.ts | 16 ++++ .../main/ecs/transactions/restart-game.ts | 22 +++++ .../transactions}/transaction-database.ts | 4 +- .../action-database/actions/play-move.ts | 9 -- .../action-database/actions/restart-game.ts | 8 -- .../computed-database/computed/board.ts | 22 ----- .../computed/current-player.ts | 12 --- .../computed-database/computed/move-count.ts | 10 -- .../computed-database/computed/state.ts | 21 ----- .../conformance/conformance.test.ts | 10 -- .../main-service/conformance/projection.ts | 51 ----------- .../main-service/core-database/archetypes.ts | 17 ---- .../main-service/core-database/components.ts | 11 --- .../core-database/core-database.ts | 40 -------- .../main-service/core-database/resources.ts | 13 --- .../index-database/indexes/by-cell.ts | 9 -- .../services/main-service/main-service.ts | 7 -- .../service-database/service-database.ts | 26 ------ .../services/create-agent-service.ts | 91 ------------------- .../services/create-root-agent-service.ts | 26 ------ .../transactions/read-board.ts | 20 ---- .../transactions/restart-game.ts | 25 ----- .../src/features/main/spec/board.cases.ts | 29 ++++++ .../src/features/main/spec/board.ts | 7 ++ .../src/features/main/spec/create.ts | 12 +++ .../state => spec}/current-player.cases.ts | 24 +---- .../src/features/main/spec/current-player.ts | 9 ++ .../src/features/main/spec/marks-of.ts | 15 +++ .../src/features/main/spec/play-move.cases.ts | 55 +++++++++++ .../src/features/main/spec/play-move.ts | 18 ++++ .../src/features/main/spec/public.ts | 3 + .../features/main/spec/restart-game.cases.ts | 32 +++++++ .../main/{data/state => spec}/restart-game.ts | 12 +-- .../src/features/main/spec/samples.ts | 10 ++ .../src/features/main/spec/spec.test.ts | 6 ++ .../src/features/main/spec/spec.ts | 18 ++++ .../src/features/main/spec/state.ts | 24 +++++ .../src/features/main/spec/transforms.ts | 9 ++ .../main/ui/tictactoe-app/tictactoe-app.ts | 2 +- .../main/ui/tictactoe-cell/observe-cell.ts | 8 +- .../tictactoe-cell-presentation.ts | 2 +- .../src/features/main/ui/tictactoe-element.ts | 2 +- .../ui/tictactoe-hud/tictactoe-hud-element.ts | 2 +- .../tictactoe-hud-presentation.ts | 4 +- packages/data-lit-tictactoe/src/index.ts | 10 +- .../data-lit-tictactoe/tsconfig.build.json | 29 ------ .../data-lit-tictactoe/tsconfig.data.json | 16 ++++ packages/data-lit-tictactoe/tsconfig.ecs.json | 16 ++++ .../data-lit-tictactoe/tsconfig.spec.json | 17 ++++ .../data-lit-tictactoe/tsconfig.test.json | 22 ++--- 172 files changed, 803 insertions(+), 917 deletions(-) rename packages/data-lit-tictactoe/src/{ => app}/main.ts (74%) delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/board-state.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/create-initial-board.test.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/create-initial-board.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/from-marks.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/set-board-cell.test.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/set-board-cell.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/cell-index/cell-index.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/components/cell-index.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/components/index.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/components/mark.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/entities/index.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/entities/placed-mark.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/game-status/game-status.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/move-reject-reason/move-reject-reason.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/move-reject-reason/schema.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/placed-mark/placed-mark.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/placed-mark/schema.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/player-mark/player-mark.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/resources/draws.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/resources/first-player.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/resources/index.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/resources/o-wins.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/resources/x-wins.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/score/public.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/score/score.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/create.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/play-move.cases.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/public.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/restart-game.cases.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/samples.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/spec.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/state.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/transforms.ts rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-cell/blank.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-cell/blank.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-cell/board-cell.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-cell/public.ts (100%) create mode 100644 packages/data-lit-tictactoe/src/features/main/data/values/board-state/board-state.ts rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/current-player.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/current-player.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/derive-status.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/derive-status.ts (79%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/from-marks.test.ts (78%) create mode 100644 packages/data-lit-tictactoe/src/features/main/data/values/board-state/from-marks.ts rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/get-move-count.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/get-move-count.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/get-winner.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/get-winner.ts (71%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/get-winning-line.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/get-winning-line.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/is-board-full.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/is-board-full.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/is-game-over.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/is-game-over.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/public.ts (81%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/board-state/schema.ts (69%) create mode 100644 packages/data-lit-tictactoe/src/features/main/data/values/cell-index/cell-index.ts rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/cell-index/public.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/cell-index/schema.ts (61%) create mode 100644 packages/data-lit-tictactoe/src/features/main/data/values/game-status/game-status.ts rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/game-status/is-active.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/game-status/is-active.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/game-status/public.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/game-status/schema.ts (55%) create mode 100644 packages/data-lit-tictactoe/src/features/main/data/values/move-reject-reason/move-reject-reason.ts rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/move-reject-reason/public.ts (100%) create mode 100644 packages/data-lit-tictactoe/src/features/main/data/values/move-reject-reason/schema.ts rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/play-move-args/can-play-move.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/play-move-args/can-play-move.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/play-move-args/play-move-args.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/play-move-args/public.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/player-mark/is.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/player-mark/is.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/player-mark/mark-color.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/player-mark/mark-color.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/player-mark/opponent.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/player-mark/opponent.ts (100%) create mode 100644 packages/data-lit-tictactoe/src/features/main/data/values/player-mark/player-mark.ts rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/player-mark/public.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/player-mark/schema.ts (57%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/player-mark/values.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/player-mark/values.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{placed-mark => values/score}/public.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/score/schema.ts (62%) create mode 100644 packages/data-lit-tictactoe/src/features/main/data/values/score/score.ts rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/winning-line/lines.test.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/winning-line/lines.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/winning-line/public.ts (100%) rename packages/data-lit-tictactoe/src/features/main/data/{ => values}/winning-line/winning-line.ts (100%) rename packages/data-lit-tictactoe/src/features/main/{services/main-service/action-database => ecs/actions}/action-database.ts (78%) rename packages/data-lit-tictactoe/src/features/main/{services/main-service/action-database => ecs}/actions/index.ts (100%) create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/actions/play-move.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/actions/restart-game.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/computed/board.ts rename packages/data-lit-tictactoe/src/features/main/{services/main-service/computed-database => ecs/computed}/computed-database.ts (77%) create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/computed/current-player.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/computed/current-player.ts rename packages/data-lit-tictactoe/src/features/main/{services/main-service/computed-database => ecs}/computed/index.ts (80%) rename packages/data-lit-tictactoe/src/features/main/{services/main-service/computed-database => ecs}/computed/is-game-over.ts (66%) rename packages/data-lit-tictactoe/src/features/main/{services/main-service/computed-database => ecs}/computed/status.ts (66%) rename packages/data-lit-tictactoe/src/features/main/{services/main-service/computed-database => ecs}/computed/winner.ts (66%) rename packages/data-lit-tictactoe/src/features/main/{services/main-service/computed-database => ecs}/computed/winning-line.ts (66%) create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/conformance/conformance.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/conformance/implementation.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/conformance/projection.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/core/archetypes.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/core/core-database.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/indexes/by-cell.ts rename packages/data-lit-tictactoe/src/features/main/{services/main-service/index-database => ecs/indexes}/index-database.ts (79%) rename packages/data-lit-tictactoe/src/features/main/{services/main-service/index-database => ecs}/indexes/index.ts (100%) create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/main-service.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/services/create-agent-service.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/services/create-root-agent-service.ts rename packages/data-lit-tictactoe/src/features/main/{services/main-service/service-database => ecs}/services/index.ts (100%) create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/services/service-database.ts rename packages/data-lit-tictactoe/src/features/main/{services/main-service/transaction-database => ecs}/transactions/index.ts (100%) rename packages/data-lit-tictactoe/src/features/main/{services/main-service/transaction-database => ecs}/transactions/play-move.ts (70%) create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/transactions/read-board.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/ecs/transactions/restart-game.ts rename packages/data-lit-tictactoe/src/features/main/{services/main-service/transaction-database => ecs/transactions}/transaction-database.ts (79%) delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-move.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/restart-game.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/board.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/current-player.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/move-count.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/state.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/conformance.test.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/archetypes.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/components.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/core-database.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/core-database/resources.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/index-database/indexes/by-cell.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/main-service.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/service-database.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/services/create-agent-service.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/services/create-root-agent-service.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/read-board.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/restart-game.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/spec/board.cases.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/spec/board.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/spec/create.ts rename packages/data-lit-tictactoe/src/features/main/{data/state => spec}/current-player.cases.ts (53%) create mode 100644 packages/data-lit-tictactoe/src/features/main/spec/current-player.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/spec/marks-of.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/spec/play-move.cases.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/spec/play-move.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/spec/public.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/spec/restart-game.cases.ts rename packages/data-lit-tictactoe/src/features/main/{data/state => spec}/restart-game.ts (57%) create mode 100644 packages/data-lit-tictactoe/src/features/main/spec/samples.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/spec/spec.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/spec/spec.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/spec/state.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/spec/transforms.ts delete mode 100644 packages/data-lit-tictactoe/tsconfig.build.json create mode 100644 packages/data-lit-tictactoe/tsconfig.data.json create mode 100644 packages/data-lit-tictactoe/tsconfig.ecs.json create mode 100644 packages/data-lit-tictactoe/tsconfig.spec.json diff --git a/packages/data-lit-tictactoe/index.html b/packages/data-lit-tictactoe/index.html index 1960da5ed..f3756a311 100644 --- a/packages/data-lit-tictactoe/index.html +++ b/packages/data-lit-tictactoe/index.html @@ -13,6 +13,6 @@

data-lit-tictactoe

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

- + diff --git a/packages/data-lit-tictactoe/src/main.ts b/packages/data-lit-tictactoe/src/app/main.ts similarity index 74% rename from packages/data-lit-tictactoe/src/main.ts rename to packages/data-lit-tictactoe/src/app/main.ts index 60eeeb9c5..d5dd481af 100644 --- a/packages/data-lit-tictactoe/src/main.ts +++ b/packages/data-lit-tictactoe/src/app/main.ts @@ -2,8 +2,8 @@ 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"; +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) { 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/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/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/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/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/public.ts b/packages/data-lit-tictactoe/src/features/main/data/score/public.ts deleted file mode 100644 index 363fd32b4..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/score/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-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.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/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..c5d88325b --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/from-marks.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { BoardState } from "./board-state.js"; +import type { PlacedMark } from "../../entities/placed-mark.js"; + +// Fold the placed marks (the entity source of truth) into the compact +// index-addressed board the other helpers operate on. +export const fromMarks = (marks: Iterable): 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/board-state/get-winner.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-winner.ts similarity index 71% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/get-winner.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-winner.ts index 1d5cf3971..f9469132a 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/board-state/get-winner.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/get-winner.ts @@ -1,7 +1,7 @@ // © 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 { PlayerMark } from "../player-mark/player-mark.js"; import { getWinningLine } from "./get-winning-line.js"; import { isBoardFull } from "./is-board-full.js"; @@ -9,6 +9,7 @@ export const getWinner = ( board: BoardState, ): PlayerMark | "cat" | null => { const line = getWinningLine(board); - if (line) return board[line[0]] as PlayerMark; + 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/board-state/public.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/public.ts similarity index 81% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/public.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/public.ts index efd52236b..239d943ed 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/board-state/public.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/public.ts @@ -2,8 +2,6 @@ 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"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/schema.ts similarity index 69% rename from packages/data-lit-tictactoe/src/features/main/data/board-state/schema.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/board-state/schema.ts index a5ef21ec2..c00073e89 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/board-state/schema.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/values/board-state/schema.ts @@ -1,4 +1,6 @@ // © 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". @@ -10,3 +12,5 @@ export const schema = { 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-lit-tictactoe/src/features/main/data/cell-index/public.ts b/packages/data-lit-tictactoe/src/features/main/data/values/cell-index/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/cell-index/public.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/cell-index/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/values/cell-index/schema.ts similarity index 61% rename from packages/data-lit-tictactoe/src/features/main/data/cell-index/schema.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/cell-index/schema.ts index ecbdc13fe..581c5dd13 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/cell-index/schema.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/values/cell-index/schema.ts @@ -1,4 +1,6 @@ // © 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. @@ -7,3 +9,5 @@ export const schema = { 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/game-status/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/values/game-status/schema.ts similarity index 55% rename from packages/data-lit-tictactoe/src/features/main/data/game-status/schema.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/game-status/schema.ts index c0f6fa3f8..75fa746e8 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/game-status/schema.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/values/game-status/schema.ts @@ -1,7 +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/move-reject-reason/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/move-reject-reason/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/player-mark/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/schema.ts similarity index 57% rename from packages/data-lit-tictactoe/src/features/main/data/player-mark/schema.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/player-mark/schema.ts index b27a63335..0089c6da6 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/player-mark/schema.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/values/player-mark/schema.ts @@ -1,4 +1,6 @@ // © 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 = { @@ -7,3 +9,5 @@ export const schema = { 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/placed-mark/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/placed-mark/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/score/schema.ts b/packages/data-lit-tictactoe/src/features/main/data/values/score/schema.ts similarity index 62% rename from packages/data-lit-tictactoe/src/features/main/data/score/schema.ts rename to packages/data-lit-tictactoe/src/features/main/data/values/score/schema.ts index ae015c673..16f1072f8 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/score/schema.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/values/score/schema.ts @@ -1,4 +1,6 @@ // © 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. @@ -7,3 +9,5 @@ export const schema = { 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/services/main-service/action-database/action-database.ts b/packages/data-lit-tictactoe/src/features/main/ecs/actions/action-database.ts similarity index 78% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/action-database.ts rename to packages/data-lit-tictactoe/src/features/main/ecs/actions/action-database.ts index 84dc6b4ac..6d8034b33 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/action-database.ts +++ b/packages/data-lit-tictactoe/src/features/main/ecs/actions/action-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { ServiceDatabase } from "../services/service-database.js"; +import * as actions from "./index.js"; const actionDatabasePlugin = Database.Plugin.create({ extends: ServiceDatabase.plugin, 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..a2a7d7bdf --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/board.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 { BoardState } from "../../data/values/board-state/board-state.js"; +import type { PlacedMark } from "../../data/entities/placed-mark.js"; +import type { IndexDatabase } from "../indexes/index-database.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((db) => { + 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/services/main-service/computed-database/computed-database.ts b/packages/data-lit-tictactoe/src/features/main/ecs/computed/computed-database.ts similarity index 77% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed-database.ts rename to packages/data-lit-tictactoe/src/features/main/ecs/computed/computed-database.ts index d66e1ad68..268bf3446 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed-database.ts +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/computed-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; +import * as computed from "./index.js"; const computedDatabasePlugin = Database.Plugin.create({ extends: TransactionDatabase.plugin, 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/services/main-service/computed-database/computed/index.ts b/packages/data-lit-tictactoe/src/features/main/ecs/computed/index.ts similarity index 80% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/index.ts rename to packages/data-lit-tictactoe/src/features/main/ecs/computed/index.ts index 654195b19..82be88367 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/index.ts +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/index.ts @@ -1,8 +1,6 @@ // © 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"; 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/ecs/computed/is-game-over.ts similarity index 66% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/is-game-over.ts rename to packages/data-lit-tictactoe/src/features/main/ecs/computed/is-game-over.ts index 8463cd41e..940755bfd 100644 --- 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/ecs/computed/is-game-over.ts @@ -1,8 +1,8 @@ // © 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 { 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) => 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/ecs/computed/status.ts similarity index 66% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/status.ts rename to packages/data-lit-tictactoe/src/features/main/ecs/computed/status.ts index d65d7e44a..326f2edcc 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/status.ts +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/status.ts @@ -1,8 +1,8 @@ // © 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 { 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) => 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/ecs/computed/winner.ts similarity index 66% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/winner.ts rename to packages/data-lit-tictactoe/src/features/main/ecs/computed/winner.ts index 644e45835..05dd178cf 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/winner.ts +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/winner.ts @@ -1,8 +1,8 @@ // © 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 { 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) => 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/ecs/computed/winning-line.ts similarity index 66% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/winning-line.ts rename to packages/data-lit-tictactoe/src/features/main/ecs/computed/winning-line.ts index 33dcc4d4c..31b2c6553 100644 --- 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/ecs/computed/winning-line.ts @@ -1,8 +1,8 @@ // © 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 { 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) => 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/services/main-service/index-database/index-database.ts b/packages/data-lit-tictactoe/src/features/main/ecs/indexes/index-database.ts similarity index 79% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/index-database/index-database.ts rename to packages/data-lit-tictactoe/src/features/main/ecs/indexes/index-database.ts index 04b996c61..3002e615c 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/index-database/index-database.ts +++ b/packages/data-lit-tictactoe/src/features/main/ecs/indexes/index-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { CoreDatabase } from "../core/core-database.js"; +import * as indexes from "./index.js"; const indexDatabasePlugin = Database.Plugin.create({ extends: CoreDatabase.plugin, 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..e201b247b --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/services/create-agent-service.ts @@ -0,0 +1,71 @@ +// © 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 { ComputedDatabase } from "../computed/computed-database.js"; +import { board, currentPlayer, isGameOver, winner } from "../computed/index.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. It calls the +// computeds directly because `db.computed` is not yet populated while services build. +export const createAgentService = (db: ComputedDatabase, agentMark: PlayerMark): AgenticService => { + const gameOver = isGameOver(db); + const yourTurn = Observe.withMap( + Observe.fromProperties({ isGameOver: gameOver, currentPlayer: currentPlayer(db) }), + ({ isGameOver, currentPlayer }) => !isGameOver && currentPlayer === 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: board(db), + yourTurn, + winner: winner(db), + 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..403100f59 --- /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 { ComputedDatabase } from "../computed/computed-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: ComputedDatabase): 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..fa724d1b8 --- /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 { ComputedDatabase } from "../computed/computed-database.js"; +import { createAgentService, createRootAgentService } from "./index.js"; + +// AI agent services. Each reads the game's computeds and plays through its +// transactions, so it is built from the database rather than injected. +const serviceDatabasePlugin = Database.Plugin.create({ + extends: ComputedDatabase.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/services/main-service/transaction-database/transactions/play-move.ts b/packages/data-lit-tictactoe/src/features/main/ecs/transactions/play-move.ts similarity index 70% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/play-move.ts rename to packages/data-lit-tictactoe/src/features/main/ecs/transactions/play-move.ts index da85c2bf3..f2840a28b 100644 --- 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/ecs/transactions/play-move.ts @@ -1,7 +1,7 @@ // © 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 { 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"; /** @@ -15,5 +15,5 @@ export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => { 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 }); + 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..241ca36d8 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/ecs/transactions/read-board.ts @@ -0,0 +1,16 @@ +// © 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 synchronously from the placed-mark entities (the +// `board` computed is the reactive counterpart). Shared by playMove and restartGame. +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), cellIndex: arch.columns.cellIndex.get(row) }); + } + } + 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/services/main-service/transaction-database/transaction-database.ts b/packages/data-lit-tictactoe/src/features/main/ecs/transactions/transaction-database.ts similarity index 79% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transaction-database.ts rename to packages/data-lit-tictactoe/src/features/main/ecs/transactions/transaction-database.ts index ce4267576..b81bb52ac 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transaction-database.ts +++ b/packages/data-lit-tictactoe/src/features/main/ecs/transactions/transaction-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { IndexDatabase } from "../indexes/index-database.js"; +import * as transactions from "./index.js"; const transactionDatabasePlugin = Database.Plugin.create({ extends: IndexDatabase.plugin, 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/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/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/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/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/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/data/state/current-player.cases.ts b/packages/data-lit-tictactoe/src/features/main/spec/current-player.cases.ts similarity index 53% rename from packages/data-lit-tictactoe/src/features/main/data/state/current-player.cases.ts rename to packages/data-lit-tictactoe/src/features/main/spec/current-player.cases.ts index d7c1d0934..a9f574b6b 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/current-player.cases.ts +++ b/packages/data-lit-tictactoe/src/features/main/spec/current-player.cases.ts @@ -1,42 +1,28 @@ // © 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"; -// 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", - }, + input: { marks: new Map(), firstPlayer: "X" }, value: "X", }, { name: "honors a first player of O on an empty board", - input: { - board: " ", - firstPlayer: "O", - }, + input: { marks: new Map(), firstPlayer: "O" }, value: "O", }, { name: "alternates to the opponent after the first move", - input: { - board: " X ", - firstPlayer: "X", - }, + input: { marks: marksOf(" X "), firstPlayer: "X" }, value: "O", }, { name: "returns to the first player after both have moved", - input: { - board: "XO ", - firstPlayer: "X", - }, + 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/data/state/restart-game.ts b/packages/data-lit-tictactoe/src/features/main/spec/restart-game.ts similarity index 57% rename from packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts rename to packages/data-lit-tictactoe/src/features/main/spec/restart-game.ts index 4ab570d2d..e297fc38e 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts +++ b/packages/data-lit-tictactoe/src/features/main/spec/restart-game.ts @@ -1,18 +1,18 @@ // © 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 { 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(state.board); - const status = BoardState.deriveStatus(state.board); + const winner = BoardState.getWinner(board(state)); return { - board: BoardState.createInitialBoard(), + 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 + (status === "draw" ? 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/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" }] } From b74243d40d8af7a06ce088c8982a3534f143ca86 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 18:09:27 -0700 Subject: [PATCH 07/24] refactor(data-p2p-tictactoe): port to the layered feature layout Co-Authored-By: Claude Opus 5.5 --- packages/data-p2p-tictactoe/ARCHITECTURE.md | 2 +- packages/data-p2p-tictactoe/index.html | 2 +- packages/data-p2p-tictactoe/src/app/main.ts | 27 ++++++++++ packages/data-p2p-tictactoe/src/app/schema.ts | 12 +++++ .../negotiation/data/resources/answer-code.ts | 6 +++ .../data/resources/banner-error.ts | 7 +++ .../negotiation/data/resources/banner-text.ts | 6 +++ .../negotiation/data/resources/connection.ts | 6 +++ .../negotiation/data/resources/game-db.ts | 8 +++ .../data/resources/host-answer-input.ts | 6 +++ .../negotiation/data/resources/index.ts | 26 +++++++++ .../data/resources/joiner-offer-input.ts | 6 +++ .../negotiation/data/resources/offer-code.ts | 6 +++ .../negotiation/data/resources/phase.ts | 7 +++ .../negotiation/data/resources/role.ts | 8 +++ .../negotiation/data/resources/session-id.ts | 7 +++ .../negotiation/data/state/spec.test.ts | 8 --- .../features/negotiation/data/state/spec.ts | 52 ------------------ .../negotiation/data/state/transforms.ts | 16 ------ .../connection-state/connection-state.ts | 0 .../{ => values}/connection-state/public.ts | 0 .../{ => values}/connection-state/schema.ts | 0 .../data/{ => values}/phase/phase.ts | 2 +- .../data/{ => values}/phase/public.ts | 0 .../data/{ => values}/phase/schema.ts | 0 .../data/{ => values}/role/public.ts | 0 .../data/{ => values}/role/role.ts | 0 .../data/{ => values}/role/schema.ts | 0 .../actions}/action-database.ts | 4 +- .../actions/dispose.ts | 2 +- .../negotiation/ecs/actions/enter-game.ts | 8 +++ .../actions/generate-answer.ts | 2 +- .../features/negotiation/ecs/actions/index.ts | 19 +++++++ .../actions/reconnect.ts | 2 +- .../ecs/actions/set-answer-code.ts | 6 +++ .../actions/set-banner.ts | 4 +- .../negotiation/ecs/actions/set-connection.ts | 10 ++++ .../ecs/actions/set-host-answer-input.ts | 6 +++ .../actions/set-joiner-offer-input.ts | 4 +- .../negotiation/ecs/actions/set-offer-code.ts | 6 +++ .../ecs/actions/start-host-signaling.ts | 6 +++ .../actions/start-host.ts | 2 +- .../ecs/actions/start-join-signaling.ts | 6 +++ .../actions/start-join.ts | 2 +- .../actions/submit-answer.ts | 2 +- .../ecs/conformance/conformance.test.ts | 7 +++ .../ecs/conformance/implementation.ts | 14 +++++ .../conformance/projection.ts | 4 +- .../core}/core-database.ts | 2 +- .../main-service => ecs}/main-service.ts | 2 +- .../create-connection-service.test.ts | 9 ++-- .../services/create-connection-service.ts | 40 ++++---------- .../services/index.ts | 0 .../ecs/services/service-database.ts | 31 +++++++++++ .../transactions/index.ts | 0 .../transactions/set-answer-code.ts | 2 +- .../transactions/set-banner.ts | 2 +- .../transactions/set-connection.ts | 4 +- .../ecs/transactions/set-game-db.ts | 13 +++++ .../transactions/set-host-answer-input.ts | 2 +- .../transactions/set-joiner-offer-input.ts | 2 +- .../transactions/set-offer-code.ts | 2 +- .../transactions/start-host-signaling.ts | 2 +- .../transactions/start-join-signaling.ts | 2 +- .../transactions}/transaction-database.ts | 4 +- .../services/game-service/create-fake.ts | 10 ++++ .../services/game-service/create.ts | 7 +++ .../services/game-service/game-service.ts | 20 +++++++ .../services/game-service/public.ts | 3 ++ .../action-database/actions/configure.ts | 6 --- .../action-database/actions/enter-game.ts | 11 ---- .../action-database/actions/index.ts | 24 --------- .../actions/set-answer-code.ts | 8 --- .../action-database/actions/set-connection.ts | 12 ----- .../actions/set-host-answer-input.ts | 8 --- .../action-database/actions/set-offer-code.ts | 8 --- .../actions/start-host-signaling.ts | 9 ---- .../actions/start-join-signaling.ts | 8 --- .../conformance/conformance.test.ts | 10 ---- .../main-service/core-database/resources.ts | 29 ---------- .../service-database/service-database.ts | 20 ------- .../transactions/set-game-db.ts | 15 ------ .../services/signaling-service/create-fake.ts | 15 ++++++ .../services/signaling-service/create.ts | 2 +- .../services/signaling-service/public.ts | 1 + .../signaling-service/signaling.fake.ts | 20 ------- .../{data/state => spec}/create.ts | 0 .../{data/state => spec}/enter-game.cases.ts | 5 +- .../{data/state => spec}/enter-game.ts | 0 .../data/state => negotiation/spec}/public.ts | 1 - .../{data/state => spec}/samples.ts | 0 .../state => spec}/set-answer-code.cases.ts | 4 +- .../{data/state => spec}/set-answer-code.ts | 0 .../{data/state => spec}/set-banner.cases.ts | 3 +- .../{data/state => spec}/set-banner.ts | 0 .../state => spec}/set-connection.cases.ts | 4 +- .../{data/state => spec}/set-connection.ts | 2 +- .../set-host-answer-input.cases.ts | 4 +- .../state => spec}/set-host-answer-input.ts | 0 .../set-joiner-offer-input.cases.ts | 4 +- .../state => spec}/set-joiner-offer-input.ts | 0 .../state => spec}/set-offer-code.cases.ts | 4 +- .../{data/state => spec}/set-offer-code.ts | 0 .../features/negotiation/spec/spec.test.ts | 6 +++ .../src/features/negotiation/spec/spec.ts | 33 ++++++++++++ .../start-host-signaling.cases.ts | 7 ++- .../state => spec}/start-host-signaling.ts | 0 .../start-join-signaling.cases.ts | 6 +-- .../state => spec}/start-join-signaling.ts | 0 .../negotiation/{data/state => spec}/state.ts | 13 +++-- .../state/public.ts => spec/transforms.ts} | 6 ++- .../negotiation/ui/p2p-app/p2p-app.ts | 25 --------- .../p2p-negotiation-element.ts | 34 +++--------- .../p2p-negotiation-presentation.ts | 4 +- .../ui/p2p-negotiation/p2p-negotiation.ts | 28 +++++----- .../presence/data/resources/cursors.ts | 10 ++++ .../features/presence/data/resources/index.ts | 4 ++ .../features/presence/data/state/spec.test.ts | 8 --- .../src/features/presence/data/state/spec.ts | 20 ------- .../src/features/presence/data/state/state.ts | 11 ---- .../data/{ => values}/cursors/cursors.ts | 0 .../data/{ => values}/cursors/public.ts | 2 +- .../presence/data/{ => values}/cursors/set.ts | 0 .../actions}/action-database.ts | 8 +-- .../features/presence/ecs/actions/index.ts | 5 ++ .../presence/ecs/actions/move-presence.ts | 8 +++ .../actions/track-presence.ts | 2 +- .../ecs/conformance/conformance.test.ts | 54 +++++++++++++++++++ .../ecs/conformance/implementation.ts | 13 +++++ .../conformance/projection.ts | 8 ++- .../core}/core-database.ts | 2 +- .../main-service => ecs}/main-service.ts | 4 +- .../transactions/index.ts | 0 .../transactions/move-presence.ts | 11 ++-- .../transactions}/transaction-database.ts | 4 +- .../action-database/actions/index.ts | 10 ---- .../action-database/actions/move-presence.ts | 12 ----- .../main-service/conformance/actions.test.ts | 47 ---------------- .../main-service/conformance/create-store.ts | 7 --- .../conformance/projection.test.ts | 21 -------- .../main-service/conformance/seed-user-id.ts | 11 ---- .../conformance/transactions.test.ts | 23 -------- .../main-service/core-database/resources.ts | 11 ---- .../presence/{data/state => spec}/create.ts | 0 .../state => spec}/move-presence.cases.ts | 0 .../{data/state => spec}/move-presence.ts | 2 +- .../src/features/presence/spec/public.ts | 3 ++ .../presence/{data/state => spec}/samples.ts | 0 .../src/features/presence/spec/spec.test.ts | 6 +++ .../src/features/presence/spec/spec.ts | 13 +++++ .../src/features/presence/spec/state.ts | 18 +++++++ .../{data/state => spec}/transforms.ts | 0 .../p2p-presence-overlay-element.ts | 2 +- .../p2p-presence-overlay-presentation.ts | 4 +- .../p2p-presence-overlay.ts | 2 +- packages/data-p2p-tictactoe/src/main.ts | 9 ---- .../data-p2p-tictactoe/tsconfig.build.json | 29 ---------- .../data-p2p-tictactoe/tsconfig.data.json | 16 ++++++ packages/data-p2p-tictactoe/tsconfig.ecs.json | 16 ++++++ .../data-p2p-tictactoe/tsconfig.spec.json | 17 ++++++ .../data-p2p-tictactoe/tsconfig.test.json | 22 +++----- 161 files changed, 684 insertions(+), 685 deletions(-) create mode 100644 packages/data-p2p-tictactoe/src/app/main.ts create mode 100644 packages/data-p2p-tictactoe/src/app/schema.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/resources/answer-code.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-error.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-text.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/resources/connection.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/resources/game-db.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/resources/host-answer-input.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/resources/index.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/resources/joiner-offer-input.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/resources/offer-code.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/resources/phase.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/resources/role.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/resources/session-id.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/transforms.ts rename packages/data-p2p-tictactoe/src/features/negotiation/data/{ => values}/connection-state/connection-state.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/data/{ => values}/connection-state/public.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/data/{ => values}/connection-state/schema.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/data/{ => values}/phase/phase.ts (75%) rename packages/data-p2p-tictactoe/src/features/negotiation/data/{ => values}/phase/public.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/data/{ => values}/phase/schema.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/data/{ => values}/role/public.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/data/{ => values}/role/role.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/data/{ => values}/role/schema.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/action-database => ecs/actions}/action-database.ts (85%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/action-database => ecs}/actions/dispose.ts (62%) create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/enter-game.ts rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/action-database => ecs}/actions/generate-answer.ts (65%) create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/index.ts rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/action-database => ecs}/actions/reconnect.ts (63%) create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-answer-code.ts rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/action-database => ecs}/actions/set-banner.ts (52%) create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-connection.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-host-answer-input.ts rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/action-database => ecs}/actions/set-joiner-offer-input.ts (50%) create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-offer-code.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-host-signaling.ts rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/action-database => ecs}/actions/start-host.ts (63%) create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-join-signaling.ts rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/action-database => ecs}/actions/start-join.ts (63%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/action-database => ecs}/actions/submit-answer.ts (64%) create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/ecs/conformance/conformance.test.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/ecs/conformance/implementation.ts rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service => ecs}/conformance/projection.ts (92%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/core-database => ecs/core}/core-database.ts (91%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service => ecs}/main-service.ts (80%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/service-database => ecs}/services/create-connection-service.test.ts (73%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/service-database => ecs}/services/create-connection-service.ts (80%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/service-database => ecs}/services/index.ts (100%) create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/ecs/services/service-database.ts rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/transaction-database => ecs}/transactions/index.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/transaction-database => ecs}/transactions/set-answer-code.ts (74%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/transaction-database => ecs}/transactions/set-banner.ts (77%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/transaction-database => ecs}/transactions/set-connection.ts (65%) create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-game-db.ts rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/transaction-database => ecs}/transactions/set-host-answer-input.ts (72%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/transaction-database => ecs}/transactions/set-joiner-offer-input.ts (73%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/transaction-database => ecs}/transactions/set-offer-code.ts (73%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/transaction-database => ecs}/transactions/start-host-signaling.ts (82%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/transaction-database => ecs}/transactions/start-join-signaling.ts (80%) rename packages/data-p2p-tictactoe/src/features/negotiation/{services/main-service/transaction-database => ecs/transactions}/transaction-database.ts (84%) create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/create-fake.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/create.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/game-service.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/game-service/public.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/configure.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/enter-game.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/index.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-answer-code.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-connection.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-host-answer-input.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-offer-code.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-host-signaling.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-join-signaling.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/conformance.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/core-database/resources.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/service-database.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-game-db.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/create-fake.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/signaling.fake.ts rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/create.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/enter-game.cases.ts (69%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/enter-game.ts (100%) rename packages/data-p2p-tictactoe/src/features/{presence/data/state => negotiation/spec}/public.ts (72%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/samples.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/set-answer-code.cases.ts (83%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/set-answer-code.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/set-banner.cases.ts (87%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/set-banner.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/set-connection.cases.ts (82%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/set-connection.ts (84%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/set-host-answer-input.cases.ts (80%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/set-host-answer-input.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/set-joiner-offer-input.cases.ts (79%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/set-joiner-offer-input.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/set-offer-code.cases.ts (82%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/set-offer-code.ts (100%) create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/spec/spec.test.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/spec/spec.ts rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/start-host-signaling.cases.ts (68%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/start-host-signaling.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/start-join-signaling.cases.ts (77%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/start-join-signaling.ts (100%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state => spec}/state.ts (58%) rename packages/data-p2p-tictactoe/src/features/negotiation/{data/state/public.ts => spec/transforms.ts} (73%) delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/ui/p2p-app/p2p-app.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/resources/cursors.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/resources/index.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/spec.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/state.ts rename packages/data-p2p-tictactoe/src/features/presence/data/{ => values}/cursors/cursors.ts (100%) rename packages/data-p2p-tictactoe/src/features/presence/data/{ => values}/cursors/public.ts (64%) rename packages/data-p2p-tictactoe/src/features/presence/data/{ => values}/cursors/set.ts (100%) rename packages/data-p2p-tictactoe/src/features/presence/{services/main-service/action-database => ecs/actions}/action-database.ts (74%) create mode 100644 packages/data-p2p-tictactoe/src/features/presence/ecs/actions/index.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/ecs/actions/move-presence.ts rename packages/data-p2p-tictactoe/src/features/presence/{services/main-service/action-database => ecs}/actions/track-presence.ts (85%) create mode 100644 packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/conformance.test.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/implementation.ts rename packages/data-p2p-tictactoe/src/features/presence/{services/main-service => ecs}/conformance/projection.ts (65%) rename packages/data-p2p-tictactoe/src/features/presence/{services/main-service/core-database => ecs/core}/core-database.ts (87%) rename packages/data-p2p-tictactoe/src/features/presence/{services/main-service => ecs}/main-service.ts (57%) rename packages/data-p2p-tictactoe/src/features/presence/{services/main-service/transaction-database => ecs}/transactions/index.ts (100%) rename packages/data-p2p-tictactoe/src/features/presence/{services/main-service/transaction-database => ecs}/transactions/move-presence.ts (62%) rename packages/data-p2p-tictactoe/src/features/presence/{services/main-service/transaction-database => ecs/transactions}/transaction-database.ts (79%) delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/index.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/move-presence.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/create-store.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/seed-user-id.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/core-database/resources.ts rename packages/data-p2p-tictactoe/src/features/presence/{data/state => spec}/create.ts (100%) rename packages/data-p2p-tictactoe/src/features/presence/{data/state => spec}/move-presence.cases.ts (100%) rename packages/data-p2p-tictactoe/src/features/presence/{data/state => spec}/move-presence.ts (91%) create mode 100644 packages/data-p2p-tictactoe/src/features/presence/spec/public.ts rename packages/data-p2p-tictactoe/src/features/presence/{data/state => spec}/samples.ts (100%) create mode 100644 packages/data-p2p-tictactoe/src/features/presence/spec/spec.test.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/spec/spec.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/spec/state.ts rename packages/data-p2p-tictactoe/src/features/presence/{data/state => spec}/transforms.ts (100%) delete mode 100644 packages/data-p2p-tictactoe/src/main.ts delete mode 100644 packages/data-p2p-tictactoe/tsconfig.build.json create mode 100644 packages/data-p2p-tictactoe/tsconfig.data.json create mode 100644 packages/data-p2p-tictactoe/tsconfig.ecs.json create mode 100644 packages/data-p2p-tictactoe/tsconfig.spec.json 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/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/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/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-p2p-tictactoe/src/features/negotiation/data/connection-state/public.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/values/connection-state/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/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/phase/phase.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/values/phase/phase.ts similarity index 75% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/phase/phase.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/data/values/phase/phase.ts index f48b46a6a..4a146d9dd 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/phase/phase.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/values/phase/phase.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /** - * Local-only screen the peer is currently on. Held as a nonPersistent resource + * 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"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/phase/public.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/values/phase/public.ts similarity index 100% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/phase/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/role/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/role/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/services/main-service/action-database/action-database.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/action-database.ts similarity index 85% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/action-database.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/action-database.ts index d7ed2b086..7dec5e304 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/action-database.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/action-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { ServiceDatabase } from "../services/service-database.js"; +import * as actions from "./index.js"; /** * The full negotiation database: state surface + the imperative `connection` 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/ecs/actions/dispose.ts similarity index 62% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/dispose.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/dispose.ts index baf2a8ee0..13c4a6461 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/dispose.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/dispose.ts @@ -1,4 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; +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/services/main-service/action-database/actions/generate-answer.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/generate-answer.ts similarity index 65% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/generate-answer.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/generate-answer.ts index e68b2cd39..d44c1b68b 100644 --- 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/ecs/actions/generate-answer.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; +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/services/main-service/action-database/actions/reconnect.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/reconnect.ts similarity index 63% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/reconnect.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/reconnect.ts index 692c02de8..35ffb3eb5 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/reconnect.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/reconnect.ts @@ -1,4 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; +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/services/main-service/action-database/actions/set-banner.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-banner.ts similarity index 52% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-banner.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-banner.ts index 2fbff926e..c5c8cbdc3 100644 --- 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/ecs/actions/set-banner.ts @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; +import type { ServiceDatabase } from "../services/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/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/services/main-service/action-database/actions/set-joiner-offer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-joiner-offer-input.ts similarity index 50% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-joiner-offer-input.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/set-joiner-offer-input.ts index a756d8192..ce7bbdff0 100644 --- 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/ecs/actions/set-joiner-offer-input.ts @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; +import type { ServiceDatabase } from "../services/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/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/services/main-service/action-database/actions/start-host.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-host.ts similarity index 63% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-host.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-host.ts index 0907a097a..7824ae010 100644 --- 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/ecs/actions/start-host.ts @@ -1,4 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; +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/services/main-service/action-database/actions/start-join.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-join.ts similarity index 63% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-join.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/start-join.ts index b0f4be603..31554dd2f 100644 --- 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/ecs/actions/start-join.ts @@ -1,4 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; +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/services/main-service/action-database/actions/submit-answer.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/submit-answer.ts similarity index 64% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/submit-answer.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/actions/submit-answer.ts index fa8582651..e3c0649b1 100644 --- 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/ecs/actions/submit-answer.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ServiceDatabase } from "../../service-database/service-database.js"; +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/services/main-service/conformance/projection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/conformance/projection.ts similarity index 92% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/conformance/projection.ts index 0a25a4eef..9f4704e76 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/conformance/projection.ts @@ -1,6 +1,6 @@ // © 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"; +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 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/ecs/core/core-database.ts similarity index 91% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/core-database/core-database.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/core/core-database.ts index c79c4f708..cbbc77eb2 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/core-database/core-database.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/core/core-database.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { resources } from "./resources.js"; +import { resources } from "../../data/resources/index.js"; const coreDatabasePlugin = Database.Plugin.create({ resources, diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/main-service.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/main-service.ts similarity index 80% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/main-service.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/main-service.ts index 6df0f9303..507de89cb 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/main-service.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/main-service.ts @@ -4,4 +4,4 @@ // `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"; +export { ActionDatabase as MainService } from "./actions/action-database.js"; 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/ecs/services/create-connection-service.test.ts similarity index 73% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.test.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/services/create-connection-service.test.ts index f3902a55e..165b0cd93 100644 --- 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/ecs/services/create-connection-service.test.ts @@ -1,8 +1,9 @@ // © 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 { 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. @@ -11,7 +12,7 @@ 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); + const service = createConnectionService(db, GameService.createFake(), SignalingService.createFake); service.startHost(); expect(db.resources.phase).toBe("host-signaling"); @@ -24,7 +25,7 @@ describe("connection service (deterministic fake signaling)", () => { it("generateAnswer records the answer code", async () => { const db = Database.create(TransactionDatabase.plugin); - const service = createConnectionService(db, createFake); + const service = createConnectionService(db, GameService.createFake(), SignalingService.createFake); db.transactions.setJoinerOfferInput({ value: "OFFER-xyz" }); service.startJoin(); 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/services/main-service/transaction-database/transactions/set-answer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-answer-code.ts similarity index 74% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-answer-code.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-answer-code.ts index 83a011707..58b46c81a 100644 --- 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/ecs/transactions/set-answer-code.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"; export const setAnswerCode = (t: CoreDatabase.Store, { code }: { code: string }) => { t.resources.answerCode = code; 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/ecs/transactions/set-banner.ts similarity index 77% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-banner.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-banner.ts index 6a2bd1081..79543841b 100644 --- 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/ecs/transactions/set-banner.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"; export const setBanner = ( t: CoreDatabase.Store, 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/ecs/transactions/set-connection.ts similarity index 65% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-connection.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-connection.ts index 5822925e3..9fab6ca61 100644 --- 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/ecs/transactions/set-connection.ts @@ -1,6 +1,6 @@ // © 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"; +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, 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/services/main-service/transaction-database/transactions/set-host-answer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-host-answer-input.ts similarity index 72% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-host-answer-input.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-host-answer-input.ts index 3ad8004fd..b92feda6c 100644 --- 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/ecs/transactions/set-host-answer-input.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"; export const setHostAnswerInput = ( t: CoreDatabase.Store, 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/ecs/transactions/set-joiner-offer-input.ts similarity index 73% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-joiner-offer-input.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-joiner-offer-input.ts index 060e118e6..16f7db9ba 100644 --- 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/ecs/transactions/set-joiner-offer-input.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"; export const setJoinerOfferInput = ( t: CoreDatabase.Store, 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/ecs/transactions/set-offer-code.ts similarity index 73% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-offer-code.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/set-offer-code.ts index 17ff29a3e..bea25ec7c 100644 --- 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/ecs/transactions/set-offer-code.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"; export const setOfferCode = (t: CoreDatabase.Store, { code }: { code: string }) => { t.resources.offerCode = code; 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/ecs/transactions/start-host-signaling.ts similarity index 82% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-host-signaling.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/start-host-signaling.ts index 475727121..f966cff4c 100644 --- 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/ecs/transactions/start-host-signaling.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"; export const startHostSignaling = (t: CoreDatabase.Store) => { t.resources.phase = "host-signaling"; 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/ecs/transactions/start-join-signaling.ts similarity index 80% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-join-signaling.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/start-join-signaling.ts index fc85fd537..a8057a7d7 100644 --- 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/ecs/transactions/start-join-signaling.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"; export const startJoinSignaling = (t: CoreDatabase.Store) => { t.resources.phase = "join-signaling"; 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/ecs/transactions/transaction-database.ts similarity index 84% rename from packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transaction-database.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/transaction-database.ts index 6f05583e0..8931d4e39 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transaction-database.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/ecs/transactions/transaction-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { CoreDatabase } from "../core/core-database.js"; +import * as transactions from "./index.js"; const transactionDatabasePlugin = Database.Plugin.create({ extends: CoreDatabase.plugin, 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/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/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/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/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-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-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-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/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/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/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/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/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/data/state/enter-game.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/enter-game.cases.ts similarity index 69% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.cases.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/enter-game.cases.ts index de378a2d4..0cb829489 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.cases.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/enter-game.cases.ts @@ -3,9 +3,8 @@ 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. +// Inert cases for `enterGame`, run against the pure spec and the `enterGame` action. A +// no-arg transition. export const cases: Conformance.SpecCases = { cases: [ { 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/presence/data/state/public.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/public.ts similarity index 72% rename from packages/data-p2p-tictactoe/src/features/presence/data/state/public.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/public.ts index 437afe363..69dfc9c89 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/public.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/public.ts @@ -1,4 +1,3 @@ // © 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/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/data/state/set-connection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-connection.ts similarity index 84% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/set-connection.ts index a02921417..17c330fe1 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/set-connection.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ConnectionState } from "../connection-state/connection-state.js"; +import type { ConnectionState } from "../data/values/connection-state/connection-state.js"; import type { State } from "./state.js"; /** 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/data/state/start-host-signaling.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/start-host-signaling.cases.ts similarity index 68% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.cases.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/start-host-signaling.cases.ts index 19b5d7e50..f5626f8d7 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.cases.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/start-host-signaling.cases.ts @@ -3,10 +3,9 @@ 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. +// 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: [ { 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/data/state/state.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/state.ts similarity index 58% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/state.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/state.ts index 20b752c1e..d9d617210 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/state.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/state.ts @@ -1,7 +1,10 @@ // © 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"; +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 @@ -21,4 +24,8 @@ export type State = { 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/data/state/public.ts b/packages/data-p2p-tictactoe/src/features/negotiation/spec/transforms.ts similarity index 73% rename from packages/data-p2p-tictactoe/src/features/negotiation/data/state/public.ts rename to packages/data-p2p-tictactoe/src/features/negotiation/spec/transforms.ts index c8049674c..0d34673ab 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/public.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/spec/transforms.ts @@ -1,6 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export { create } from "./create.js"; -export { samples } from "./samples.js"; + +// 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"; 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/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/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/cursors/public.ts b/packages/data-p2p-tictactoe/src/features/presence/data/values/cursors/public.ts similarity index 64% rename from packages/data-p2p-tictactoe/src/features/presence/data/cursors/public.ts rename to packages/data-p2p-tictactoe/src/features/presence/data/values/cursors/public.ts index 1e5eef992..e90d09c06 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/data/cursors/public.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/data/values/cursors/public.ts @@ -1,2 +1,2 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export * from "./set.js"; +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/services/main-service/action-database/action-database.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/actions/action-database.ts similarity index 74% rename from packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/action-database.ts rename to packages/data-p2p-tictactoe/src/features/presence/ecs/actions/action-database.ts index 0bdd4b728..e0cd18c68 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/action-database.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/actions/action-database.ts @@ -1,11 +1,11 @@ // © 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"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; +import * as actions from "./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` + * 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. */ 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/services/main-service/conformance/projection.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/projection.ts similarity index 65% rename from packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.ts rename to packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/projection.ts index dd2ef1f97..e1284022f 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/projection.ts @@ -1,14 +1,12 @@ // © 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"; +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`. 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. +// used), and there is no per-entity `toData`. const fromState = (store: CoreDatabase.Store, state: State): ReadonlyMap => { store.resources.cursors = state.cursors; return new Map(); 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/ecs/core/core-database.ts similarity index 87% rename from packages/data-p2p-tictactoe/src/features/presence/services/main-service/core-database/core-database.ts rename to packages/data-p2p-tictactoe/src/features/presence/ecs/core/core-database.ts index 22f057543..ed07cc2f0 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/core-database/core-database.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/core/core-database.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { resources } from "./resources.js"; +import { resources } from "../../data/resources/index.js"; const coreDatabasePlugin = Database.Plugin.create({ resources, diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/main-service.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/main-service.ts similarity index 57% rename from packages/data-p2p-tictactoe/src/features/presence/services/main-service/main-service.ts rename to packages/data-p2p-tictactoe/src/features/presence/ecs/main-service.ts index 979daeaf7..78c573d99 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/main-service.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/main-service.ts @@ -1,6 +1,6 @@ // © 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`, +// (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 "./action-database/action-database.js"; +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/services/main-service/transaction-database/transactions/move-presence.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/transactions/move-presence.ts similarity index 62% rename from packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transactions/move-presence.ts rename to packages/data-p2p-tictactoe/src/features/presence/ecs/transactions/move-presence.ts index eb8ddad5d..a04866472 100644 --- 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/ecs/transactions/move-presence.ts @@ -1,19 +1,16 @@ // © 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"; +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. The write itself is delegated to the pure `State.movePresence` spec. + * ignored. */ 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; + t.resources.cursors = Cursors.set(t.resources.cursors, t.userId, x, y); }; 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/ecs/transactions/transaction-database.ts similarity index 79% rename from packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transaction-database.ts rename to packages/data-p2p-tictactoe/src/features/presence/ecs/transactions/transaction-database.ts index 451e077c6..c9bc5f895 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transaction-database.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/transactions/transaction-database.ts @@ -1,7 +1,7 @@ // © 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"; +import { CoreDatabase } from "../core/core-database.js"; +import * as transactions from "./index.js"; const transactionDatabasePlugin = Database.Plugin.create({ extends: CoreDatabase.plugin, 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/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/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/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/data/state/move-presence.ts b/packages/data-p2p-tictactoe/src/features/presence/spec/move-presence.ts similarity index 91% rename from packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.ts rename to packages/data-p2p-tictactoe/src/features/presence/spec/move-presence.ts index ec9fbc588..0f3b4ae04 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/spec/move-presence.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { PlayerMark } from "data-lit-tictactoe"; -import { Cursors } from "../cursors/cursors.js"; +import { Cursors } from "../data/values/cursors/cursors.js"; import type { State } from "./state.js"; /** 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" }] } From c06c3cf7a8312c42a1dfcf6f5b0a5dc4e20343df Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 18:10:24 -0700 Subject: [PATCH 08/24] feat(conformance): frame option conforms a per-frame spec op by driving one system frame Real-time features keep in-place column-writing systems; checkFeature seeds a system db, applies the case args via setup, runs every system once, and compares. Adds a frame conformance test. Co-Authored-By: Claude Opus 5.5 --- .../src/conformance/check-feature.ts | 1 + .../src/conformance/frame.test.ts | 60 +++++++++++++++++++ .../src/conformance/implementation.ts | 20 ++++++- .../src/conformance/run-actions.ts | 4 +- .../src/conformance/run-feature.ts | 53 +++++++++++++++- 5 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 packages/data-testing/src/conformance/frame.test.ts diff --git a/packages/data-testing/src/conformance/check-feature.ts b/packages/data-testing/src/conformance/check-feature.ts index 9acc1fe4a..0f29447ef 100644 --- a/packages/data-testing/src/conformance/check-feature.ts +++ b/packages/data-testing/src/conformance/check-feature.ts @@ -21,6 +21,7 @@ export const checkFeature = => ({ + distance: state.distance + state.speed * dt, +}); + +const plugin = Database.Plugin.create({ + resources: { + distance: { default: 0 as number }, + speed: { default: 0 as number }, + frameDelta: { default: 0 as number }, + }, + systems: { + move: { + create: (db) => () => { + db.store.resources.distance += db.store.resources.speed * db.store.resources.frameDelta; + }, + }, + }, +}); + +type Store = Database.Plugin.ToStore; + +const cases: SpecCases = { + cases: [ + { name: "advances by speed × dt", before: { speed: 2 }, args: { dt: 3 }, after: { distance: 6 } }, + { name: "stands still at zero speed", before: { distance: 1 }, args: { dt: 5 }, after: { distance: 1 } }, + ], +}; + +checkFeature( + implementation( + spec({ state: { create: (): State => ({ distance: 0, speed: 0 }) }, fns: { step }, cases: { step: cases } }), + { + plugin, + projection: { + fromState: (store: Store, state: State): void => { + store.resources.distance = state.distance; + store.resources.speed = state.speed; + }, + toState: (store: Store): State => ({ distance: store.resources.distance, speed: store.resources.speed }), + }, + frame: { + op: "step", + setup: (db, { dt }) => { + db.store.resources.frameDelta = dt; + }, + }, + }, + ), +); diff --git a/packages/data-testing/src/conformance/implementation.ts b/packages/data-testing/src/conformance/implementation.ts index b45f05b8f..8bc2cb05e 100644 --- a/packages/data-testing/src/conformance/implementation.ts +++ b/packages/data-testing/src/conformance/implementation.ts @@ -12,6 +12,8 @@ import type { Projection } from "./run-feature.js"; import type { Spec } from "./spec.js"; type AnyFn = (...args: never[]) => unknown; +// A transition's args — its second parameter. +type ArgsOf = F extends (state: never, args: infer A, ...rest: never[]) => unknown ? A : never; // 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 @@ -24,6 +26,15 @@ export interface Implementation void; + }; // 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; @@ -50,9 +61,16 @@ export const implementation = < C extends object, P extends ProjectionShape, const H extends readonly Extract[] = readonly [], + PL extends Database.Plugin = Database.Plugin, + O extends Extract = never, >( spec: Spec, - impl: Omit>, "spec" | "projection" | "hydrate" | "computedPlugin" | "transactionOps"> & { + impl: Omit>, "spec" | "plugin" | "projection" | "hydrate" | "computedPlugin" | "transactionOps" | "frame"> & { + readonly plugin: PL; + readonly frame?: { + readonly op: O; + readonly setup?: (db: Database.Plugin.ToSystemDatabase, args: ArgsOf) => void; + }; readonly projection: P; readonly hydrate?: H; readonly transactionOps?: readonly Extract[]; 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 33767366d..9d09f9b2b 100644 --- a/packages/data-testing/src/conformance/run-feature.ts +++ b/packages/data-testing/src/conformance/run-feature.ts @@ -45,6 +45,8 @@ export interface FeatureRunConfig { readonly hydrate?: readonly string[]; // Spec transitions that conform against a same-named transaction, not an action. readonly transactionOps?: readonly string[]; + // The per-frame spec transition realized by the systems tick loop (see `Implementation`). + readonly frame?: { readonly op: string; readonly setup?: (db: never, args: never) => void }; // 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>>; @@ -61,6 +63,23 @@ export interface FeatureRunConfig { // `create-plugin.ts`), so this reads the ops directly off it. type PluginFacets = { transactions: Record; actions: Record; computed: Record }; +// The slice of a system database a headless frame needs. +type FrameDatabase = { + readonly system: { readonly order: readonly (readonly string[])[]; readonly functions: Readonly> }; +}; + +// Run exactly one frame headlessly, as the scheduler would: every system in dependency +// order, skipping the scheduler's own driver system and init-only systems. +const driveFrame = (db: FrameDatabase): void => { + for (const tier of db.system.order) { + for (const name of tier) { + if (name === "schedulerSystem") continue; + const fn = db.system.functions[name]; + if (typeof fn === "function") fn(); + } + } +}; + const pick = (source: ReadonlyMap, names: ReadonlySet): Record => Object.fromEntries([...source].filter(([name]) => names.has(name))); @@ -84,10 +103,15 @@ export function runFeature viaTransaction.has(name) && transactions.has(name))); - const byAction = new Set([...transitions.keys()].filter((name) => !viaTransaction.has(name) && actions.has(name))); + const byAction = new Set( + [...transitions.keys()].filter((name) => !viaTransaction.has(name) && name !== frameOp && actions.has(name)), + ); const missing = [ - ...[...transitions.keys()].filter((name) => !byAction.has(name) && !byTransaction.has(name)), + ...[...transitions.keys()].filter( + (name) => !byAction.has(name) && !byTransaction.has(name) && name !== frameOp, + ), ...[...derivations.keys()].filter((name) => !computeds.has(name)), ]; @@ -129,6 +153,31 @@ export function runFeature void) | undefined; + runActions({ + makeDb: (services) => + Database.toSystemDatabase( + Database.create(config.plugin as never, { services: { ...baseServices(), ...services } }), + ) as unknown as FrameDatabase & Db, + store: (db) => db.store, + fromState, + toState, + initial, + transitions: config.transitions, + kind: "frame", + actions: { + [config.frame.op]: (db: FrameDatabase, args: unknown) => { + setup?.(db, args); + driveFrame(db); + }, + }, + match: config.match, + }); + } + if (config.computedPlugin) { runComputeds({ makeDb: () => From 65f69e13d7a17fdbd0085a26d453624004c92691 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 18:10:37 -0700 Subject: [PATCH 09/24] refactor(data-gpu-hopper): port to the layered feature layout Frog is a resource; hop is a real action; per-frame systems keep in-place column writes and step conforms via frame conformance. Co-Authored-By: Claude Opus 5.5 --- packages/data-gpu-hopper/index.html | 2 +- .../data-gpu-hopper/src/{ => app}/main.ts | 2 +- .../src/features/main/data/color/color.ts | 6 - .../features/main/data/components/index.ts | 8 + .../src/features/main/data/components/kind.ts | 7 + .../src/features/main/data/components/lane.ts | 6 + .../features/main/data/components/velocity.ts | 6 + .../features/main/data/components/width.ts | 6 + .../src/features/main/data/components/x.ts | 6 + .../features/main/data/direction/direction.ts | 6 - .../src/features/main/data/entities/hazard.ts | 8 + .../src/features/main/data/entities/index.ts | 4 + .../src/features/main/data/frog/frog.ts | 6 - .../main/data/game-status/game-status.ts | 6 - .../main/data/hazard-kind/hazard-kind.ts | 6 - .../features/main/data/hazard/advance.test.ts | 20 -- .../src/features/main/data/hazard/advance.ts | 10 - .../features/main/data/hazard/covers-at.ts | 8 - .../features/main/data/hazard/covers.test.ts | 20 -- .../src/features/main/data/hazard/covers.ts | 7 - .../src/features/main/data/hazard/hazard.ts | 6 - .../src/features/main/data/hazard/is.ts | 19 -- .../src/features/main/data/hazard/next-x.ts | 10 - .../src/features/main/data/hazard/schema.ts | 19 -- .../features/main/data/lane-kind/lane-kind.ts | 6 - .../src/features/main/data/lane/lane.ts | 6 - .../src/features/main/data/outcome/outcome.ts | 6 - .../main/data/resources/frame-delta.ts | 7 + .../src/features/main/data/resources/frog.ts | 7 + .../features/main/data/resources/height.ts | 6 + .../src/features/main/data/resources/index.ts | 11 ++ .../src/features/main/data/resources/lanes.ts | 7 + .../src/features/main/data/resources/lives.ts | 5 + .../src/features/main/data/resources/score.ts | 5 + .../features/main/data/resources/status.ts | 6 + .../src/features/main/data/resources/width.ts | 6 + .../src/features/main/data/state/create.ts | 55 ------ .../features/main/data/state/frog-outcome.ts | 26 --- .../src/features/main/data/state/hop.ts | 23 --- .../src/features/main/data/state/lane-at.ts | 7 - .../main/data/state/lose-life.cases.ts | 30 --- .../src/features/main/data/state/new-game.ts | 10 - .../src/features/main/data/state/public.ts | 11 -- .../src/features/main/data/state/spec.test.ts | 9 - .../src/features/main/data/state/spec.ts | 36 ---- .../main/data/state/start-position.ts | 9 - .../src/features/main/data/state/state.ts | 23 --- .../src/features/main/data/state/step.ts | 69 ------- .../features/main/data/state/transforms.ts | 16 -- .../main/data/state/win-goal.cases.ts | 25 --- .../src/features/main/data/state/win-goal.ts | 12 -- .../features/main/data/values/color/color.ts | 5 + .../main/data/{ => values}/color/public.ts | 0 .../main/data/{ => values}/color/schema.ts | 4 + .../main/data/{ => values}/direction/delta.ts | 2 +- .../main/data/values/direction/direction.ts | 4 + .../data/{ => values}/direction/public.ts | 0 .../data/{ => values}/direction/schema.ts | 4 + .../main/data/{ => values}/frog/frog-color.ts | 0 .../features/main/data/values/frog/frog.ts | 5 + .../src/features/main/data/values/frog/hop.ts | 15 ++ .../main/data/values/frog/on-board.ts | 6 + .../main/data/{ => values}/frog/public.ts | 3 + .../main/data/{ => values}/frog/schema.ts | 4 + .../features/main/data/values/frog/start.ts | 5 + .../data/values/game-status/game-status.ts | 4 + .../{ => values}/game-status/is-playing.ts | 0 .../data/{ => values}/game-status/public.ts | 0 .../data/{ => values}/game-status/schema.ts | 4 + .../{ => values}/hazard-kind/hazard-color.ts | 0 .../data/values/hazard-kind/hazard-kind.ts | 4 + .../data/{ => values}/hazard-kind/public.ts | 0 .../data/{ => values}/hazard-kind/schema.ts | 4 + .../{ => values}/lane-kind/covered-outcome.ts | 0 .../{ => values}/lane-kind/empty-outcome.ts | 0 .../data/{ => values}/lane-kind/lane-color.ts | 0 .../main/data/values/lane-kind/lane-kind.ts | 4 + .../main/data/values/lane-kind/outcome.ts | 9 + .../data/{ => values}/lane-kind/public.ts | 1 + .../data/{ => values}/lane-kind/schema.ts | 4 + .../src/features/main/data/values/lane/at.ts | 6 + .../{hazard => values/lane}/covers-at.test.ts | 12 +- .../main/data/values/lane/covers-at.ts | 6 + .../features/main/data/values/lane/lane.ts | 6 + .../{hazard => values/lane}/next-x.test.ts | 12 +- .../features/main/data/values/lane/next-x.ts | 9 + .../data/{hazard => values/lane}/public.ts | 6 +- .../main/data/{ => values}/lane/schema.ts | 4 + .../main/data/values/level/initial.ts | 37 ++++ .../features/main/data/values/level/level.ts | 19 ++ .../data/{lane => values/level}/public.ts | 1 + .../features/main/data/values/level/schema.ts | 35 ++++ .../data/{ => values}/outcome/is-fatal.ts | 0 .../main/data/values/outcome/outcome.ts | 4 + .../main/data/{ => values}/outcome/public.ts | 0 .../main/data/{ => values}/outcome/schema.ts | 4 + .../main/ecs/actions/action-database.ts | 16 ++ .../src/features/main/ecs/actions/hop.ts | 8 + .../src/features/main/ecs/actions/index.ts | 3 + .../src/features/main/ecs/actions/new-game.ts | 7 + .../main/ecs/conformance/conformance.test.ts | 7 + .../ecs/conformance/create-system-database.ts | 8 + .../conformance/drive-frame.ts | 6 +- .../main/ecs/conformance/implementation.ts | 19 ++ .../conformance}/outcome-selection.test.ts | 20 +- .../main/ecs/conformance/projection.ts | 53 ++++++ .../src/features/main/ecs/core/archetypes.ts | 7 + .../features/main/ecs/core/core-database.ts | 19 ++ .../src/features/main/ecs/main-service.ts | 2 + .../main/ecs/systems/system-database.ts | 90 +++++++++ .../src/features/main/ecs/transactions/hop.ts | 12 ++ .../transactions/index.ts | 2 +- .../main/ecs/transactions/lose-life.ts | 17 ++ .../main/ecs/transactions/new-game.ts | 24 +++ .../transactions}/transaction-database.ts | 11 +- .../main/ecs/transactions/win-goal.ts | 8 + .../conformance/conformance.test.ts | 15 -- .../conformance/create-system-database.ts | 13 -- .../main-service/conformance/projection.ts | 81 -------- .../main-service/core-database/archetypes.ts | 23 --- .../main-service/core-database/components.ts | 24 --- .../core-database/core-database.ts | 35 ---- .../main-service/core-database/resources.ts | 39 ---- .../services/main-service/main-service.ts | 5 - .../system-database/system-database.ts | 177 ------------------ .../system-database/tick-loop.test.ts | 48 ----- .../transaction-database/transactions/hop.ts | 18 -- .../transactions/lose-life.ts | 20 -- .../transactions/new-game.ts | 29 --- .../transactions/read-frog.ts | 18 -- .../transactions/win-goal.ts | 12 -- .../main/{data/state => spec}/create.test.ts | 10 +- .../src/features/main/spec/create.ts | 20 ++ .../{data/state => spec}/frog-outcome.test.ts | 4 +- .../src/features/main/spec/frog-outcome.ts | 19 ++ .../main/{data/state => spec}/hop.cases.ts | 5 +- .../src/features/main/spec/hop.ts | 15 ++ .../main/{data/state => spec}/lose-life.ts | 11 +- .../{data/state => spec}/new-game.cases.ts | 9 +- .../src/features/main/spec/new-game.ts | 6 + .../src/features/main/spec/public.ts | 4 + .../main/{data/state => spec}/samples.ts | 16 +- .../src/features/main/spec/spec.test.ts | 6 + .../src/features/main/spec/spec.ts | 16 ++ .../src/features/main/spec/state.ts | 28 +++ .../main/{data/state => spec}/step.cases.ts | 8 +- .../src/features/main/spec/step.ts | 54 ++++++ .../src/features/main/spec/transforms.ts | 7 + .../src/features/main/spec/win-goal.ts | 10 + .../src/features/main/ui/hopper-app-plugin.ts | 2 +- .../main/ui/hopper-app/hopper-app-element.ts | 4 +- .../ui/hopper-app/hopper-app-presentation.ts | 2 +- .../main/ui/render/hopper-render-plugin.ts | 84 ++++----- packages/data-gpu-hopper/tsconfig.build.json | 29 --- packages/data-gpu-hopper/tsconfig.data.json | 16 ++ packages/data-gpu-hopper/tsconfig.ecs.json | 16 ++ packages/data-gpu-hopper/tsconfig.spec.json | 17 ++ packages/data-gpu-hopper/tsconfig.test.json | 22 +-- 158 files changed, 969 insertions(+), 1262 deletions(-) rename packages/data-gpu-hopper/src/{ => app}/main.ts (77%) delete mode 100644 packages/data-gpu-hopper/src/features/main/data/color/color.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/components/index.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/components/kind.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/components/lane.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/components/velocity.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/components/width.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/components/x.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/direction/direction.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/entities/hazard.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/entities/index.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/frog/frog.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/game-status/game-status.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/hazard-kind/hazard-kind.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/hazard/advance.test.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/hazard/advance.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/hazard/covers-at.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/hazard/covers.test.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/hazard/covers.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/hazard/hazard.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/hazard/is.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/hazard/next-x.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/hazard/schema.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/lane-kind/lane-kind.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/lane/lane.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/outcome/outcome.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/resources/frame-delta.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/resources/frog.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/resources/height.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/resources/index.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/resources/lanes.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/resources/lives.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/resources/score.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/resources/status.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/resources/width.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/create.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/frog-outcome.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/hop.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/lane-at.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/lose-life.cases.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/new-game.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/public.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/spec.test.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/spec.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/start-position.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/state.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/step.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/transforms.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/win-goal.cases.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/win-goal.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/color/color.ts rename packages/data-gpu-hopper/src/features/main/data/{ => values}/color/public.ts (100%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/color/schema.ts (70%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/direction/delta.ts (82%) create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/direction/direction.ts rename packages/data-gpu-hopper/src/features/main/data/{ => values}/direction/public.ts (100%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/direction/schema.ts (63%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/frog/frog-color.ts (100%) create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/frog/frog.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/frog/hop.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/frog/on-board.ts rename packages/data-gpu-hopper/src/features/main/data/{ => values}/frog/public.ts (56%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/frog/schema.ts (74%) create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/frog/start.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/game-status/game-status.ts rename packages/data-gpu-hopper/src/features/main/data/{ => values}/game-status/is-playing.ts (100%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/game-status/public.ts (100%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/game-status/schema.ts (63%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/hazard-kind/hazard-color.ts (100%) create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/hazard-kind/hazard-kind.ts rename packages/data-gpu-hopper/src/features/main/data/{ => values}/hazard-kind/public.ts (100%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/hazard-kind/schema.ts (62%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/lane-kind/covered-outcome.ts (100%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/lane-kind/empty-outcome.ts (100%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/lane-kind/lane-color.ts (100%) create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/lane-kind/lane-kind.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/lane-kind/outcome.ts rename packages/data-gpu-hopper/src/features/main/data/{ => values}/lane-kind/public.ts (86%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/lane-kind/schema.ts (74%) create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/lane/at.ts rename packages/data-gpu-hopper/src/features/main/data/{hazard => values/lane}/covers-at.test.ts (54%) create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/lane/covers-at.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/lane/lane.ts rename packages/data-gpu-hopper/src/features/main/data/{hazard => values/lane}/next-x.test.ts (57%) create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/lane/next-x.ts rename packages/data-gpu-hopper/src/features/main/data/{hazard => values/lane}/public.ts (61%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/lane/schema.ts (73%) create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/level/initial.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/level/level.ts rename packages/data-gpu-hopper/src/features/main/data/{lane => values/level}/public.ts (70%) create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/level/schema.ts rename packages/data-gpu-hopper/src/features/main/data/{ => values}/outcome/is-fatal.ts (100%) create mode 100644 packages/data-gpu-hopper/src/features/main/data/values/outcome/outcome.ts rename packages/data-gpu-hopper/src/features/main/data/{ => values}/outcome/public.ts (100%) rename packages/data-gpu-hopper/src/features/main/data/{ => values}/outcome/schema.ts (77%) create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/actions/action-database.ts create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/actions/hop.ts create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/actions/index.ts create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/actions/new-game.ts create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/conformance/conformance.test.ts create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/conformance/create-system-database.ts rename packages/data-gpu-hopper/src/features/main/{services/main-service => ecs}/conformance/drive-frame.ts (74%) create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/conformance/implementation.ts rename packages/data-gpu-hopper/src/features/main/{services/main-service/system-database => ecs/conformance}/outcome-selection.test.ts (80%) create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/conformance/projection.ts create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/core/archetypes.ts create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/core/core-database.ts create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/main-service.ts create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/systems/system-database.ts create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/transactions/hop.ts rename packages/data-gpu-hopper/src/features/main/{services/main-service/transaction-database => ecs}/transactions/index.ts (100%) create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/transactions/lose-life.ts create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/transactions/new-game.ts rename packages/data-gpu-hopper/src/features/main/{services/main-service/transaction-database => ecs/transactions}/transaction-database.ts (52%) create mode 100644 packages/data-gpu-hopper/src/features/main/ecs/transactions/win-goal.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/conformance/conformance.test.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/conformance/create-system-database.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/conformance/projection.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/core-database/archetypes.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/core-database/components.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/core-database/core-database.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/core-database/resources.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/main-service.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/system-database/system-database.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/system-database/tick-loop.test.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/hop.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/lose-life.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/new-game.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/read-frog.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/win-goal.ts rename packages/data-gpu-hopper/src/features/main/{data/state => spec}/create.test.ts (80%) create mode 100644 packages/data-gpu-hopper/src/features/main/spec/create.ts rename packages/data-gpu-hopper/src/features/main/{data/state => spec}/frog-outcome.test.ts (93%) create mode 100644 packages/data-gpu-hopper/src/features/main/spec/frog-outcome.ts rename packages/data-gpu-hopper/src/features/main/{data/state => spec}/hop.cases.ts (90%) create mode 100644 packages/data-gpu-hopper/src/features/main/spec/hop.ts rename packages/data-gpu-hopper/src/features/main/{data/state => spec}/lose-life.ts (60%) rename packages/data-gpu-hopper/src/features/main/{data/state => spec}/new-game.cases.ts (63%) create mode 100644 packages/data-gpu-hopper/src/features/main/spec/new-game.ts create mode 100644 packages/data-gpu-hopper/src/features/main/spec/public.ts rename packages/data-gpu-hopper/src/features/main/{data/state => spec}/samples.ts (56%) create mode 100644 packages/data-gpu-hopper/src/features/main/spec/spec.test.ts create mode 100644 packages/data-gpu-hopper/src/features/main/spec/spec.ts create mode 100644 packages/data-gpu-hopper/src/features/main/spec/state.ts rename packages/data-gpu-hopper/src/features/main/{data/state => spec}/step.cases.ts (92%) create mode 100644 packages/data-gpu-hopper/src/features/main/spec/step.ts create mode 100644 packages/data-gpu-hopper/src/features/main/spec/transforms.ts create mode 100644 packages/data-gpu-hopper/src/features/main/spec/win-goal.ts delete mode 100644 packages/data-gpu-hopper/tsconfig.build.json create mode 100644 packages/data-gpu-hopper/tsconfig.data.json create mode 100644 packages/data-gpu-hopper/tsconfig.ecs.json create mode 100644 packages/data-gpu-hopper/tsconfig.spec.json 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/main.ts b/packages/data-gpu-hopper/src/app/main.ts similarity index 77% rename from packages/data-gpu-hopper/src/main.ts rename to packages/data-gpu-hopper/src/app/main.ts index f1bf9c356..8ba6d23b6 100644 --- a/packages/data-gpu-hopper/src/main.ts +++ b/packages/data-gpu-hopper/src/app/main.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { render } from "lit"; -import { Hopper } from "./index.js"; +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"); 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/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/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/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/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/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.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.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/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/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/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/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.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/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/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.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/color/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/color/schema.ts similarity index 70% rename from packages/data-gpu-hopper/src/features/main/data/color/schema.ts rename to packages/data-gpu-hopper/src/features/main/data/values/color/schema.ts index be518f831..996b2477b 100644 --- a/packages/data-gpu-hopper/src/features/main/data/color/schema.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/color/schema.ts @@ -1,4 +1,6 @@ // © 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 @@ -10,3 +12,5 @@ export const schema = { 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/direction/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/direction/schema.ts similarity index 63% rename from packages/data-gpu-hopper/src/features/main/data/direction/schema.ts rename to packages/data-gpu-hopper/src/features/main/data/values/direction/schema.ts index 774c0a4ae..ec03d2fec 100644 --- a/packages/data-gpu-hopper/src/features/main/data/direction/schema.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/direction/schema.ts @@ -1,4 +1,6 @@ // © 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. @@ -7,3 +9,5 @@ export const schema = { 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/frog/public.ts b/packages/data-gpu-hopper/src/features/main/data/values/frog/public.ts similarity index 56% rename from packages/data-gpu-hopper/src/features/main/data/frog/public.ts rename to packages/data-gpu-hopper/src/features/main/data/values/frog/public.ts index 762a059cf..706b01138 100644 --- a/packages/data-gpu-hopper/src/features/main/data/frog/public.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/frog/public.ts @@ -1,3 +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/frog/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/frog/schema.ts similarity index 74% rename from packages/data-gpu-hopper/src/features/main/data/frog/schema.ts rename to packages/data-gpu-hopper/src/features/main/data/values/frog/schema.ts index e8aaaad27..adfdb468f 100644 --- a/packages/data-gpu-hopper/src/features/main/data/frog/schema.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/frog/schema.ts @@ -1,4 +1,6 @@ // © 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 @@ -12,3 +14,5 @@ export const schema = { 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/game-status/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/game-status/schema.ts similarity index 63% rename from packages/data-gpu-hopper/src/features/main/data/game-status/schema.ts rename to packages/data-gpu-hopper/src/features/main/data/values/game-status/schema.ts index ad5d05a4d..aa4eca935 100644 --- a/packages/data-gpu-hopper/src/features/main/data/game-status/schema.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/game-status/schema.ts @@ -1,4 +1,6 @@ // © 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). @@ -7,3 +9,5 @@ export const schema = { 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/hazard-kind/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/hazard-kind/schema.ts similarity index 62% rename from packages/data-gpu-hopper/src/features/main/data/hazard-kind/schema.ts rename to packages/data-gpu-hopper/src/features/main/data/values/hazard-kind/schema.ts index e0ed57dc3..c2107b915 100644 --- a/packages/data-gpu-hopper/src/features/main/data/hazard-kind/schema.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/hazard-kind/schema.ts @@ -1,4 +1,6 @@ // © 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). @@ -7,3 +9,5 @@ export const schema = { 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/lane-kind/public.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/public.ts similarity index 86% rename from packages/data-gpu-hopper/src/features/main/data/lane-kind/public.ts rename to packages/data-gpu-hopper/src/features/main/data/values/lane-kind/public.ts index 6b9fcac3c..f66d7df3d 100644 --- a/packages/data-gpu-hopper/src/features/main/data/lane-kind/public.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/public.ts @@ -3,3 +3,4 @@ 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/lane-kind/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/schema.ts similarity index 74% rename from packages/data-gpu-hopper/src/features/main/data/lane-kind/schema.ts rename to packages/data-gpu-hopper/src/features/main/data/values/lane-kind/schema.ts index 85e43b4a3..d81ad9608 100644 --- a/packages/data-gpu-hopper/src/features/main/data/lane-kind/schema.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane-kind/schema.ts @@ -1,4 +1,6 @@ // © 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: @@ -11,3 +13,5 @@ export const schema = { 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/hazard/covers-at.test.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane/covers-at.test.ts similarity index 54% rename from packages/data-gpu-hopper/src/features/main/data/hazard/covers-at.test.ts rename to packages/data-gpu-hopper/src/features/main/data/values/lane/covers-at.test.ts index 1f1a1d73a..3c678174a 100644 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/covers-at.test.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane/covers-at.test.ts @@ -1,18 +1,18 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, it, expect } from "vitest"; -import { Hazard } from "./hazard.js"; +import { Lane } from "./lane.js"; -describe("Hazard.coversAt", () => { +describe("Lane.coversAt", () => { it("includes the left edge", () => { - expect(Hazard.coversAt(2, 3, 2)).toBe(true); + expect(Lane.coversAt(2, 3, 2)).toBe(true); }); it("includes a point inside the span", () => { - expect(Hazard.coversAt(2, 3, 4.9)).toBe(true); + expect(Lane.coversAt(2, 3, 4.9)).toBe(true); }); it("excludes the right edge (half-open span)", () => { - expect(Hazard.coversAt(2, 3, 5)).toBe(false); + expect(Lane.coversAt(2, 3, 5)).toBe(false); }); it("excludes a point left of the span", () => { - expect(Hazard.coversAt(2, 3, 1.9)).toBe(false); + 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/hazard/next-x.test.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane/next-x.test.ts similarity index 57% rename from packages/data-gpu-hopper/src/features/main/data/hazard/next-x.test.ts rename to packages/data-gpu-hopper/src/features/main/data/values/lane/next-x.test.ts index ce0d2a3f8..165d05362 100644 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/next-x.test.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane/next-x.test.ts @@ -1,18 +1,18 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, it, expect } from "vitest"; -import { Hazard } from "./hazard.js"; +import { Lane } from "./lane.js"; -describe("Hazard.nextX", () => { +describe("Lane.nextX", () => { it("moves by velocity * dt", () => { - expect(Hazard.nextX(3, 2, 1, 9)).toBe(5); + expect(Lane.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); + expect(Lane.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); + expect(Lane.nextX(0, -1, 1, 9)).toBe(8); }); it("is stationary at dt 0", () => { - expect(Hazard.nextX(3.5, 4, 0, 9)).toBe(3.5); + 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/hazard/public.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane/public.ts similarity index 61% rename from packages/data-gpu-hopper/src/features/main/data/hazard/public.ts rename to packages/data-gpu-hopper/src/features/main/data/values/lane/public.ts index 733151524..3f982592f 100644 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/public.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane/public.ts @@ -1,7 +1,5 @@ // © 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 { 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/lane/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/lane/schema.ts similarity index 73% rename from packages/data-gpu-hopper/src/features/main/data/lane/schema.ts rename to packages/data-gpu-hopper/src/features/main/data/values/lane/schema.ts index 60a9db184..c5dd16813 100644 --- a/packages/data-gpu-hopper/src/features/main/data/lane/schema.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/lane/schema.ts @@ -1,4 +1,6 @@ // © 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"; @@ -12,3 +14,5 @@ export const 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/lane/public.ts b/packages/data-gpu-hopper/src/features/main/data/values/level/public.ts similarity index 70% rename from packages/data-gpu-hopper/src/features/main/data/lane/public.ts rename to packages/data-gpu-hopper/src/features/main/data/values/level/public.ts index 363fd32b4..0c2e9f93e 100644 --- a/packages/data-gpu-hopper/src/features/main/data/lane/public.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/level/public.ts @@ -1,2 +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/outcome/is-fatal.ts b/packages/data-gpu-hopper/src/features/main/data/values/outcome/is-fatal.ts similarity index 100% rename from packages/data-gpu-hopper/src/features/main/data/outcome/is-fatal.ts rename to packages/data-gpu-hopper/src/features/main/data/values/outcome/is-fatal.ts 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/outcome/schema.ts b/packages/data-gpu-hopper/src/features/main/data/values/outcome/schema.ts similarity index 77% rename from packages/data-gpu-hopper/src/features/main/data/outcome/schema.ts rename to packages/data-gpu-hopper/src/features/main/data/values/outcome/schema.ts index b0e7ff54f..2355ce1c6 100644 --- a/packages/data-gpu-hopper/src/features/main/data/outcome/schema.ts +++ b/packages/data-gpu-hopper/src/features/main/data/values/outcome/schema.ts @@ -1,4 +1,6 @@ // © 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: @@ -12,3 +14,5 @@ export const schema = { 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/create-system-database.ts b/packages/data-gpu-hopper/src/features/main/ecs/conformance/create-system-database.ts new file mode 100644 index 000000000..e65b37d5e --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/conformance/create-system-database.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { MainService } from "../main-service.js"; + +// The assembled feature database with its writable system surface (`db.store`, +// `db.system.functions`), for the tests that drive frames headlessly. +export const createSystemDatabase = (): Database.Plugin.ToSystemDatabase => + 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/ecs/conformance/drive-frame.ts similarity index 74% rename from packages/data-gpu-hopper/src/features/main/services/main-service/conformance/drive-frame.ts rename to packages/data-gpu-hopper/src/features/main/ecs/conformance/drive-frame.ts index 56cdff467..55badb194 100644 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/drive-frame.ts +++ b/packages/data-gpu-hopper/src/features/main/ecs/conformance/drive-frame.ts @@ -1,11 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { SystemDatabase } from "../system-database/system-database.js"; +import type { SystemDatabase } from "../systems/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. +// skipping the scheduler's own bookkeeping system. Test-only. export const driveFrame = (db: SystemDatabase): void => { for (const tier of db.system.order) { for (const name of tier) { 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..6997eafaf --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/conformance/implementation.ts @@ -0,0 +1,19 @@ +// © 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: `hop` and `newGame` conform against the +// same-named actions; `step` is realized by the systems, so each case drives one +// headless frame of `frameDelta = dt`. +export const implementation = Conformance.implementation(spec, { + plugin: MainService.plugin, + projection, + frame: { + op: "step", + setup: (db, { dt }) => { + db.store.resources.frameDelta = dt; + }, + }, +}); 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/ecs/conformance/outcome-selection.test.ts similarity index 80% rename from packages/data-gpu-hopper/src/features/main/services/main-service/system-database/outcome-selection.test.ts rename to packages/data-gpu-hopper/src/features/main/ecs/conformance/outcome-selection.test.ts index fb9ed71c9..dccaff199 100644 --- 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/ecs/conformance/outcome-selection.test.ts @@ -1,18 +1,14 @@ // © 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. +// Outcome detection edge cases the whole-tick cases don't isolate (lane lookup, the +// exact coverage boundary). Each case seeds a geometry, drives ONE frame with dt = 0 +// (hazards and frog stay put), and reads back the resulting `State`. 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"; +import type { State } from "../../spec/state.js"; +import type { Lane } from "../../data/values/lane/lane.js"; +import { createSystemDatabase } from "./create-system-database.js"; +import { projection } from "./projection.js"; +import { driveFrame } from "./drive-frame.js"; const roadLanes: readonly Lane[] = [ { row: 0, kind: "grass" }, 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..49c28af93 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/ecs/systems/system-database.ts @@ -0,0 +1,90 @@ +// © 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; together the two systems realize one spec `step` per frame: +// +// 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 in `db.system.order`. +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/services/main-service/transaction-database/transactions/index.ts b/packages/data-gpu-hopper/src/features/main/ecs/transactions/index.ts similarity index 100% rename from packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/index.ts rename to packages/data-gpu-hopper/src/features/main/ecs/transactions/index.ts index e0055b32b..dcdf4337e 100644 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/index.ts +++ b/packages/data-gpu-hopper/src/features/main/ecs/transactions/index.ts @@ -1,5 +1,5 @@ // © 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"; +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/services/main-service/transaction-database/transaction-database.ts b/packages/data-gpu-hopper/src/features/main/ecs/transactions/transaction-database.ts similarity index 52% rename from packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transaction-database.ts rename to packages/data-gpu-hopper/src/features/main/ecs/transactions/transaction-database.ts index 094f29018..60a2f8789 100644 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transaction-database.ts +++ b/packages/data-gpu-hopper/src/features/main/ecs/transactions/transaction-database.ts @@ -1,18 +1,15 @@ // © 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"; +import { CoreDatabase } from "../core/core-database.js"; +import * as transactions from "./index.js"; -// Hopper has no index layer (a dozen hazards brute-force fine), so the -// transaction layer extends the core schema directly. +// 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< - typeof transactionDatabasePlugin ->; +export type TransactionDatabase = Database.Plugin.ToDatabase; export namespace TransactionDatabase { export const plugin = transactionDatabasePlugin; 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/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/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/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/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/data/state/create.test.ts b/packages/data-gpu-hopper/src/features/main/spec/create.test.ts similarity index 80% rename from packages/data-gpu-hopper/src/features/main/data/state/create.test.ts rename to packages/data-gpu-hopper/src/features/main/spec/create.test.ts index 62d5b4e33..05e3f5840 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/create.test.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/create.test.ts @@ -1,7 +1,9 @@ // © 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"; +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(); @@ -13,19 +15,19 @@ describe("State.create", () => { }); it("spawns the frog centred on the bottom row", () => { - expect(state.frog).toEqual(State.startPosition(state)); + 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(State.laneAt(state, row)).toBeDefined(); + 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 = State.laneAt(state, hazard.lane)?.kind; + const kind = Lane.at(state.lanes, hazard.lane)?.kind; const expected = hazard.kind === "car" ? "road" : "river"; expect(kind).toBe(expected); } 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/data/state/lose-life.ts b/packages/data-gpu-hopper/src/features/main/spec/lose-life.ts similarity index 60% rename from packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts rename to packages/data-gpu-hopper/src/features/main/spec/lose-life.ts index 47972e465..6db123365 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/lose-life.ts @@ -1,11 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { GameStatus } from "../game-status/game-status.js"; +import { Frog } from "../data/values/frog/frog.js"; +import { GameStatus } from "../data/values/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). +// 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 step sub-transition, not an operation of its own. export const loseLife = ( state: Pick, ): Pick => { @@ -14,5 +13,5 @@ export const loseLife = ( const lives = state.lives - 1; return lives <= 0 ? { lives: 0, status: "gameOver", frog: state.frog } - : { lives, status: state.status, frog: startPosition(state) }; + : { lives, status: state.status, frog: Frog.start(state.width) }; }; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/new-game.cases.ts b/packages/data-gpu-hopper/src/features/main/spec/new-game.cases.ts similarity index 63% rename from packages/data-gpu-hopper/src/features/main/data/state/new-game.cases.ts rename to packages/data-gpu-hopper/src/features/main/spec/new-game.cases.ts index 02cd5bae1..ef4f8a267 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/new-game.cases.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/new-game.cases.ts @@ -4,12 +4,9 @@ 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). +// `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: [ { 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/data/state/samples.ts b/packages/data-gpu-hopper/src/features/main/spec/samples.ts similarity index 56% rename from packages/data-gpu-hopper/src/features/main/data/state/samples.ts rename to packages/data-gpu-hopper/src/features/main/spec/samples.ts index dfc19daa9..152ebe1d5 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/samples.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/samples.ts @@ -2,18 +2,12 @@ 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). +// 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[] = [ - initial, + create(), { width: 5, height: 3, 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..618f8e8e0 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/spec.ts @@ -0,0 +1,16 @@ +// © 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 hop } from "./hop.cases.js"; +import { cases as step } from "./step.cases.js"; +import { cases as newGame } from "./new-game.cases.js"; + +// The feature's spec: every operation with its inert cases. No entity references and +// no injected services, so no `schemas` or `services`. +export const spec = Conformance.spec({ + state: State, + fns: transforms, + cases: { hop, step, newGame }, +}); 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/data/state/step.cases.ts b/packages/data-gpu-hopper/src/features/main/spec/step.cases.ts similarity index 92% rename from packages/data-gpu-hopper/src/features/main/data/state/step.cases.ts rename to packages/data-gpu-hopper/src/features/main/spec/step.cases.ts index 1da227bc3..9b539152c 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/step.cases.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/step.cases.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Conformance } from "@adobe/data-testing"; -import type { Lane } from "../lane/lane.js"; +import type { Lane } from "../data/values/lane/lane.js"; import type { State } from "./state.js"; import type { step } from "./step.js"; @@ -16,10 +16,8 @@ const riverLanes: readonly Lane[] = [ { 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). +// Every case uses dt = 1 so hazard and carry displacements are exact. `before` +// overrides the default with a small test board; `after` is the writes patch. // 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 diff --git a/packages/data-gpu-hopper/src/features/main/spec/step.ts b/packages/data-gpu-hopper/src/features/main/spec/step.ts new file mode 100644 index 000000000..595bd6f54 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/step.ts @@ -0,0 +1,54 @@ +// © 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 { Outcome } from "../data/values/outcome/outcome.js"; +import { GameStatus } from "../data/values/game-status/game-status.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"; + +// The slice `step` writes. Every branch supplies all five keys; the outcome sub-patch +// (`winGoal` / `loseLife`) layers 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 on the last life). A no-op once the game has ended. +export const step = ( + state: Pick, + { dt }: { readonly dt: number }, +): StepPatch => { + const unchanged: StepPatch = { + entities: state.entities, + frog: state.frog, + score: state.score, + status: state.status, + lives: state.lives, + }; + if (!GameStatus.isPlaying(state.status)) return unchanged; + + const entities = new Map( + [...state.entities].map( + ([id, hazard]) => [id, { ...hazard, x: Lane.nextX(hazard.x, hazard.velocity, dt, state.width) }] as const, + ), + ); + + // The log the frog stands on, from the pre-scroll positions, drags it along. + 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 frog = carrier ? { x: state.frog.x + carrier.velocity * dt, y: state.frog.y } : state.frog; + + const moved = { ...state, entities, frog }; + const movedPatch: StepPatch = { ...unchanged, entities, frog }; + 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/spec/transforms.ts b/packages/data-gpu-hopper/src/features/main/spec/transforms.ts new file mode 100644 index 000000000..676a4feb2 --- /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 and the `step` tick. +// Coverage is checked at compile time against one `*.cases.ts` module each. +export { hop } from "./hop.js"; +export { step } from "./step.js"; +export { newGame } from "./new-game.js"; 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..e387af1ed --- /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 step 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/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" }] } From e976c060860c262ddc1a8a487d75553f4f71a6ec Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 18:11:22 -0700 Subject: [PATCH 10/24] refactor(data-react-pixie): declare the init-only system inline Co-Authored-By: Claude Opus 5.5 --- .../src/features/main/ecs/systems/index.ts | 2 -- .../features/main/ecs/systems/seed-sprites.ts | 14 -------------- .../main/ecs/systems/system-database.ts | 18 ++++++++++++++---- 3 files changed, 14 insertions(+), 20 deletions(-) delete mode 100644 packages/data-react-pixie/src/features/main/ecs/systems/index.ts delete mode 100644 packages/data-react-pixie/src/features/main/ecs/systems/seed-sprites.ts diff --git a/packages/data-react-pixie/src/features/main/ecs/systems/index.ts b/packages/data-react-pixie/src/features/main/ecs/systems/index.ts deleted file mode 100644 index 9c9b8ca6d..000000000 --- a/packages/data-react-pixie/src/features/main/ecs/systems/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export * from "./seed-sprites.js"; diff --git a/packages/data-react-pixie/src/features/main/ecs/systems/seed-sprites.ts b/packages/data-react-pixie/src/features/main/ecs/systems/seed-sprites.ts deleted file mode 100644 index d4b86e5e9..000000000 --- a/packages/data-react-pixie/src/features/main/ecs/systems/seed-sprites.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ActionDatabase } from "../actions/action-database.js"; - -// Init-only: `create` runs once at database construction to populate the initial -// scene and returns no per-frame function. System `create` receives the database -// plus its raw `store`. -export const seedSprites = { - create: (db: ActionDatabase & { readonly store: ActionDatabase.Store }) => { - 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 }); - }, -}; 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 index e3dd6a269..17889a5e5 100644 --- 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 @@ -1,13 +1,23 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; import { ActionDatabase } from "../actions/action-database.js"; -import * as systems from "./index.js"; -// Systems sit atop the action layer. Per-frame rotation is driven from the ui via the -// `tick` action, so this feature needs no scheduled system. +// 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, + 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; From 0d614d35c6b688123f881b49acb52723902c4234 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 18:11:42 -0700 Subject: [PATCH 11/24] refactor(data-lit-tictactoe): values helper takes mark fields structurally, never importing entities Co-Authored-By: Claude Opus 5.5 --- .../main/data/values/board-state/from-marks.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 index c5d88325b..c1d62ed20 100644 --- 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 @@ -1,10 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { BoardState } from "./board-state.js"; -import type { PlacedMark } from "../../entities/placed-mark.js"; +import type { CellIndex } from "../cell-index/cell-index.js"; +import type { PlayerMark } from "../player-mark/player-mark.js"; -// Fold the placed marks (the entity source of truth) into the compact -// index-addressed board the other helpers operate on. -export const fromMarks = (marks: Iterable): BoardState => { +// 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(""); From 44810ee194beb0edb171c9322ea075b207daf266 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 18:12:28 -0700 Subject: [PATCH 12/24] refactor(data-lit-space-rock-game): port to the layered feature layout Ship is a resource; shared logic moved from the spec into data/values helpers (Wave.ring, Bullet.fire, Lives.isGameOver); per-frame systems keep in-place column writes and step conforms via frame conformance. Co-Authored-By: Claude Opus 5.5 --- packages/data-lit-space-rock-game/index.html | 2 +- .../src/{ => app}/main.ts | 4 +- .../features/main/data/asteroid/asteroid.ts | 6 - .../src/features/main/data/bullet/bullet.ts | 6 - .../src/features/main/data/components/age.ts | 7 + .../features/main/data/components/index.ts | 7 + .../features/main/data/components/position.ts | 7 + .../src/features/main/data/components/size.ts | 7 + .../features/main/data/components/velocity.ts | 7 + .../features/main/data/entities/asteroid.ts | 11 + .../src/features/main/data/entities/bullet.ts | 11 + .../src/features/main/data/entities/index.ts | 5 + .../src/features/main/data/input/input.ts | 6 - .../features/main/data/resources/bounds.ts | 7 + .../main/data/resources/frame-delta.ts | 7 + .../src/features/main/data/resources/index.ts | 10 + .../src/features/main/data/resources/input.ts | 7 + .../src/features/main/data/resources/lives.ts | 7 + .../src/features/main/data/resources/score.ts | 7 + .../src/features/main/data/resources/ship.ts | 7 + .../src/features/main/data/resources/wave.ts | 7 + .../src/features/main/data/ship/ship.ts | 6 - .../src/features/main/data/size/size.ts | 6 - .../main/data/state/create-initial.ts | 28 -- .../src/features/main/data/state/create.ts | 17 -- .../features/main/data/state/fire-bullet.ts | 16 - .../features/main/data/state/is-game-over.ts | 6 - .../main/data/state/spawn-random-wave.ts | 53 ---- .../features/main/data/state/spawn-wave.ts | 43 --- .../src/features/main/data/state/spec.test.ts | 9 - .../src/features/main/data/state/spec.ts | 54 ---- .../src/features/main/data/state/state.ts | 21 -- .../features/main/data/state/transforms.ts | 26 -- .../main/data/values/asteroid/asteroid.ts | 12 + .../main/data/{ => values}/asteroid/is.ts | 0 .../main/data/{ => values}/asteroid/public.ts | 0 .../data/{ => values}/asteroid/radius.test.ts | 0 .../main/data/{ => values}/asteroid/radius.ts | 0 .../main/data/{ => values}/asteroid/schema.ts | 4 + .../data/{ => values}/asteroid/score.test.ts | 0 .../main/data/{ => values}/asteroid/score.ts | 0 .../data/{ => values}/asteroid/split.test.ts | 0 .../main/data/{ => values}/asteroid/split.ts | 0 .../main/data/values/bullet/bullet.ts | 11 + .../features/main/data/values/bullet/fire.ts | 7 + .../{ => values}/bullet/is-expired.test.ts | 0 .../data/{ => values}/bullet/is-expired.ts | 0 .../main/data/{ => values}/bullet/is.ts | 0 .../main/data/{ => values}/bullet/lifetime.ts | 0 .../main/data/{ => values}/bullet/public.ts | 1 + .../main/data/{ => values}/bullet/radius.ts | 0 .../main/data/{ => values}/bullet/schema.ts | 4 + .../main/data/{ => values}/bullet/speed.ts | 0 .../collision/circles-overlap.test.ts | 0 .../{ => values}/collision/circles-overlap.ts | 0 .../data/{ => values}/collision/collision.ts | 0 .../data/{ => values}/collision/public.ts | 0 .../collision/segment-circle-overlap.test.ts | 0 .../collision/segment-circle-overlap.ts | 0 .../features/main/data/values/input/input.ts | 10 + .../main/data/{ => values}/input/none.ts | 0 .../main/data/{ => values}/input/public.ts | 0 .../main/data/{ => values}/input/schema.ts | 4 + .../main/data/values/lives/initial.ts | 5 + .../lives}/is-game-over.test.ts | 8 +- .../main/data/values/lives/is-game-over.ts | 5 + .../features/main/data/values/lives/lives.ts | 5 + .../features/main/data/values/lives/public.ts | 4 + .../features/main/data/values/lives/schema.ts | 9 + .../data/{ => values}/motion/advance.test.ts | 0 .../main/data/{ => values}/motion/advance.ts | 0 .../main/data/{ => values}/motion/motion.ts | 0 .../main/data/{ => values}/motion/public.ts | 0 .../data/{ => values}/motion/rotate.test.ts | 0 .../main/data/{ => values}/motion/rotate.ts | 0 .../data/{ => values}/motion/wrap.test.ts | 0 .../main/data/{ => values}/motion/wrap.ts | 0 .../data/{ => values}/ship/facing.test.ts | 0 .../main/data/{ => values}/ship/facing.ts | 0 .../data/{ => values}/ship/muzzle.test.ts | 0 .../main/data/{ => values}/ship/muzzle.ts | 0 .../main/data/{ => values}/ship/public.ts | 0 .../main/data/{ => values}/ship/radius.ts | 0 .../main/data/{ => values}/ship/schema.ts | 4 + .../features/main/data/values/ship/ship.ts | 11 + .../main/data/{ => values}/ship/spawn.test.ts | 0 .../main/data/{ => values}/ship/spawn.ts | 0 .../data/{ => values}/ship/thrust-accel.ts | 0 .../data/{ => values}/ship/thrust.test.ts | 0 .../main/data/{ => values}/ship/thrust.ts | 0 .../main/data/{ => values}/ship/turn-rate.ts | 0 .../main/data/{ => values}/ship/turn.test.ts | 0 .../main/data/{ => values}/ship/turn.ts | 0 .../main/data/{ => values}/size/color.ts | 0 .../main/data/{ => values}/size/is.ts | 0 .../main/data/{ => values}/size/largest.ts | 0 .../main/data/{ => values}/size/public.ts | 0 .../main/data/{ => values}/size/radius.ts | 0 .../main/data/{ => values}/size/schema.ts | 4 + .../main/data/{ => values}/size/score.ts | 0 .../main/data/{ => values}/size/size.test.ts | 0 .../features/main/data/values/size/size.ts | 5 + .../main/data/{ => values}/size/smaller.ts | 0 .../data/{ => values}/size/split-count.ts | 0 .../main/data/{ => values}/size/values.ts | 0 .../{ => values}/spatial/cell-key.test.ts | 0 .../data/{ => values}/spatial/cell-key.ts | 0 .../data/{ => values}/spatial/cell-size.ts | 0 .../spatial/neighbor-keys.test.ts | 0 .../{ => values}/spatial/neighbor-keys.ts | 0 .../main/data/{ => values}/spatial/public.ts | 0 .../main/data/{ => values}/spatial/spatial.ts | 0 .../main/data/values/wave/asteroid-count.ts | 5 + .../features/main/data/values/wave/public.ts | 5 + .../main/data/values/wave/ring.test.ts | 22 ++ .../features/main/data/values/wave/ring.ts | 28 ++ .../features/main/data/values/wave/schema.ts | 9 + .../features/main/data/values/wave/speed.ts | 4 + .../features/main/data/values/wave/wave.ts | 5 + .../actions}/action-database.ts | 10 +- .../main/ecs/actions/create-initial.ts | 8 + .../features/main/ecs/actions/fire-bullet.ts | 7 + .../action-database => ecs}/actions/index.ts | 2 + .../features/main/ecs/actions/set-input.ts | 8 + .../main/ecs/actions/spawn-random-wave.ts | 8 + .../computed}/computed-database.ts | 6 +- .../main/ecs/computed/game-over.test.ts | 30 ++ .../features/main/ecs/computed/game-over.ts | 11 + .../computed/index.ts | 0 .../conformance}/collision-detection.test.ts | 18 +- .../main/ecs/conformance/conformance.test.ts | 7 + .../conformance/create-system-database.ts | 2 +- .../conformance/drive-frame.ts | 6 +- .../main/ecs/conformance/implementation.ts | 21 ++ .../main/ecs/conformance/projection.ts | 59 ++++ .../main/ecs/conformance/waves.test.ts | 48 +++ .../src/features/main/ecs/core/archetypes.ts | 10 + .../features/main/ecs/core/core-database.ts | 26 ++ .../index-database => ecs}/indexes/by-cell.ts | 6 +- .../indexes}/index-database.ts | 4 +- .../index-database => ecs}/indexes/index.ts | 0 .../main-service => ecs}/main-service.ts | 2 +- .../services}/service-database.ts | 6 +- .../main/ecs/systems/system-database.ts | 176 +++++++++++ .../main/ecs/transactions/clear-entities.ts | 13 + .../main/ecs/transactions/create-initial.ts | 23 ++ .../main/ecs/transactions/fire-bullet.ts | 8 + .../main/ecs/transactions/hit-asteroid.ts | 20 ++ .../transactions/index.ts | 2 - .../main/ecs/transactions/lose-life.ts | 11 + .../main/ecs/transactions/next-wave.ts | 16 + .../main/ecs/transactions/refill-wave.ts | 15 + .../main/ecs/transactions/set-input.ts | 8 + .../transactions}/transaction-database.ts | 13 +- .../action-database/actions/fire-bullet.ts | 10 - .../actions/spawn-random-wave.ts | 14 - .../computed/game-over.test.ts | 39 --- .../computed-database/computed/game-over.ts | 13 - .../conformance/conformance.test.ts | 16 - .../main-service/conformance/projection.ts | 104 ------- .../main-service/core-database/archetypes.ts | 31 -- .../main-service/core-database/components.ts | 22 -- .../core-database/core-database.ts | 40 --- .../main-service/core-database/resources.ts | 19 -- .../system-database/system-database.ts | 283 ------------------ .../system-database/tick-loop.test.ts | 99 ------ .../transactions/clear-entities.ts | 14 - .../transactions/create-initial.ts | 17 -- .../transactions/fire-bullet.ts | 19 -- .../transactions/hit-asteroid.ts | 28 -- .../transactions/lose-life.ts | 18 -- .../transactions/new-game.ts | 24 -- .../transactions/read-asteroids.ts | 20 -- .../transactions/read-ship.ts | 20 -- .../transactions/refill-wave.ts | 38 --- .../transactions/set-bounds.ts | 9 - .../transactions/set-input.ts | 9 - .../services/random-service/create-fake.ts | 12 + .../main/services/random-service/create.ts | 7 +- .../main/services/random-service/public.ts | 1 + .../services/random-service/random.fake.ts | 17 -- .../state => spec}/create-initial.cases.ts | 12 +- .../src/features/main/spec/create-initial.ts | 23 ++ .../main/{data/state => spec}/create.test.ts | 3 +- .../src/features/main/spec/create.ts | 15 + .../{data/state => spec}/fire-bullet.cases.ts | 10 +- .../src/features/main/spec/fire-bullet.ts | 12 + .../src/features/main/spec/helpers.test.ts | 24 ++ .../{data/state/public.ts => spec/helpers.ts} | 11 +- .../src/features/main/spec/public.ts | 3 + .../resolve-bullet-hits.cases.ts | 15 +- .../state => spec}/resolve-bullet-hits.ts | 6 +- .../state => spec}/resolve-ship-hits.cases.ts | 14 +- .../{data/state => spec}/resolve-ship-hits.ts | 6 +- .../main/{data/state => spec}/samples.ts | 0 .../state => spec}/spawn-random-wave.cases.ts | 18 +- .../features/main/spec/spawn-random-wave.ts | 28 ++ .../{data/state => spec}/spawn-wave.cases.ts | 12 +- .../src/features/main/spec/spawn-wave.ts | 24 ++ .../src/features/main/spec/spec.test.ts | 6 + .../src/features/main/spec/spec.ts | 29 ++ .../src/features/main/spec/state.ts | 27 ++ .../state => spec}/step-asteroids.cases.ts | 6 +- .../{data/state => spec}/step-asteroids.ts | 4 +- .../state => spec}/step-bullets.cases.ts | 10 +- .../main/{data/state => spec}/step-bullets.ts | 4 +- .../{data/state => spec}/step-ship.cases.ts | 9 +- .../main/{data/state => spec}/step-ship.ts | 6 +- .../main/{data/state => spec}/step.cases.ts | 22 +- .../main/{data/state => spec}/step.ts | 25 +- .../src/features/main/spec/transforms.ts | 10 + .../space-rock-game-draw.test.ts | 4 +- .../space-rock-game/space-rock-game-draw.ts | 6 +- .../space-rock-game-element.ts | 47 ++- .../ui/space-rock-game/space-rock-game.ts | 2 +- .../data-lit-space-rock-game/src/index.ts | 13 +- .../tsconfig.build.json | 29 -- .../tsconfig.data.json | 16 + .../tsconfig.ecs.json | 16 + .../tsconfig.spec.json | 17 ++ .../tsconfig.test.json | 22 +- 221 files changed, 1256 insertions(+), 1484 deletions(-) rename packages/data-lit-space-rock-game/src/{ => app}/main.ts (76%) delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/asteroid/asteroid.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/bullet/bullet.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/components/age.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/components/index.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/components/position.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/components/size.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/components/velocity.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/entities/asteroid.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/entities/bullet.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/entities/index.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/input/input.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/resources/bounds.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/resources/frame-delta.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/resources/index.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/resources/input.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/resources/lives.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/resources/score.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/resources/ship.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/resources/wave.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/ship/ship.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/size/size.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/create.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/spec.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/state.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/transforms.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/asteroid.ts rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/asteroid/is.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/asteroid/public.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/asteroid/radius.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/asteroid/radius.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/asteroid/schema.ts (73%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/asteroid/score.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/asteroid/score.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/asteroid/split.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/asteroid/split.ts (100%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/bullet/bullet.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/bullet/fire.ts rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/bullet/is-expired.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/bullet/is-expired.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/bullet/is.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/bullet/lifetime.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/bullet/public.ts (89%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/bullet/radius.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/bullet/schema.ts (77%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/bullet/speed.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/collision/circles-overlap.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/collision/circles-overlap.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/collision/collision.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/collision/public.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/collision/segment-circle-overlap.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/collision/segment-circle-overlap.ts (100%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/input/input.ts rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/input/none.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/input/public.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/input/schema.ts (73%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/lives/initial.ts rename packages/data-lit-space-rock-game/src/features/main/data/{state => values/lives}/is-game-over.test.ts (54%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/lives/is-game-over.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/lives/lives.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/lives/public.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/lives/schema.ts rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/motion/advance.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/motion/advance.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/motion/motion.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/motion/public.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/motion/rotate.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/motion/rotate.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/motion/wrap.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/motion/wrap.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/facing.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/facing.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/muzzle.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/muzzle.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/public.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/radius.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/schema.ts (78%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/ship/ship.ts rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/spawn.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/spawn.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/thrust-accel.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/thrust.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/thrust.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/turn-rate.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/turn.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/ship/turn.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/size/color.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/size/is.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/size/largest.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/size/public.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/size/radius.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/size/schema.ts (72%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/size/score.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/size/size.test.ts (100%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/size/size.ts rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/size/smaller.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/size/split-count.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/size/values.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/spatial/cell-key.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/spatial/cell-key.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/spatial/cell-size.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/spatial/neighbor-keys.test.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/spatial/neighbor-keys.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/spatial/public.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/data/{ => values}/spatial/spatial.ts (100%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/wave/asteroid-count.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/wave/public.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/wave/ring.test.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/wave/ring.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/wave/schema.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/wave/speed.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/values/wave/wave.ts rename packages/data-lit-space-rock-game/src/features/main/{services/main-service/action-database => ecs/actions}/action-database.ts (52%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/actions/create-initial.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/actions/fire-bullet.ts rename packages/data-lit-space-rock-game/src/features/main/{services/main-service/action-database => ecs}/actions/index.ts (65%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/actions/set-input.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/actions/spawn-random-wave.ts rename packages/data-lit-space-rock-game/src/features/main/{services/main-service/computed-database => ecs/computed}/computed-database.ts (79%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/computed/game-over.test.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/computed/game-over.ts rename packages/data-lit-space-rock-game/src/features/main/{services/main-service/computed-database => ecs}/computed/index.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/{services/main-service/system-database => ecs/conformance}/collision-detection.test.ts (92%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/conformance/conformance.test.ts rename packages/data-lit-space-rock-game/src/features/main/{services/main-service => ecs}/conformance/create-system-database.ts (90%) rename packages/data-lit-space-rock-game/src/features/main/{services/main-service => ecs}/conformance/drive-frame.ts (79%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/conformance/implementation.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/conformance/projection.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/conformance/waves.test.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/core/archetypes.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/core/core-database.ts rename packages/data-lit-space-rock-game/src/features/main/{services/main-service/index-database => ecs}/indexes/by-cell.ts (78%) rename packages/data-lit-space-rock-game/src/features/main/{services/main-service/index-database => ecs/indexes}/index-database.ts (86%) rename packages/data-lit-space-rock-game/src/features/main/{services/main-service/index-database => ecs}/indexes/index.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/{services/main-service => ecs}/main-service.ts (74%) rename packages/data-lit-space-rock-game/src/features/main/{services/main-service/service-database => ecs/services}/service-database.ts (85%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/systems/system-database.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/transactions/clear-entities.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/transactions/create-initial.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/transactions/fire-bullet.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/transactions/hit-asteroid.ts rename packages/data-lit-space-rock-game/src/features/main/{services/main-service/transaction-database => ecs}/transactions/index.ts (80%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/transactions/lose-life.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/transactions/next-wave.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/transactions/refill-wave.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/transactions/set-input.ts rename packages/data-lit-space-rock-game/src/features/main/{services/main-service/transaction-database => ecs/transactions}/transaction-database.ts (58%) delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/fire-bullet.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/spawn-random-wave.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/computed-database/computed/game-over.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/computed-database/computed/game-over.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/conformance.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/archetypes.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/components.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/core-database.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/core-database/resources.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/system-database.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/clear-entities.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/create-initial.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/fire-bullet.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/hit-asteroid.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/lose-life.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/read-asteroids.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/read-ship.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/refill-wave.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-bounds.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-input.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/services/random-service/create-fake.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/random-service/random.fake.ts rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/create-initial.cases.ts (78%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/create-initial.ts rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/create.test.ts (83%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/create.ts rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/fire-bullet.cases.ts (83%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/fire-bullet.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/helpers.test.ts rename packages/data-lit-space-rock-game/src/features/main/{data/state/public.ts => spec/helpers.ts} (53%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/public.ts rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/resolve-bullet-hits.cases.ts (89%) rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/resolve-bullet-hits.ts (93%) rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/resolve-ship-hits.cases.ts (81%) rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/resolve-ship-hits.ts (82%) rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/samples.ts (100%) rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/spawn-random-wave.cases.ts (64%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/spawn-random-wave.ts rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/spawn-wave.cases.ts (69%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/spawn-wave.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/spec.test.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/spec.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/state.ts rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/step-asteroids.cases.ts (88%) rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/step-asteroids.ts (87%) rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/step-bullets.cases.ts (83%) rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/step-bullets.ts (89%) rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/step-ship.cases.ts (87%) rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/step-ship.ts (84%) rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/step.cases.ts (81%) rename packages/data-lit-space-rock-game/src/features/main/{data/state => spec}/step.ts (51%) create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/transforms.ts delete mode 100644 packages/data-lit-space-rock-game/tsconfig.build.json create mode 100644 packages/data-lit-space-rock-game/tsconfig.data.json create mode 100644 packages/data-lit-space-rock-game/tsconfig.ecs.json create mode 100644 packages/data-lit-space-rock-game/tsconfig.spec.json 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/main.ts b/packages/data-lit-space-rock-game/src/app/main.ts similarity index 76% rename from packages/data-lit-space-rock-game/src/main.ts rename to packages/data-lit-space-rock-game/src/app/main.ts index d423f1f68..d456c9573 100644 --- a/packages/data-lit-space-rock-game/src/main.ts +++ b/packages/data-lit-space-rock-game/src/app/main.ts @@ -2,8 +2,8 @@ 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/services/main-service/main-service.js"; +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) { 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/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/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/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/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/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.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.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/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.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/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/asteroid/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/schema.ts similarity index 73% rename from packages/data-lit-space-rock-game/src/features/main/data/asteroid/schema.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/asteroid/schema.ts index c7abd7d69..b5c0f1ccc 100644 --- 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/values/asteroid/schema.ts @@ -1,4 +1,6 @@ // © 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"; @@ -9,3 +11,5 @@ 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/bullet/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/public.ts similarity index 89% rename from packages/data-lit-space-rock-game/src/features/main/data/bullet/public.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/bullet/public.ts index 93612f42b..c53871c4a 100644 --- 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/values/bullet/public.ts @@ -6,3 +6,4 @@ 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/bullet/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/bullet/schema.ts similarity index 77% rename from packages/data-lit-space-rock-game/src/features/main/data/bullet/schema.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/bullet/schema.ts index 9b6716be6..63c5d5e82 100644 --- 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/values/bullet/schema.ts @@ -1,4 +1,6 @@ // © 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"; @@ -11,3 +13,5 @@ export const schema = Schema.fromStructProperties({ 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/input/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/input/schema.ts similarity index 73% rename from packages/data-lit-space-rock-game/src/features/main/data/input/schema.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/input/schema.ts index 3df090a04..7d535d13c 100644 --- 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/values/input/schema.ts @@ -1,4 +1,6 @@ // © 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"; @@ -8,3 +10,5 @@ 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/state/is-game-over.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/lives/is-game-over.test.ts similarity index 54% rename from packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.test.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/lives/is-game-over.test.ts index 23e827662..2c2f6f1fc 100644 --- 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/values/lives/is-game-over.test.ts @@ -1,13 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, it, expect } from "vitest"; -import { State } from "./state.js"; +import { Lives } from "./lives.js"; -describe("State.isGameOver", () => { +describe("Lives.isGameOver", () => { it("is over once lives reach zero", () => { - expect(State.isGameOver({ lives: 0 })).toBe(true); + expect(Lives.isGameOver(0)).toBe(true); }); it("is not over while a life remains", () => { - expect(State.isGameOver({ lives: 1 })).toBe(false); + 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/ship/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/ship/schema.ts similarity index 78% rename from packages/data-lit-space-rock-game/src/features/main/data/ship/schema.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/ship/schema.ts index 4aecd913b..e198f12a1 100644 --- 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/values/ship/schema.ts @@ -1,4 +1,6 @@ // © 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"; @@ -12,3 +14,5 @@ export const schema = Schema.fromStructProperties({ 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/size/schema.ts b/packages/data-lit-space-rock-game/src/features/main/data/values/size/schema.ts similarity index 72% rename from packages/data-lit-space-rock-game/src/features/main/data/size/schema.ts rename to packages/data-lit-space-rock-game/src/features/main/data/values/size/schema.ts index b5530628b..3e56e8613 100644 --- 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/values/size/schema.ts @@ -1,4 +1,6 @@ // © 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 @@ -9,3 +11,5 @@ export const schema = { 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/services/main-service/action-database/action-database.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/actions/action-database.ts similarity index 52% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/action-database.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/actions/action-database.ts index f23aede57..a792ef8b5 100644 --- 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/ecs/actions/action-database.ts @@ -1,12 +1,10 @@ // © 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"; +import { ServiceDatabase } from "../services/service-database.js"; +import * as actions from "./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. +// Extends the service database with the `actions` facet: the app-facing +// operations the UI calls. const actionDatabasePlugin = Database.Plugin.create({ extends: ServiceDatabase.plugin, actions, 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/services/main-service/action-database/actions/index.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/actions/index.ts similarity index 65% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/index.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/actions/index.ts index d40e650a9..cb89a9568 100644 --- 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/ecs/actions/index.ts @@ -1,3 +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/services/main-service/computed-database/computed-database.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/computed/computed-database.ts similarity index 79% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/computed-database/computed-database.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/computed/computed-database.ts index caba9ef35..278ae1e7a 100644 --- 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/ecs/computed/computed-database.ts @@ -1,10 +1,10 @@ // © 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"; +import { TransactionDatabase } from "../transactions/transaction-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 ← State.isGameOver). +// observable values wired to pure data/ derivations (gameOver ← Lives.isGameOver). const computedDatabasePlugin = Database.Plugin.create({ extends: TransactionDatabase.plugin, computed, 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/services/main-service/system-database/collision-detection.test.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/collision-detection.test.ts similarity index 92% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/collision-detection.test.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/conformance/collision-detection.test.ts index e562e22ba..f8e990c8a 100644 --- 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/ecs/conformance/collision-detection.test.ts @@ -11,15 +11,15 @@ // 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"; +import type { State } from "../../spec/state.js"; +import { Ship } from "../../data/values/ship/ship.js"; +import { Bullet } from "../../data/values/bullet/bullet.js"; +import { Asteroid } from "../../data/values/asteroid/asteroid.js"; +import { Input } from "../../data/values/input/input.js"; +import { Size } from "../../data/values/size/size.js"; +import { createSystemDatabase } from "./create-system-database.js"; +import { projection } from "./projection.js"; +import { driveFrame } from "./drive-frame.js"; const base = (overrides: Partial): State => ({ bounds: [800, 600], 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..860f8e331 --- /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, for `step`, one driven +// frame of the systems), and round-trips `State.samples` through the projection. +Conformance.checkFeature(implementation); 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/ecs/conformance/create-system-database.ts similarity index 90% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-system-database.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/conformance/create-system-database.ts index 746f19d64..9366ca710 100644 --- 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/ecs/conformance/create-system-database.ts @@ -5,7 +5,7 @@ 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 +// twin of `UIService.restrict`). ONLY the collision-detection / waves 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. 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/ecs/conformance/drive-frame.ts similarity index 79% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/drive-frame.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/conformance/drive-frame.ts index 1d03634b4..1e5bc927e 100644 --- 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/ecs/conformance/drive-frame.ts @@ -1,11 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { SystemDatabase } from "../system-database/system-database.js"; +import type { SystemDatabase } from "../systems/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. +// headless-host pattern (see systems.md), used by the collision-detection and +// waves tests. Test-only. export const driveFrame = (db: SystemDatabase): void => { for (const tier of db.system.order) { for (const name of tier) { 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..437fcebcf --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/implementation.ts @@ -0,0 +1,21 @@ +// © 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. `createInitial`, `fireBullet` and +// `spawnRandomWave` conform against same-named actions; `step` against one driven +// frame of the systems, with the case's `dt` and `input` seeded into the resources +// the systems read. +export const implementation = Conformance.implementation(spec, { + plugin: MainService.plugin, + projection, + frame: { + op: "step", + setup: (db, { dt, input }) => { + db.store.resources.frameDelta = dt; + db.store.resources.input = input; + }, + }, +}); 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/conformance/waves.test.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/waves.test.ts new file mode 100644 index 000000000..e957d940b --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/waves.test.ts @@ -0,0 +1,48 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// The `waves` system draws from the real random source, so its drift speeds can't be +// compared with the spec. A driven frame over a cleared field must still 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. +import { describe, it, expect } from "vitest"; +import { State } from "../../spec/state.js"; +import { Ship } from "../../data/values/ship/ship.js"; +import { Asteroid } from "../../data/values/asteroid/asteroid.js"; +import { Input } from "../../data/values/input/input.js"; +import { createSystemDatabase } from "./create-system-database.js"; +import { projection } from "./projection.js"; +import { driveFrame } from "./drive-frame.js"; + +describe("waves system", () => { + it("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/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/services/main-service/index-database/indexes/by-cell.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/indexes/by-cell.ts similarity index 78% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/index-database/indexes/by-cell.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/indexes/by-cell.ts index 54ef5f5a2..a6e617f78 100644 --- 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/ecs/indexes/by-cell.ts @@ -1,13 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Spatial } from "../../../../data/spatial/spatial.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; +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` (Ship, Asteroid, Bullet) is indexed. +// 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. 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/ecs/indexes/index-database.ts similarity index 86% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/index-database/index-database.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/indexes/index-database.ts index c319f829b..b9cbfc559 100644 --- 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/ecs/indexes/index-database.ts @@ -1,7 +1,7 @@ // © 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"; +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 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/services/main-service/main-service.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/main-service.ts similarity index 74% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/main-service.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/main-service.ts index dfdf7c39f..866607061 100644 --- 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/ecs/main-service.ts @@ -3,4 +3,4 @@ // 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"; +export { SystemDatabase as MainService } from "./systems/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/ecs/services/service-database.ts similarity index 85% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/service-database/service-database.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/services/service-database.ts index ca899dd94..633a1a2b7 100644 --- 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/ecs/services/service-database.ts @@ -1,9 +1,9 @@ // © 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"; +import { ComputedDatabase } from "../computed/computed-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 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..7ea4415a2 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/systems/system-database.ts @@ -0,0 +1,176 @@ +// © 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`. One frame of these systems realizes the spec's whole-tick `step`, +// in its 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 in `db.system.order`. +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/services/main-service/transaction-database/transactions/index.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/index.ts similarity index 80% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/index.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/transactions/index.ts index a9e480b8a..f8f4c3c2c 100644 --- 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/ecs/transactions/index.ts @@ -1,7 +1,5 @@ // © 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"; 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/services/main-service/transaction-database/transaction-database.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/transactions/transaction-database.ts similarity index 58% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transaction-database.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/transactions/transaction-database.ts index 4755776fe..87cd2e69a 100644 --- 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/ecs/transactions/transaction-database.ts @@ -1,13 +1,12 @@ // © 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"; +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 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. +// 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, 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/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/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/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/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/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/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/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/data/state/create.test.ts b/packages/data-lit-space-rock-game/src/features/main/spec/create.test.ts similarity index 83% rename from packages/data-lit-space-rock-game/src/features/main/data/state/create.test.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/create.test.ts index 661be7851..57a03fa29 100644 --- 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/spec/create.test.ts @@ -1,6 +1,7 @@ // © 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", () => { @@ -14,6 +15,6 @@ describe("State.create", () => { }); it("is not game over", () => { - expect(State.isGameOver(State.create())).toBe(false); + 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/helpers.test.ts b/packages/data-lit-space-rock-game/src/features/main/spec/helpers.test.ts new file mode 100644 index 000000000..56fe0ecad --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/helpers.test.ts @@ -0,0 +1,24 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as helpers from "./helpers.js"; +import { components } from "../data/components/index.js"; +import { resources } from "../data/resources/index.js"; + +import { cases as spawnWave } from "./spawn-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"; + +// Runs each sub-step helper's cases against the pure function. Spec-only: these are +// covered on the ECS side through the transitions that compose them. +Conformance.checkSpec( + Conformance.spec({ + state: State, + fns: helpers, + schemas: { ...components, ...resources }, + cases: { spawnWave, stepShip, stepBullets, stepAsteroids, resolveBulletHits, resolveShipHits }, + }), +); 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/spec/helpers.ts similarity index 53% rename from packages/data-lit-space-rock-game/src/features/main/data/state/public.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/helpers.ts index 86f77fe1e..6b66a8c8c 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/public.ts +++ b/packages/data-lit-space-rock-game/src/features/main/spec/helpers.ts @@ -1,15 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export { create } from "./create.js"; -export { samples } from "./samples.js"; -export { createInitial } from "./create-initial.js"; +// The sub-steps the conformed transitions compose. Pure spec helpers with their own +// cases, checked by `helpers.test.ts`; no ECS op realizes one on its own. +export { spawnWave } from "./spawn-wave.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/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.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/resolve-bullet-hits.cases.ts similarity index 89% rename from packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.cases.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/resolve-bullet-hits.cases.ts index 064e0ac23..3b90ad886 100644 --- 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/spec/resolve-bullet-hits.cases.ts @@ -4,15 +4,12 @@ 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 +// Cases for the `resolveBulletHits` helper `step` composes (args `{ dt }`). 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] }; 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.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/resolve-ship-hits.cases.ts similarity index 81% rename from packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.cases.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/resolve-ship-hits.cases.ts index 2608d0cff..ffa6758fb 100644 --- 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/spec/resolve-ship-hits.cases.ts @@ -3,16 +3,12 @@ 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"; +import { Ship } from "../data/values/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. +// Cases for the `resolveShipHits` helper `step` composes (no args). 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 stay in place. const field = { ...create(), bounds: [200, 200] as [number, number] }; const respawned = Ship.spawn([100, 100]); 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/data/state/spawn-random-wave.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/spawn-random-wave.cases.ts similarity index 64% rename from packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.cases.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/spawn-random-wave.cases.ts index 13de73a5a..81a4952cf 100644 --- 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/spec/spawn-random-wave.cases.ts @@ -4,18 +4,12 @@ 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. +// 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 = { 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/data/state/spawn-wave.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/spawn-wave.cases.ts similarity index 69% rename from packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.cases.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/spawn-wave.cases.ts index 9100699d4..4a1450070 100644 --- 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/spec/spawn-wave.cases.ts @@ -4,14 +4,10 @@ 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. +// Cases for the deterministic `spawnWave` helper `createInitial` composes (no args). When the field is clear it bumps the wave and +// spawns a ring of large asteroids drifting tangentially at 60px/s; while asteroids +// remain it is a no-op. Field 200×200 → centre [100,100], ring radius 80; wave 1 has +// 4 rocks, on the four quadrant angles. const field = { ...create(), bounds: [200, 200] as [number, number] }; export const cases: Conformance.SpecCases = { 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..c97c76f5e --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/spec.ts @@ -0,0 +1,29 @@ +// © 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 { RandomService } from "../services/random-service/random-service.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 step } from "./step.cases.js"; + +// The feature's spec: every transition, one inert cases module each (coverage checked +// at compile time against `transforms`). `schemas` supplies the column precision the +// comparator reads; `services` is the fake template the runner builds `random` +// recording doubles from. `ecs/conformance/` pairs this with the ECS build. +export const spec = Conformance.spec({ + state: State, + fns: transforms, + schemas: { ...components, ...resources }, + services: { random: RandomService.createFake }, + cases: { + createInitial, + fireBullet, + spawnRandomWave, + step, + }, +}); 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.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/step-asteroids.cases.ts similarity index 88% rename from packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.cases.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/step-asteroids.cases.ts index 373cd2ca9..1a326faee 100644 --- 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/spec/step-asteroids.cases.ts @@ -3,11 +3,9 @@ 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"; +import { Size } from "../data/values/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. +// Cases for the `stepAsteroids` helper `step` composes (args `{ dt }`). The 100×100 field forces wrap. // Asteroids drift by constant velocity, so `after` is exact. const field = { ...create(), bounds: [100, 100] as [number, number] }; 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 87% 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..df533dda0 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,7 +1,7 @@ // © 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 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/spec/step-bullets.cases.ts similarity index 83% rename from packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.cases.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/step-bullets.cases.ts index 5fd769afc..2e441b7ac 100644 --- 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/spec/step-bullets.cases.ts @@ -3,13 +3,11 @@ 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"; +import { Bullet } from "../data/values/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. +// Cases for the `stepBullets` helper `step` composes (args `{ dt }`). `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 = { 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 89% 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..aff0436a2 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,7 +1,7 @@ // © 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 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/spec/step-ship.cases.ts similarity index 87% rename from packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.cases.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/step-ship.cases.ts index ce381e423..1066eef79 100644 --- 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/spec/step-ship.cases.ts @@ -2,14 +2,11 @@ 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 type { Input } from "../data/values/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). +// Cases for the `stepShip` helper `step` composes (args `{ dt, input }`). 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 }; 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/spec/step-ship.ts similarity index 84% rename from packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/step-ship.ts index 90d14181d..00b0c2736 100644 --- 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/spec/step-ship.ts @@ -1,8 +1,8 @@ // © 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"; +import { Ship } from "../data/values/ship/ship.js"; +import type { Input } from "../data/values/input/input.js"; +import { Motion } from "../data/values/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 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/spec/step.cases.ts similarity index 81% rename from packages/data-lit-space-rock-game/src/features/main/data/state/step.cases.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/step.cases.ts index 92bd79170..30d91b067 100644 --- 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/spec/step.cases.ts @@ -2,21 +2,15 @@ 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"; +import { Input } from "../data/values/input/input.js"; +import { Ship } from "../data/values/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. +// Cases for the whole-tick `step` (args `{ dt, input }`), shared with one driven +// frame of the ECS systems (`frame` conformance). Each case exercises one branch of +// the pipeline — advance/wrap, fire, bullet↔asteroid, ship↔asteroid, 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 covered by the +// `spawnRandomWave` cases and `ecs/conformance/waves.test.ts`. export const cases: Conformance.SpecCases = { cases: [ { 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/spec/step.ts similarity index 51% rename from packages/data-lit-space-rock-game/src/features/main/data/state/step.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/step.ts index dcce1c371..e57420b1b 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts +++ b/packages/data-lit-space-rock-game/src/features/main/spec/step.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; -import type { Input } from "../input/input.js"; +import type { Input } from "../data/values/input/input.js"; import { stepShip } from "./step-ship.js"; import { fireBullet } from "./fire-bullet.js"; import { stepBullets } from "./step-bullets.js"; @@ -8,23 +8,12 @@ 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"; +import { Lives } from "../data/values/lives/lives.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). +// Advance the whole game one tick: 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. Realized by one frame of the ECS systems. export const step = ( state: State, { @@ -36,7 +25,7 @@ export const step = ( readonly input: Input; } & Pick, ): State => { - if (isGameOver(state)) { + if (Lives.isGameOver(state.lives)) { return state; } // Each sub-transition returns only the fields it writes; layer each patch over 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..8a7ddd240 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/transforms.ts @@ -0,0 +1,10 @@ +// © 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). `createInitial`, `fireBullet` and +// `spawnRandomWave` pair with same-named actions; `step` is one frame of the systems. +// The sub-steps `step` and `createInitial` compose are in `helpers.ts`. +export { createInitial } from "./create-initial.js"; +export { fireBullet } from "./fire-bullet.js"; +export { spawnRandomWave } from "./spawn-random-wave.js"; +export { step } from "./step.js"; 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 DatabaseElement Date: Tue, 29 Sep 2026 18:13:53 -0700 Subject: [PATCH 13/24] =?UTF-8?q?refactor(conformance):=20drop=20transacti?= =?UTF-8?q?onOps=20=E2=80=94=20frame=20covers=20system-driven=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../src/conformance/check-feature.ts | 1 - .../src/conformance/implementation.ts | 8 +-- .../src/conformance/run-feature.ts | 49 +++++-------------- 3 files changed, 13 insertions(+), 45 deletions(-) diff --git a/packages/data-testing/src/conformance/check-feature.ts b/packages/data-testing/src/conformance/check-feature.ts index 0f29447ef..c7e748594 100644 --- a/packages/data-testing/src/conformance/check-feature.ts +++ b/packages/data-testing/src/conformance/check-feature.ts @@ -20,7 +20,6 @@ export const checkFeature = ; // The assembled feature plugin (`MainService.plugin`): its actions are the ops under test. readonly plugin: Database.Plugin; - // Spec transitions a system dispatches straight to a same-named transaction (a tick - // step, not a user action). These conform against the transaction; every other - // transition must have a same-named action. - readonly transactionOps?: readonly string[]; // A real-time feature's per-frame spec transition (e.g. `step`), realized by the // systems tick loop rather than an action. Each case seeds a db built from `plugin`, // runs `setup` to apply the case args (e.g. `frameDelta = dt`), drives exactly one @@ -46,7 +42,6 @@ export interface Implementation object>>; // Override the ops read off the plugin facets. readonly ops?: { - readonly transactions?: Record; readonly actions?: Record; readonly computeds?: Record; }; @@ -65,7 +60,7 @@ export const implementation = < O extends Extract = never, >( spec: Spec, - impl: Omit>, "spec" | "plugin" | "projection" | "hydrate" | "computedPlugin" | "transactionOps" | "frame"> & { + impl: Omit>, "spec" | "plugin" | "projection" | "hydrate" | "computedPlugin" | "frame"> & { readonly plugin: PL; readonly frame?: { readonly op: O; @@ -73,7 +68,6 @@ export const implementation = < }; readonly projection: P; readonly hydrate?: H; - readonly transactionOps?: readonly Extract[]; } & (HasDerivations extends true ? { readonly computedPlugin: Database.Plugin } : { readonly computedPlugin?: Database.Plugin }) & HydrateExact>>, ): Implementation> => diff --git a/packages/data-testing/src/conformance/run-feature.ts b/packages/data-testing/src/conformance/run-feature.ts index 9d09f9b2b..cdc268014 100644 --- a/packages/data-testing/src/conformance/run-feature.ts +++ b/packages/data-testing/src/conformance/run-feature.ts @@ -3,7 +3,6 @@ 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"; @@ -18,12 +17,12 @@ export interface Projection { } // One call conforms a whole feature. The runner pulls the ops off the plugin's -// registered facets (`plugin.actions` / `plugin.transactions` / -// `computedPlugin.computed`) and constructs the stores/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 transition -// named in `transactionOps` (a system-dispatched step) conforms against its -// same-named transaction instead. A spec op with no implementation is a named failure. It also round-trips `State.samples` through the projection. +// 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. The `frame` op is realized by the systems tick loop and conforms by driving +// one frame. A spec op with no implementation is a named failure. It also round-trips +// `State.samples` through the projection. // // A feature that needs ambient per-case context (a user-scoped `userId`) uses the // lower-level `runTransactions`/`runActions`/`runComputeds` directly instead. @@ -34,8 +33,8 @@ export interface FeatureRunConfig { readonly state: { create(): State; readonly samples?: readonly State[] }; // The adapted `{ fn, cases }` map (see `adaptCases`). readonly transitions: Record>; - // The assembled feature plugin (`MainService.plugin`) — its `.actions` and - // `.transactions` 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,8 +42,6 @@ export interface FeatureRunConfig { readonly projection: Projection; // Names of computeds that emit an entity-id list (hydrated through `toData`). readonly hydrate?: readonly string[]; - // Spec transitions that conform against a same-named transaction, not an action. - readonly transactionOps?: readonly string[]; // The per-frame spec transition realized by the systems tick loop (see `Implementation`). readonly frame?: { readonly op: string; readonly setup?: (db: never, args: never) => void }; // Base service factories injected into every db (fakes for services whose factory @@ -53,7 +50,6 @@ export interface FeatureRunConfig { readonly match?: MatchOptions; // Override the ops discovered from the plugin facets. readonly ops?: { - readonly transactions?: Record; readonly actions?: Record; readonly computeds?: Record; }; @@ -61,7 +57,7 @@ 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 }; // The slice of a system database a headless frame needs. type FrameDatabase = { @@ -99,19 +95,12 @@ export function runFeature viaTransaction.has(name) && transactions.has(name))); - const byAction = new Set( - [...transitions.keys()].filter((name) => !viaTransaction.has(name) && name !== frameOp && actions.has(name)), - ); + const byAction = new Set([...transitions.keys()].filter((name) => name !== frameOp && actions.has(name))); const missing = [ - ...[...transitions.keys()].filter( - (name) => !byAction.has(name) && !byTransaction.has(name) && name !== frameOp, - ), + ...[...transitions.keys()].filter((name) => !byAction.has(name) && name !== frameOp), ...[...derivations.keys()].filter((name) => !computeds.has(name)), ]; @@ -119,26 +108,12 @@ export function runFeature { for (const name of missing) { it(name, () => { - throw new Error( - viaTransaction.has(name) - ? `spec op "${name}" is listed in transactionOps but has no same-named transaction` - : `spec op "${name}" has no same-named ${derivations.has(name) ? "computed" : "action"}`, - ); + throw new Error(`spec op "${name}" has no same-named ${derivations.has(name) ? "computed" : "action"}`); }); } }); } - runTransactions({ - createStore: makeStore, - fromState, - toState, - initial, - transitions: config.transitions, - transactions: pick(transactions, byTransaction), - match: config.match, - }); - runActions({ makeDb: (services) => Database.toSystemDatabase( From 4d68c00fce29d87701baf9a2e698726fe9d97121 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 18:21:43 -0700 Subject: [PATCH 14/24] fix(data-lit-todo): id-addressed todo ops ignore other features' entities toggleComplete/deleteTodo/selectTodo read through the Todo archetype, so a user id changes nothing. Adds a cross-feature regression test. Co-Authored-By: Claude Opus 5.5 --- .../src/app/cross-feature.test.ts | 30 +++++++++++++++++++ .../main/ecs/transactions/delete-todo.ts | 6 ++-- .../main/ecs/transactions/select-todo.ts | 6 ++-- .../main/ecs/transactions/toggle-complete.ts | 7 ++--- 4 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 packages/data-lit-todo/src/app/cross-feature.test.ts 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/features/main/ecs/transactions/delete-todo.ts b/packages/data-lit-todo/src/features/main/ecs/transactions/delete-todo.ts index fbb3e02a9..1958427fd 100644 --- 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 @@ -2,9 +2,7 @@ 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 }) => { - const todo = t.read(id); - if (todo) { - t.delete(id); - } + if (t.read(id, t.archetypes.Todo) !== null) t.delete(id); }; 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 index d864bdcb3..090a66828 100644 --- 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 @@ -2,8 +2,8 @@ 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 if the id names no -// entity, so the selection never dangles. Conforms to the `selectTodo` transition. +// 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.resources.selectedTodo = id; + 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 index e3bc011a0..3b7c6009c 100644 --- 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 @@ -2,9 +2,8 @@ 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); - if (todo) { - t.update(id, { complete: !todo.complete }); - } + const todo = t.read(id, t.archetypes.Todo); + if (todo !== null) t.update(id, { complete: !todo.complete }); }; From 39e23fc9e966dcb93a57670c0923a663fc4274b6 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 18:21:43 -0700 Subject: [PATCH 15/24] docs(data-ai): rewrite feature guidance for the layered layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules mirror the new tree: data/ (values, components, resources, entities), services/, spec/ (test tier), ecs/ (core → indexes → transactions → computed → services → actions → systems, conformance), ui/, and app.md for the composition root. Build skills follow the new order, with a new build-spec step. Relaxed tag guidance (a kind is its component set); per-file scope flags; frame conformance for real-time features; persistence and cross-feature-id pitfalls. Fixes stale ecs.md guidance (a nonexistent ephemeral flag, unsafe null casts). Co-Authored-By: Claude Opus 5.5 --- .claude/rules/app.md | 1 + packages/data-ai/.claude/rules/README.md | 56 ++-- packages/data-ai/.claude/rules/app.md | 45 ++++ packages/data-ai/.claude/rules/archetypes.md | 6 +- .../data-ai/.claude/rules/data-modelling.md | 12 +- packages/data-ai/.claude/rules/ecs.md | 12 +- packages/data-ai/.claude/rules/element.md | 6 +- .../.claude/rules/features/data/components.md | 51 ++++ .../.claude/rules/features/data/entities.md | 37 +++ .../.claude/rules/features/data/index.md | 87 ++---- .../.claude/rules/features/data/resources.md | 34 +++ .../.claude/rules/features/data/state.md | 209 --------------- .../.claude/rules/features/data/values.md | 36 +++ .../.claude/rules/features/ecs/actions.md | 31 +++ .../.claude/rules/features/ecs/computed.md | 32 +++ .../.claude/rules/features/ecs/conformance.md | 80 ++++++ .../.claude/rules/features/ecs/core.md | 43 +++ .../.claude/rules/features/ecs/index.md | 71 +++++ .../{services/main-service => ecs}/indexes.md | 13 +- .../.claude/rules/features/ecs/services.md | 33 +++ .../{services/main-service => ecs}/systems.md | 50 ++-- .../rules/features/ecs/transactions.md | 32 +++ .../data-ai/.claude/rules/features/index.md | 252 +++++++----------- .../.claude/rules/features/services/index.md | 136 ++++------ .../features/services/main-service/actions.md | 70 ----- .../services/main-service/archetypes.md | 49 ---- .../services/main-service/components.md | 41 --- .../services/main-service/computed.md | 82 ------ .../services/main-service/conformance.md | 95 ------- .../services/main-service/core-database.md | 48 ---- .../features/services/main-service/index.md | 187 ------------- .../services/main-service/resources.md | 28 -- .../services/main-service/services.md | 25 -- .../services/main-service/transactions.md | 59 ---- .../.claude/rules/features/spec/index.md | 131 +++++++++ .../rules/features/ui/binding-element.md | 4 +- .../.claude/rules/features/ui/index.md | 6 +- .../data-ai/.claude/rules/global/namespace.md | 3 +- .../data-ai/.claude/rules/global/react.md | 6 +- packages/data-ai/.claude/rules/lit.md | 2 +- packages/data-ai/.claude/rules/versioning.md | 7 +- .../data-ai/skills/build-actions/SKILL.md | 15 +- .../data-ai/skills/build-app-entry/SKILL.md | 20 +- .../data-ai/skills/build-computed/SKILL.md | 15 +- .../skills/build-core-database/SKILL.md | 33 +-- packages/data-ai/skills/build-data/SKILL.md | 27 +- .../data-ai/skills/build-feature/SKILL.md | 1 + packages/data-ai/skills/build-game/SKILL.md | 10 +- .../data-ai/skills/build-indexes/SKILL.md | 13 +- .../skills/build-service-database/SKILL.md | 16 +- .../data-ai/skills/build-services/SKILL.md | 16 +- packages/data-ai/skills/build-spec/SKILL.md | 22 ++ .../data-ai/skills/build-systems/SKILL.md | 28 +- .../skills/build-transactions/SKILL.md | 16 +- packages/data-ai/skills/build-ui/SKILL.md | 9 +- packages/data-ai/skills/review/SKILL.md | 6 +- packages/data-testing/README.md | 64 +++-- 57 files changed, 1093 insertions(+), 1426 deletions(-) create mode 120000 .claude/rules/app.md create mode 100644 packages/data-ai/.claude/rules/app.md create mode 100644 packages/data-ai/.claude/rules/features/data/components.md create mode 100644 packages/data-ai/.claude/rules/features/data/entities.md create mode 100644 packages/data-ai/.claude/rules/features/data/resources.md delete mode 100644 packages/data-ai/.claude/rules/features/data/state.md create mode 100644 packages/data-ai/.claude/rules/features/data/values.md create mode 100644 packages/data-ai/.claude/rules/features/ecs/actions.md create mode 100644 packages/data-ai/.claude/rules/features/ecs/computed.md create mode 100644 packages/data-ai/.claude/rules/features/ecs/conformance.md create mode 100644 packages/data-ai/.claude/rules/features/ecs/core.md create mode 100644 packages/data-ai/.claude/rules/features/ecs/index.md rename packages/data-ai/.claude/rules/features/{services/main-service => ecs}/indexes.md (85%) create mode 100644 packages/data-ai/.claude/rules/features/ecs/services.md rename packages/data-ai/.claude/rules/features/{services/main-service => ecs}/systems.md (76%) create mode 100644 packages/data-ai/.claude/rules/features/ecs/transactions.md delete mode 100644 packages/data-ai/.claude/rules/features/services/main-service/actions.md delete mode 100644 packages/data-ai/.claude/rules/features/services/main-service/archetypes.md delete mode 100644 packages/data-ai/.claude/rules/features/services/main-service/components.md delete mode 100644 packages/data-ai/.claude/rules/features/services/main-service/computed.md delete mode 100644 packages/data-ai/.claude/rules/features/services/main-service/conformance.md delete mode 100644 packages/data-ai/.claude/rules/features/services/main-service/core-database.md delete mode 100644 packages/data-ai/.claude/rules/features/services/main-service/index.md delete mode 100644 packages/data-ai/.claude/rules/features/services/main-service/resources.md delete mode 100644 packages/data-ai/.claude/rules/features/services/main-service/services.md delete mode 100644 packages/data-ai/.claude/rules/features/services/main-service/transactions.md create mode 100644 packages/data-ai/.claude/rules/features/spec/index.md create mode 100644 packages/data-ai/skills/build-spec/SKILL.md 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/packages/data-ai/.claude/rules/README.md b/packages/data-ai/.claude/rules/README.md index f10041c5d..582e99c90 100644 --- a/packages/data-ai/.claude/rules/README.md +++ b/packages/data-ai/.claude/rules/README.md @@ -7,43 +7,37 @@ 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 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/`. -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..8b4420389 --- /dev/null +++ b/packages/data-ai/.claude/rules/app.md @@ -0,0 +1,45 @@ +--- +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 + 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..d8cff03b4 100644 --- a/packages/data-ai/.claude/rules/archetypes.md +++ b/packages/data-ai/.claude/rules/archetypes.md @@ -8,9 +8,9 @@ paths: # 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..4e0ff5b2c 100644 --- a/packages/data-ai/.claude/rules/data-modelling.md +++ b/packages/data-ai/.claude/rules/data-modelling.md @@ -54,15 +54,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..aba9d14ec 100644 --- a/packages/data-ai/.claude/rules/ecs.md +++ b/packages/data-ai/.claude/rules/ecs.md @@ -99,7 +99,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 +108,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 }, }, ``` diff --git a/packages/data-ai/.claude/rules/element.md b/packages/data-ai/.claude/rules/element.md index b5e26642e..4d025f94c 100644 --- a/packages/data-ai/.claude/rules/element.md +++ b/packages/data-ai/.claude/rules/element.md @@ -84,9 +84,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 @@ -124,7 +124,7 @@ 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..f399e267e 100644 --- a/packages/data-ai/.claude/rules/features/data/index.md +++ b/packages/data-ai/.claude/rules/features/data/index.md @@ -3,79 +3,26 @@ 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 handles, +no services, no ECS machinery. 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..83394247e --- /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 transactions, preferably one per action so undo is one step. +- **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..f79e07a36 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/ecs/computed.md @@ -0,0 +1,32 @@ +--- +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. Computeds are not + available inside service factories while services are being built, so a factory + calls the computed function directly with its `db`. +- 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..404185a71 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/ecs/conformance.md @@ -0,0 +1,80 @@ +--- +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 three files: + +``` +conformance/ + projection.ts the store ⇄ State mapping + implementation.ts the spec paired with its ECS build + 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: { op: "step", setup: (db, { dt }) => { db.store.resources.frameDelta = dt; } }, +}); +``` + +- **`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 for a real-time feature. For each case of the `step` op, the runner + seeds a system database, runs `setup` to apply the args, drives one frame (every + system in `db.system.order`), and compares. Systems keep their in-place column + writes; nothing extra is written to be conformed. +- `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, +computed or `frame` fails by name. 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..dba1af6bc --- /dev/null +++ b/packages/data-ai/.claude/rules/features/ecs/index.md @@ -0,0 +1,71 @@ +--- +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 + computed/ computed-database.ts + one file per computed + index.ts + services/ service-database.ts + database-bound service factories + 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 → computed → services → actions +→ systems`. 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..8081b74b3 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/ecs/services.md @@ -0,0 +1,33 @@ +--- +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: ComputedDatabase.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. 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/services/main-service/systems.md b/packages/data-ai/.claude/rules/features/ecs/systems.md similarity index 76% rename from packages/data-ai/.claude/rules/features/services/main-service/systems.md rename to packages/data-ai/.claude/rules/features/ecs/systems.md index c9cade2f5..7946e9012 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/systems.md +++ b/packages/data-ai/.claude/rules/features/ecs/systems.md @@ -1,9 +1,9 @@ --- paths: - - '**/features/*/services/main-service/system-database/**/*.ts' + - '**/features/*/ecs/systems/**/*.ts' --- -# database/systems/ — the real-time tick loop +# 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 @@ -21,14 +21,14 @@ arrow must be written inline in `Plugin.create` for two reasons: only there is ` 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`. +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/motion/motion.js"; +import { Motion } from "../../data/values/motion/motion.js"; const plugin = Database.Plugin.create({ // combine scheduler with the CURRENT TOP layer — ComputedDatabase here, but @@ -59,18 +59,15 @@ const plugin = Database.Plugin.create({ `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 + 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. 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.) + 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 @@ -79,7 +76,7 @@ const plugin = Database.Plugin.create({ 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/` +- **Replicate the spec's top-level guard in every system.** If the spec `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. @@ -90,7 +87,7 @@ const plugin = Database.Plugin.create({ `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 + spec `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 @@ -121,20 +118,21 @@ with no rAF and no rendering attached. ## Layer -`system-database.ts` extends the feature's **current top** main-service layer (`ActionDatabase` / +`system-database.ts` extends the feature's **current top** ecs 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. +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 + +The spec's `step(state, { dt, … })` is conformed by `frame` on the implementation +manifest (`conformance.md`): one headless frame from a seeded state must equal +`step`. Test **selection/detection** logic (which entities collide, which pair +resolves) separately in `ecs/conformance/`, with seeded edge-case geometries — it's +where subtle bugs hide. ## Iterating archetype rows 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..a300b046b 100644 --- a/packages/data-ai/.claude/rules/features/index.md +++ b/packages/data-ai/.claude/rules/features/index.md @@ -1,161 +1,113 @@ --- 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/ computed/ services/ 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/`** (a convention); elements bind to + `ecs/main-service`. +- **A base feature never imports a feature built on it.** The one exception is the + UI's lazy-wrapper seam (`ui/lazy-wrapper.md`). + +## 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, computed, services, actions — one op at a time | `checkFeature` | +| 7 | `ecs/systems` (real-time only) | `frame` conformance + system tests | +| 8 | `ui/` presentations, then elements | presentation tests | +| 9 | `app/` | versioning test, smoke test | ## 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). +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 systems 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..3de9e6a64 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 on its namespace (`AnalyticsService.Timing`), + or in its interface file. + +## `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/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..2911552ba --- /dev/null +++ b/packages/data-ai/.claude/rules/features/spec/index.md @@ -0,0 +1,131 @@ +--- +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 + 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 one `step(state, { dt, … })` action, + conformed by driving one frame of systems (`../ecs/conformance.md`). Its sub-steps + are plain helpers here, not ops. +- 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`**, 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 inject. A helper with +cases of its own that isn't an op (a `step` sub-step) is checked with a second, +spec-only manifest, or with plain unit tests. 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..ab0489444 100644 --- a/packages/data-ai/.claude/rules/features/ui/binding-element.md +++ b/packages/data-ai/.claude/rules/features/ui/binding-element.md @@ -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..00bd7d880 100644 --- a/packages/data-ai/.claude/rules/features/ui/index.md +++ b/packages/data-ai/.claude/rules/features/ui/index.md @@ -5,9 +5,9 @@ paths: # 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.*`. 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/global/namespace.md b/packages/data-ai/.claude/rules/global/namespace.md index 1e234678c..064405ac9 100644 --- a/packages/data-ai/.claude/rules/global/namespace.md +++ b/packages/data-ai/.claude/rules/global/namespace.md @@ -161,5 +161,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..7c7b69aa9 100644 --- a/packages/data-ai/.claude/rules/global/react.md +++ b/packages/data-ai/.claude/rules/global/react.md @@ -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..d4e951e3a 100644 --- a/packages/data-ai/.claude/rules/lit.md +++ b/packages/data-ai/.claude/rules/lit.md @@ -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/versioning.md b/packages/data-ai/.claude/rules/versioning.md index d38837ca8..5280bcc61 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]` @@ -97,14 +100,14 @@ 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): diff --git a/packages/data-ai/skills/build-actions/SKILL.md b/packages/data-ai/skills/build-actions/SKILL.md index 791e40fcf..d73fdbeb3 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 transactions (build each +one's transaction alongside it). 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..f9713b928 100644 --- a/packages/data-ai/skills/build-feature/SKILL.md +++ b/packages/data-ai/skills/build-feature/SKILL.md @@ -8,6 +8,7 @@ output: feature /graph-execute /build-data /build-services + /build-spec /build-core-database /build-indexes /build-transactions diff --git a/packages/data-ai/skills/build-game/SKILL.md b/packages/data-ai/skills/build-game/SKILL.md index 0679d9787..3b152917d 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 (+ the per-frame `step`); 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. `step` conforms by driving one 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..170cd8241 --- /dev/null +++ b/packages/data-ai/skills/build-spec/SKILL.md @@ -0,0 +1,22 @@ +--- +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 + specs its per-frame work as one `step` action. +- `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..a632b8ec7 100644 --- a/packages/data-ai/skills/build-systems/SKILL.md +++ b/packages/data-ai/skills/build-systems/SKILL.md @@ -1,27 +1,19 @@ --- 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` to mirror the spec `step`. -- 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. +Conform `step` with `frame: { op: "step", setup }` on the implementation. Gate: the +frame conformance passes, plus selection/detection tests. -**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..908fb818e 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). +Transactions are conformed through the actions that call them, so build each one +together with its action (`build-actions`). -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..d23e25cb6 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/`. 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-testing/README.md b/packages/data-testing/README.md index 97db9b7ad..25c4b7633 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,41 @@ 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; + - a real-time `step` by driving one frame (the `frame` option). + + 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. From 9534615f403ff67aa7d9e1eff056bba05737d5e7 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 18:35:11 -0700 Subject: [PATCH 16/24] docs(data-ai): reconcile guidance with the ported samples - ecs.md / plugin-modelling.md / service.md defer to the feature rules for layout and naming inside features/ - presentations may call sibling lazy wrappers; the cross-feature UI seam points at lazy-element.md - systems examples extend ActionDatabase; headless frame recipe corrected - globs: data-modelling, archetypes, .tsx UI files, **/src prefixes - element.md delegation matches presentation.render; stale layer names fixed - versioning.md fence and cross-reference fixed - ship the real-time samples and the layer tsconfigs as references - stale sample comments Co-Authored-By: Claude Opus 5.5 --- packages/data-ai/.claude/rules/README.md | 5 +++-- packages/data-ai/.claude/rules/app.md | 1 + packages/data-ai/.claude/rules/archetypes.md | 2 ++ packages/data-ai/.claude/rules/data-modelling.md | 3 ++- packages/data-ai/.claude/rules/ecs.md | 6 ++++-- packages/data-ai/.claude/rules/element.md | 5 +++-- .../data-ai/.claude/rules/features/data/index.md | 7 ++++--- .../.claude/rules/features/ecs/actions.md | 2 +- .../.claude/rules/features/ecs/conformance.md | 2 +- .../.claude/rules/features/ecs/systems.md | 16 ++++++++-------- packages/data-ai/.claude/rules/features/index.md | 16 +++++++++------- .../.claude/rules/features/services/index.md | 4 ++-- .../.claude/rules/features/ui/binding-element.md | 6 +++--- .../data-ai/.claude/rules/features/ui/index.md | 5 +++++ .../.claude/rules/features/ui/lazy-wrapper.md | 4 ++-- .../rules/features/ui/presentation-test.md | 1 + .../.claude/rules/features/ui/presentation.md | 1 + .../.claude/rules/global/function-references.md | 4 ++-- .../data-ai/.claude/rules/global/namespace.md | 3 ++- packages/data-ai/.claude/rules/global/react.md | 2 +- packages/data-ai/.claude/rules/lit.md | 2 +- .../data-ai/.claude/rules/plugin-modelling.md | 2 ++ packages/data-ai/.claude/rules/service.md | 2 ++ packages/data-ai/.claude/rules/versioning.md | 7 +++---- packages/data-ai/skills/build-actions/SKILL.md | 4 ++-- .../data-ai/skills/build-transactions/SKILL.md | 4 ++-- packages/data-ai/skills/build-ui/SKILL.md | 2 +- .../features/main/ecs/systems/system-database.ts | 3 ++- .../ecs/conformance/create-system-database.ts | 10 +++------- .../features/main/ecs/systems/system-database.ts | 3 ++- .../src/features/main/spec/transforms.ts | 10 +++------- packages/data/scripts/copy-references.js | 14 +++++++++++++- 32 files changed, 93 insertions(+), 65 deletions(-) diff --git a/packages/data-ai/.claude/rules/README.md b/packages/data-ai/.claude/rules/README.md index 582e99c90..8f2d37011 100644 --- a/packages/data-ai/.claude/rules/README.md +++ b/packages/data-ai/.claude/rules/README.md @@ -33,8 +33,9 @@ The `global/` rules are **intentionally repo-wide** — their `paths:` globs (`* 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 file rules (`element.md`, -`lazy-element.md`, `presentation.md`). They live here so the bundle is +`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 feature rule's `paths:` glob is scoped to `**/features/*//…`, so these diff --git a/packages/data-ai/.claude/rules/app.md b/packages/data-ai/.claude/rules/app.md index 8b4420389..6f93eb735 100644 --- a/packages/data-ai/.claude/rules/app.md +++ b/packages/data-ai/.claude/rules/app.md @@ -14,6 +14,7 @@ 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 ``` diff --git a/packages/data-ai/.claude/rules/archetypes.md b/packages/data-ai/.claude/rules/archetypes.md index d8cff03b4..a3cd823e8 100644 --- a/packages/data-ai/.claude/rules/archetypes.md +++ b/packages/data-ai/.claude/rules/archetypes.md @@ -4,6 +4,8 @@ paths: - '**/*-plugin/**/*.ts' - '**/*-database.ts' - '**/*-database/**/*.ts' + - '**/features/*/data/entities/**/*.ts' + - '**/features/*/ecs/core/**/*.ts' --- # Archetypes — the ECS query & iteration model diff --git a/packages/data-ai/.claude/rules/data-modelling.md b/packages/data-ai/.claude/rules/data-modelling.md index 4e0ff5b2c..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 diff --git a/packages/data-ai/.claude/rules/ecs.md b/packages/data-ai/.claude/rules/ecs.md index aba9d14ec..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 | @@ -158,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 4d025f94c..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 @@ -117,8 +118,8 @@ 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 diff --git a/packages/data-ai/.claude/rules/features/data/index.md b/packages/data-ai/.claude/rules/features/data/index.md index f399e267e..47d596195 100644 --- a/packages/data-ai/.claude/rules/features/data/index.md +++ b/packages/data-ai/.claude/rules/features/data/index.md @@ -7,9 +7,10 @@ paths: 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 handles, -no services, no ECS machinery. It depends only on `@adobe/data` and the `data/` of -features this one builds on. +(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. ``` data/ diff --git a/packages/data-ai/.claude/rules/features/ecs/actions.md b/packages/data-ai/.claude/rules/features/ecs/actions.md index 83394247e..93654c43e 100644 --- a/packages/data-ai/.claude/rules/features/ecs/actions.md +++ b/packages/data-ai/.claude/rules/features/ecs/actions.md @@ -24,7 +24,7 @@ export const createRandomTodo = async (db: ServiceDatabase) => { - **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 transactions, preferably one per action so undo is one step. + 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. diff --git a/packages/data-ai/.claude/rules/features/ecs/conformance.md b/packages/data-ai/.claude/rules/features/ecs/conformance.md index 404185a71..0b4051b21 100644 --- a/packages/data-ai/.claude/rules/features/ecs/conformance.md +++ b/packages/data-ai/.claude/rules/features/ecs/conformance.md @@ -7,7 +7,7 @@ paths: 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 three files: +computed. The folder holds: ``` conformance/ diff --git a/packages/data-ai/.claude/rules/features/ecs/systems.md b/packages/data-ai/.claude/rules/features/ecs/systems.md index 7946e9012..b007e1a51 100644 --- a/packages/data-ai/.claude/rules/features/ecs/systems.md +++ b/packages/data-ai/.claude/rules/features/ecs/systems.md @@ -31,9 +31,9 @@ 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 — ComputedDatabase here, but - // ActionDatabase / ServiceDatabase if the feature built those (systems come last). - extends: Database.Plugin.combine(ComputedDatabase.plugin, scheduler), + // 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: { @@ -106,15 +106,15 @@ Systems don't run themselves. Combine the built-in scheduler so they run on ```ts import { Database, scheduler } from "@adobe/data/ecs"; // in system-database.ts: -Database.Plugin.create({ extends: Database.Plugin.combine(ComputedDatabase.plugin, scheduler), systems }); +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) 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. +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 diff --git a/packages/data-ai/.claude/rules/features/index.md b/packages/data-ai/.claude/rules/features/index.md index a300b046b..2cd9c3d74 100644 --- a/packages/data-ai/.claude/rules/features/index.md +++ b/packages/data-ai/.claude/rules/features/index.md @@ -49,10 +49,11 @@ tests: ecs/conformance → spec + ecs - **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/`** (a convention); elements bind to - `ecs/main-service`. -- **A base feature never imports a feature built on it.** The one exception is the - UI's lazy-wrapper seam (`ui/lazy-wrapper.md`). +- **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 @@ -68,7 +69,7 @@ Each step has a gate that must pass before the next: | 6 | `ecs` indexes, transactions, computed, services, actions — one op at a time | `checkFeature` | | 7 | `ecs/systems` (real-time only) | `frame` conformance + system tests | | 8 | `ui/` presentations, then elements | presentation tests | -| 9 | `app/` | versioning test, smoke test | +| 9 | `app/` | versioning and persistence tests; the app builds | ## One app, many features @@ -97,8 +98,9 @@ One TS project per layer (see `data-lit-todo`'s tsconfigs): `ecs/conformance/`, and every `*.test.ts`. It references ecs + spec. So `spec/` importing `ecs/`, or `ecs/` importing `spec/`, is a compile error (TS6307). -The package's `typecheck` script is `tsc -b tsconfig.test.json`, which builds all of -them. +`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 diff --git a/packages/data-ai/.claude/rules/features/services/index.md b/packages/data-ai/.claude/rules/features/services/index.md index 3de9e6a64..97d60a60e 100644 --- a/packages/data-ai/.claude/rules/features/services/index.md +++ b/packages/data-ai/.claude/rules/features/services/index.md @@ -37,8 +37,8 @@ export * as AnalyticsService from "./public.js"; `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 on its namespace (`AnalyticsService.Timing`), - or in its interface file. +- A type only one service uses lives in its interface file (`Timing` beside + `AnalyticsService`). ## `create` and `createFake` 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 ab0489444..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 diff --git a/packages/data-ai/.claude/rules/features/ui/index.md b/packages/data-ai/.claude/rules/features/ui/index.md index 00bd7d880..d38b07caf 100644 --- a/packages/data-ai/.claude/rules/features/ui/index.md +++ b/packages/data-ai/.claude/rules/features/ui/index.md @@ -1,6 +1,7 @@ --- paths: - '**/features/*/ui/**/*.ts' + - '**/features/*/ui/**/*.tsx' --- # ui/ — user interface @@ -9,6 +10,10 @@ The top layer: UI that binds the feature's `MainService` database (`ecs/main-ser 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 for the concept, exposed through a single lazy wrapper so consumers pay no 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 064405ac9..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 diff --git a/packages/data-ai/.claude/rules/global/react.md b/packages/data-ai/.claude/rules/global/react.md index 7c7b69aa9..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`. --- diff --git a/packages/data-ai/.claude/rules/lit.md b/packages/data-ai/.claude/rules/lit.md index d4e951e3a..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. --- 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 5280bcc61..1e755086c 100644 --- a/packages/data-ai/.claude/rules/versioning.md +++ b/packages/data-ai/.claude/rules/versioning.md @@ -62,7 +62,7 @@ persisted data covers every feature's schema in the database. 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 @@ -86,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. @@ -110,7 +110,7 @@ describe("database schema 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) => }, }, @@ -119,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/skills/build-actions/SKILL.md b/packages/data-ai/skills/build-actions/SKILL.md index d73fdbeb3..798051183 100644 --- a/packages/data-ai/skills/build-actions/SKILL.md +++ b/packages/data-ai/skills/build-actions/SKILL.md @@ -7,8 +7,8 @@ output: feature 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 transactions (build each -one's transaction alongside it). Point `ecs/main-service.ts` at the top layer. +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. Gate: `checkFeature` passes for each op as it is added. diff --git a/packages/data-ai/skills/build-transactions/SKILL.md b/packages/data-ai/skills/build-transactions/SKILL.md index 908fb818e..e7248ce32 100644 --- a/packages/data-ai/skills/build-transactions/SKILL.md +++ b/packages/data-ai/skills/build-transactions/SKILL.md @@ -10,7 +10,7 @@ Create `ecs/transactions/`: `transaction-database.ts` (extends the previous laye `CoreDatabase.Store`, or `IndexDatabase.Store` once it reads an index. Guard by entity kind, and apply `data/values/` helpers; never import `spec/`. -Transactions are conformed through the actions that call them, so build each one -together with its action (`build-actions`). +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/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 d23e25cb6..ef2f2eb57 100644 --- a/packages/data-ai/skills/build-ui/SKILL.md +++ b/packages/data-ai/skills/build-ui/SKILL.md @@ -8,7 +8,7 @@ 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 `MainService` state and calls `service.actions.*`; the -presentation imports only `data/`. No business logic here. +presentation imports only `data/` and sibling `ui/` lazy wrappers. No business logic here. 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-gpu-hopper/src/features/main/ecs/systems/system-database.ts b/packages/data-gpu-hopper/src/features/main/ecs/systems/system-database.ts index 49c28af93..63162c0fd 100644 --- 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 @@ -16,7 +16,8 @@ import { GameStatus } from "../../data/values/game-status/game-status.js"; // // 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 in `db.system.order`. +// `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: { diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/create-system-database.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/create-system-database.ts index 9366ca710..9119473af 100644 --- a/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/create-system-database.ts +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/create-system-database.ts @@ -2,13 +2,9 @@ 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 collision-detection / waves 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. +// The assembled feature database viewed as its writable system surface — the same +// `Database & { store }` a system's `create(db)` receives — for the collision and +// waves tests, which drive systems through `db.system.functions`. 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/ecs/systems/system-database.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/systems/system-database.ts index 7ea4415a2..885d5e74d 100644 --- 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 @@ -27,7 +27,8 @@ import { Size } from "../../data/values/size/size.js"; // 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 in `db.system.order`. +// `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: { diff --git a/packages/data-lit-todo/src/features/main/spec/transforms.ts b/packages/data-lit-todo/src/features/main/spec/transforms.ts index 4bd1570c7..84ef035c6 100644 --- a/packages/data-lit-todo/src/features/main/spec/transforms.ts +++ b/packages/data-lit-todo/src/features/main/spec/transforms.ts @@ -1,12 +1,8 @@ // © 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. +// 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"; 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); From 2230a1753c9d207cdcde0fb21d07533c62a3edfd Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 19:06:08 -0700 Subject: [PATCH 17/24] refactor: services sit below computed in the ecs layer chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core → indexes → transactions → services → computed → actions → systems, so computeds may derive from service observables and service factories never read computeds. tictactoe's agent derives its observables via a shared readBoard reader. Rules and build skills follow; systems conformance text spells out how a frame case runs. Co-Authored-By: Claude Opus 5.5 --- .../.claude/rules/features/ecs/computed.md | 5 ++--- .../.claude/rules/features/ecs/index.md | 9 ++++---- .../.claude/rules/features/ecs/services.md | 7 ++++-- .../.claude/rules/features/ecs/systems.md | 18 ++++++++++----- .../data-ai/.claude/rules/features/index.md | 4 ++-- .../data-ai/skills/build-feature/SKILL.md | 2 +- .../main/ecs/actions/action-database.ts | 4 ++-- .../main/ecs/computed/computed-database.ts | 4 ++-- .../main/ecs/services/service-database.ts | 4 ++-- .../main/ecs/actions/action-database.ts | 4 ++-- .../src/features/main/ecs/computed/board.ts | 16 ++------------ .../main/ecs/computed/computed-database.ts | 4 ++-- .../main/ecs/services/create-agent-service.ts | 22 ++++++++++--------- .../ecs/services/create-root-agent-service.ts | 4 ++-- .../main/ecs/services/service-database.ts | 8 +++---- .../main/ecs/transactions/read-board.ts | 18 +++++++++------ .../main/ecs/actions/action-database.ts | 4 ++-- .../main/ecs/computed/computed-database.ts | 4 ++-- .../main/ecs/services/service-database.ts | 4 ++-- 19 files changed, 75 insertions(+), 70 deletions(-) diff --git a/packages/data-ai/.claude/rules/features/ecs/computed.md b/packages/data-ai/.claude/rules/features/ecs/computed.md index f79e07a36..d3b9008de 100644 --- a/packages/data-ai/.claude/rules/features/ecs/computed.md +++ b/packages/data-ai/.claude/rules/features/ecs/computed.md @@ -26,7 +26,6 @@ export const status = cached((db: IndexDatabase) => 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. Computeds are not - available inside service factories while services are being built, so a factory - calls the computed function directly with its `db`. +- **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/index.md b/packages/data-ai/.claude/rules/features/ecs/index.md index dba1af6bc..af0f3fbb0 100644 --- a/packages/data-ai/.claude/rules/features/ecs/index.md +++ b/packages/data-ai/.claude/rules/features/ecs/index.md @@ -15,17 +15,18 @@ 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 - computed/ computed-database.ts + one file per computed + 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 → computed → services → actions -→ systems`. A feature creates only the layers it uses. Each layer's file exports a -namespace mirroring its plugin: +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 }); diff --git a/packages/data-ai/.claude/rules/features/ecs/services.md b/packages/data-ai/.claude/rules/features/ecs/services.md index 8081b74b3..250f74eb2 100644 --- a/packages/data-ai/.claude/rules/features/ecs/services.md +++ b/packages/data-ai/.claude/rules/features/ecs/services.md @@ -10,7 +10,7 @@ keyed as the spec's `Services` map, and pins the two together: ```ts const serviceDatabasePlugin = Database.Plugin.create({ - extends: ComputedDatabase.plugin, + extends: TransactionDatabase.plugin, services: { nameGenerator: NameGeneratorService.create, analytics: AnalyticsService.create, @@ -25,7 +25,10 @@ type _ServicesPin = Assert>; 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. The `index.ts` barrel re-exports the factories. + 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. diff --git a/packages/data-ai/.claude/rules/features/ecs/systems.md b/packages/data-ai/.claude/rules/features/ecs/systems.md index b007e1a51..abd99137f 100644 --- a/packages/data-ai/.claude/rules/features/ecs/systems.md +++ b/packages/data-ai/.claude/rules/features/ecs/systems.md @@ -118,9 +118,8 @@ is fully runnable and testable with no rAF and no rendering attached. ## Layer -`system-database.ts` extends the feature's **current top** ecs 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 +`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 @@ -129,8 +128,17 @@ helpers (`(db) => () => void`) sit beside it only when an inline body grows too ## Conformance The spec's `step(state, { dt, … })` is conformed by `frame` on the implementation -manifest (`conformance.md`): one headless frame from a seeded state must equal -`step`. Test **selection/detection** logic (which entities collide, which pair +manifest (`conformance.md`): + +```ts +frame: { op: "step", setup: (db, { dt, input }) => { db.store.resources.frameDelta = dt; db.store.resources.input = input; } }, +``` + +Each `step` case seeds the store from `before`, `setup` writes the case args into what +the systems read, one headless frame runs every system in schedule order, and the +result must equal `step`'s. A frame checks the whole tick, so keep each `step` case +small and aimed at one behavior; then a failure names the behavior that diverged. +Test **selection/detection** logic (which entities collide, which pair resolves) separately in `ecs/conformance/`, with seeded edge-case geometries — it's where subtle bugs hide. diff --git a/packages/data-ai/.claude/rules/features/index.md b/packages/data-ai/.claude/rules/features/index.md index 2cd9c3d74..8f5545230 100644 --- a/packages/data-ai/.claude/rules/features/index.md +++ b/packages/data-ai/.claude/rules/features/index.md @@ -28,7 +28,7 @@ src/ 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/ computed/ services/ actions/ systems/ + 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 @@ -66,7 +66,7 @@ Each step has a gate that must pass before the next: | 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, computed, services, actions — one op at a time | `checkFeature` | +| 6 | `ecs` indexes, transactions, services, computed, actions — one op at a time | `checkFeature` | | 7 | `ecs/systems` (real-time only) | `frame` conformance + system tests | | 8 | `ui/` presentations, then elements | presentation tests | | 9 | `app/` | versioning and persistence tests; the app builds | diff --git a/packages/data-ai/skills/build-feature/SKILL.md b/packages/data-ai/skills/build-feature/SKILL.md index f9713b928..2ef8b6165 100644 --- a/packages/data-ai/skills/build-feature/SKILL.md +++ b/packages/data-ai/skills/build-feature/SKILL.md @@ -12,8 +12,8 @@ output: feature /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-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 index a792ef8b5..e5333825f 100644 --- 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 @@ -1,12 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { ServiceDatabase } from "../services/service-database.js"; +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: ServiceDatabase.plugin, + extends: ComputedDatabase.plugin, actions, }); 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 index 278ae1e7a..4d3c62650 100644 --- 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 @@ -1,12 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { TransactionDatabase } from "../transactions/transaction-database.js"; +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: TransactionDatabase.plugin, + extends: ServiceDatabase.plugin, computed, }); 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 index 633a1a2b7..b5ec2666f 100644 --- 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 @@ -1,7 +1,7 @@ // © 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/computed-database.js"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; import { RandomService } from "../../services/random-service/random-service.js"; import type { Services } from "../../services/services.js"; @@ -12,7 +12,7 @@ import type { Services } from "../../services/services.js"; // `RandomService.createFake` through the `Database.create` service override. // Consumers reach it as `db.services.random`. const serviceDatabasePlugin = Database.Plugin.create({ - extends: ComputedDatabase.plugin, + extends: TransactionDatabase.plugin, services: { random: RandomService.create, }, 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 index 6d8034b33..7a3100e82 100644 --- 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 @@ -1,10 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { ServiceDatabase } from "../services/service-database.js"; +import { ComputedDatabase } from "../computed/computed-database.js"; import * as actions from "./index.js"; const actionDatabasePlugin = Database.Plugin.create({ - extends: ServiceDatabase.plugin, + extends: ComputedDatabase.plugin, actions, }); 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 index a2a7d7bdf..ad2fffb39 100644 --- a/packages/data-lit-tictactoe/src/features/main/ecs/computed/board.ts +++ b/packages/data-lit-tictactoe/src/features/main/ecs/computed/board.ts @@ -1,21 +1,9 @@ // © 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 { PlacedMark } from "../../data/entities/placed-mark.js"; 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((db) => { - 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); - }), - ), -); +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 index 268bf3446..1668fa07f 100644 --- 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 @@ -1,10 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { TransactionDatabase } from "../transactions/transaction-database.js"; +import { ServiceDatabase } from "../services/service-database.js"; import * as computed from "./index.js"; const computedDatabasePlugin = Database.Plugin.create({ - extends: TransactionDatabase.plugin, + extends: ServiceDatabase.plugin, computed, }); 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 index e201b247b..1a0b7a8f0 100644 --- 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 @@ -3,17 +3,19 @@ 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 { ComputedDatabase } from "../computed/computed-database.js"; -import { board, currentPlayer, isGameOver, winner } from "../computed/index.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. It calls the -// computeds directly because `db.computed` is not yet populated while services build. -export const createAgentService = (db: ComputedDatabase, agentMark: PlayerMark): AgenticService => { - const gameOver = isGameOver(db); +// 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({ isGameOver: gameOver, currentPlayer: currentPlayer(db) }), - ({ isGameOver, currentPlayer }) => !isGameOver && currentPlayer === agentMark, + Observe.fromProperties({ board, firstPlayer: db.observe.resources.firstPlayer }), + ({ board, firstPlayer }) => + !BoardState.isGameOver(board) && BoardState.currentPlayer(board, firstPlayer) === agentMark, ); return AgenticService.create({ @@ -53,9 +55,9 @@ export const createAgentService = (db: ComputedDatabase, agentMark: PlayerMark): role: Observe.fromConstant( `You are playing as ${agentMark} in tic-tac-toe. Play to the best of your ability.`, ), - board: board(db), + board, yourTurn, - winner: winner(db), + winner: Observe.withFilter(board, BoardState.getWinner), resetGame: async () => { db.transactions.restartGame(); }, 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 index 403100f59..eb262d732 100644 --- 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 @@ -1,10 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { AgenticService } from "@adobe/data/service"; -import type { ComputedDatabase } from "../computed/computed-database.js"; +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: ComputedDatabase): AgenticService => +export const createRootAgentService = (db: TransactionDatabase): AgenticService => AgenticService.create({ interface: { x: { type: "link", description: "Play as X" }, 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 index fa724d1b8..97fdb96d4 100644 --- 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 @@ -1,12 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { ComputedDatabase } from "../computed/computed-database.js"; +import { TransactionDatabase } from "../transactions/transaction-database.js"; import { createAgentService, createRootAgentService } from "./index.js"; -// AI agent services. Each reads the game's computeds and plays through its -// transactions, so it is built from the database rather than injected. +// 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: ComputedDatabase.plugin, + extends: TransactionDatabase.plugin, services: { agent: createRootAgentService, agentX: (db) => createAgentService(db, "X"), 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 index 241ca36d8..2f885e5e3 100644 --- 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 @@ -3,14 +3,18 @@ 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 synchronously from the placed-mark entities (the -// `board` computed is the reactive counterpart). Shared by playMove and restartGame. -export const readBoard = (t: CoreDatabase.Store): BoardState => { +// 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 arch of t.queryArchetypes(t.archetypes.PlacedMark.components)) { - for (let row = 0; row < arch.rowCount; row++) { - marks.push({ mark: arch.columns.mark.get(row), cellIndex: arch.columns.cellIndex.get(row) }); - } + 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-todo/src/features/main/ecs/actions/action-database.ts b/packages/data-lit-todo/src/features/main/ecs/actions/action-database.ts index 6d8034b33..7a3100e82 100644 --- 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 @@ -1,10 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { ServiceDatabase } from "../services/service-database.js"; +import { ComputedDatabase } from "../computed/computed-database.js"; import * as actions from "./index.js"; const actionDatabasePlugin = Database.Plugin.create({ - extends: ServiceDatabase.plugin, + extends: ComputedDatabase.plugin, actions, }); 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 index 268bf3446..1668fa07f 100644 --- 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 @@ -1,10 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { TransactionDatabase } from "../transactions/transaction-database.js"; +import { ServiceDatabase } from "../services/service-database.js"; import * as computed from "./index.js"; const computedDatabasePlugin = Database.Plugin.create({ - extends: TransactionDatabase.plugin, + extends: ServiceDatabase.plugin, computed, }); 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 index e620d8bb0..047f76a88 100644 --- 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 @@ -1,7 +1,7 @@ // © 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/computed-database.js"; +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"; @@ -11,7 +11,7 @@ import type { Services } from "../../services/services.js"; // 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, + extends: TransactionDatabase.plugin, services: { nameGenerator: NameGeneratorService.create, analytics: AnalyticsService.create, From 57e8a5dcecd3cf32d9102f3e59de4068846a5da4 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 20:55:30 -0700 Subject: [PATCH 18/24] feat(conformance): per-system conformance, frames composed from schedule order A spec's systems group pairs pure functions with same-named ECS systems. Each system conforms by running just that system; each frame case folds the spec systems in db.system.order and drives one real frame (awaiting async systems). frame: { args, unmodelled } replaces { op, setup }: one writer per frame data arg, and every ECS system is modelled xor declared unmodelled (checked at compile time), so wasm or init-only systems are skipped and listed. Systems sharing a tier must commute. Co-Authored-By: Claude Opus 5.5 --- packages/data-testing/README.md | 5 +- .../src/conformance/check-feature.ts | 33 +++- .../src/conformance/check-spec.ts | 12 +- .../src/conformance/feature-types.ts | 4 +- .../src/conformance/frame.test.ts | 60 ------ .../src/conformance/implementation.ts | 40 ++-- .../data-testing/src/conformance/public.ts | 2 + .../src/conformance/run-feature.ts | 54 +----- .../src/conformance/run-systems.ts | 178 ++++++++++++++++++ packages/data-testing/src/conformance/spec.ts | 19 +- .../src/conformance/systems-types.ts | 50 +++++ .../src/conformance/systems.test.ts | 96 ++++++++++ .../src/conformance/systems.type-test.ts | 44 +++++ packages/data-testing/src/index.ts | 2 + 14 files changed, 459 insertions(+), 140 deletions(-) delete mode 100644 packages/data-testing/src/conformance/frame.test.ts create mode 100644 packages/data-testing/src/conformance/run-systems.ts create mode 100644 packages/data-testing/src/conformance/systems-types.ts create mode 100644 packages/data-testing/src/conformance/systems.test.ts create mode 100644 packages/data-testing/src/conformance/systems.type-test.ts diff --git a/packages/data-testing/README.md b/packages/data-testing/README.md index 25c4b7633..d149f1a3d 100644 --- a/packages/data-testing/README.md +++ b/packages/data-testing/README.md @@ -46,7 +46,10 @@ Conformance.checkFeature(implementation); - **`checkFeature`** runs every case against the ECS: - each spec action against the same-named action; - each derivation against its computed; - - a real-time `step` by driving one frame (the `frame` option). + - 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. diff --git a/packages/data-testing/src/conformance/check-feature.ts b/packages/data-testing/src/conformance/check-feature.ts index c7e748594..992df2c88 100644 --- a/packages/data-testing/src/conformance/check-feature.ts +++ b/packages/data-testing/src/conformance/check-feature.ts @@ -1,11 +1,22 @@ // © 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 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. @@ -20,9 +31,29 @@ export const checkFeature = => + 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, spec.systems.cases, spec.services, true), + frameCases: frameCasesOf(spec.systems.frame, 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 index 1e71a0919..c7cd3b6c5 100644 --- a/packages/data-testing/src/conformance/check-spec.ts +++ b/packages/data-testing/src/conformance/check-spec.ts @@ -7,11 +7,17 @@ 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. -export const checkSpec = , C extends object>(spec: Spec): void => { +// 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), + transitions: { + ...adaptCases(spec.fns, spec.cases, spec.services, true), + ...(spec.systems ? adaptCases(spec.systems.fns, spec.systems.cases, spec.services, true) : {}), + }, schemas: spec.schemas, match: spec.match, }); 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/frame.test.ts b/packages/data-testing/src/conformance/frame.test.ts deleted file mode 100644 index b78eaa71e..000000000 --- a/packages/data-testing/src/conformance/frame.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// A real-time feature's per-frame `step` conforms by driving one frame of its systems: -// the system writes a resource in place, and no action or transaction exists for it. -import { Database } from "@adobe/data/ecs"; -import { spec } from "./spec.js"; -import { implementation } from "./implementation.js"; -import { checkFeature } from "./check-feature.js"; -import type { SpecCases } from "./feature-types.js"; - -type State = { readonly distance: number; readonly speed: number }; - -const step = (state: State, { dt }: { readonly dt: number }): Pick => ({ - distance: state.distance + state.speed * dt, -}); - -const plugin = Database.Plugin.create({ - resources: { - distance: { default: 0 as number }, - speed: { default: 0 as number }, - frameDelta: { default: 0 as number }, - }, - systems: { - move: { - create: (db) => () => { - db.store.resources.distance += db.store.resources.speed * db.store.resources.frameDelta; - }, - }, - }, -}); - -type Store = Database.Plugin.ToStore; - -const cases: SpecCases = { - cases: [ - { name: "advances by speed × dt", before: { speed: 2 }, args: { dt: 3 }, after: { distance: 6 } }, - { name: "stands still at zero speed", before: { distance: 1 }, args: { dt: 5 }, after: { distance: 1 } }, - ], -}; - -checkFeature( - implementation( - spec({ state: { create: (): State => ({ distance: 0, speed: 0 }) }, fns: { step }, cases: { step: cases } }), - { - plugin, - projection: { - fromState: (store: Store, state: State): void => { - store.resources.distance = state.distance; - store.resources.speed = state.speed; - }, - toState: (store: Store): State => ({ distance: store.resources.distance, speed: store.resources.speed }), - }, - frame: { - op: "step", - setup: (db, { dt }) => { - db.store.resources.frameDelta = dt; - }, - }, - }, - ), -); diff --git a/packages/data-testing/src/conformance/implementation.ts b/packages/data-testing/src/conformance/implementation.ts index 367feffeb..170695a8d 100644 --- a/packages/data-testing/src/conformance/implementation.ts +++ b/packages/data-testing/src/conformance/implementation.ts @@ -10,26 +10,25 @@ import type { } from "./feature-types.js"; import type { Projection } from "./run-feature.js"; import type { Spec } from "./spec.js"; +import type { FrameArgWriters, SystemKeys, SystemsExact } from "./systems-types.js"; type AnyFn = (...args: never[]) => unknown; -// A transition's args — its second parameter. -type ArgsOf = F extends (state: never, args: infer A, ...rest: never[]) => unknown ? A : never; // 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; + 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 per-frame spec transition (e.g. `step`), realized by the - // systems tick loop rather than an action. Each case seeds a db built from `plugin`, - // runs `setup` to apply the case args (e.g. `frameDelta = dt`), drives exactly one - // frame (every system in `db.system.order`), and compares the result. Systems keep - // their in-place column writes; no per-frame transaction is needed to conform them. + // 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 frame case runs every + // modelled system once in `db.system.order`, the order the `schedule`s declare. readonly frame?: { - readonly op: string; - readonly setup?: (db: never, args: never) => void; + readonly args: Readonly void>>; + readonly unmodelled?: Readonly>; }; // 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. @@ -50,6 +49,8 @@ export interface Implementation, @@ -57,19 +58,24 @@ export const implementation = < P extends ProjectionShape, const H extends readonly Extract[] = readonly [], PL extends Database.Plugin = Database.Plugin, - O extends Extract = never, + Sys extends Record = {}, + U extends string = never, >( - spec: Spec, + spec: Spec, impl: Omit>, "spec" | "plugin" | "projection" | "hydrate" | "computedPlugin" | "frame"> & { readonly plugin: PL; - readonly frame?: { - readonly op: O; - readonly setup?: (db: Database.Plugin.ToSystemDatabase, args: ArgsOf) => void; - }; readonly projection: P; readonly hydrate?: H; } & (HasDerivations extends true ? { readonly computedPlugin: Database.Plugin } : { readonly computedPlugin?: Database.Plugin }) & - HydrateExact>>, + HydrateExact>> & + ([keyof Sys] extends [never] + ? { readonly frame?: undefined } + : { + readonly frame: { + readonly args: FrameArgWriters; + readonly unmodelled?: { readonly [K in U]: string }; + } & 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[]`; diff --git a/packages/data-testing/src/conformance/public.ts b/packages/data-testing/src/conformance/public.ts index 0f64f27fc..55a63866a 100644 --- a/packages/data-testing/src/conformance/public.ts +++ b/packages/data-testing/src/conformance/public.ts @@ -8,6 +8,8 @@ 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-feature.ts b/packages/data-testing/src/conformance/run-feature.ts index cdc268014..63e0e4b4c 100644 --- a/packages/data-testing/src/conformance/run-feature.ts +++ b/packages/data-testing/src/conformance/run-feature.ts @@ -20,9 +20,8 @@ export interface Projection { // 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. The `frame` op is realized by the systems tick loop and conforms by driving -// one frame. A spec op with no implementation is a named failure. It also round-trips -// `State.samples` through the projection. +// 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 that needs ambient per-case context (a user-scoped `userId`) uses the // lower-level `runTransactions`/`runActions`/`runComputeds` directly instead. @@ -42,8 +41,6 @@ export interface FeatureRunConfig { readonly projection: Projection; // Names of computeds that emit an entity-id list (hydrated through `toData`). readonly hydrate?: readonly string[]; - // The per-frame spec transition realized by the systems tick loop (see `Implementation`). - readonly frame?: { readonly op: string; readonly setup?: (db: never, args: never) => void }; // 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>>; @@ -59,23 +56,6 @@ export interface FeatureRunConfig { // `create-plugin.ts`), so this reads the ops directly off it. type PluginFacets = { actions: Record; computed: Record }; -// The slice of a system database a headless frame needs. -type FrameDatabase = { - readonly system: { readonly order: readonly (readonly string[])[]; readonly functions: Readonly> }; -}; - -// Run exactly one frame headlessly, as the scheduler would: every system in dependency -// order, skipping the scheduler's own driver system and init-only systems. -const driveFrame = (db: FrameDatabase): void => { - for (const tier of db.system.order) { - for (const name of tier) { - if (name === "schedulerSystem") continue; - const fn = db.system.functions[name]; - if (typeof fn === "function") fn(); - } - } -}; - const pick = (source: ReadonlyMap, names: ReadonlySet): Record => Object.fromEntries([...source].filter(([name]) => names.has(name))); @@ -97,10 +77,9 @@ export function runFeature name !== frameOp && actions.has(name))); + const byAction = new Set([...transitions.keys()].filter((name) => actions.has(name))); const missing = [ - ...[...transitions.keys()].filter((name) => !byAction.has(name) && name !== frameOp), + ...[...transitions.keys()].filter((name) => !byAction.has(name)), ...[...derivations.keys()].filter((name) => !computeds.has(name)), ]; @@ -128,31 +107,6 @@ export function runFeature void) | undefined; - runActions({ - makeDb: (services) => - Database.toSystemDatabase( - Database.create(config.plugin as never, { services: { ...baseServices(), ...services } }), - ) as unknown as FrameDatabase & Db, - store: (db) => db.store, - fromState, - toState, - initial, - transitions: config.transitions, - kind: "frame", - actions: { - [config.frame.op]: (db: FrameDatabase, args: unknown) => { - setup?.(db, args); - driveFrame(db); - }, - }, - match: config.match, - }); - } - if (config.computedPlugin) { runComputeds({ makeDb: () => 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..0b6dc245c --- /dev/null +++ b/packages/data-testing/src/conformance/run-systems.ts @@ -0,0 +1,178 @@ +// © 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 (doubles injected into `args`). + 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); + }); + } + }); + } + + if (config.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", () => { + for (const testCase of config.frameCases) { + it(`${testCase.name} (spec systems in schedule order)`, async () => { + 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(testCase.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(`${testCase.name} (one ECS frame)`, async () => { + 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 index 966cf3fbd..877789201 100644 --- a/packages/data-testing/src/conformance/spec.ts +++ b/packages/data-testing/src/conformance/spec.ts @@ -2,13 +2,14 @@ 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> { +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[] }; @@ -26,22 +27,28 @@ export interface Spec, C // 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, plus optional whole-frame cases. The frame order + // is never authored here; it comes from the systems' `schedule` declarations. + 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 declare. +// • `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 & + 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 => +): 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; + manifest as unknown as Spec; 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..e5d0fff0a --- /dev/null +++ b/packages/data-testing/src/conformance/systems-types.ts @@ -0,0 +1,50 @@ +// © 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. +export type FrameArgs = UnionToIntersection<{ [K in keyof Sys]: 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 }; + +// The spec's systems group: pure functions named after the ECS systems they specify. +export type SpecSystems = { + readonly fns: Sys; + readonly cases: SystemCases; + readonly frame?: FrameCases; +}; + +// 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..6ae464c1d --- /dev/null +++ b/packages/data-testing/src/conformance/systems.test.ts @@ -0,0 +1,96 @@ +// © 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. `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. +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 position: number; readonly velocity: number }; + +const accelerate = (state: Pick, { dt }: { readonly dt: number }): Pick => ({ + velocity: state.velocity + dt, +}); +const integrate = (state: State, { dt }: { readonly dt: number }): Pick => ({ + position: state.position + state.velocity * dt, +}); +const systems = { accelerate, integrate }; + +const plugin = Database.Plugin.create({ + resources: { + position: { default: 0 as number }, + velocity: { default: 0 as number }, + jitter: { default: 0 as number }, + frameDelta: { default: 0 as number }, + }, + systems: { + integrate: { + schedule: { after: ["accelerate"] }, + create: (db) => () => { + db.store.resources.position += db.store.resources.velocity * db.store.resources.frameDelta; + }, + }, + accelerate: { + create: (db) => () => { + db.store.resources.velocity += db.store.resources.frameDelta; + }, + }, + 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 frame: FrameCases = { + cases: [ + // accelerate first: velocity 1 → 3, then position 0 + 3 × 2 = 6 (the other order gives 2). + { name: "accelerates, then integrates", before: { velocity: 1 }, args: { dt: 2 }, after: { velocity: 3, position: 6 } }, + ], +}; + +const motion = spec({ + state: { create: (): State => ({ position: 0, velocity: 0 }) }, + fns: {}, + cases: {}, + systems: { fns: systems, cases: { accelerate: accelerateCases, integrate: integrateCases }, frame }, +}); + +checkSpec(motion); + +checkFeature( + implementation(motion, { + plugin, + projection: { + fromState: (store: Store, state: State): void => { + store.resources.position = state.position; + store.resources.velocity = state.velocity; + }, + toState: (store: Store): State => ({ position: store.resources.position, velocity: store.resources.velocity }), + }, + frame: { + args: { dt: (db, dt) => { db.store.resources.frameDelta = dt; } }, + unmodelled: { physics: "stands in for a wasm system", seed: "init-only" }, + }, + }), +); 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/index.ts b/packages/data-testing/src/index.ts index 581043604..62285ba58 100644 --- a/packages/data-testing/src/index.ts +++ b/packages/data-testing/src/index.ts @@ -25,6 +25,8 @@ export type { Spec, Implementation, StateMatches, + FrameCases, + SystemCases, Effects, ServiceCall, } from "./conformance/public.js"; From 58ed9a441d5b6c8ede3e1d28c1f0c6079718e0fb Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 21:04:59 -0700 Subject: [PATCH 19/24] feat: per-system conformance in hopper; fresh doubles per frame run; rules - data-testing: every frame run and the tier-commute check get fresh recording doubles (a frame case with service responses no longer drains one run's schedule in the other); a no-arg system adds nothing to FrameArgs - data-gpu-hopper: spec systems movement + collision mirror the ECS systems; step removed; outcome-selection tests became collision cases - data-ai: systems/conformance/spec rules and skills describe per-system and frame conformance, schedule-derived order, and unmodelled systems Co-Authored-By: Claude Opus 5.5 --- .../.claude/rules/features/ecs/conformance.md | 16 ++- .../.claude/rules/features/ecs/systems.md | 51 +++---- .../data-ai/.claude/rules/features/index.md | 4 +- .../.claude/rules/features/spec/index.md | 34 ++++- packages/data-ai/skills/build-game/SKILL.md | 4 +- packages/data-ai/skills/build-spec/SKILL.md | 3 +- .../data-ai/skills/build-systems/SKILL.md | 7 +- .../main/data/values/outcome/is-fatal.ts | 4 +- .../ecs/conformance/create-system-database.ts | 8 -- .../main/ecs/conformance/drive-frame.ts | 18 --- .../main/ecs/conformance/implementation.ts | 12 +- .../ecs/conformance/outcome-selection.test.ts | 127 ------------------ .../main/ecs/systems/system-database.ts | 2 +- .../src/features/main/spec/case-boards.ts | 29 ++++ .../src/features/main/spec/collision.cases.ts | 47 +++++++ .../src/features/main/spec/collision.ts | 23 ++++ .../src/features/main/spec/frame.cases.ts | 48 +++++++ .../src/features/main/spec/lose-life.ts | 3 +- .../src/features/main/spec/movement.cases.ts | 42 ++++++ .../src/features/main/spec/movement.ts | 28 ++++ .../src/features/main/spec/spec.ts | 11 +- .../src/features/main/spec/step.cases.ts | 84 ------------ .../src/features/main/spec/step.ts | 54 -------- .../src/features/main/spec/systems.ts | 6 + .../src/features/main/spec/transforms.ts | 6 +- .../src/features/main/spec/while-playing.ts | 8 ++ .../src/features/main/spec/win-goal.ts | 2 +- .../src/conformance/check-feature.ts | 2 +- .../src/conformance/run-systems.ts | 21 +-- .../src/conformance/systems-types.ts | 3 +- .../src/conformance/systems.test.ts | 45 ++++++- 31 files changed, 384 insertions(+), 368 deletions(-) delete mode 100644 packages/data-gpu-hopper/src/features/main/ecs/conformance/create-system-database.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/ecs/conformance/drive-frame.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/ecs/conformance/outcome-selection.test.ts create mode 100644 packages/data-gpu-hopper/src/features/main/spec/case-boards.ts create mode 100644 packages/data-gpu-hopper/src/features/main/spec/collision.cases.ts create mode 100644 packages/data-gpu-hopper/src/features/main/spec/collision.ts create mode 100644 packages/data-gpu-hopper/src/features/main/spec/frame.cases.ts create mode 100644 packages/data-gpu-hopper/src/features/main/spec/movement.cases.ts create mode 100644 packages/data-gpu-hopper/src/features/main/spec/movement.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/spec/step.cases.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/spec/step.ts create mode 100644 packages/data-gpu-hopper/src/features/main/spec/systems.ts create mode 100644 packages/data-gpu-hopper/src/features/main/spec/while-playing.ts diff --git a/packages/data-ai/.claude/rules/features/ecs/conformance.md b/packages/data-ai/.claude/rules/features/ecs/conformance.md index 0b4051b21..75afa40ac 100644 --- a/packages/data-ai/.claude/rules/features/ecs/conformance.md +++ b/packages/data-ai/.claude/rules/features/ecs/conformance.md @@ -38,7 +38,8 @@ export const implementation = Conformance.implementation(spec, { projection, hydrate: ["visibleTodos"], // exactly the computeds that emit entity ids services: { game: GameService.createFake }, // base fakes for app-injected services - frame: { op: "step", setup: (db, { dt }) => { db.store.resources.frameDelta = dt; } }, + frame: { args: { dt: (db, dt) => { db.store.resources.frameDelta = dt; } }, + unmodelled: { physics: "wasm rigid-body step" } }, // real-time features }); ``` @@ -47,10 +48,11 @@ export const implementation = Conformance.implementation(spec, { - **`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 for a real-time feature. For each case of the `step` op, the runner - seeds a system database, runs `setup` to apply the args, drives one frame (every - system in `db.system.order`), and compares. Systems keep their in-place column - writes; nothing extra is written to be conformed. +- **`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. 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` @@ -60,8 +62,8 @@ 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, -computed or `frame` fails by name. So when the UI's real op is richer (`dragTodo`), +`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. diff --git a/packages/data-ai/.claude/rules/features/ecs/systems.md b/packages/data-ai/.claude/rules/features/ecs/systems.md index abd99137f..be62a5f85 100644 --- a/packages/data-ai/.claude/rules/features/ecs/systems.md +++ b/packages/data-ai/.claude/rules/features/ecs/systems.md @@ -76,25 +76,22 @@ const plugin = Database.Plugin.create({ 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 spec's top-level guard in every system.** If the spec - `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. +- **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. When you split a - spec `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. + (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). @@ -127,20 +124,28 @@ helpers (`(db) => () => void`) sit beside it only when an inline body grows too ## Conformance -The spec's `step(state, { dt, … })` is conformed by `frame` on the implementation -manifest (`conformance.md`): +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: { op: "step", setup: (db, { dt, input }) => { db.store.resources.frameDelta = dt; db.store.resources.input = input; } }, +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" }, +}, ``` -Each `step` case seeds the store from `before`, `setup` writes the case args into what -the systems read, one headless frame runs every system in schedule order, and the -result must equal `step`'s. A frame checks the whole tick, so keep each `step` case -small and aimed at one behavior; then a failure names the behavior that diverged. -Test **selection/detection** logic (which entities collide, which pair -resolves) separately in `ecs/conformance/`, with seeded edge-case geometries — it's -where subtle bugs hide. +- **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 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. +- **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 diff --git a/packages/data-ai/.claude/rules/features/index.md b/packages/data-ai/.claude/rules/features/index.md index 8f5545230..0d53fb55c 100644 --- a/packages/data-ai/.claude/rules/features/index.md +++ b/packages/data-ai/.claude/rules/features/index.md @@ -67,7 +67,7 @@ Each step has a gate that must pass before the next: | 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) | `frame` conformance + system tests | +| 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 | @@ -109,7 +109,7 @@ Working samples ship inside `@adobe/data` at - **`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 systems and `frame` conformance. +- **`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/spec/index.md b/packages/data-ai/.claude/rules/features/spec/index.md index 2911552ba..15fe16e96 100644 --- a/packages/data-ai/.claude/rules/features/spec/index.md +++ b/packages/data-ai/.claude/rules/features/spec/index.md @@ -19,6 +19,7 @@ spec/ 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 frame.cases.ts # real-time only: one function per ECS system spec.ts spec.test.ts # the manifest + Conformance.checkSpec ``` @@ -70,9 +71,10 @@ export const toggleComplete = ( - **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 one `step(state, { dt, … })` action, - conformed by driving one frame of systems (`../ecs/conformance.md`). Its sub-steps - are plain helpers here, not ops. +- **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. @@ -126,6 +128,26 @@ 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 inject. A helper with -cases of its own that isn't an op (a `step` sub-step) is checked with a second, -spec-only manifest, or with plain unit tests. +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 }, frame: frameCases }, +}); +``` + +- **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. +- **Frame cases** (`Conformance.FrameCases`) cover how systems + interact over one tick; `checkFeature` runs them in the schedule's order. +- 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/skills/build-game/SKILL.md b/packages/data-ai/skills/build-game/SKILL.md index 3b152917d..eeb713629 100644 --- a/packages/data-ai/skills/build-game/SKILL.md +++ b/packages/data-ai/skills/build-game/SKILL.md @@ -9,7 +9,7 @@ output: game 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 (+ the per-frame `step`); indexes = spatial/lookup queries; +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 @@ -18,6 +18,6 @@ transactions + actions = moves/spawns; computed = score/status/winner; ui = view 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, and never turn per-row work into per-entity - transactions. `step` conforms by driving one frame (see `features/ecs/systems.md`). + 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-spec/SKILL.md b/packages/data-ai/skills/build-spec/SKILL.md index 170cd8241..5acc7c249 100644 --- a/packages/data-ai/skills/build-spec/SKILL.md +++ b/packages/data-ai/skills/build-spec/SKILL.md @@ -12,7 +12,8 @@ Create the feature's `spec/` folder, the specification the ECS will be conformed `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 - specs its per-frame work as one `step` action. + adds `systems.ts`: one pure function per ECS system it will have, named the same, + each with cases, plus frame cases. - `transforms.ts` (the barrel of every action and derivation), `spec.ts` (`Conformance.spec`) and `spec.test.ts` (`Conformance.checkSpec`). diff --git a/packages/data-ai/skills/build-systems/SKILL.md b/packages/data-ai/skills/build-systems/SKILL.md index a632b8ec7..f35c3b7d6 100644 --- a/packages/data-ai/skills/build-systems/SKILL.md +++ b/packages/data-ai/skills/build-systems/SKILL.md @@ -11,9 +11,10 @@ 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` to mirror the spec `step`. +systems under `schedule`; it is the only statement of order. -Conform `step` with `frame: { op: "step", setup }` on the implementation. Gate: the -frame conformance passes, plus selection/detection tests. +Add `frame: { args, unmodelled? }` to the implementation: a writer per frame arg, and +each system the spec can't model (wasm, init-only) with its reason. Gate: every +system's own cases and the frame cases pass. The how is in the auto-loading `features/ecs/systems.md` rule. 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 index d8634109b..5d8aa5ca3 100644 --- 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 @@ -1,8 +1,8 @@ // © 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. +// Which outcomes cost the frog a life, so callers need not re-enumerate the fatal +// members. export const isFatal: Record = { safe: false, ride: false, diff --git a/packages/data-gpu-hopper/src/features/main/ecs/conformance/create-system-database.ts b/packages/data-gpu-hopper/src/features/main/ecs/conformance/create-system-database.ts deleted file mode 100644 index e65b37d5e..000000000 --- a/packages/data-gpu-hopper/src/features/main/ecs/conformance/create-system-database.ts +++ /dev/null @@ -1,8 +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 with its writable system surface (`db.store`, -// `db.system.functions`), for the tests that drive frames headlessly. -export const createSystemDatabase = (): Database.Plugin.ToSystemDatabase => - Database.toSystemDatabase(Database.create(MainService.plugin)); diff --git a/packages/data-gpu-hopper/src/features/main/ecs/conformance/drive-frame.ts b/packages/data-gpu-hopper/src/features/main/ecs/conformance/drive-frame.ts deleted file mode 100644 index 55badb194..000000000 --- a/packages/data-gpu-hopper/src/features/main/ecs/conformance/drive-frame.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { SystemDatabase } from "../systems/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. 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/ecs/conformance/implementation.ts b/packages/data-gpu-hopper/src/features/main/ecs/conformance/implementation.ts index 6997eafaf..e4743e9b2 100644 --- a/packages/data-gpu-hopper/src/features/main/ecs/conformance/implementation.ts +++ b/packages/data-gpu-hopper/src/features/main/ecs/conformance/implementation.ts @@ -5,15 +5,17 @@ import { MainService } from "../main-service.js"; import { projection } from "./projection.js"; // The spec paired with its ECS build: `hop` and `newGame` conform against the -// same-named actions; `step` is realized by the systems, so each case drives one -// headless frame of `frameDelta = dt`. +// same-named actions, `movement` and `collision` against the same-named systems. A +// frame's `dt` reaches the systems as `frameDelta`. `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: { - op: "step", - setup: (db, { dt }) => { - db.store.resources.frameDelta = dt; + args: { + dt: (db, dt) => { + db.store.resources.frameDelta = dt; + }, }, }, }); diff --git a/packages/data-gpu-hopper/src/features/main/ecs/conformance/outcome-selection.test.ts b/packages/data-gpu-hopper/src/features/main/ecs/conformance/outcome-selection.test.ts deleted file mode 100644 index dccaff199..000000000 --- a/packages/data-gpu-hopper/src/features/main/ecs/conformance/outcome-selection.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// Outcome detection edge cases the whole-tick cases don't isolate (lane lookup, the -// exact coverage boundary). Each case seeds a geometry, drives ONE frame with dt = 0 -// (hazards and frog stay put), and reads back the resulting `State`. -import { describe, it, expect } from "vitest"; -import type { State } from "../../spec/state.js"; -import type { Lane } from "../../data/values/lane/lane.js"; -import { createSystemDatabase } from "./create-system-database.js"; -import { projection } from "./projection.js"; -import { driveFrame } from "./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/ecs/systems/system-database.ts b/packages/data-gpu-hopper/src/features/main/ecs/systems/system-database.ts index 63162c0fd..c1e3f5238 100644 --- 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 @@ -7,7 +7,7 @@ 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; together the two systems realize one spec `step` per frame: +// 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. 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..bccdeb711 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/collision.cases.ts @@ -0,0 +1,47 @@ +// © 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" } }, + { name: "is frozen once the game is over", + before: board({ frog: { x: 2, y: 1 }, entities: new Map([[1, car]]), lives: 0, status: "gameOver" }), + after: { lives: 0, status: "gameOver", frog: { x: 2, y: 1 } } }, + ], +}; 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/frame.cases.ts b/packages/data-gpu-hopper/src/features/main/spec/frame.cases.ts new file mode 100644 index 000000000..33a8edfc2 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/frame.cases.ts @@ -0,0 +1,48 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type * as systems from "./systems.js"; +import { board, riverLanes } from "./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" } }, + { name: "does nothing once the game is over", + before: board({ frog: { x: 1, y: 1 }, lives: 0, status: "gameOver", + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]) }), + args: { dt: 1 }, + after: {} }, + ], +}; 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 index 6db123365..f09395d2a 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/lose-life.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/lose-life.ts @@ -4,7 +4,8 @@ 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 step sub-transition, not an operation of its own. +// no-op once the game has finished. A `collision` sub-transition, not an operation of +// its own. export const loseLife = ( state: Pick, ): Pick => { 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..d5d88b8f3 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/spec/movement.cases.ts @@ -0,0 +1,42 @@ +// © 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 } } }, + { name: "is frozen once the game is over", + before: board({ status: "gameOver", lives: 0, + 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: 0, width: 1, velocity: 1 }]]), frog: { x: 2, y: 0 } } }, + ], +}; 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/spec.ts b/packages/data-gpu-hopper/src/features/main/spec/spec.ts index 618f8e8e0..c80f681ad 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/spec.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/spec.ts @@ -2,15 +2,20 @@ 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 step } from "./step.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 { cases as frame } from "./frame.cases.js"; -// The feature's spec: every operation with its inert cases. No entity references and +// The feature's spec: the `hop` / `newGame` actions and the `movement` / `collision` +// systems, each with its inert cases, plus whole-frame cases. No entity references and // no injected services, so no `schemas` or `services`. export const spec = Conformance.spec({ state: State, fns: transforms, - cases: { hop, step, newGame }, + cases: { hop, newGame }, + systems: { fns: systems, cases: { movement, collision }, frame }, }); diff --git a/packages/data-gpu-hopper/src/features/main/spec/step.cases.ts b/packages/data-gpu-hopper/src/features/main/spec/step.cases.ts deleted file mode 100644 index 9b539152c..000000000 --- a/packages/data-gpu-hopper/src/features/main/spec/step.cases.ts +++ /dev/null @@ -1,84 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance } from "@adobe/data-testing"; -import type { Lane } from "../data/values/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" }, -]; - -// Every case uses dt = 1 so hazard and carry displacements are exact. `before` -// overrides the default with a small test board; `after` is the writes patch. -// 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/spec/step.ts b/packages/data-gpu-hopper/src/features/main/spec/step.ts deleted file mode 100644 index 595bd6f54..000000000 --- a/packages/data-gpu-hopper/src/features/main/spec/step.ts +++ /dev/null @@ -1,54 +0,0 @@ -// © 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 { Outcome } from "../data/values/outcome/outcome.js"; -import { GameStatus } from "../data/values/game-status/game-status.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"; - -// The slice `step` writes. Every branch supplies all five keys; the outcome sub-patch -// (`winGoal` / `loseLife`) layers 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 on the last life). A no-op once the game has ended. -export const step = ( - state: Pick, - { dt }: { readonly dt: number }, -): StepPatch => { - const unchanged: StepPatch = { - entities: state.entities, - frog: state.frog, - score: state.score, - status: state.status, - lives: state.lives, - }; - if (!GameStatus.isPlaying(state.status)) return unchanged; - - const entities = new Map( - [...state.entities].map( - ([id, hazard]) => [id, { ...hazard, x: Lane.nextX(hazard.x, hazard.velocity, dt, state.width) }] as const, - ), - ); - - // The log the frog stands on, from the pre-scroll positions, drags it along. - 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 frog = carrier ? { x: state.frog.x + carrier.velocity * dt, y: state.frog.y } : state.frog; - - const moved = { ...state, entities, frog }; - const movedPatch: StepPatch = { ...unchanged, entities, frog }; - 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/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 index 676a4feb2..ba1505796 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/transforms.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/transforms.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -// The conformed operations: the `hop` and `newGame` actions and the `step` tick. -// Coverage is checked at compile time against one `*.cases.ts` module each. +// 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 { step } from "./step.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 index e387af1ed..08ce27f88 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/win-goal.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/win-goal.ts @@ -3,7 +3,7 @@ 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 step sub-transition, not an operation of its own. +// 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" } diff --git a/packages/data-testing/src/conformance/check-feature.ts b/packages/data-testing/src/conformance/check-feature.ts index 992df2c88..8aed5dc82 100644 --- a/packages/data-testing/src/conformance/check-feature.ts +++ b/packages/data-testing/src/conformance/check-feature.ts @@ -49,7 +49,7 @@ export const checkFeature = frameCasesOf(spec.systems?.frame, spec.services), // Runtime invariant: `implementation` types each writer against this db and arg. args: impl.frame.args as never, unmodelled: impl.frame.unmodelled ?? {}, diff --git a/packages/data-testing/src/conformance/run-systems.ts b/packages/data-testing/src/conformance/run-systems.ts index 0b6dc245c..bd1235f87 100644 --- a/packages/data-testing/src/conformance/run-systems.ts +++ b/packages/data-testing/src/conformance/run-systems.ts @@ -41,8 +41,9 @@ export interface SystemRunConfig { 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 (doubles injected into `args`). - readonly frameCases: readonly SystemCase[]; + // 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. @@ -127,7 +128,8 @@ export function runSystems(config: SystemRun }); } - if (config.frameCases.length === 0) return; + 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))); @@ -140,8 +142,10 @@ export function runSystems(config: SystemRun }; describe("frame conforms", () => { - for (const testCase of config.frameCases) { - it(`${testCase.name} (spec systems in schedule order)`, async () => { + 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; @@ -149,7 +153,7 @@ export function runSystems(config: SystemRun 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(testCase.args).args); + 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`); } @@ -161,7 +165,8 @@ export function runSystems(config: SystemRun expectEffects(calls, testCase.effects); }); - it(`${testCase.name} (one ECS frame)`, async () => { + 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) { @@ -173,6 +178,6 @@ export function runSystems(config: SystemRun } expectCase(db, before, testCase, calls); }); - } + }); }); } diff --git a/packages/data-testing/src/conformance/systems-types.ts b/packages/data-testing/src/conformance/systems-types.ts index e5d0fff0a..a94d61c03 100644 --- a/packages/data-testing/src/conformance/systems-types.ts +++ b/packages/data-testing/src/conformance/systems-types.ts @@ -7,7 +7,8 @@ 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. -export type FrameArgs = UnionToIntersection<{ [K in keyof Sys]: ArgsOf }[keyof Sys]>; +// 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`. diff --git a/packages/data-testing/src/conformance/systems.test.ts b/packages/data-testing/src/conformance/systems.test.ts index 6ae464c1d..64721d390 100644 --- a/packages/data-testing/src/conformance/systems.test.ts +++ b/packages/data-testing/src/conformance/systems.test.ts @@ -3,7 +3,8 @@ // scheduled after `accelerate`, and the frame case only holds in that order, so it // proves the order comes from `schedule`, not from the spec. `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. +// unmodelled and skipped. `drift` reads a service, so its frame case also proves each +// run gets fresh recording doubles. import { Database } from "@adobe/data/ecs"; import { spec } from "./spec.js"; import { implementation } from "./implementation.js"; @@ -12,7 +13,12 @@ import { checkFeature } from "./check-feature.js"; import type { SpecCases } from "./feature-types.js"; import type { FrameCases } from "./systems-types.js"; -type State = { readonly position: number; readonly velocity: number }; +type State = { 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.velocity + dt, @@ -20,10 +26,15 @@ const accelerate = (state: Pick, { dt }: { readonly dt: numbe const integrate = (state: State, { dt }: { readonly dt: number }): Pick => ({ position: state.position + state.velocity * dt, }); -const systems = { accelerate, integrate }; +const drift = (state: Pick, { rng }: { readonly rng: Rng }): Pick => ({ + offset: state.offset + rng.next(), +}); +const systems = { accelerate, integrate, drift }; const plugin = Database.Plugin.create({ + services: { rng: (): Rng => ({ serviceName: "rng", next: () => 0 }) }, resources: { + offset: { default: 0 as number }, position: { default: 0 as number }, velocity: { default: 0 as number }, jitter: { default: 0 as number }, @@ -41,6 +52,11 @@ const plugin = Database.Plugin.create({ db.store.resources.velocity += db.store.resources.frameDelta; }, }, + drift: { + create: (db) => () => { + db.store.resources.offset += db.services.rng.next(); + }, + }, physics: { create: (db) => () => { db.store.resources.jitter += 1; @@ -62,18 +78,28 @@ const accelerateCases: SpecCases = { 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 frame: FrameCases = { cases: [ // accelerate first: velocity 1 → 3, then position 0 + 3 × 2 = 6 (the other order gives 2). - { name: "accelerates, then integrates", before: { velocity: 1 }, args: { dt: 2 }, after: { velocity: 3, position: 6 } }, + { + name: "accelerates, then integrates", + before: { velocity: 1 }, + args: { dt: 2 }, + responses: { rng: { next: [5] } }, + after: { velocity: 3, position: 6, offset: 5 }, + }, ], }; const motion = spec({ - state: { create: (): State => ({ position: 0, velocity: 0 }) }, + state: { create: (): State => ({ position: 0, velocity: 0, offset: 0 }) }, fns: {}, cases: {}, - systems: { fns: systems, cases: { accelerate: accelerateCases, integrate: integrateCases }, frame }, + services: { rng: (): Rng => ({ serviceName: "rng", next: () => 0 }) }, + systems: { fns: systems, cases: { accelerate: accelerateCases, integrate: integrateCases, drift: driftCases }, frame }, }); checkSpec(motion); @@ -85,8 +111,13 @@ checkFeature( fromState: (store: Store, state: State): void => { store.resources.position = state.position; store.resources.velocity = state.velocity; + store.resources.offset = state.offset; }, - toState: (store: Store): State => ({ position: store.resources.position, velocity: store.resources.velocity }), + toState: (store: Store): State => ({ + position: store.resources.position, + velocity: store.resources.velocity, + offset: store.resources.offset, + }), }, frame: { args: { dt: (db, dt) => { db.store.resources.frameDelta = dt; } }, From d25139b669be9aad19f09eda12dfc89818a83a2b Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 21:05:40 -0700 Subject: [PATCH 20/24] feat(data-lit-space-rock-game): per-system conformance Spec systems control/movement/lifetime/collision/waves mirror the ECS systems; step and the second helper manifest removed; collision-detection and waves tests became per-system cases; frame cases include an order-dependent refill (collision before waves). Co-Authored-By: Claude Opus 5.5 --- .../conformance/collision-detection.test.ts | 190 ----------- .../main/ecs/conformance/conformance.test.ts | 4 +- .../ecs/conformance/create-system-database.ts | 10 - .../main/ecs/conformance/drive-frame.ts | 23 -- .../main/ecs/conformance/implementation.ts | 17 +- .../main/ecs/conformance/waves.test.ts | 48 --- .../main/ecs/systems/system-database.ts | 4 +- .../src/features/main/spec/collision.cases.ts | 304 ++++++++++++++++++ .../src/features/main/spec/collision.ts | 16 + .../src/features/main/spec/control.cases.ts | 49 +++ .../src/features/main/spec/control.ts | 16 + .../spec/{step.cases.ts => frame.cases.ts} | 99 +++--- .../src/features/main/spec/helpers.test.ts | 24 -- .../src/features/main/spec/helpers.ts | 10 - .../src/features/main/spec/lifetime.cases.ts | 76 +++++ .../src/features/main/spec/lifetime.ts | 16 + .../src/features/main/spec/movement.cases.ts | 84 +++++ .../src/features/main/spec/movement.ts | 19 ++ .../main/spec/resolve-bullet-hits.cases.ts | 198 ------------ .../main/spec/resolve-ship-hits.cases.ts | 93 ------ .../features/main/spec/spawn-wave.cases.ts | 47 --- .../src/features/main/spec/spawn-wave.test.ts | 23 ++ .../src/features/main/spec/spec.ts | 23 +- .../main/spec/step-asteroids.cases.ts | 57 ---- .../src/features/main/spec/step-asteroids.ts | 4 +- .../features/main/spec/step-bullets.cases.ts | 61 ---- .../src/features/main/spec/step-bullets.ts | 3 +- .../src/features/main/spec/step-ship.cases.ts | 60 ---- .../src/features/main/spec/step-ship.ts | 25 -- .../src/features/main/spec/step.ts | 43 --- .../src/features/main/spec/systems.ts | 10 + .../src/features/main/spec/transforms.ts | 6 +- .../src/features/main/spec/waves.cases.ts | 53 +++ .../src/features/main/spec/waves.ts | 11 + .../src/features/main/spec/while-playing.ts | 8 + 35 files changed, 770 insertions(+), 964 deletions(-) delete mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/conformance/collision-detection.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/conformance/create-system-database.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/conformance/drive-frame.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/ecs/conformance/waves.test.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/collision.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/collision.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/control.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/control.ts rename packages/data-lit-space-rock-game/src/features/main/spec/{step.cases.ts => frame.cases.ts} (55%) delete mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/helpers.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/helpers.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/lifetime.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/lifetime.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/movement.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/movement.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/resolve-bullet-hits.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/resolve-ship-hits.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/spawn-wave.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/spawn-wave.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/step-asteroids.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/step-bullets.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/step-ship.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/step-ship.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/step.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/systems.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/waves.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/waves.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/spec/while-playing.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/collision-detection.test.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/collision-detection.test.ts deleted file mode 100644 index f8e990c8a..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/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 "../../spec/state.js"; -import { Ship } from "../../data/values/ship/ship.js"; -import { Bullet } from "../../data/values/bullet/bullet.js"; -import { Asteroid } from "../../data/values/asteroid/asteroid.js"; -import { Input } from "../../data/values/input/input.js"; -import { Size } from "../../data/values/size/size.js"; -import { createSystemDatabase } from "./create-system-database.js"; -import { projection } from "./projection.js"; -import { driveFrame } from "./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/ecs/conformance/conformance.test.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/conformance.test.ts index 860f8e331..95f9415be 100644 --- 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 @@ -2,6 +2,6 @@ import { Conformance } from "@adobe/data-testing"; import { implementation } from "./implementation.js"; -// Replays every spec case against the same-named action (or, for `step`, one driven -// frame of the systems), and round-trips `State.samples` through the projection. +// 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/create-system-database.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/create-system-database.ts deleted file mode 100644 index 9119473af..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/create-system-database.ts +++ /dev/null @@ -1,10 +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 — for the collision and -// waves tests, which drive systems through `db.system.functions`. 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/ecs/conformance/drive-frame.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/drive-frame.ts deleted file mode 100644 index 1e5bc927e..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/drive-frame.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { SystemDatabase } from "../systems/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 collision-detection and -// waves 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/ecs/conformance/implementation.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/implementation.ts index 437fcebcf..a1954c050 100644 --- 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 @@ -5,17 +5,20 @@ import { MainService } from "../main-service.js"; import { projection } from "./projection.js"; // The spec paired with its ECS build. `createInitial`, `fireBullet` and -// `spawnRandomWave` conform against same-named actions; `step` against one driven -// frame of the systems, with the case's `dt` and `input` seeded into the resources -// the systems read. +// `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. export const implementation = Conformance.implementation(spec, { plugin: MainService.plugin, projection, frame: { - op: "step", - setup: (db, { dt, input }) => { - db.store.resources.frameDelta = dt; - db.store.resources.input = input; + args: { + dt: (db, dt) => { + db.store.resources.frameDelta = dt; + }, + input: (db, input) => { + db.store.resources.input = input; + }, }, }, }); diff --git a/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/waves.test.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/waves.test.ts deleted file mode 100644 index e957d940b..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/waves.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// The `waves` system draws from the real random source, so its drift speeds can't be -// compared with the spec. A driven frame over a cleared field must still 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. -import { describe, it, expect } from "vitest"; -import { State } from "../../spec/state.js"; -import { Ship } from "../../data/values/ship/ship.js"; -import { Asteroid } from "../../data/values/asteroid/asteroid.js"; -import { Input } from "../../data/values/input/input.js"; -import { createSystemDatabase } from "./create-system-database.js"; -import { projection } from "./projection.js"; -import { driveFrame } from "./drive-frame.js"; - -describe("waves system", () => { - it("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/ecs/systems/system-database.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/systems/system-database.ts index 885d5e74d..67799f323 100644 --- 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 @@ -13,8 +13,8 @@ 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`. One frame of these systems realizes the spec's whole-tick `step`, -// in its order — advance → fire → age → collide → refill: +// `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 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..1e45da095 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/collision.cases.ts @@ -0,0 +1,304 @@ +// © 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 }, + }, + { + name: "does nothing once the game is over", + before: { + ...small, + ship: respawned, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "small" }], + [3, { position: [100, 100], velocity: [0, 0], size: "large" }], + ]), + lives: 0, + }, + args: { dt }, + after: {}, + }, + ], +}; 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..2db8cd56f --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/control.cases.ts @@ -0,0 +1,49 @@ +// © 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 } }, + }, + { + name: "does nothing once the game is over", + before: { ship: { position: [50, 50], velocity: [0, 0], rotation: 0 }, lives: 0 }, + args: { dt: 1, input: { turn: 1, thrust: true, fire: false } }, + after: {}, + }, + ], +}; 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/spec/step.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/frame.cases.ts similarity index 55% rename from packages/data-lit-space-rock-game/src/features/main/spec/step.cases.ts rename to packages/data-lit-space-rock-game/src/features/main/spec/frame.cases.ts index 30d91b067..795a040cb 100644 --- a/packages/data-lit-space-rock-game/src/features/main/spec/step.cases.ts +++ b/packages/data-lit-space-rock-game/src/features/main/spec/frame.cases.ts @@ -1,59 +1,60 @@ // © 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 type * as systems from "./systems.js"; import { Input } from "../data/values/input/input.js"; import { Ship } from "../data/values/ship/ship.js"; -// Cases for the whole-tick `step` (args `{ dt, input }`), shared with one driven -// frame of the ECS systems (`frame` conformance). Each case exercises one branch of -// the pipeline — advance/wrap, fire, bullet↔asteroid, ship↔asteroid, 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 covered by the -// `spawnRandomWave` cases and `ecs/conformance/waves.test.ts`. -export const cases: Conformance.SpecCases = { +// 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: [ { - name: "advances and wraps every body (movement)", + // 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" }]]), - 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)", + // 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: [0, 0], rotation: 0 }, + ship: { position: [100, 100], velocity: [100, 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 }, + ship: { position: [110, 100], velocity: [100, 0], rotation: 0 }, entities: new Map([ - [1, { position: [152, 100], velocity: [400, 0], age: 0.1 }], + [1, { position: [172, 100], velocity: [500, 0], age: 0.1 }], [2, { position: [350, 350], velocity: [0, 0], size: "large" }], ]), - score: 0, - lives: 3, - wave: 1, }, }, { @@ -65,21 +66,15 @@ export const cases: Conformance.SpecCases = { [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, }, }, { @@ -88,22 +83,13 @@ export const cases: Conformance.SpecCases = { 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, - }, + after: { lives: 2 }, }, { - name: "freezes the whole tick once the game is over", + name: "freezes the whole frame once the game is over", before: { bounds: [200, 200], ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, @@ -116,16 +102,31 @@ export const cases: Conformance.SpecCases = { wave: 2, }, args: { dt: 0.1, input: { turn: 1, thrust: true, fire: true } }, - after: { + 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: { position: [50, 50], velocity: [10, 0], rotation: 0 }, + ship: Ship.spawn([100, 100]), entities: new Map([ - [1, { position: [60, 60], velocity: [0, 0], age: 0.5 }], - [2, { position: [100, 100], velocity: [0, 0], size: "large" }], + [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" }], ]), - score: 40, - lives: 0, - wave: 2, }, }, ], diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/helpers.test.ts b/packages/data-lit-space-rock-game/src/features/main/spec/helpers.test.ts deleted file mode 100644 index 56fe0ecad..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/spec/helpers.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import * as helpers from "./helpers.js"; -import { components } from "../data/components/index.js"; -import { resources } from "../data/resources/index.js"; - -import { cases as spawnWave } from "./spawn-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"; - -// Runs each sub-step helper's cases against the pure function. Spec-only: these are -// covered on the ECS side through the transitions that compose them. -Conformance.checkSpec( - Conformance.spec({ - state: State, - fns: helpers, - schemas: { ...components, ...resources }, - cases: { spawnWave, stepShip, stepBullets, stepAsteroids, resolveBulletHits, resolveShipHits }, - }), -); diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/helpers.ts b/packages/data-lit-space-rock-game/src/features/main/spec/helpers.ts deleted file mode 100644 index 6b66a8c8c..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/spec/helpers.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -// The sub-steps the conformed transitions compose. Pure spec helpers with their own -// cases, checked by `helpers.test.ts`; no ECS op realizes one on its own. -export { spawnWave } from "./spawn-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"; 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..f7db7548a --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/lifetime.cases.ts @@ -0,0 +1,76 @@ +// © 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 }]]) }, + }, + { + name: "does nothing once the game is over, even with the trigger pulled", + before: { + ...field, + ship: { position: [50, 50], velocity: [0, 0], rotation: 0 }, + entities: new Map([[1, { position: [10, 50], velocity: [100, 0], age: 0 }]]), + lives: 0, + }, + args: { dt: 0.1, input: trigger }, + after: {}, + }, + ], +}; 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..2f158f1e7 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/movement.cases.ts @@ -0,0 +1,84 @@ +// © 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 }]]) }, + }, + { + name: "does nothing once the game is over", + before: { + ...field, + ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, + entities: new Map([[1, { position: [10, 10], velocity: [30, 0], size: "large" }]]), + lives: 0, + }, + args: { dt: 1 }, + after: {}, + }, + ], +}; 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/resolve-bullet-hits.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/resolve-bullet-hits.cases.ts deleted file mode 100644 index 3b90ad886..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/spec/resolve-bullet-hits.cases.ts +++ /dev/null @@ -1,198 +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"; - -// Cases for the `resolveBulletHits` helper `step` composes (args `{ dt }`). 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/spec/resolve-ship-hits.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/resolve-ship-hits.cases.ts deleted file mode 100644 index ffa6758fb..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/spec/resolve-ship-hits.cases.ts +++ /dev/null @@ -1,93 +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 "../data/values/ship/ship.js"; - -// Cases for the `resolveShipHits` helper `step` composes (no args). 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 stay 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/spec/spawn-wave.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/spawn-wave.cases.ts deleted file mode 100644 index 4a1450070..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/spec/spawn-wave.cases.ts +++ /dev/null @@ -1,47 +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"; - -// Cases for the deterministic `spawnWave` helper `createInitial` composes (no args). When the field is clear it bumps the wave and -// spawns a ring of large asteroids drifting tangentially at 60px/s; while asteroids -// remain it is a no-op. Field 200×200 → centre [100,100], ring radius 80; wave 1 has -// 4 rocks, on the four quadrant angles. -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/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/spec.ts b/packages/data-lit-space-rock-game/src/features/main/spec/spec.ts index c97c76f5e..6dd4b751b 100644 --- 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 @@ -2,6 +2,7 @@ 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"; @@ -9,12 +10,18 @@ import { RandomService } from "../services/random-service/random-service.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 step } from "./step.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"; +import { cases as frame } from "./frame.cases.js"; -// The feature's spec: every transition, one inert cases module each (coverage checked -// at compile time against `transforms`). `schemas` supplies the column precision the -// comparator reads; `services` is the fake template the runner builds `random` -// recording doubles from. `ecs/conformance/` pairs this with the ECS build. +// The feature's spec: every transition and every ECS system, one inert cases module +// each (coverage checked at compile time), plus whole-frame cases. `schemas` supplies +// the column precision the comparator reads; `services` is the fake template the +// runner builds `random` recording doubles from. `ecs/conformance/` pairs this with +// the ECS build. export const spec = Conformance.spec({ state: State, fns: transforms, @@ -24,6 +31,10 @@ export const spec = Conformance.spec({ createInitial, fireBullet, spawnRandomWave, - step, + }, + systems: { + fns: systems, + cases: { control, movement, lifetime, collision, waves }, + frame, }, }); diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/step-asteroids.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/step-asteroids.cases.ts deleted file mode 100644 index 1a326faee..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/spec/step-asteroids.cases.ts +++ /dev/null @@ -1,57 +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 "../data/values/size/size.js"; - -// Cases for the `stepAsteroids` helper `step` composes (args `{ dt }`). 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/spec/step-asteroids.ts b/packages/data-lit-space-rock-game/src/features/main/spec/step-asteroids.ts index df533dda0..e43aff11e 100644 --- a/packages/data-lit-space-rock-game/src/features/main/spec/step-asteroids.ts +++ b/packages/data-lit-space-rock-game/src/features/main/spec/step-asteroids.ts @@ -4,9 +4,7 @@ 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/spec/step-bullets.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/step-bullets.cases.ts deleted file mode 100644 index 2e441b7ac..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/spec/step-bullets.cases.ts +++ /dev/null @@ -1,61 +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 "../data/values/bullet/bullet.js"; - -// Cases for the `stepBullets` helper `step` composes (args `{ dt }`). `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/spec/step-bullets.ts b/packages/data-lit-space-rock-game/src/features/main/spec/step-bullets.ts index aff0436a2..167ae6aa4 100644 --- a/packages/data-lit-space-rock-game/src/features/main/spec/step-bullets.ts +++ b/packages/data-lit-space-rock-game/src/features/main/spec/step-bullets.ts @@ -5,8 +5,7 @@ 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/step-ship.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/step-ship.cases.ts deleted file mode 100644 index 1066eef79..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/spec/step-ship.cases.ts +++ /dev/null @@ -1,60 +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 "../data/values/input/input.js"; -import { create } from "./create.js"; - -// Cases for the `stepShip` helper `step` composes (args `{ dt, input }`). 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/spec/step-ship.ts b/packages/data-lit-space-rock-game/src/features/main/spec/step-ship.ts deleted file mode 100644 index 00b0c2736..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/spec/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 "../data/values/ship/ship.js"; -import type { Input } from "../data/values/input/input.js"; -import { Motion } from "../data/values/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/spec/step.ts b/packages/data-lit-space-rock-game/src/features/main/spec/step.ts deleted file mode 100644 index e57420b1b..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/spec/step.ts +++ /dev/null @@ -1,43 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; -import type { Input } from "../data/values/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 { Lives } from "../data/values/lives/lives.js"; -import type { Services } from "../services/services.js"; - -// Advance the whole game one tick: 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. Realized by one frame of the ECS systems. -export const step = ( - state: State, - { - dt, - input, - random, - }: { - readonly dt: number; - readonly input: Input; - } & Pick, -): State => { - if (Lives.isGameOver(state.lives)) { - 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/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 index 8a7ddd240..00e3b8831 100644 --- 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 @@ -1,10 +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). `createInitial`, `fireBullet` and -// `spawnRandomWave` pair with same-named actions; `step` is one frame of the systems. -// The sub-steps `step` and `createInitial` compose are in `helpers.ts`. +// 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"; -export { step } from "./step.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..5d27242b0 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/spec/waves.cases.ts @@ -0,0 +1,53 @@ +// © 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, and draws nothing", + before: { ...field, wave: 1, entities: new Map([[1, { position: [10, 10], velocity: [0, 0], size: "large" }]]) }, + after: {}, + effects: { random: [] }, + }, + { + name: "does nothing once the game is over, even on a cleared field", + before: { ...field, entities: new Map(), wave: 3, lives: 0 }, + after: {}, + effects: { random: [] }, + }, + ], +}; 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); From ae29ac0c62fa1342b275d1d51c02347128fa733c Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 21:28:03 -0700 Subject: [PATCH 21/24] feat(conformance): strict doubles, frame cases on the implementation, noOp cases - a value-returning service method called with no response scheduled throws (one probe call per fake method tells value from void), so a case owns every value its code reads - whole-frame cases move to Conformance.implementation's frame.cases: they depend on the ECS's schedule, so the spec stays purely per-system - systems.noOp: states every modelled system must leave unchanged (e.g. game over), appended to each system's cases on both the spec and ECS side Co-Authored-By: Claude Opus 5.5 --- .../src/conformance/build-double.test.ts | 35 +++++++++++ .../src/conformance/build-double.ts | 49 +++++++-------- .../src/conformance/check-feature.ts | 5 +- .../src/conformance/check-spec.ts | 3 +- .../src/conformance/implementation.ts | 9 ++- packages/data-testing/src/conformance/spec.ts | 4 +- .../src/conformance/systems-types.ts | 14 ++++- .../src/conformance/systems.test.ts | 60 ++++++++++++------- .../src/conformance/with-no-op.ts | 20 +++++++ 9 files changed, 142 insertions(+), 57 deletions(-) create mode 100644 packages/data-testing/src/conformance/build-double.test.ts create mode 100644 packages/data-testing/src/conformance/with-no-op.ts 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 index 8aed5dc82..170070200 100644 --- a/packages/data-testing/src/conformance/check-feature.ts +++ b/packages/data-testing/src/conformance/check-feature.ts @@ -3,6 +3,7 @@ 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"; @@ -48,8 +49,8 @@ export const checkFeature = frameCasesOf(spec.systems?.frame, spec.services), + 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 ?? {}, diff --git a/packages/data-testing/src/conformance/check-spec.ts b/packages/data-testing/src/conformance/check-spec.ts index c7cd3b6c5..50e566c8a 100644 --- a/packages/data-testing/src/conformance/check-spec.ts +++ b/packages/data-testing/src/conformance/check-spec.ts @@ -1,6 +1,7 @@ // © 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; @@ -16,7 +17,7 @@ export const checkSpec = unknown; @@ -24,11 +24,13 @@ export interface Implementation 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. @@ -74,6 +76,7 @@ export const implementation = < readonly frame: { readonly args: FrameArgWriters; readonly unmodelled?: { readonly [K in U]: string }; + readonly cases?: FrameCases; } & SystemsExact, U, SystemKeys>; }), ): Implementation> => diff --git a/packages/data-testing/src/conformance/spec.ts b/packages/data-testing/src/conformance/spec.ts index 877789201..63900917e 100644 --- a/packages/data-testing/src/conformance/spec.ts +++ b/packages/data-testing/src/conformance/spec.ts @@ -28,8 +28,8 @@ export interface Spec, C 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, plus optional whole-frame cases. The frame order - // is never authored here; it comes from the systems' `schedule` declarations. + // 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; } diff --git a/packages/data-testing/src/conformance/systems-types.ts b/packages/data-testing/src/conformance/systems-types.ts index a94d61c03..cc6e84465 100644 --- a/packages/data-testing/src/conformance/systems-types.ts +++ b/packages/data-testing/src/conformance/systems-types.ts @@ -17,11 +17,21 @@ export type FrameCases = SpecCases = { readonly [K in keyof Sys]: SpecCases }; -// The spec's systems group: pure functions named after the ECS systems they specify. +// 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 frame?: FrameCases; + readonly noOp?: readonly NoOpCase[]; }; // The names of a plugin's systems, less the scheduler's own driver system. diff --git a/packages/data-testing/src/conformance/systems.test.ts b/packages/data-testing/src/conformance/systems.test.ts index 64721d390..30bff1436 100644 --- a/packages/data-testing/src/conformance/systems.test.ts +++ b/packages/data-testing/src/conformance/systems.test.ts @@ -1,10 +1,11 @@ // © 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. `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. `drift` reads a service, so its frame case also proves each -// run gets fresh recording doubles. +// 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"; @@ -13,30 +14,31 @@ import { checkFeature } from "./check-feature.js"; import type { SpecCases } from "./feature-types.js"; import type { FrameCases } from "./systems-types.js"; -type State = { readonly position: number; readonly velocity: number; readonly offset: number }; +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.velocity + dt, +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.position + state.velocity * dt, + position: state.frozen ? state.position : state.position + state.velocity * dt, }); -const drift = (state: Pick, { rng }: { readonly rng: Rng }): Pick => ({ - offset: state.offset + rng.next(), +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: { - offset: { default: 0 as number }, + 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 }, }, @@ -44,17 +46,20 @@ const plugin = Database.Plugin.create({ integrate: { schedule: { after: ["accelerate"] }, create: (db) => () => { - db.store.resources.position += db.store.resources.velocity * db.store.resources.frameDelta; + const r = db.store.resources; + if (!r.frozen) r.position += r.velocity * r.frameDelta; }, }, accelerate: { create: (db) => () => { - db.store.resources.velocity += db.store.resources.frameDelta; + const r = db.store.resources; + if (!r.frozen) r.velocity += r.frameDelta; }, }, drift: { create: (db) => () => { - db.store.resources.offset += db.services.rng.next(); + const r = db.store.resources; + if (!r.frozen) r.offset += db.services.rng.next(); }, }, physics: { @@ -81,9 +86,23 @@ const integrateCases: SpecCases = { 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: [ - // accelerate first: velocity 1 → 3, then position 0 + 3 × 2 = 6 (the other order gives 2). { name: "accelerates, then integrates", before: { velocity: 1 }, @@ -94,14 +113,6 @@ const frame: FrameCases = { ], }; -const motion = spec({ - state: { create: (): State => ({ 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 }, frame }, -}); - checkSpec(motion); checkFeature( @@ -109,11 +120,13 @@ checkFeature( 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, @@ -122,6 +135,7 @@ checkFeature( 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/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: {} }))] }, + ]), + ); +}; From 5e7427bf226257683ab986005a25ce0d40918167 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 21:38:31 -0700 Subject: [PATCH 22/24] refactor(data-gpu-hopper): frame cases on the implementation, one noOp game-over case Co-Authored-By: Claude Opus 5.5 --- .../{spec => ecs/conformance}/frame.cases.ts | 11 +++-------- .../main/ecs/conformance/implementation.ts | 7 +++++-- .../src/features/main/spec/collision.cases.ts | 3 --- .../src/features/main/spec/movement.cases.ts | 5 ----- .../src/features/main/spec/spec.ts | 17 +++++++++++++---- 5 files changed, 21 insertions(+), 22 deletions(-) rename packages/data-gpu-hopper/src/features/main/{spec => ecs/conformance}/frame.cases.ts (85%) diff --git a/packages/data-gpu-hopper/src/features/main/spec/frame.cases.ts b/packages/data-gpu-hopper/src/features/main/ecs/conformance/frame.cases.ts similarity index 85% rename from packages/data-gpu-hopper/src/features/main/spec/frame.cases.ts rename to packages/data-gpu-hopper/src/features/main/ecs/conformance/frame.cases.ts index 33a8edfc2..3d0d45bb8 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/frame.cases.ts +++ b/packages/data-gpu-hopper/src/features/main/ecs/conformance/frame.cases.ts @@ -1,8 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type * as systems from "./systems.js"; -import { board, riverLanes } from "./case-boards.js"; +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 @@ -39,10 +39,5 @@ export const cases: Conformance.FrameCases = { args: { dt: 1 }, after: { entities: new Map([[1, { kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }]]), score: 1, status: "won" } }, - { name: "does nothing once the game is over", - before: board({ frog: { x: 1, y: 1 }, lives: 0, status: "gameOver", - entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]) }), - args: { dt: 1 }, - after: {} }, ], }; 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 index e4743e9b2..2c1ab15ba 100644 --- a/packages/data-gpu-hopper/src/features/main/ecs/conformance/implementation.ts +++ b/packages/data-gpu-hopper/src/features/main/ecs/conformance/implementation.ts @@ -3,11 +3,13 @@ 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`. `MainService.plugin` is headless -// (the render plugin's systems are layered on in the UI), so every system is modelled. +// 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, @@ -17,5 +19,6 @@ export const implementation = Conformance.implementation(spec, { db.store.resources.frameDelta = dt; }, }, + cases, }, }); 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 index bccdeb711..b5ecff5a6 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/collision.cases.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/collision.cases.ts @@ -40,8 +40,5 @@ export const cases: Conformance.SpecCases = { { name: "grass is always safe", before: board({ frog: { x: 2, y: 0 } }), after: { lives: 3, score: 0, status: "playing" } }, - { name: "is frozen once the game is over", - before: board({ frog: { x: 2, y: 1 }, entities: new Map([[1, car]]), lives: 0, status: "gameOver" }), - after: { lives: 0, status: "gameOver", frog: { x: 2, y: 1 } } }, ], }; 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 index d5d88b8f3..2092f6d7e 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/movement.cases.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/movement.cases.ts @@ -33,10 +33,5 @@ export const cases: Conformance.SpecCases = { 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 } } }, - { name: "is frozen once the game is over", - before: board({ status: "gameOver", lives: 0, - 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: 0, width: 1, velocity: 1 }]]), frog: { x: 2, y: 0 } } }, ], }; diff --git a/packages/data-gpu-hopper/src/features/main/spec/spec.ts b/packages/data-gpu-hopper/src/features/main/spec/spec.ts index c80f681ad..69c4e77e6 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/spec.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/spec.ts @@ -8,14 +8,23 @@ 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 { cases as frame } from "./frame.cases.js"; +import { board } from "./case-boards.js"; // The feature's spec: the `hop` / `newGame` actions and the `movement` / `collision` -// systems, each with its inert cases, plus whole-frame cases. No entity references and -// no injected services, so no `schemas` or `services`. +// 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 }, frame }, + 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 } }, + ], + }, }); From de20357c00956225f806007bf3914ebe82037e48 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 21:39:38 -0700 Subject: [PATCH 23/24] refactor(data-lit-space-rock-game): frame cases on the implementation, noOp game-over states; rules Two noOp states (live field, cleared field) replace every per-system game-over case; the strict doubles make effects: { random: [] } redundant. data-ai: spec/conformance/systems rules and skills describe noOp, frame cases in ecs/conformance, and strict response schedules. Co-Authored-By: Claude Opus 5.5 --- .../.claude/rules/features/ecs/conformance.md | 11 +++-- .../.claude/rules/features/ecs/systems.md | 9 +++-- .../.claude/rules/features/spec/index.md | 18 ++++++--- packages/data-ai/skills/build-spec/SKILL.md | 2 +- .../data-ai/skills/build-systems/SKILL.md | 5 ++- .../{spec => ecs/conformance}/frame.cases.ts | 8 ++-- .../main/ecs/conformance/implementation.ts | 5 ++- .../src/features/main/spec/collision.cases.ts | 15 ------- .../src/features/main/spec/control.cases.ts | 6 --- .../src/features/main/spec/lifetime.cases.ts | 11 ----- .../src/features/main/spec/movement.cases.ts | 11 ----- .../src/features/main/spec/spec.ts | 40 ++++++++++++++++--- .../src/features/main/spec/waves.cases.ts | 9 +---- 13 files changed, 73 insertions(+), 77 deletions(-) rename packages/data-lit-space-rock-game/src/features/main/{spec => ecs/conformance}/frame.cases.ts (95%) diff --git a/packages/data-ai/.claude/rules/features/ecs/conformance.md b/packages/data-ai/.claude/rules/features/ecs/conformance.md index 75afa40ac..69babcace 100644 --- a/packages/data-ai/.claude/rules/features/ecs/conformance.md +++ b/packages/data-ai/.claude/rules/features/ecs/conformance.md @@ -13,6 +13,7 @@ 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) ``` @@ -39,7 +40,8 @@ export const implementation = Conformance.implementation(spec, { 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" } }, // real-time features + unmodelled: { physics: "wasm rigid-body step" }, + cases: frameCases }, // real-time features }); ``` @@ -50,9 +52,10 @@ export const implementation = Conformance.implementation(spec, { 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. 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. + 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` diff --git a/packages/data-ai/.claude/rules/features/ecs/systems.md b/packages/data-ai/.claude/rules/features/ecs/systems.md index be62a5f85..3bf0250a9 100644 --- a/packages/data-ai/.claude/rules/features/ecs/systems.md +++ b/packages/data-ai/.claude/rules/features/ecs/systems.md @@ -133,15 +133,18 @@ 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 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. +- **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 diff --git a/packages/data-ai/.claude/rules/features/spec/index.md b/packages/data-ai/.claude/rules/features/spec/index.md index 15fe16e96..25dd9c01e 100644 --- a/packages/data-ai/.claude/rules/features/spec/index.md +++ b/packages/data-ai/.claude/rules/features/spec/index.md @@ -19,7 +19,7 @@ spec/ 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 frame.cases.ts # real-time only: one function per ECS system + systems.ts .cases.ts # real-time only: one function per ECS system spec.ts spec.test.ts # the manifest + Conformance.checkSpec ``` @@ -100,7 +100,8 @@ export const cases: Conformance.SpecCases = { - **`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`**, and calls to assert go in + 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. @@ -139,7 +140,11 @@ export const collision = whilePlaying((s) => ({ ...resolveHits(s) })); // spec.ts export const spec = Conformance.spec({ …, - systems: { fns: systems, cases: { movement, collision }, frame: frameCases }, + systems: { + fns: systems, + cases: { movement, collision }, + noOp: [{ name: "frozen once the game is over", before: { status: "over" }, args: { dt: 1 } }], + }, }); ``` @@ -147,7 +152,10 @@ export const spec = Conformance.spec({ …, 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. -- **Frame cases** (`Conformance.FrameCases`) cover how systems - interact over one tick; `checkFeature` runs them in the schedule's order. +- **`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/skills/build-spec/SKILL.md b/packages/data-ai/skills/build-spec/SKILL.md index 5acc7c249..120799151 100644 --- a/packages/data-ai/skills/build-spec/SKILL.md +++ b/packages/data-ai/skills/build-spec/SKILL.md @@ -13,7 +13,7 @@ Create the feature's `spec/` folder, the specification the ECS will be conformed - 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 frame cases. + 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`). diff --git a/packages/data-ai/skills/build-systems/SKILL.md b/packages/data-ai/skills/build-systems/SKILL.md index f35c3b7d6..902d86714 100644 --- a/packages/data-ai/skills/build-systems/SKILL.md +++ b/packages/data-ai/skills/build-systems/SKILL.md @@ -13,8 +13,9 @@ Database.Plugin.combine(.plugin, scheduler), systems: { … } })`, w per-row work writes columns in place; discrete events dispatch transactions. Order systems under `schedule`; it is the only statement of order. -Add `frame: { args, unmodelled? }` to the implementation: a writer per frame arg, and -each system the spec can't model (wasm, init-only) with its reason. Gate: every +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. The how is in the auto-loading `features/ecs/systems.md` rule. diff --git a/packages/data-lit-space-rock-game/src/features/main/spec/frame.cases.ts b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/frame.cases.ts similarity index 95% rename from packages/data-lit-space-rock-game/src/features/main/spec/frame.cases.ts rename to packages/data-lit-space-rock-game/src/features/main/ecs/conformance/frame.cases.ts index 795a040cb..087f7c93b 100644 --- a/packages/data-lit-space-rock-game/src/features/main/spec/frame.cases.ts +++ b/packages/data-lit-space-rock-game/src/features/main/ecs/conformance/frame.cases.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Conformance } from "@adobe/data-testing"; -import type { State } from "./state.js"; -import type * as systems from "./systems.js"; -import { Input } from "../data/values/input/input.js"; -import { Ship } from "../data/values/ship/ship.js"; +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 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 index a1954c050..aa9e6526c 100644 --- 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 @@ -3,11 +3,13 @@ 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. +// the systems read. Every system is modelled; `frame` replays whole frames in +// schedule order. export const implementation = Conformance.implementation(spec, { plugin: MainService.plugin, projection, @@ -20,5 +22,6 @@ export const implementation = Conformance.implementation(spec, { db.store.resources.input = input; }, }, + cases: frame, }, }); 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 index 1e45da095..a7361680f 100644 --- 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 @@ -285,20 +285,5 @@ export const cases: Conformance.SpecCases = { args: { dt }, after: { entities: new Map(), score: 100 }, }, - { - name: "does nothing once the game is over", - before: { - ...small, - ship: respawned, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [50, 50], velocity: [0, 0], size: "small" }], - [3, { position: [100, 100], velocity: [0, 0], size: "large" }], - ]), - lives: 0, - }, - args: { dt }, - after: {}, - }, ], }; 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 index 2db8cd56f..451e8e25c 100644 --- 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 @@ -39,11 +39,5 @@ export const cases: Conformance.SpecCases = { args: { dt: 1, input: { turn: 1, thrust: true, fire: false } }, after: { ship: { position: [50, 50], velocity: [200, 0], rotation: 0 } }, }, - { - name: "does nothing once the game is over", - before: { ship: { position: [50, 50], velocity: [0, 0], rotation: 0 }, lives: 0 }, - args: { dt: 1, input: { turn: 1, thrust: true, fire: false } }, - after: {}, - }, ], }; 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 index f7db7548a..dd740e7e4 100644 --- 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 @@ -61,16 +61,5 @@ export const cases: Conformance.SpecCases = { args: { dt: 0.1, input: trigger }, after: { entities: new Map([[1, { position: [152, 100], velocity: [400, 0], age: 0.1 }]]) }, }, - { - name: "does nothing once the game is over, even with the trigger pulled", - before: { - ...field, - ship: { position: [50, 50], velocity: [0, 0], rotation: 0 }, - entities: new Map([[1, { position: [10, 50], velocity: [100, 0], age: 0 }]]), - lives: 0, - }, - args: { dt: 0.1, input: trigger }, - after: {}, - }, ], }; 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 index 2f158f1e7..88aa60f89 100644 --- 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 @@ -69,16 +69,5 @@ export const cases: Conformance.SpecCases = { args: { dt: 1 }, after: { entities: new Map([[1, { position: [10, 10], velocity: [10, 0], age: 0 }]]) }, }, - { - name: "does nothing once the game is over", - before: { - ...field, - ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, - entities: new Map([[1, { position: [10, 10], velocity: [30, 0], size: "large" }]]), - lives: 0, - }, - args: { dt: 1 }, - after: {}, - }, ], }; 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 index 6dd4b751b..329dae7cf 100644 --- 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 @@ -6,6 +6,9 @@ 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"; @@ -15,13 +18,13 @@ 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"; -import { cases as frame } from "./frame.cases.js"; // The feature's spec: every transition and every ECS system, one inert cases module -// each (coverage checked at compile time), plus whole-frame cases. `schemas` supplies -// the column precision the comparator reads; `services` is the fake template the -// runner builds `random` recording doubles from. `ecs/conformance/` pairs this with -// the ECS build. +// 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, @@ -35,6 +38,31 @@ export const spec = Conformance.spec({ systems: { fns: systems, cases: { control, movement, lifetime, collision, waves }, - frame, + 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/waves.cases.ts b/packages/data-lit-space-rock-game/src/features/main/spec/waves.cases.ts index 5d27242b0..a31965c28 100644 --- 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 @@ -38,16 +38,9 @@ export const cases: Conformance.SpecCases = { }, }, { - name: "does nothing while asteroids remain, and draws nothing", + name: "does nothing while asteroids remain", before: { ...field, wave: 1, entities: new Map([[1, { position: [10, 10], velocity: [0, 0], size: "large" }]]) }, after: {}, - effects: { random: [] }, - }, - { - name: "does nothing once the game is over, even on a cleared field", - before: { ...field, entities: new Map(), wave: 3, lives: 0 }, - after: {}, - effects: { random: [] }, }, ], }; From dc3e956ca11ed046b717d8ca56c93232f7317566 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 29 Sep 2026 21:49:06 -0700 Subject: [PATCH 24/24] chore: bump versions to 0.10.22 Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- packages/data-ai/.claude-plugin/plugin.json | 2 +- packages/data-ai/package.json | 2 +- packages/data-gpu/package.json | 2 +- packages/data-lit/package.json | 2 +- packages/data-persistence/package.json | 2 +- packages/data-react/package.json | 2 +- packages/data-rpc/package.json | 2 +- packages/data-solid/package.json | 2 +- packages/data-sync/package.json | 2 +- packages/data-testing/package.json | 2 +- packages/data/package.json | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) 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/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-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/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-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/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/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/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/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,