diff --git a/package.json b/package.json index db983aa9..97a50ac9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.9.96", + "version": "0.9.97", "private": true, "engines": { "node": ">=24" diff --git a/packages/data-ai/.claude-plugin/plugin.json b/packages/data-ai/.claude-plugin/plugin.json index 72e9749d..929c7f40 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.9.96", + "version": "0.9.97", "description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.", "author": { "name": "Adobe" diff --git a/packages/data-ai/.claude/rules/data-modelling.md b/packages/data-ai/.claude/rules/data-modelling.md index ffa97b92..8674b619 100644 --- a/packages/data-ai/.claude/rules/data-modelling.md +++ b/packages/data-ai/.claude/rules/data-modelling.md @@ -36,6 +36,27 @@ function record(m: unknown) { } ``` +## Collection ordering is carried by the type + +A collection's type states whether its order is meaningful — the model is the +single source of truth, not a downstream comparison flag: + +- **`ReadonlyArray`** — order is meaningful. A display list rendered in sequence, + a drag-reorderable list, a positional tuple (`Vec2 = readonly [number, number]`). +- **`ReadonlySet`** — an unordered bag. Entities materialised in nondeterministic + order, a membership set. Use this for **identity-keyed** collections: the element + carries its own `id`, so a `ReadonlySet` replaces any + `ReadonlyMap`. +- **`ReadonlyMap`** — a keyed lookup whose **keys are meaningful/deterministic** + (an enum, a name, a stable string). Not for identity keys (those are Sets). + +These are first-class `Data` (see `features/data/index.md`) — serialize a +Set/Map-bearing value with `Data.stringify` / `Data.parse` (plain `JSON.stringify` +cannot represent them), and `equals` compares them faithfully. Conformance mirrors +the semantics: `ReadonlyArray` compares positionally, `ReadonlySet` / `ReadonlyMap` +order-independently, and a numeric `id` is ignored (the ECS allocates it) — so there +is no separate "unordered" declaration when writing conformance cases. + ## Shape of keyed collections - `Record` — every key required at all times. Default lists diff --git a/packages/data-ai/.claude/rules/features/data/index.md b/packages/data-ai/.claude/rules/features/data/index.md index 06d6c540..85c6ee3d 100644 --- a/packages/data-ai/.claude/rules/features/data/index.md +++ b/packages/data-ai/.claude/rules/features/data/index.md @@ -6,9 +6,10 @@ paths: # data/ — the data model The foundation layer: the feature's **data types** and the pure declarations -over them. A data type is a readonly, JSON-serializable value suitable for -persistence and for communication over the wire — no functions, no handles, just -plain data. A `data/` **type** depends on nothing but `@adobe/data` and other +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 diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index c72d4470..8ebdd979 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -9,14 +9,24 @@ paths: source of truth. Each transition is a read→write **patch** over state; each derivation a pure selector. Reference: `data-lit-todo`'s `data/state/`. +The presence of this folder makes the feature **state-based** — its Functional +State Specification is authoritative and the ECS conforms to it. A feature without +`data/state/` is **ECS-based** (the ECS is the source of truth, no conformance) and +this rule does not apply — see `../index.md`, Two modes. + ```ts // state.ts — the aggregate + the transition/derivation namespace. export type State = { readonly todos: readonly Todo[]; readonly displayCompleted: boolean }; export * as State from "./public.js"; ``` -Every feature with ECS resources/transactions owns a `State` (a scalar -`{ playing: boolean }`, or `{}` when there is none). +Every **state-based** feature owns a `State` (a scalar `{ playing: boolean }`, or +`{}` when there is none). An ECS-based feature has no `State` at all. + +**A `State` field's collection type carries its ordering** — see +`../../data-modelling.md` (Collection ordering is carried by the type). `todos` +above is a `ReadonlyArray` because todo order is a user-visible, reorderable fact; +an unordered entity bag (bullets, sprites) is a `ReadonlySet`. **`State` has a standard shape.** Two exports are conventional and drive conformance: @@ -97,9 +107,10 @@ export const entity = ConformanceApi.entity; // create-todo.ts import { Match } from "@adobe/data-testing"; import type { Conformance } from "./conformance-case.js"; // the thin per-feature alias above +import type { Services } from "../../services/services.js"; // the feature's service map export const createTodo = ( state: Pick, - { name, complete, analytics }: { name: string; complete?: boolean; analytics: AnalyticsService }, + { name, complete, analytics }: { name: string; complete?: boolean } & Pick, ): Pick => { analytics.todoCreated({ name }); return { todos: [...state.todos, { name, complete: complete ?? false }] }; // writes patch only @@ -144,9 +155,12 @@ export const cases: Conformance = [ overrides the default wholesale.) - **`after` leaves minted values open** with the shared matchers `Match.anyNumber` / `Match.anyString`, imported from `@adobe/data-testing` — there is **no** - per-feature `matchers.ts` anymore. An id the ECS assigns from its own id-space is - `id: Match.anyNumber`, so the pure spec and the ECS satisfy the same case — match - by content, not by the value you don't control. `Match` is framework-agnostic and + per-feature `matchers.ts` anymore. An entity's own numeric `id` is **ignored by + default** (the ECS allocates it from its own id-space), so a case simply **omits + `id`** and the entity's content still compares — no `id: Match.anyNumber` needed. + (Reach for `id: Match.anyNumber` only when the type makes `id` required and a + literal would otherwise pin it.) Match by content, not by the value you don't + control. `Match` is framework-agnostic and honors any asymmetric matcher, so vitest's `expect.stringContaining(...)` interops on the expected side too. When an id must **line up in two places** within one comparison — a `selectedId` that points at a specific todo, say — use @@ -167,8 +181,9 @@ export const cases: Conformance = [ `value` case → derivation; otherwise a transition whose declared `effects` are also asserted). Passing `{ state: State }` (the same `state` shape `runFeature` takes) is what makes each case's `before`/`input` a delta over `State.create()`. - Add `match` alongside it only when - the feature needs float tolerance or unordered collections (see `conformance.md`). + Add `match` alongside it only when the feature needs float `tolerance` (see + `conformance.md`); unordered collections are modeled as `ReadonlySet` / + `ReadonlyMap` on `State` (below), not declared as a match option. There is no per-feature `expect-state-matches.ts`, `record-effects.ts`, `expect-conforms.ts`, or `conformance-case.type-test.ts` — those are gone; the shared driver owns comparison, effect recording, and name-based auto-pairing, and @@ -179,13 +194,16 @@ export const cases: Conformance = [ ## Injected services and side effects -A transform that needs an outside capability receives it as a **named parameter** -in the args object, keyed by the service name minus its `-service` suffix -(`AnalyticsService` → `analytics`, `NameGeneratorService` → `nameGenerator`); -plain data args sit alongside. Import the service straight from `services/` (an -ordinary import — layers split by kind of type, not dependency). The **same -services appear on `db.services` for the matching action**, so the transition is -the complete spec of *both* the state change and the service calls. +A transform that needs an outside capability injects it with **`Pick`** — never an ad-hoc inline type. `Services` is the feature's service map +(`services/services.ts`, see `../services/index.md`), keyed by the service name +minus its `-service` suffix (`analytics`, `nameGenerator`), so the key and its type +come from **one** place and can't drift per-transition. Plain data args sit +alongside via intersection: `{ readonly count: number } & Pick` +(or `Pick` when a transition takes only services). The **same +services appear on `db.services` for the matching action** — pinned to `Services` in +the ecs `service-database` — so the transition is the complete spec of *both* the +state change and the service calls. - A transition **is deterministic given its dependencies** — inject a fixed double and the output (and its calls) are fixed. An **async** dependency makes diff --git a/packages/data-ai/.claude/rules/features/index.md b/packages/data-ai/.claude/rules/features/index.md index 47f6de9c..30109482 100644 --- a/packages/data-ai/.claude/rules/features/index.md +++ b/packages/data-ai/.claude/rules/features/index.md @@ -24,6 +24,34 @@ 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, + transitions, co-located `cases`, `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 diff --git a/packages/data-ai/.claude/rules/features/services/index.md b/packages/data-ai/.claude/rules/features/services/index.md index 90e7d7ba..62dabd2e 100644 --- a/packages/data-ai/.claude/rules/features/services/index.md +++ b/packages/data-ai/.claude/rules/features/services/index.md @@ -20,6 +20,33 @@ Everything a feature exposes as a service lives here. Two kinds: The rest of this rule governs the capability contracts; `main-service` follows its own subtree. +## `services/services.ts` — the injectable service map + +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. + +```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 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 index 3499d14f..b5641b41 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/actions.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/actions.md @@ -48,6 +48,10 @@ export const addRandomTodo = async (service: ServiceDatabase) => { 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.runFeature({...})` call. It pulls actions off **`plugin.actions`** and pairs each to the **same-named** `data/state` 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 index c9787752..a6cb48f9 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md @@ -5,6 +5,11 @@ paths: # services/main-service/conformance/ — keeping the ECS honest against the spec +**State-based features only.** This folder exists only when the feature has a +`data/state/` spec to conform to. An **ECS-based** feature (no `data/state/`) omits +this folder entirely and unit-tests its transactions/actions directly instead (see +`../../index.md`, Two modes, and `transactions.md` / `actions.md`). + Test-only (imported only by `*.test.ts`, in no facet barrel). The `data/state` cases are the shared truth; the shared **`@adobe/data-testing`** runner replays them against the ECS. This folder holds only the *feature-specific projection* @@ -33,10 +38,13 @@ installing `@adobe/data` never pulls in a `vitest` peer dependency): - **`Match`** — the tolerant, matcher-aware value comparison: `matches(actual, expected, options?)` and its throwing wrapper `assert(...)`, plus the matchers `Match.anyNumber` / `Match.anyString` / `Match.ref(label)`. Options are - `{ unordered?: ReadonlySet; tolerance?: number }` — arrays compare **in - order** (default) unless a key is named in `unordered`, and numbers snap to - `tolerance` (default `0.01`) to absorb F32↔f64 / trig noise. Framework-agnostic: - it honors any asymmetric matcher, so vitest's `expect.any(...)` interops. + `{ tolerance?: number }` — numbers snap to `tolerance` (default `0.01`) to absorb + F32↔f64 / trig noise. **Ordering is carried by the value's type**: a + `ReadonlyArray` compares **in order**, a `ReadonlySet` / `ReadonlyMap` + **order-independently** — there is no `unordered` option. A numeric `id` a case + does not mention is **ignored** (the ECS allocates it), so entity content compares + without pinning ids. Framework-agnostic: it honors any asymmetric matcher, so + vitest's `expect.any(...)` interops. - **`Conformance`** — the case types (`Case`, `Cases`, `DerivationCase`, `DerivationCases`, `Effects`, `ServiceCall`), the `entity(specId)` identity marker, the id `resolver(map)`, the whole-feature driver **`runFeature`**, the @@ -92,7 +100,7 @@ Conformance.runFeature({ computedPlugin: ComputedDatabase.plugin, // omit if no state/ derivations projection, hydrate: ["visibleTodos"], // entity-id-list computeds; omit if none - match: { unordered: new Set(["bullets", "asteroids"]) }, // entity bags / tolerance; omit if none + match: { tolerance: 0.1 }, // float grid only; omit if 0.01 is fine ops: { actions: import.meta.glob([...]) }, // ONLY when ops aren't in the plugin facet }); ``` @@ -136,8 +144,9 @@ Conformance.runFeature({ `data-lit-tictactoe` is the zero-config call (no `computedPlugin`, no `hydrate`, no `match`, no `ops` — moves are board-index addressed, so no `entity()` markers). `data-lit-todo` adds `hydrate: ["visibleTodos"]` and `entity()` markers. -`data-lit-space-rock-game` adds `match: { unordered: … }` for its entity bags -(its per-frame transitions are conformed by the systems tick loop, not here — see +`data-lit-space-rock-game` models its entity bags (`bullets`, `asteroids`) as +`ReadonlySet` on `State`, so they compare order-independently by type — no `match` +option (its per-frame transitions are conformed by the systems tick loop, not here — see `systems.md`). ## The pure spec — `data/state/spec.test.ts` @@ -189,24 +198,24 @@ op is infra, and a thin **same-named** op (todo's `reorderTodo` action, space-ro `createInitial` transaction) gives the transition something to pair with. Do **not** reintroduce a per-item adapter to bridge a name mismatch — add the same-named op. -## Ordering, tolerance, `ref` — all via `match` - -`runFeature` threads `match?: MatchOptions` through to every comparison, so -per-feature tuning is data, not code: - -- **Ordered by default.** `toState` reads a display-ordered collection in order — - that is what verifies a reorder — and positional tuples (a `Vec2`) must stay in - order. -- **Entity bags compare as multisets.** A collection the ECS materialises with no - display order (bullets, asteroids) is named in - `match: { unordered: new Set(["bullets", "asteroids"]) }` so just those fields - compare order-independently — never blanket-unordered (it would conflate - `[100,180]` with `[180,100]`). -- **Float noise** is absorbed by the default `tolerance` (`0.01`); raise it only - when a case genuinely needs a looser grid. -- **`Match.ref(label)`** on the expected side asserts id correspondence for a - referential feature (an id that must line up in two places); `Match.anyNumber` / - `anyString` leave an id a case doesn't pin fully open. +## Ordering, tolerance, `ref` + +Ordering is carried by the value's **type**, not a match option — `ReadonlyArray` +positional, `ReadonlySet` / `ReadonlyMap` order-independent (the rule and its +rationale live in `../../../data-modelling.md`). What's specific to writing +conformance cases: + +- **A numeric `id` is ignored unless a case pins it.** The ECS allocates entity ids + from its own space, so a case omits `id` and the entity's content still compares. + Pin it only to assert a reference (below). +- **Float noise** is absorbed by the default `tolerance` (`0.01`), threaded through + `match?: { tolerance }`; raise it only when a case needs a looser grid. +- **`Match.ref(label)`** on the expected side asserts id *correspondence* for a + referential feature — a reference that must line up with the entity it points at + (a `selectedId` → a specific todo). Put `ref("t")` on both the reference **and** + that entity's `id`; the labels form a bijection over the actual ids. + `Match.anyNumber` / `anyString` leave an id a case doesn't pin fully open. `ref` + correspondence holds even across a `ReadonlySet` boundary. ## Recording side effects — built in, no Proxy 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 index 9a8de75e..d4231b87 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/transactions.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/transactions.md @@ -32,6 +32,11 @@ export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => { 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.runFeature({...})` call conforms transactions: it pulls them off **`plugin.transactions`** (the diff --git a/packages/data-ai/.claude/rules/service.md b/packages/data-ai/.claude/rules/service.md index 68ea6fb6..477136f2 100644 --- a/packages/data-ai/.claude/rules/service.md +++ b/packages/data-ai/.claude/rules/service.md @@ -8,7 +8,7 @@ paths: Asynchronous data services. Live in the `services/` layer. Adhere to the namespace rule for type and function organization. -**Data** = readonly JSON values or Blobs. +**Data** = readonly JSON values, `ReadonlySet`, `ReadonlyMap`, or Blobs. --- diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index 378802ea..250392ad 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.9.96", + "version": "0.9.97", "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-hopper/package.json b/packages/data-gpu-hopper/package.json index 43c41404..84a43d34 100644 --- a/packages/data-gpu-hopper/package.json +++ b/packages/data-gpu-hopper/package.json @@ -1,6 +1,6 @@ { "name": "data-gpu-hopper", - "version": "0.9.96", + "version": "0.9.97", "description": "Hopper sample - real-time ECS game rendered as colored cubes via @adobe/data-gpu", "type": "module", "private": true, diff --git a/packages/data-gpu-hopper/src/features/main/data/state/create.test.ts b/packages/data-gpu-hopper/src/features/main/data/state/create.test.ts index eb16f851..a8613e09 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/create.test.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/create.test.ts @@ -35,7 +35,7 @@ describe("State.create", () => { const carrying = new Set(["road", "river"]); for (const lane of state.lanes) { if (!carrying.has(lane.kind)) continue; - const populated = state.hazards.some((hazard) => hazard.lane === lane.row); + const populated = [...state.hazards].some((hazard) => hazard.lane === lane.row); expect(populated, `lane ${lane.row} (${lane.kind}) has no hazard`).toBe(true); } }); 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 index faf7991c..e3693d03 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/create.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/create.ts @@ -23,7 +23,7 @@ const lanes: readonly Lane[] = [ // 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 hazards: readonly Hazard[] = [ +const hazards: ReadonlySet = new Set([ { 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 }, @@ -36,7 +36,7 @@ const hazards: readonly Hazard[] = [ { 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 }, -]; +]); // The initial, full game state: frog at the bottom, three lives, nothing scored. export const create = (): State => ({ diff --git a/packages/data-gpu-hopper/src/features/main/data/state/frog-outcome.test.ts b/packages/data-gpu-hopper/src/features/main/data/state/frog-outcome.test.ts index 33530237..1550d37a 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/frog-outcome.test.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/frog-outcome.test.ts @@ -13,7 +13,7 @@ const lanes: readonly Lane[] = [ ]; const board = (hazards: readonly Hazard[], x: number, y: number): StateType => ({ - width: 5, height: 4, lanes, hazards, frog: { x, y }, lives: 3, score: 0, status: "playing", + width: 5, height: 4, lanes, hazards: new Set(hazards), frog: { x, y }, lives: 3, score: 0, status: "playing", }); const car: Hazard = { kind: "car", lane: 1, x: 2, width: 1, velocity: 1 }; 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 index 1c23dc69..b62cc8ca 100644 --- 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 @@ -19,6 +19,6 @@ export const frogOutcome = < const onBoard = state.frog.x >= 0 && state.frog.x <= state.width - 1; const covered = onBoard && - state.hazards.some((hazard) => hazard.lane === state.frog.y && Hazard.covers(hazard, state.frog.x)); + [...state.hazards].some((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 index a4f456f1..734b9974 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/hop.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/hop.ts @@ -29,7 +29,7 @@ const base: Omit = { width: 5, height: 3, lanes: [], - hazards: [], + hazards: new Set(), lives: 3, score: 0, status: "playing", diff --git a/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts b/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts index 8064830c..c7ac8183 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts @@ -24,7 +24,7 @@ const base: Omit = { width: 5, height: 3, lanes: [], - hazards: [], + hazards: new Set(), score: 0, }; 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 index 19a60629..e86d8c92 100644 --- 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 @@ -24,7 +24,7 @@ export const cases: Conformance = [ { row: 1, kind: "river" }, { row: 2, kind: "goal" }, ], - hazards: [{ kind: "log", lane: 1, x: 0, width: 2, velocity: 1 }], + hazards: new Set([{ kind: "log", lane: 1, x: 0, width: 2, velocity: 1 }]), frog: { x: 1, y: 2 }, lives: 0, score: 7, diff --git a/packages/data-gpu-hopper/src/features/main/data/state/samples.ts b/packages/data-gpu-hopper/src/features/main/data/state/samples.ts index 966ba282..d9ae8888 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/samples.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/samples.ts @@ -16,10 +16,10 @@ export const samples: readonly State[] = [ { row: 1, kind: "river" }, { row: 2, kind: "goal" }, ], - hazards: [ + hazards: new Set([ { kind: "log", lane: 1, x: 1.5, width: 3, velocity: 1 }, { kind: "log", lane: 1, x: 4, width: 2, velocity: 1 }, - ], + ]), frog: { x: 2.5, y: 1 }, lives: 1, score: 4, @@ -32,7 +32,7 @@ export const samples: readonly State[] = [ { row: 0, kind: "grass" }, { row: 1, kind: "goal" }, ], - hazards: [], + hazards: new Set(), frog: { x: 1, y: 0 }, lives: 3, score: 0, 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 index 8a87fbc0..ba79b66c 100644 --- 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 @@ -6,10 +6,8 @@ import { transitions } from "./transitions.js"; // The single pure-spec test for every transform in this folder. `runSpec` // auto-discovers each module in `transitions` that exports `cases`, requires it to // export exactly its function plus `cases`, and dispatches on case shape. The -// hazard bag the ecs materialises in nondeterministic row order compares as a -// multiset via `match.unordered`. +// hazard bag is a `ReadonlySet`, so the comparator matches it order-independently. Conformance.runSpec({ state: State, transitions, - match: { unordered: new Set(["hazards"]) }, }); 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 index d00e0b9f..d2371593 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/state.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/state.ts @@ -11,7 +11,7 @@ export type State = { readonly width: number; readonly height: number; readonly lanes: readonly Lane[]; - readonly hazards: readonly Hazard[]; + readonly hazards: ReadonlySet; readonly frog: Frog; readonly lives: number; readonly score: number; 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 index 802eeb77..7e2ea10e 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/step.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/step.ts @@ -33,7 +33,7 @@ export const step = ( }; } - const hazards = state.hazards.map((hazard) => Hazard.advance(hazard, dt, state.width)); + const hazards = new Set([...state.hazards].map((hazard) => Hazard.advance(hazard, dt, state.width))); const lane = laneAt(state, state.frog.y); // Ride a log: on a carrying lane, the log the frog is standing on drags it @@ -41,7 +41,7 @@ export const step = ( // log the frog was actually on this frame. const carrier = lane && LaneKind.coveredOutcome[lane.kind] === "ride" - ? state.hazards.find( + ? [...state.hazards].find( (hazard) => hazard.lane === state.frog.y && Hazard.covers(hazard, state.frog.x), ) : undefined; @@ -89,58 +89,58 @@ const riverLanes: readonly Lane[] = [ export const cases: Conformance = [ { name: "scrolls hazards while the frog rests on grass", before: { width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], + hazards: new Set([{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]), frog: { x: 2, y: 0 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], + after: { hazards: new Set([{ 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, - hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], + hazards: new Set([{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]), frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], + after: { hazards: new Set([{ 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, - hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], + hazards: new Set([{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]), frog: { x: 1, y: 1 }, lives: 1, score: 0, status: "playing" }, args: 1, - after: { hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], + after: { hazards: new Set([{ 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, - hazards: [{ kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }], + hazards: new Set([{ kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }]), frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { hazards: [{ kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }], + after: { hazards: new Set([{ 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, - hazards: [{ kind: "log", lane: 1, x: 0, width: 3, velocity: 1 }], + hazards: new Set([{ kind: "log", lane: 1, x: 0, width: 3, velocity: 1 }]), frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { hazards: [{ kind: "log", lane: 1, x: 1, width: 3, velocity: 1 }], + after: { hazards: new Set([{ 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, - hazards: [{ kind: "log", lane: 1, x: 3, width: 2, velocity: 2 }], + hazards: new Set([{ kind: "log", lane: 1, x: 3, width: 2, velocity: 2 }]), frog: { x: 4, y: 1 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { hazards: [{ kind: "log", lane: 1, x: 0, width: 2, velocity: 2 }], + after: { hazards: new Set([{ 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, - hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], + hazards: new Set([{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]), frog: { x: 2, y: 2 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], + after: { hazards: new Set([{ 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, - hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], + hazards: new Set([{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]), frog: { x: 2, y: 0 }, lives: 0, score: 0, status: "gameOver" }, args: 1, - after: { hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], + after: { hazards: new Set([{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]), frog: { x: 2, y: 0 }, lives: 0, score: 0, status: "gameOver" } }, ]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/win-goal.ts b/packages/data-gpu-hopper/src/features/main/data/state/win-goal.ts index 4addcc6c..a867b6ec 100644 --- 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 @@ -17,7 +17,7 @@ const base: Omit = { width: 5, height: 3, lanes: [], - hazards: [], + hazards: new Set(), lives: 3, frog: { x: 2, y: 2 }, }; 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 index 2e2836c1..b75ba98d 100644 --- 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 @@ -9,14 +9,14 @@ import { projection } from "./projection.js"; // 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 -// hazard bag the ecs materialises in nondeterministic row order compares as a -// multiset via `match.unordered`. `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`. +// hazard bag is a `ReadonlySet`, so the comparator matches it order-independently +// (the ecs materialises it in nondeterministic row order). `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.runFeature({ state: State, transitions, plugin: MainService.plugin, projection, - match: { unordered: new Set(["hazards"]) }, }); 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 index 7a5dbed2..e2249a92 100644 --- 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 @@ -15,11 +15,11 @@ const readFrog = (store: CoreDatabase.Store): Frog => { throw new Error("frog entity missing from store"); }; -const readHazards = (store: CoreDatabase.Store): Hazard[] => { - const hazards: Hazard[] = []; +const readHazards = (store: CoreDatabase.Store): Set => { + const hazards = new Set(); for (const arch of store.queryArchetypes(store.archetypes.Hazard.components)) { for (let row = 0; row < arch.rowCount; row++) { - hazards.push({ + hazards.add({ kind: arch.columns.kind.get(row), lane: arch.columns.lane.get(row), x: arch.columns.x.get(row), diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/outcome-selection.test.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/outcome-selection.test.ts index 67b2e999..705bf0f1 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/services/main-service/system-database/outcome-selection.test.ts @@ -29,7 +29,7 @@ const base = (overrides: Partial): State => ({ width: 5, height: 3, lanes: roadLanes, - hazards: [], + hazards: new Set(), frog: { x: 2, y: 0 }, lives: 3, score: 0, @@ -51,7 +51,7 @@ describe("outcome selection — road", () => { const after = detect( base({ frog: { x: 2, y: 1 }, - hazards: [{ kind: "car", lane: 1, x: 2, width: 1, velocity: 0 }], + hazards: new Set([{ kind: "car", lane: 1, x: 2, width: 1, velocity: 0 }]), }), ); expect(after.lives).toBe(2); @@ -63,7 +63,7 @@ describe("outcome selection — road", () => { const after = detect( base({ frog: { x: 2, y: 1 }, - hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 0 }], + hazards: new Set([{ kind: "car", lane: 1, x: 0, width: 1, velocity: 0 }]), }), ); expect(after.lives).toBe(3); @@ -74,7 +74,7 @@ describe("outcome selection — road", () => { const after = detect( base({ frog: { x: 2, y: 1 }, - hazards: [{ kind: "car", lane: 1, x: 2, width: 1, velocity: 0 }], + hazards: new Set([{ kind: "car", lane: 1, x: 2, width: 1, velocity: 0 }]), lives: 1, }), ); @@ -90,7 +90,7 @@ describe("outcome selection — river", () => { base({ lanes: riverLanes, frog: { x: 2, y: 1 }, - hazards: [{ kind: "log", lane: 1, x: 0, width: 3, velocity: 0 }], + hazards: new Set([{ kind: "log", lane: 1, x: 0, width: 3, velocity: 0 }]), }), ); expect(after.lives).toBe(3); @@ -98,7 +98,7 @@ describe("outcome selection — river", () => { }); it("open water with no log drowns the frog", () => { - const after = detect(base({ lanes: riverLanes, frog: { x: 2, y: 1 }, hazards: [] })); + const after = detect(base({ lanes: riverLanes, frog: { x: 2, y: 1 }, hazards: new Set() })); expect(after.lives).toBe(2); expect(after.frog).toEqual({ x: 2, y: 0 }); }); @@ -108,7 +108,7 @@ describe("outcome selection — river", () => { base({ lanes: riverLanes, frog: { x: 3, y: 1 }, // log covers [0, 3); x = 3 is NOT covered - hazards: [{ kind: "log", lane: 1, x: 0, width: 3, velocity: 0 }], + hazards: new Set([{ kind: "log", lane: 1, x: 0, width: 3, velocity: 0 }]), }), ); expect(after.lives).toBe(2); 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 index 72ee06fc..95021bf1 100644 --- 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 @@ -134,10 +134,10 @@ const systemDatabasePlugin = Database.Plugin.create({ } if (!hasFrog) return; - const hazards: Hazard[] = []; + const hazards = new Set(); for (const arch of db.store.queryArchetypes(["kind", "lane", "x", "width", "velocity"])) { for (let i = 0; i < arch.rowCount; i++) { - hazards.push({ + hazards.add({ kind: arch.columns.kind.get(i), lane: arch.columns.lane.get(i), x: arch.columns.x.get(i), 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 index a2a6936c..a02b9a91 100644 --- 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 @@ -14,7 +14,8 @@ // 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 hazard bag compares as a multiset. +// keeping the shared case honest. The hazard bag is a `ReadonlySet`, so the +// comparator matches it order-independently. import { describe, it } from "vitest"; import { Match } from "@adobe/data-testing"; import { State } from "../../../data/state/state.js"; @@ -23,8 +24,6 @@ import { createSystemDatabase } from "../conformance/create-system-database.js"; import { projection } from "../conformance/projection.js"; import { driveFrame } from "../conformance/drive-frame.js"; -const unordered = { unordered: new Set(["hazards"]) }; - describe("ECS system tick loop conforms to State.step (one frame = one step)", () => { for (const testCase of cases) { it(testCase.name, () => { @@ -34,13 +33,13 @@ describe("ECS system tick loop conforms to State.step (one frame = one step)", ( // seed and the full expected state the same way the runners do. const before = { ...State.create(), ...testCase.before }; const expected = { ...before, ...testCase.after }; - Match.assert({ ...before, ...State.step(before, dt) }, expected, unordered); + Match.assert({ ...before, ...State.step(before, dt) }, expected); const db = createSystemDatabase(); projection.fromState(db.store, before); db.store.resources.frameDelta = dt; driveFrame(db); - Match.assert(projection.toState(db.store), expected, unordered); + Match.assert(projection.toState(db.store), expected); }); } }); diff --git a/packages/data-gpu-samples/package.json b/packages/data-gpu-samples/package.json index f07d71ba..6d9bb3aa 100644 --- a/packages/data-gpu-samples/package.json +++ b/packages/data-gpu-samples/package.json @@ -1,6 +1,6 @@ { "name": "data-gpu-samples", - "version": "0.9.96", + "version": "0.9.97", "description": "WebGPU samples built on @adobe/data-gpu", "type": "module", "private": true, diff --git a/packages/data-gpu-samples/src/samples/rigid-stack/rigid-stack-debug-render.ts b/packages/data-gpu-samples/src/samples/rigid-stack/rigid-stack-debug-render.ts index 1a32cbf9..5f8a9707 100644 --- a/packages/data-gpu-samples/src/samples/rigid-stack/rigid-stack-debug-render.ts +++ b/packages/data-gpu-samples/src/samples/rigid-stack/rigid-stack-debug-render.ts @@ -79,7 +79,7 @@ const RENDER_COMPONENTS = ["position", "rotation", "halfExtents", "colliderShape export const rigidStackDebugRender = Database.Plugin.create({ extends: Database.Plugin.combine(graphics, physicsData, SceneUniforms.plugin), resources: { - rigidGpu: { default: null as RigidGpu | null, nonPersistent: true }, + rigidGpu: { default: null as RigidGpu | null, nonPersistent: true, mutable: true }, }, systems: { rigidRenderInit: { diff --git a/packages/data-gpu/package.json b/packages/data-gpu/package.json index 09eb80ff..415af48e 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.9.96", + "version": "0.9.97", "description": "Adobe data WebGPU plugins and types for graphics and compute", "type": "module", "private": false, diff --git a/packages/data-gpu/src/graphics/scene/model/mesh-plugin.ts b/packages/data-gpu/src/graphics/scene/model/mesh-plugin.ts index 8f53cc9d..d6641380 100644 --- a/packages/data-gpu/src/graphics/scene/model/mesh-plugin.ts +++ b/packages/data-gpu/src/graphics/scene/model/mesh-plugin.ts @@ -41,7 +41,7 @@ export const mesh = Database.Plugin.create({ cpuSkin: { default: null as { positions: Float32Array; joints: Uint32Array; weights: Float32Array } | null }, }, resources: { - _gltfMeshByUrl: { default: null as Map | null, nonPersistent: true }, + _gltfMeshByUrl: { default: null as Map | null, nonPersistent: true, mutable: true }, }, archetypes: { GltfMeshPending: ["gltfUrl"], diff --git a/packages/data-lit-space-rock-game/package.json b/packages/data-lit-space-rock-game/package.json index 89183f6e..4f8e6776 100644 --- a/packages/data-lit-space-rock-game/package.json +++ b/packages/data-lit-space-rock-game/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-space-rock-game", - "version": "0.9.96", + "version": "0.9.97", "description": "Space Rock Game sample - real-time ECS game with Lit and @adobe/data", "type": "module", "private": true, 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 index d91e46d7..b472418a 100644 --- 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 @@ -19,8 +19,8 @@ export const createInitial = ( const fresh: State = { bounds, ship: Ship.spawn(Vec2.scale(bounds, 0.5)), - bullets: [], - asteroids: [], + bullets: new Set(), + asteroids: new Set(), score: 0, lives: 3, wave: 0, @@ -38,8 +38,8 @@ export const createInitial = ( const dirty: State = { bounds: [1, 1], ship: { position: [10, 10], velocity: [5, 5], rotation: 1 }, - bullets: [{ position: [1, 1], velocity: [0, 0], age: 0.5 }], - asteroids: [{ position: [9, 9], velocity: [0, 0], size: "small" }], + bullets: new Set([{ position: [1, 1], velocity: [0, 0], age: 0.5 }]), + asteroids: new Set([{ position: [9, 9], velocity: [0, 0], size: "small" }]), score: 99, lives: 1, wave: 7, @@ -53,13 +53,13 @@ export const cases: Conformance = [ after: { bounds: [200, 200], ship: { position: [100, 100], velocity: [0, 0], rotation: -Math.PI / 2 }, - bullets: [], - asteroids: [ + bullets: new Set(), + asteroids: new Set([ { position: [180, 100], velocity: [0, 60], size: "large" }, { position: [100, 180], velocity: [-60, 0], size: "large" }, { position: [20, 100], velocity: [0, -60], size: "large" }, { position: [100, 20], velocity: [60, 0], size: "large" }, - ], + ]), score: 0, lives: 3, wave: 1, @@ -72,13 +72,13 @@ export const cases: Conformance = [ after: { bounds: [400, 400], ship: { position: [200, 200], velocity: [0, 0], rotation: -Math.PI / 2 }, - bullets: [], - asteroids: [ + bullets: new Set(), + asteroids: new Set([ { position: [360, 200], velocity: [0, 60], size: "large" }, { position: [200, 360], velocity: [-60, 0], size: "large" }, { position: [40, 200], velocity: [0, -60], size: "large" }, { position: [200, 40], velocity: [60, 0], size: "large" }, - ], + ]), score: 0, lives: 3, wave: 1, diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/create.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/create.test.ts index 07673897..2d1dacc5 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/data/state/create.test.ts @@ -7,8 +7,8 @@ describe("State.create", () => { const state = State.create(); expect(state.bounds).toEqual([0, 0]); expect(state.ship.velocity).toEqual([0, 0]); - expect(state.bullets).toEqual([]); - expect(state.asteroids).toEqual([]); + expect(state.bullets).toEqual(new Set()); + expect(state.asteroids).toEqual(new Set()); expect(state.score).toBe(0); expect(state.lives).toBe(3); expect(state.wave).toBe(0); 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 index 8b589eb1..acf610ea 100644 --- 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 @@ -10,8 +10,8 @@ import { Ship } from "../ship/ship.js"; export const create = (): State => ({ bounds: [0, 0], ship: Ship.spawn([0, 0]), - bullets: [], - asteroids: [], + bullets: new Set(), + asteroids: new Set(), 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 index 67ee98d0..19e6533c 100644 --- 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 @@ -12,7 +12,7 @@ export const fireBullet = ( ): Pick => { const { position, velocity } = Ship.muzzle(state.ship, Bullet.speed); const bullet: Bullet = { position, velocity, age: 0 }; - return { bullets: [...state.bullets, bullet] }; + return { bullets: new Set(state.bullets).add(bullet) }; }; // Spec-owned cases, shared with the ecs `fireBullet` transaction. A bullet leaves @@ -27,13 +27,13 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: [], + bullets: new Set(), }, args: undefined, after: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: [{ position: [112, 100], velocity: [400, 0], age: 0 }], + bullets: new Set([{ position: [112, 100], velocity: [400, 0], age: 0 }]), }, }, { @@ -41,13 +41,13 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [100, 100], velocity: [10, 20], rotation: 0 }, - bullets: [], + bullets: new Set(), }, args: undefined, after: { ...field, ship: { position: [100, 100], velocity: [10, 20], rotation: 0 }, - bullets: [{ position: [112, 100], velocity: [410, 20], age: 0 }], + bullets: new Set([{ position: [112, 100], velocity: [410, 20], age: 0 }]), }, }, { @@ -55,16 +55,16 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: [{ position: [0, 0], velocity: [1, 0], age: 0.2 }], + bullets: new Set([{ position: [0, 0], velocity: [1, 0], age: 0.2 }]), }, args: undefined, after: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: [ + bullets: new Set([ { position: [0, 0], velocity: [1, 0], age: 0.2 }, { position: [112, 100], velocity: [400, 0], age: 0 }, - ], + ]), }, }, { @@ -72,13 +72,13 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: -Math.PI / 2 }, - bullets: [], + bullets: new Set(), }, args: undefined, after: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: -Math.PI / 2 }, - bullets: [{ position: [100, 88], velocity: [0, -400], age: 0 }], + bullets: new Set([{ position: [100, 88], velocity: [0, -400], age: 0 }]), }, }, ]; 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/data/state/resolve-bullet-hits.ts index 74c5e794..31310421 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/data/state/resolve-bullet-hits.ts @@ -50,8 +50,8 @@ export const resolveBulletHits = ( spawned.push(...Asteroid.split(asteroid)); } return { - bullets: survivors, - asteroids: [...asteroids, ...spawned], + bullets: new Set(survivors), + asteroids: new Set([...asteroids, ...spawned]), score, }; }; @@ -72,18 +72,20 @@ export const cases: Conformance = [ name: "destroys bullet + asteroid, scores, and spawns split children (large → 2 medium)", before: { ...field, - bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [50, 50], velocity: [0, 0], size: "large" }], + bullets: new Set([{ position: [50, 50], velocity: [0, 0], age: 0 }]), + asteroids: new Set([ + { position: [50, 50], velocity: [0, 0], size: "large" }, + ]), score: 0, }, args: 1 / 60, after: { ...field, - bullets: [], - asteroids: [ + bullets: new Set(), + asteroids: new Set([ { position: [50, 50], velocity: [0, 0], size: "medium" }, { position: [50, 50], velocity: [0, 0], size: "medium" }, - ], + ]), score: 20, }, }, @@ -91,18 +93,20 @@ export const cases: Conformance = [ name: "medium splits into two small", before: { ...field, - bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [50, 50], velocity: [0, 0], size: "medium" }], + bullets: new Set([{ position: [50, 50], velocity: [0, 0], age: 0 }]), + asteroids: new Set([ + { position: [50, 50], velocity: [0, 0], size: "medium" }, + ]), score: 5, }, args: 1 / 60, after: { ...field, - bullets: [], - asteroids: [ + bullets: new Set(), + asteroids: new Set([ { position: [50, 50], velocity: [0, 0], size: "small" }, { position: [50, 50], velocity: [0, 0], size: "small" }, - ], + ]), score: 55, }, }, @@ -110,26 +114,32 @@ export const cases: Conformance = [ name: "the smallest tier is destroyed outright — no children", before: { ...field, - bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [50, 50], velocity: [0, 0], size: "small" }], + bullets: new Set([{ position: [50, 50], velocity: [0, 0], age: 0 }]), + asteroids: new Set([ + { position: [50, 50], velocity: [0, 0], size: "small" }, + ]), score: 0, }, args: 1 / 60, - after: { ...field, bullets: [], asteroids: [], score: 100 }, + after: { ...field, bullets: new Set(), asteroids: new Set(), score: 100 }, }, { name: "a bullet that hits nothing is left untouched", before: { ...field, - bullets: [{ position: [10, 10], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [500, 500], velocity: [0, 0], size: "large" }], + bullets: new Set([{ position: [10, 10], velocity: [0, 0], age: 0 }]), + asteroids: new Set([ + { position: [500, 500], velocity: [0, 0], size: "large" }, + ]), score: 7, }, args: 1 / 60, after: { ...field, - bullets: [{ position: [10, 10], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [500, 500], velocity: [0, 0], size: "large" }], + bullets: new Set([{ position: [10, 10], velocity: [0, 0], age: 0 }]), + asteroids: new Set([ + { position: [500, 500], velocity: [0, 0], size: "large" }, + ]), score: 7, }, }, @@ -137,22 +147,22 @@ export const cases: Conformance = [ name: "only the overlapping asteroid is hit; distant ones remain", before: { ...field, - bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], - asteroids: [ + bullets: new Set([{ position: [50, 50], velocity: [0, 0], age: 0 }]), + asteroids: new Set([ { position: [50, 50], velocity: [0, 0], size: "large" }, { position: [500, 500], velocity: [0, 0], size: "small" }, - ], + ]), score: 0, }, args: 1 / 60, after: { ...field, - bullets: [], - asteroids: [ + bullets: new Set(), + asteroids: new Set([ { position: [50, 50], velocity: [0, 0], size: "medium" }, { position: [50, 50], velocity: [0, 0], size: "medium" }, { position: [500, 500], velocity: [0, 0], size: "small" }, - ], + ]), score: 20, }, }, @@ -160,28 +170,30 @@ export const cases: Conformance = [ name: "two bullets each destroy their own asteroid", before: { ...field, - bullets: [ + bullets: new Set([ { position: [50, 50], velocity: [0, 0], age: 0 }, { position: [500, 500], velocity: [0, 0], age: 0 }, - ], - asteroids: [ + ]), + asteroids: new Set([ { position: [50, 50], velocity: [0, 0], size: "small" }, { position: [500, 500], velocity: [0, 0], size: "small" }, - ], + ]), score: 0, }, args: 1 / 60, - after: { ...field, bullets: [], asteroids: [], score: 200 }, + after: { ...field, bullets: new Set(), asteroids: new Set(), score: 200 }, }, { name: "split children are not hittable by another bullet in the same pass", before: { ...field, - bullets: [ + bullets: new Set([ { position: [50, 50], velocity: [0, 0], age: 0 }, { position: [50, 50], velocity: [0, 0], age: 0 }, - ], - asteroids: [{ position: [50, 50], velocity: [0, 0], size: "large" }], + ]), + asteroids: new Set([ + { position: [50, 50], velocity: [0, 0], size: "large" }, + ]), score: 0, }, args: 1 / 60, @@ -190,11 +202,11 @@ export const cases: Conformance = [ // 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. - bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], - asteroids: [ + bullets: new Set([{ position: [50, 50], velocity: [0, 0], age: 0 }]), + asteroids: new Set([ { position: [50, 50], velocity: [0, 0], size: "medium" }, { position: [50, 50], velocity: [0, 0], size: "medium" }, - ], + ]), score: 20, }, }, @@ -202,18 +214,20 @@ export const cases: Conformance = [ name: "boundary: distance exactly equal to the radius sum still overlaps", before: { ...field, - bullets: [{ position: [0, 0], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [42, 0], velocity: [0, 0], size: "large" }], + bullets: new Set([{ position: [0, 0], velocity: [0, 0], age: 0 }]), + asteroids: new Set([ + { position: [42, 0], velocity: [0, 0], size: "large" }, + ]), score: 0, }, args: 1 / 60, after: { ...field, - bullets: [], - asteroids: [ + bullets: new Set(), + asteroids: new Set([ { position: [42, 0], velocity: [0, 0], size: "medium" }, { position: [42, 0], velocity: [0, 0], size: "medium" }, - ], + ]), score: 20, }, }, @@ -225,18 +239,20 @@ export const cases: Conformance = [ // 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. - bullets: [{ position: [0, 0], velocity: [-3000, 0], age: 0 }], - asteroids: [{ position: [25, 0], velocity: [0, 0], size: "medium" }], + bullets: new Set([{ position: [0, 0], velocity: [-3000, 0], age: 0 }]), + asteroids: new Set([ + { position: [25, 0], velocity: [0, 0], size: "medium" }, + ]), score: 0, }, args: 1 / 60, after: { ...field, - bullets: [], - asteroids: [ + bullets: new Set(), + asteroids: new Set([ { position: [25, 0], velocity: [0, 0], size: "small" }, { position: [25, 0], velocity: [0, 0], size: "small" }, - ], + ]), score: 50, }, }, diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts index 4d97185b..dbb665f1 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/data/state/resolve-ship-hits.ts @@ -12,7 +12,7 @@ import { Collision } from "../collision/collision.js"; export const resolveShipHits = ( state: Pick, ): Pick => { - const struck = state.asteroids.some((a) => + const struck = [...state.asteroids].some((a) => Collision.circlesOverlap( state.ship.position, Ship.radius, @@ -44,14 +44,18 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [10, 10], velocity: [5, 5], rotation: 1 }, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + asteroids: new Set([ + { position: [10, 10], velocity: [0, 0], size: "large" }, + ]), lives: 3, }, args: undefined, after: { ...field, ship: respawned, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + asteroids: new Set([ + { position: [10, 10], velocity: [0, 0], size: "large" }, + ]), lives: 2, }, }, @@ -60,14 +64,18 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: [{ position: [500, 500], velocity: [0, 0], size: "large" }], + asteroids: new Set([ + { position: [500, 500], velocity: [0, 0], size: "large" }, + ]), lives: 3, }, args: undefined, after: { ...field, ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: [{ position: [500, 500], velocity: [0, 0], size: "large" }], + asteroids: new Set([ + { position: [500, 500], velocity: [0, 0], size: "large" }, + ]), lives: 3, }, }, @@ -76,14 +84,18 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + asteroids: new Set([ + { position: [10, 10], velocity: [0, 0], size: "large" }, + ]), lives: 0, }, args: undefined, after: { ...field, ship: respawned, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + asteroids: new Set([ + { position: [10, 10], velocity: [0, 0], size: "large" }, + ]), lives: 0, }, }, @@ -92,14 +104,18 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [0, 0], velocity: [0, 0], rotation: 0 }, - asteroids: [{ position: [52, 0], velocity: [0, 0], size: "large" }], + asteroids: new Set([ + { position: [52, 0], velocity: [0, 0], size: "large" }, + ]), lives: 3, }, args: undefined, after: { ...field, ship: respawned, - asteroids: [{ position: [52, 0], velocity: [0, 0], size: "large" }], + asteroids: new Set([ + { position: [52, 0], velocity: [0, 0], size: "large" }, + ]), lives: 2, }, }, @@ -108,14 +124,14 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: [], + asteroids: new Set(), lives: 3, }, args: undefined, after: { ...field, ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: [], + asteroids: new Set(), lives: 3, }, }, 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/data/state/samples.ts index 203bd44c..e469446f 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/samples.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/samples.ts @@ -8,15 +8,15 @@ export const samples: readonly State[] = [ { bounds: [800, 600], ship: { position: [400, 300], velocity: [12, -7], rotation: 1.25 }, - bullets: [ + bullets: new Set([ { position: [100, 100], velocity: [400, 0], age: 0.1 }, { position: [220, 340], velocity: [-100, 200], age: 0.9 }, - ], - asteroids: [ + ]), + asteroids: new Set([ { position: [50, 60], velocity: [10, 20], size: "large" }, { position: [700, 80], velocity: [-30, 5], size: "medium" }, { position: [640, 540], velocity: [0, -15], size: "small" }, - ], + ]), score: 240, lives: 2, wave: 5, @@ -24,8 +24,8 @@ export const samples: readonly State[] = [ { bounds: [320, 240], ship: { position: [160, 120], velocity: [0, 0], rotation: -Math.PI / 2 }, - bullets: [], - asteroids: [], + bullets: new Set(), + asteroids: new Set(), score: 0, lives: 3, wave: 0, @@ -33,12 +33,12 @@ export const samples: readonly State[] = [ { bounds: [500, 500], ship: { position: [250, 250], velocity: [0, 0], rotation: 0 }, - bullets: [], - asteroids: [ + bullets: new Set(), + asteroids: new Set([ { position: [250, 250], velocity: [0, 0], size: "medium" }, { position: [250, 250], velocity: [0, 0], size: "medium" }, { position: [250, 250], velocity: [0, 0], size: "medium" }, - ], + ]), score: 90, lives: 1, wave: 3, 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 index b2ec52dd..959b214b 100644 --- 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 @@ -7,6 +7,7 @@ import type { Asteroid } from "../asteroid/asteroid.js"; import { Size } from "../size/size.js"; import { Motion } from "../motion/motion.js"; import { RandomService } from "../../services/random-service/random-service.js"; +import type { Services } from "../../services/services.js"; // Base drift speed; each rock's actual speed is jittered around it. const waveSpeed = 60; @@ -28,9 +29,9 @@ const asteroidsFor = (wave: number): number => 3 + wave; */ export const spawnRandomWave = ( state: Pick, - { random }: { random: RandomService }, + { random }: Pick, ): Pick => { - if (state.asteroids.length > 0) { + if (state.asteroids.size > 0) { return { asteroids: state.asteroids, wave: state.wave }; } const wave = state.wave + 1; @@ -49,7 +50,7 @@ export const spawnRandomWave = ( size: Size.largest, }); } - return { wave, asteroids }; + return { wave, asteroids: new Set(asteroids) }; }; // Spec-owned cases, shared with the ecs `spawnRandomWave` transaction. Each case @@ -69,17 +70,17 @@ const randoms = [0, 0.5, 0.25, 0.75]; export const cases: Conformance = [ { name: "spawns a randomized wave (jittered drift speeds) when the field is clear", - before: { ...field, asteroids: [], wave: 0 }, + before: { ...field, asteroids: new Set(), wave: 0 }, args: { random: RandomService.createFake(randoms) }, after: { ...field, wave: 1, - asteroids: [ + asteroids: new Set([ { 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" }, - ], + ]), }, }, { @@ -87,13 +88,17 @@ export const cases: Conformance = [ before: { ...field, wave: 1, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + asteroids: new Set([ + { position: [10, 10], velocity: [0, 0], size: "large" }, + ]), }, args: { random: RandomService.createFake(randoms) }, after: { ...field, wave: 1, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + asteroids: new Set([ + { position: [10, 10], velocity: [0, 0], size: "large" }, + ]), }, }, ]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts index 4ed2a018..ef5ef935 100644 --- 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 @@ -21,7 +21,7 @@ const asteroidsFor = (wave: number): number => 3 + wave; export const spawnWave = ( state: Pick, ): Pick => { - if (state.asteroids.length > 0) { + if (state.asteroids.size > 0) { return { asteroids: state.asteroids, wave: state.wave }; } const wave = state.wave + 1; @@ -39,7 +39,7 @@ export const spawnWave = ( size: Size.largest, }); } - return { wave, asteroids }; + return { wave, asteroids: new Set(asteroids) }; }; // Spec-owned cases for the deterministic `spawnWave` (no args) — the fixed FIRST @@ -53,17 +53,17 @@ const field = { ...create(), bounds: [200, 200] as [number, number] }; export const cases: Conformance = [ { name: "spawns the next wave of large asteroids when the field is clear", - before: { ...field, asteroids: [], wave: 0 }, + before: { ...field, asteroids: new Set(), wave: 0 }, args: undefined, after: { ...field, wave: 1, - asteroids: [ + asteroids: new Set([ { position: [180, 100], velocity: [0, 60], size: "large" }, { position: [100, 180], velocity: [-60, 0], size: "large" }, { position: [20, 100], velocity: [0, -60], size: "large" }, { position: [100, 20], velocity: [60, 0], size: "large" }, - ], + ]), }, }, { @@ -71,13 +71,17 @@ export const cases: Conformance = [ before: { ...field, wave: 1, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + asteroids: new Set([ + { position: [10, 10], velocity: [0, 0], size: "large" }, + ]), }, args: undefined, after: { ...field, wave: 1, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + asteroids: new Set([ + { position: [10, 10], velocity: [0, 0], size: "large" }, + ]), }, }, ]; 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 index b5247e1b..08e1493f 100644 --- 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 @@ -10,5 +10,4 @@ import { transitions } from "./transitions.js"; Conformance.runSpec({ state: State, transitions, - match: { unordered: new Set(["bullets", "asteroids"]) }, }); 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 index b499de14..0735eae2 100644 --- 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 @@ -10,8 +10,8 @@ import type { Asteroid } from "../asteroid/asteroid.js"; export type State = { readonly bounds: Vec2; // play-field size [width, height]; entities wrap within it readonly ship: Ship; - readonly bullets: readonly Bullet[]; - readonly asteroids: readonly Asteroid[]; + readonly bullets: ReadonlySet; + readonly asteroids: ReadonlySet; readonly score: number; readonly lives: number; readonly wave: 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/data/state/step-asteroids.ts index a0ea1e60..b0f26739 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/data/state/step-asteroids.ts @@ -10,13 +10,15 @@ export const stepAsteroids = ( state: Pick, dt: number, ): Pick => { - const asteroids = state.asteroids.map((a) => ({ - ...a, - position: Motion.wrap( - Motion.advance(a.position, a.velocity, dt), - state.bounds, - ), - })); + const asteroids = new Set( + [...state.asteroids].map((a) => ({ + ...a, + position: Motion.wrap( + Motion.advance(a.position, a.velocity, dt), + state.bounds, + ), + })), + ); return { asteroids }; }; @@ -30,68 +32,72 @@ export const cases: Conformance = [ name: "drifts an asteroid by its velocity", before: { ...field, - asteroids: [ + asteroids: new Set([ { position: [10, 10], velocity: [30, 0], size: Size.largest }, - ], + ]), }, args: 1, after: { ...field, - asteroids: [ + asteroids: new Set([ { position: [40, 10], velocity: [30, 0], size: Size.largest }, - ], + ]), }, }, { name: "wraps an asteroid around the toroidal field", before: { ...field, - asteroids: [ + asteroids: new Set([ { position: [80, 80], velocity: [50, 50], size: Size.largest }, - ], + ]), }, args: 1, after: { ...field, - asteroids: [ + asteroids: new Set([ { position: [30, 30], velocity: [50, 50], size: Size.largest }, - ], + ]), }, }, { name: "wraps negatively across the left edge", before: { ...field, - asteroids: [{ position: [10, 10], velocity: [-50, 0], size: "medium" }], + asteroids: new Set([ + { position: [10, 10], velocity: [-50, 0], size: "medium" }, + ]), }, args: 1, after: { ...field, - asteroids: [{ position: [60, 10], velocity: [-50, 0], size: "medium" }], + asteroids: new Set([ + { position: [60, 10], velocity: [-50, 0], size: "medium" }, + ]), }, }, { name: "advances several asteroids of different sizes independently", before: { ...field, - asteroids: [ + asteroids: new Set([ { position: [10, 10], velocity: [10, 0], size: "large" }, { position: [20, 20], velocity: [0, 10], size: "small" }, - ], + ]), }, args: 1, after: { ...field, - asteroids: [ + asteroids: new Set([ { position: [20, 10], velocity: [10, 0], size: "large" }, { position: [20, 30], velocity: [0, 10], size: "small" }, - ], + ]), }, }, { name: "an empty field stays empty", - before: { ...field, asteroids: [] }, + before: { ...field, asteroids: new Set() }, args: 1, - after: { ...field, asteroids: [] }, + after: { ...field, asteroids: new Set() }, }, ]; 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/data/state/step-bullets.ts index 6f33fd82..5f8cc58a 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/data/state/step-bullets.ts @@ -11,16 +11,18 @@ export const stepBullets = ( state: Pick, dt: number, ): Pick => { - const bullets = state.bullets - .filter((b) => !Bullet.isExpired(b.age, dt)) - .map((b) => ({ - ...b, - position: Motion.wrap( - Motion.advance(b.position, b.velocity, dt), - state.bounds, - ), - age: b.age + dt, - })); + const bullets = new Set( + [...state.bullets] + .filter((b) => !Bullet.isExpired(b.age, dt)) + .map((b) => ({ + ...b, + position: Motion.wrap( + Motion.advance(b.position, b.velocity, dt), + state.bounds, + ), + age: b.age + dt, + })), + ); return { bullets }; }; @@ -35,68 +37,68 @@ export const cases: Conformance = [ name: "moves and ages a live bullet", before: { ...field, - bullets: [{ position: [10, 50], velocity: [100, 0], age: 0 }], + bullets: new Set([{ position: [10, 50], velocity: [100, 0], age: 0 }]), }, args: 0.1, after: { ...field, - bullets: [{ position: [20, 50], velocity: [100, 0], age: 0.1 }], + bullets: new Set([{ position: [20, 50], velocity: [100, 0], age: 0.1 }]), }, }, { name: "wraps a bullet across the right edge", before: { ...field, - bullets: [{ position: [95, 50], velocity: [100, 0], age: 0 }], + bullets: new Set([{ position: [95, 50], velocity: [100, 0], age: 0 }]), }, args: 0.1, after: { ...field, - bullets: [{ position: [5, 50], velocity: [100, 0], age: 0.1 }], + bullets: new Set([{ position: [5, 50], velocity: [100, 0], age: 0.1 }]), }, }, { name: "drops a bullet that expires this tick (age + dt ≥ lifetime)", before: { ...field, - bullets: [ + bullets: new Set([ { position: [10, 50], velocity: [100, 0], age: Bullet.lifetime }, - ], + ]), }, args: 0.1, - after: { ...field, bullets: [] }, + after: { ...field, bullets: new Set() }, }, { name: "keeps and ages a bullet still under its lifetime", before: { ...field, - bullets: [{ position: [10, 50], velocity: [0, 0], age: 1.0 }], + bullets: new Set([{ position: [10, 50], velocity: [0, 0], age: 1.0 }]), }, args: 0.1, after: { ...field, - bullets: [{ position: [10, 50], velocity: [0, 0], age: 1.1 }], + bullets: new Set([{ position: [10, 50], velocity: [0, 0], age: 1.1 }]), }, }, { name: "advances survivors and drops only the expired bullet", before: { ...field, - bullets: [ + bullets: new Set([ { position: [10, 50], velocity: [100, 0], age: 0 }, { position: [10, 60], velocity: [100, 0], age: Bullet.lifetime }, - ], + ]), }, args: 0.1, after: { ...field, - bullets: [{ position: [20, 50], velocity: [100, 0], age: 0.1 }], + bullets: new Set([{ position: [20, 50], velocity: [100, 0], age: 0.1 }]), }, }, { name: "an empty list stays empty", - before: { ...field, bullets: [] }, + before: { ...field, bullets: new Set() }, args: 0.1, - after: { ...field, bullets: [] }, + after: { ...field, bullets: new Set() }, }, ]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts index 0a4cbc06..441bbe98 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/data/state/step.ts @@ -12,6 +12,7 @@ import { resolveShipHits } from "./resolve-ship-hits.js"; import { spawnRandomWave } from "./spawn-random-wave.js"; import { isGameOver } from "./is-game-over.js"; import { RandomService } from "../../services/random-service/random-service.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, @@ -36,8 +37,7 @@ export const step = ( }: { readonly dt: number; readonly input: Input; - readonly random: RandomService; - }, + } & Pick, ): State => { if (isGameOver(state)) { return state; @@ -69,8 +69,10 @@ export const cases: Conformance = [ before: { bounds: [200, 200], ship: { position: [190, 100], velocity: [30, 0], rotation: 0 }, - bullets: [], - asteroids: [{ position: [190, 180], velocity: [30, 30], size: "large" }], + bullets: new Set(), + asteroids: new Set([ + { position: [190, 180], velocity: [30, 30], size: "large" }, + ]), score: 0, lives: 3, wave: 1, @@ -79,8 +81,10 @@ export const cases: Conformance = [ after: { bounds: [200, 200], ship: { position: [20, 100], velocity: [30, 0], rotation: 0 }, - bullets: [], - asteroids: [{ position: [20, 10], velocity: [30, 30], size: "large" }], + bullets: new Set(), + asteroids: new Set([ + { position: [20, 10], velocity: [30, 30], size: "large" }, + ]), score: 0, lives: 3, wave: 1, @@ -91,8 +95,10 @@ export const cases: Conformance = [ before: { bounds: [400, 400], ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: [], - asteroids: [{ position: [350, 350], velocity: [0, 0], size: "large" }], + bullets: new Set(), + asteroids: new Set([ + { position: [350, 350], velocity: [0, 0], size: "large" }, + ]), score: 0, lives: 3, wave: 1, @@ -105,8 +111,10 @@ export const cases: Conformance = [ after: { bounds: [400, 400], ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: [{ position: [152, 100], velocity: [400, 0], age: 0.1 }], - asteroids: [{ position: [350, 350], velocity: [0, 0], size: "large" }], + bullets: new Set([{ position: [152, 100], velocity: [400, 0], age: 0.1 }]), + asteroids: new Set([ + { position: [350, 350], velocity: [0, 0], size: "large" }, + ]), score: 0, lives: 3, wave: 1, @@ -117,8 +125,10 @@ export const cases: Conformance = [ before: { bounds: [800, 600], ship: { position: [700, 500], velocity: [0, 0], rotation: 0 }, - bullets: [{ position: [100, 100], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], + bullets: new Set([{ position: [100, 100], velocity: [0, 0], age: 0 }]), + asteroids: new Set([ + { position: [100, 100], velocity: [0, 0], size: "large" }, + ]), score: 0, lives: 3, wave: 1, @@ -127,11 +137,11 @@ export const cases: Conformance = [ after: { bounds: [800, 600], ship: { position: [700, 500], velocity: [0, 0], rotation: 0 }, - bullets: [], - asteroids: [ + bullets: new Set(), + asteroids: new Set([ { position: [100, 100], velocity: [0, 0], size: "medium" }, { position: [100, 100], velocity: [0, 0], size: "medium" }, - ], + ]), score: 20, lives: 3, wave: 1, @@ -142,8 +152,10 @@ export const cases: Conformance = [ before: { bounds: [200, 200], ship: Ship.spawn([100, 100]), - bullets: [], - asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], + bullets: new Set(), + asteroids: new Set([ + { position: [100, 100], velocity: [0, 0], size: "large" }, + ]), score: 0, lives: 3, wave: 1, @@ -152,8 +164,10 @@ export const cases: Conformance = [ after: { bounds: [200, 200], ship: Ship.spawn([100, 100]), - bullets: [], - asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], + bullets: new Set(), + asteroids: new Set([ + { position: [100, 100], velocity: [0, 0], size: "large" }, + ]), score: 0, lives: 2, wave: 1, @@ -164,8 +178,10 @@ export const cases: Conformance = [ before: { bounds: [200, 200], ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, - bullets: [{ position: [60, 60], velocity: [0, 0], age: 0.5 }], - asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], + bullets: new Set([{ position: [60, 60], velocity: [0, 0], age: 0.5 }]), + asteroids: new Set([ + { position: [100, 100], velocity: [0, 0], size: "large" }, + ]), score: 40, lives: 0, wave: 2, @@ -178,8 +194,10 @@ export const cases: Conformance = [ after: { bounds: [200, 200], ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, - bullets: [{ position: [60, 60], velocity: [0, 0], age: 0.5 }], - asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], + bullets: new Set([{ position: [60, 60], velocity: [0, 0], age: 0.5 }]), + asteroids: new Set([ + { position: [100, 100], velocity: [0, 0], size: "large" }, + ]), score: 40, lives: 0, wave: 2, diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/conformance.test.ts index f9c195f5..9cd96e76 100644 --- 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 @@ -10,12 +10,12 @@ import { projection } from "./projection.js"; // delta) over `State.create()`, and round-trips `State.samples` through the // projection. `transitions` (the discovered `{ fn, cases }` modules) is shared with // spec.test. The entity bags the ecs materialises in nondeterministic row order -// (`bullets`, `asteroids`) compare as multisets via `match.unordered`. There is no -// `computedPlugin` — space-rock has no `state/` derivations. +// (`bullets`, `asteroids`) are typed `ReadonlySet`, so the comparator matches them +// order-independently. There is no `computedPlugin` — space-rock has no `state/` +// derivations. Conformance.runFeature({ state: State, transitions, plugin: MainService.plugin, projection, - match: { unordered: new Set(["bullets", "asteroids"]) }, }); 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 index 81a281d5..44d0f058 100644 --- 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 @@ -96,8 +96,8 @@ export const projection = { return { bounds: store.resources.bounds, ship, - bullets, - asteroids, + bullets: new Set(bullets), + asteroids: new Set(asteroids), score: store.resources.score, lives: store.resources.lives, wave: store.resources.wave, diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/service-database/service-database.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/service-database/service-database.ts index 85292398..ca899dd9 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/services/main-service/service-database/service-database.ts @@ -1,7 +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"; // 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 @@ -18,6 +20,10 @@ const serviceDatabasePlugin = Database.Plugin.create({ export type ServiceDatabase = Database.Plugin.ToDatabase; +// Drift-guard: the services the ECS resolves onto `db.services` must exactly match +// the injectable `Services` map the data transitions `Pick` from. +type _ServicesPin = Assert>; + export namespace ServiceDatabase { export const plugin = serviceDatabasePlugin; export type Store = Database.Plugin.ToStore; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/collision-detection.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/collision-detection.test.ts index b632ced2..5334512b 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/services/main-service/system-database/collision-detection.test.ts @@ -22,8 +22,8 @@ import { driveFrame } from "../conformance/drive-frame.js"; const base = (overrides: Partial): State => ({ bounds: [800, 600], ship: Ship.spawn([750, 550]), // far corner — no ship strike unless overridden - bullets: [], - asteroids: [], + bullets: new Set(), + asteroids: new Set(), score: 0, lives: 3, wave: 1, @@ -44,18 +44,19 @@ describe("collision detection — bullet ↔ asteroid selection", () => { it("destroys only the asteroid the bullet overlaps, scoring it", () => { const after = detect( base({ - bullets: [{ position: [100, 100], velocity: [0, 0], age: 0 }], - asteroids: [ + bullets: new Set([{ position: [100, 100], velocity: [0, 0], age: 0 }]), + asteroids: new Set([ { position: [100, 100], velocity: [0, 0], size: "large" }, // overlapped { position: [400, 300], velocity: [0, 0], size: "large" }, // far away - ], + ]), }), ); expect(after.score).toBe(Size.score.large); - expect(after.bullets).toHaveLength(0); + expect(after.bullets.size).toBe(0); // The struck large became two mediums; the distant large is untouched. - expect(after.asteroids.filter((a) => a.size === "medium")).toHaveLength(2); - expect(after.asteroids.filter((a) => a.size === "large")).toHaveLength(1); + const asteroids = [...after.asteroids]; + 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)", () => { @@ -63,48 +64,56 @@ describe("collision detection — bullet ↔ asteroid selection", () => { // 2px apart, well within 2+40, so a correct 3×3 neighbour union finds it. const after = detect( base({ - bullets: [{ position: [79, 100], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [81, 100], velocity: [0, 0], size: "large" }], + bullets: new Set([{ position: [79, 100], velocity: [0, 0], age: 0 }]), + asteroids: new Set([ + { position: [81, 100], velocity: [0, 0], size: "large" }, + ]), }), ); expect(after.score).toBe(Size.score.large); - expect(after.bullets).toHaveLength(0); + expect(after.bullets.size).toBe(0); }); it("registers a hit exactly at the radius-sum boundary (distance == r₁+r₂)", () => { const after = detect( base({ - bullets: [{ position: [0, 0], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [42, 0], velocity: [0, 0], size: "large" }], // 42 == 2+40 + bullets: new Set([{ position: [0, 0], velocity: [0, 0], age: 0 }]), + asteroids: new Set([ + { position: [42, 0], velocity: [0, 0], size: "large" }, // 42 == 2+40 + ]), }), ); expect(after.score).toBe(Size.score.large); - expect(after.bullets).toHaveLength(0); + expect(after.bullets.size).toBe(0); }); it("does NOT register just beyond the radius sum (no false positive)", () => { const after = detect( base({ - bullets: [{ position: [0, 0], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [43, 0], velocity: [0, 0], size: "large" }], // 43 > 42 + bullets: new Set([{ position: [0, 0], velocity: [0, 0], age: 0 }]), + asteroids: new Set([ + { position: [43, 0], velocity: [0, 0], size: "large" }, // 43 > 42 + ]), }), ); expect(after.score).toBe(0); - expect(after.bullets).toHaveLength(1); - expect(after.asteroids).toHaveLength(1); - expect(after.asteroids[0].size).toBe("large"); + expect(after.bullets.size).toBe(1); + expect(after.asteroids.size).toBe(1); + expect([...after.asteroids][0].size).toBe("large"); }); it("leaves a bullet that overlaps nothing untouched", () => { const after = detect( base({ - bullets: [{ position: [10, 10], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [400, 300], velocity: [0, 0], size: "large" }], + bullets: new Set([{ position: [10, 10], velocity: [0, 0], age: 0 }]), + asteroids: new Set([ + { position: [400, 300], velocity: [0, 0], size: "large" }, + ]), }), ); expect(after.score).toBe(0); - expect(after.bullets).toHaveLength(1); - expect(after.asteroids).toHaveLength(1); + expect(after.bullets.size).toBe(1); + expect(after.asteroids.size).toBe(1); }); it("does not let a second bullet hit a child the first spawned this same frame", () => { @@ -112,17 +121,20 @@ describe("collision detection — bullet ↔ asteroid selection", () => { // find no ORIGINAL target and survive — never chain onto a fresh medium. const after = detect( base({ - bullets: [ + bullets: new Set([ { position: [100, 100], velocity: [0, 0], age: 0 }, { position: [100, 100], velocity: [0, 0], age: 0 }, - ], - asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], + ]), + asteroids: new Set([ + { position: [100, 100], velocity: [0, 0], size: "large" }, + ]), }), ); expect(after.score).toBe(Size.score.large); - expect(after.asteroids.filter((a) => a.size === "medium")).toHaveLength(2); - expect(after.asteroids.filter((a) => a.size === "small")).toHaveLength(0); - expect(after.bullets).toHaveLength(1); + const asteroids = [...after.asteroids]; + expect(asteroids.filter((a) => a.size === "medium")).toHaveLength(2); + expect(asteroids.filter((a) => a.size === "small")).toHaveLength(0); + expect(after.bullets.size).toBe(1); }); }); @@ -131,7 +143,9 @@ describe("collision detection — ship ↔ asteroid selection", () => { const after = detect( base({ ship: Ship.spawn([400, 300]), - asteroids: [{ position: [400, 300], velocity: [0, 0], size: "large" }], + asteroids: new Set([ + { position: [400, 300], velocity: [0, 0], size: "large" }, + ]), lives: 3, }), ); @@ -143,10 +157,10 @@ describe("collision detection — ship ↔ asteroid selection", () => { const after = detect( base({ ship: Ship.spawn([400, 300]), - asteroids: [ + asteroids: new Set([ { position: [400, 300], velocity: [0, 0], size: "large" }, { position: [410, 300], velocity: [0, 0], size: "large" }, - ], + ]), lives: 3, }), ); @@ -157,7 +171,9 @@ describe("collision detection — ship ↔ asteroid selection", () => { const after = detect( base({ ship: Ship.spawn([400, 300]), - asteroids: [{ position: [460, 300], velocity: [0, 0], size: "large" }], // 60 > 52 + asteroids: new Set([ + { position: [460, 300], velocity: [0, 0], size: "large" }, // 60 > 52 + ]), lives: 3, }), ); 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 index 4f7174d3..e9594a96 100644 --- 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 @@ -35,24 +35,19 @@ describe("ECS system tick loop conforms to State.step (one frame = one step)", ( for (const testCase of cases) { it(testCase.name, () => { const { dt, input } = testCase.args; - const unordered = { unordered: new Set(["bullets", "asteroids"]) }; // A case `before` is a delta over the feature default (`Case.before` is // `Partial`), so materialise the full seed the same way the runners do. const before = { ...State.create(), ...testCase.before }; // The co-located case carries its own inert `random` double (no case clears // the field, so it is never drawn), so drive the oracle with the case args. - Match.assert( - State.step(before, testCase.args), - testCase.after, - unordered, - ); + Match.assert(State.step(before, testCase.args), testCase.after); const db = createSystemDatabase(); projection.fromState(db.store, before); db.store.resources.frameDelta = dt; db.transactions.setInput(input); driveFrame(db); - Match.assert(projection.toState(db.store), testCase.after, unordered); + Match.assert(projection.toState(db.store), testCase.after); }); } @@ -67,7 +62,7 @@ describe("ECS system tick loop conforms to State.step (one frame = one step)", ( ...State.create(), bounds: [200, 200], ship: Ship.spawn([100, 100]), - asteroids: [], + asteroids: new Set(), wave: 0, }); db.store.resources.frameDelta = 0.1; @@ -76,9 +71,9 @@ describe("ECS system tick loop conforms to State.step (one frame = one step)", ( const after = projection.toState(db.store); expect(after.wave).toBe(1); - expect(after.asteroids).toHaveLength(4); - expect(after.asteroids.every((a) => a.size === "large")).toBe(true); - const positions = after.asteroids.map((a) => [ + expect(after.asteroids.size).toBe(4); + expect([...after.asteroids].every((a) => a.size === "large")).toBe(true); + const positions = [...after.asteroids].map((a) => [ Math.round(a.position[0]), Math.round(a.position[1]), ]); 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 index fbb3e830..9b6027d2 100644 --- 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 @@ -10,8 +10,11 @@ import { readShip } from "./read-ship.js"; export const fireBullet = (t: CoreDatabase.Store): void => { const found = readShip(t); if (found === undefined) return; - // Typed seed so the empty bullets list widens to `Bullet[]`, not `never[]`. - const seed: Pick = { ship: found.ship, bullets: [] }; + // Typed seed so the empty bullets set widens to `Set`, not `Set`. + const seed: Pick = { + ship: found.ship, + bullets: new Set(), + }; const { bullets } = State.fireBullet(seed); const [bullet] = bullets; t.archetypes.Bullet.insert(bullet); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/spawn-random-wave.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/spawn-random-wave.ts index 24252c21..52aac5db 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/spawn-random-wave.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/spawn-random-wave.ts @@ -20,7 +20,7 @@ export const spawnRandomWave = ( // can no longer be reference-compared to detect the no-op). if (readAsteroids(t).length > 0) return; const after = State.spawnRandomWave( - { asteroids: [], wave: t.resources.wave, bounds: t.resources.bounds }, + { asteroids: new Set(), wave: t.resources.wave, bounds: t.resources.bounds }, { random }, ); t.resources.wave = after.wave; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/services.ts b/packages/data-lit-space-rock-game/src/features/main/services/services.ts new file mode 100644 index 00000000..24f24ed9 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/services.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { RandomService } from "./random-service/random-service.js"; + +// The feature's injectable capability services, keyed by short name (the service +// name minus its `-service` suffix). A transition injects the ones it needs with +// `Pick`, so the key and its type come from one place and +// can't drift per-transition. The ecs `service-database` registers these same keys +// and is pinned to this map (see its drift-guard). +export type Services = { + readonly random: RandomService; +}; diff --git a/packages/data-lit-tictactoe/package.json b/packages/data-lit-tictactoe/package.json index bd32ca02..a80ace17 100644 --- a/packages/data-lit-tictactoe/package.json +++ b/packages/data-lit-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-tictactoe", - "version": "0.9.96", + "version": "0.9.97", "description": "Tic-Tac-Toe sample - Lit web components with @adobe/data-lit and AgenticService", "type": "module", "private": true, diff --git a/packages/data-lit-todo/package.json b/packages/data-lit-todo/package.json index 6fd135be..c44d1be5 100644 --- a/packages/data-lit-todo/package.json +++ b/packages/data-lit-todo/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-todo", - "version": "0.9.96", + "version": "0.9.97", "description": "Todo application - Lit web components with @adobe/data ECS", "type": "module", "private": true, diff --git a/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts b/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts index 776e71b2..9375da67 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts +++ b/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; +import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { appendTodo } from "./append-todo.js"; @@ -11,7 +12,7 @@ export const createBulkTodos = ( { count, analytics, - }: { readonly count: number; readonly analytics: AnalyticsService }, + }: { readonly count: number } & Pick, ): Pick => { analytics.bulkTodosCreated({ count }); const total = Math.max(0, Math.floor(count)); diff --git a/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts b/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts index 2efa27e2..0668a7bc 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. 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"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { appendTodo } from "./append-todo.js"; @@ -18,10 +19,7 @@ export const createRandomTodo = async ( { nameGenerator, analytics, - }: { - readonly nameGenerator: NameGeneratorService; - readonly analytics: AnalyticsService; - }, + }: Pick, ): Promise> => { const timing = await analytics.randomTodoRequested(); const name = await nameGenerator.generateName(); diff --git a/packages/data-lit-todo/src/features/main/data/state/create-todo.ts b/packages/data-lit-todo/src/features/main/data/state/create-todo.ts index 131aca1b..2c9f2610 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/create-todo.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; +import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { appendTodo } from "./append-todo.js"; @@ -16,8 +17,7 @@ export const createTodo = ( }: { readonly name: string; readonly complete?: boolean; - readonly analytics: AnalyticsService; - }, + } & Pick, ): Pick => { analytics.todoCreated({ name }); return appendTodo(state, { name, complete }); diff --git a/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.ts b/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.ts index 7b7f413a..230b9b5f 100644 --- a/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.ts +++ b/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; +import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; @@ -7,7 +8,7 @@ import type { Conformance } from "./conformance-case.js"; // `displayCompleted` is untouched. Logs `allTodosCleared`. export const deleteAllTodos = ( state: Pick, - { analytics }: { readonly analytics: AnalyticsService }, + { analytics }: Pick, ): Pick => { analytics.allTodosCleared(); return { todos: [] }; diff --git a/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts b/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts index 9168530b..4fb8b089 100644 --- a/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; +import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data-testing"; @@ -11,7 +12,7 @@ export const deleteTodo = ( { id, analytics, - }: { readonly id: number; readonly analytics: AnalyticsService }, + }: { readonly id: number } & Pick, ): Pick => { analytics.todoDeleted(); return { todos: state.todos.filter((todo) => todo.id !== id) }; diff --git a/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts b/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts index 31a00dd2..3a6f75af 100644 --- a/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts +++ b/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; +import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data-testing"; @@ -11,7 +12,7 @@ export const toggleComplete = ( { id, analytics, - }: { readonly id: number; readonly analytics: AnalyticsService }, + }: { readonly id: number } & Pick, ): Pick => { analytics.todoToggled(); return { diff --git a/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts b/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts index 24a602d9..f0bd43c8 100644 --- a/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts +++ b/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; +import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data-testing"; @@ -8,7 +9,7 @@ import { Match } from "@adobe/data-testing"; // patch — flipping the flag; also logs `displayCompletedToggled`. export const toggleDisplayCompleted = ( state: Pick, - { analytics }: { readonly analytics: AnalyticsService }, + { analytics }: Pick, ): Pick => { analytics.displayCompletedToggled(); return { displayCompleted: !state.displayCompleted }; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/service-database/service-database.ts b/packages/data-lit-todo/src/features/main/services/main-service/service-database/service-database.ts index bfc2a8ed..9a5c0390 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/services/main-service/service-database/service-database.ts @@ -1,8 +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"; // These services are async ports with no ECS state to bind, so they are // registered directly from their `services/` contracts. A service that reads @@ -20,6 +22,10 @@ export type ServiceDatabase = Database.Plugin.ToDatabase< typeof serviceDatabasePlugin >; +// Drift-guard: the services the ECS resolves onto `db.services` must exactly match +// the injectable `Services` map the data transitions `Pick` from. +type _ServicesPin = Assert>; + export namespace ServiceDatabase { export const plugin = serviceDatabasePlugin; export type Store = Database.Plugin.ToStore; diff --git a/packages/data-lit-todo/src/features/main/services/services.ts b/packages/data-lit-todo/src/features/main/services/services.ts new file mode 100644 index 00000000..f4f07c1b --- /dev/null +++ b/packages/data-lit-todo/src/features/main/services/services.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { AnalyticsService } from "./analytics-service/analytics-service.js"; +import type { NameGeneratorService } from "./name-generator-service/name-generator-service.js"; + +// The feature's injectable capability services, keyed by short name (the service +// name minus its `-service` suffix). A transition injects the ones it needs with +// `Pick`, so the key and its type come from one place +// and can't drift per-transition. The ecs `service-database` registers these same +// keys and is pinned to this map (see its drift-guard). +export type Services = { + readonly analytics: AnalyticsService; + readonly nameGenerator: NameGeneratorService; +}; diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index cf983034..e9bd13cb 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.9.96", + "version": "0.9.97", "description": "Adobe data Lit bindings - hooks, elements, decorators", "type": "module", "private": false, diff --git a/packages/data-p2p-tictactoe/package.json b/packages/data-p2p-tictactoe/package.json index ba328089..74e26c26 100644 --- a/packages/data-p2p-tictactoe/package.json +++ b/packages/data-p2p-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-p2p-tictactoe", - "version": "0.9.96", + "version": "0.9.97", "description": "Serverless P2P tic-tac-toe — WebRTC DataChannel + @adobe/data-sync", "type": "module", "private": true, diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json index 19b5472d..492bcc14 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.9.96", + "version": "0.9.97", "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-hello/package.json b/packages/data-react-hello/package.json index 95bff072..4795126b 100644 --- a/packages/data-react-hello/package.json +++ b/packages/data-react-hello/package.json @@ -1,6 +1,6 @@ { "name": "data-react-hello", - "version": "0.9.96", + "version": "0.9.97", "description": "Hello World sample - click counter using @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-react-pixie/package.json b/packages/data-react-pixie/package.json index 9a8926cf..52de349e 100644 --- a/packages/data-react-pixie/package.json +++ b/packages/data-react-pixie/package.json @@ -1,6 +1,6 @@ { "name": "data-react-pixie", - "version": "0.9.96", + "version": "0.9.97", "description": "PixiJS React sample - ECS sprites (bunny, fox) with @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts b/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts index 18aeafc2..dd01fef4 100644 --- a/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts +++ b/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts @@ -6,7 +6,7 @@ import type { Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data-testing"; const nextSpriteId = (state: Pick): number => - state.sprites.reduce((max, sprite) => Math.max(max, sprite.id), 0) + 1; + [...state.sprites].reduce((max, sprite) => Math.max(max, sprite.id), 0) + 1; // Append a sprite to the scene. Returns only the field it writes (`sprites`). export const createSprite = ( @@ -17,17 +17,14 @@ export const createSprite = ( readonly kind: SpriteKind; }, ): Pick => ({ - sprites: [ - ...state.sprites, - { - id: nextSpriteId(state), - position: input.position, - rotation: input.rotation ?? 0, - kind: input.kind, - hovered: false, - active: false, - }, - ], + sprites: new Set(state.sprites).add({ + id: nextSpriteId(state), + position: input.position, + rotation: input.rotation ?? 0, + kind: input.kind, + hovered: false, + active: false, + }), }); // Spec-owned cases, shared with the ecs `createSprite` transaction. A sprite is @@ -40,7 +37,7 @@ export const cases: Conformance = [ before: {}, args: { position: [100, 100], kind: "bunny" }, after: { - sprites: [ + sprites: new Set([ { id: Match.anyNumber, position: [100, 100], @@ -49,13 +46,13 @@ export const cases: Conformance = [ hovered: false, active: false, }, - ], + ]), }, }, { name: "appends a fox with the next id and an explicit rotation", before: { - sprites: [ + sprites: new Set([ { id: 1, position: [100, 100], @@ -64,12 +61,12 @@ export const cases: Conformance = [ hovered: false, active: false, }, - ], + ]), filter: "sepia", }, args: { position: [300, 200], rotation: 1, kind: "fox" }, after: { - sprites: [ + sprites: new Set([ { id: Match.anyNumber, position: [100, 100], @@ -86,7 +83,7 @@ export const cases: Conformance = [ hovered: false, active: false, }, - ], + ]), }, }, ]; diff --git a/packages/data-react-pixie/src/features/main/data/state/create.ts b/packages/data-react-pixie/src/features/main/data/state/create.ts index bf661de4..621e5c20 100644 --- a/packages/data-react-pixie/src/features/main/data/state/create.ts +++ b/packages/data-react-pixie/src/features/main/data/state/create.ts @@ -4,4 +4,4 @@ import type { State } from "./state.js"; // The default scene state: no sprites, no filter. It is the baseline the // conformance cases author their `before` as deltas over, and the state a fresh // scene starts in. -export const create = (): State => ({ sprites: [], filter: "none" }); +export const create = (): State => ({ sprites: new Set(), filter: "none" }); diff --git a/packages/data-react-pixie/src/features/main/data/state/samples.ts b/packages/data-react-pixie/src/features/main/data/state/samples.ts index ae6c4a9d..08f82f12 100644 --- a/packages/data-react-pixie/src/features/main/data/state/samples.ts +++ b/packages/data-react-pixie/src/features/main/data/state/samples.ts @@ -8,7 +8,7 @@ import type { State } from "./state.js"; // + scene filters exercise the whole ecs↔State map. export const samples: readonly State[] = [ { - sprites: [ + sprites: new Set([ { id: Match.anyNumber, position: [100, 100], @@ -33,15 +33,15 @@ export const samples: readonly State[] = [ hovered: false, active: true, }, - ], + ]), filter: "sepia", }, { - sprites: [], + sprites: new Set(), filter: "none", }, { - sprites: [ + sprites: new Set([ { id: Match.anyNumber, position: [10, 10], @@ -58,7 +58,7 @@ export const samples: readonly State[] = [ hovered: false, active: false, }, - ], + ]), filter: "blur", }, ]; diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts index fcf9d3c9..41cd8b12 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts +++ b/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts @@ -9,8 +9,10 @@ export const setSpriteActive = ( state: Pick, input: { readonly id: number; readonly active: boolean }, ): Pick => ({ - sprites: state.sprites.map((sprite) => - sprite.id === input.id ? { ...sprite, active: input.active } : sprite, + sprites: new Set( + [...state.sprites].map((sprite) => + sprite.id === input.id ? { ...sprite, active: input.active } : sprite, + ), ), }); @@ -26,24 +28,24 @@ const fox: Sprite = { export const cases: Conformance = [ { name: "sets active true on the addressed sprite only", - before: { sprites: [bunny, fox] }, + before: { sprites: new Set([bunny, fox]) }, args: { id: entity(2), active: true }, after: { - sprites: [ + sprites: new Set([ { ...bunny, id: Match.anyNumber }, { ...fox, id: Match.anyNumber, active: true }, - ], + ]), }, }, { name: "is a no-op for an unknown id", - before: { sprites: [bunny, fox] }, + before: { sprites: new Set([bunny, fox]) }, args: { id: entity(99), active: true }, after: { - sprites: [ + sprites: new Set([ { ...bunny, id: Match.anyNumber }, { ...fox, id: Match.anyNumber }, - ], + ]), }, }, ]; diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts b/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts index 75f3381a..a6e9eabd 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts +++ b/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts @@ -9,8 +9,10 @@ export const setSpriteHovered = ( state: Pick, input: { readonly id: number; readonly hovered: boolean }, ): Pick => ({ - sprites: state.sprites.map((sprite) => - sprite.id === input.id ? { ...sprite, hovered: input.hovered } : sprite, + sprites: new Set( + [...state.sprites].map((sprite) => + sprite.id === input.id ? { ...sprite, hovered: input.hovered } : sprite, + ), ), }); @@ -26,24 +28,24 @@ const fox: Sprite = { export const cases: Conformance = [ { name: "sets hovered true on the addressed sprite only", - before: { sprites: [bunny, fox] }, + before: { sprites: new Set([bunny, fox]) }, args: { id: entity(1), hovered: true }, after: { - sprites: [ + sprites: new Set([ { ...bunny, id: Match.anyNumber, hovered: true }, { ...fox, id: Match.anyNumber }, - ], + ]), }, }, { name: "is a no-op for an unknown id", - before: { sprites: [bunny, fox] }, + before: { sprites: new Set([bunny, fox]) }, args: { id: entity(99), hovered: true }, after: { - sprites: [ + sprites: new Set([ { ...bunny, id: Match.anyNumber }, { ...fox, id: Match.anyNumber }, - ], + ]), }, }, ]; 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 index 7a324d5b..b39b2a9b 100644 --- a/packages/data-react-pixie/src/features/main/data/state/state.ts +++ b/packages/data-react-pixie/src/features/main/data/state/state.ts @@ -6,7 +6,7 @@ import type { FilterKind } from "../filter-kind/filter-kind.js"; // ECS implementation is verified against. `sprites` is an unordered collection; // `filter` is the scene-wide colour filter. export type State = { - readonly sprites: readonly Sprite[]; + readonly sprites: ReadonlySet; readonly filter: FilterKind; }; export * as State from "./public.js"; diff --git a/packages/data-react-pixie/src/features/main/data/state/tick.ts b/packages/data-react-pixie/src/features/main/data/state/tick.ts index 6632c81c..4c62ba67 100644 --- a/packages/data-react-pixie/src/features/main/data/state/tick.ts +++ b/packages/data-react-pixie/src/features/main/data/state/tick.ts @@ -11,10 +11,12 @@ export const tick = ( state: Pick, input: { readonly delta: number }, ): Pick => ({ - sprites: state.sprites.map((sprite) => ({ - ...sprite, - rotation: sprite.rotation + input.delta * 0.1, - })), + sprites: new Set( + [...state.sprites].map((sprite) => ({ + ...sprite, + rotation: sprite.rotation + input.delta * 0.1, + })), + ), }); const bunny: Sprite = { @@ -29,19 +31,19 @@ const fox: Sprite = { export const cases: Conformance = [ { name: "advances every sprite's rotation by delta * 0.1", - before: { sprites: [bunny, fox] }, + before: { sprites: new Set([bunny, fox]) }, args: { delta: 10 }, after: { - sprites: [ + sprites: new Set([ { ...bunny, id: Match.anyNumber, rotation: 1 }, { ...fox, id: Match.anyNumber, rotation: 2 }, - ], + ]), }, }, { name: "is a no-op on an empty scene", - before: { sprites: [], filter: "blur" }, + before: { sprites: new Set(), filter: "blur" }, args: { delta: 5 }, - after: { sprites: [] }, + after: { sprites: new Set() }, }, ]; diff --git a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts b/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts index cbcc7868..ae9acbbe 100644 --- a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts +++ b/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts @@ -9,8 +9,10 @@ export const toggleSpriteActive = ( state: Pick, input: { readonly id: number }, ): Pick => ({ - sprites: state.sprites.map((sprite) => - sprite.id === input.id ? { ...sprite, active: !sprite.active } : sprite, + sprites: new Set( + [...state.sprites].map((sprite) => + sprite.id === input.id ? { ...sprite, active: !sprite.active } : sprite, + ), ), }); @@ -26,35 +28,35 @@ const activeFox: Sprite = { export const cases: Conformance = [ { name: "toggles a sprite from inactive to active", - before: { sprites: [bunny, activeFox] }, + before: { sprites: new Set([bunny, activeFox]) }, args: { id: entity(1) }, after: { - sprites: [ + sprites: new Set([ { ...bunny, id: Match.anyNumber, active: true }, { ...activeFox, id: Match.anyNumber }, - ], + ]), }, }, { name: "toggles a sprite from active to inactive", - before: { sprites: [bunny, activeFox] }, + before: { sprites: new Set([bunny, activeFox]) }, args: { id: entity(2) }, after: { - sprites: [ + sprites: new Set([ { ...bunny, id: Match.anyNumber }, { ...activeFox, id: Match.anyNumber, active: false }, - ], + ]), }, }, { name: "is a no-op for an unknown id", - before: { sprites: [bunny, activeFox] }, + before: { sprites: new Set([bunny, activeFox]) }, args: { id: entity(99) }, after: { - sprites: [ + sprites: new Set([ { ...bunny, id: Match.anyNumber }, { ...activeFox, id: Match.anyNumber }, - ], + ]), }, }, ]; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.ts index 47ec85ef..0bcc63d1 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.ts @@ -40,7 +40,7 @@ export const projection = { } store.resources.filter = state.filter; return new Map( - state.sprites.map((sprite) => [ + [...state.sprites].map((sprite) => [ sprite.id, store.archetypes.Sprite.insert({ position: sprite.position, @@ -53,8 +53,10 @@ export const projection = { ); }, toState: (store: CoreDatabase.Store): State => ({ - sprites: [...store.select(store.archetypes.Sprite.components)].map( - (entity) => toData(store, entity), + sprites: new Set( + [...store.select(store.archetypes.Sprite.components)].map((entity) => + toData(store, entity), + ), ), filter: store.resources.filter, }), diff --git a/packages/data-react/package.json b/packages/data-react/package.json index 24dedb46..d24ed2bb 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.9.96", + "version": "0.9.97", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-solid-dashboard/package.json b/packages/data-solid-dashboard/package.json index 07ec72c8..c3cb03a6 100644 --- a/packages/data-solid-dashboard/package.json +++ b/packages/data-solid-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "data-solid-dashboard", - "version": "0.9.96", + "version": "0.9.97", "description": "Mini dashboard sample — multiple components sharing one @adobe/data ECS database with SolidJS", "type": "module", "private": true, diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index d60a9c9d..988a1bdb 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.9.96", + "version": "0.9.97", "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 c9387ffc..ab96ebb5 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.9.96", + "version": "0.9.97", "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 499e5c74..a115c03c 100644 --- a/packages/data-testing/package.json +++ b/packages/data-testing/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-testing", - "version": "0.9.96", + "version": "0.9.97", "description": "Conformance-testing utilities (Match + Conformance runners) for @adobe/data ECS features", "type": "module", "sideEffects": false, diff --git a/packages/data-testing/src/conformance/run-spec.ts b/packages/data-testing/src/conformance/run-spec.ts index 04db0418..9cbaa66c 100644 --- a/packages/data-testing/src/conformance/run-spec.ts +++ b/packages/data-testing/src/conformance/run-spec.ts @@ -17,7 +17,8 @@ export interface SpecRunConfig { // `import.meta.glob(["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true })` // — the same `transitions` glob `runFeature` takes. readonly transitions: Record>; - // Passed through to `matches` (float tolerance, unordered collections). + // 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; // Override the `describe` label per module (default `State.`). readonly label?: (path: string, fnName: string | undefined) => string; diff --git a/packages/data-testing/src/match/match.test.ts b/packages/data-testing/src/match/match.test.ts index 44688e83..7f7f5b02 100644 --- a/packages/data-testing/src/match/match.test.ts +++ b/packages/data-testing/src/match/match.test.ts @@ -23,12 +23,36 @@ describe("Match.matches", () => { expect(matches(7, expect.any(Number))).toBe(true); }); - it("compares arrays in order by default, as multisets when named", () => { + it("ignores a numeric id a case does not mention, honors one it pins", () => { + // Omitted → the ecs-allocated id is not compared. + expect(matches({ id: 7, name: "a" }, { name: "a" })).toBe(true); + expect(matches({ id: 7, name: "a" }, { name: "b" })).toBe(false); + // Present → compared like any field (so a case can still pin it). + expect(matches({ id: 7 }, { id: 7 })).toBe(true); + expect(matches({ id: 7 }, { id: 8 })).toBe(false); + // Only the `id` key is special; another extra key still fails. + expect(matches({ extra: 1, name: "a" }, { name: "a" })).toBe(false); + // Only a NUMERIC id is auto-ignored. + expect(matches({ id: "x", name: "a" }, { name: "a" })).toBe(false); + }); + + it("compares arrays in order, Sets and Maps order-independently", () => { expect(matches([1, 2, 3], [1, 2, 3])).toBe(true); expect(matches([1, 2, 3], [3, 2, 1])).toBe(false); - const opts = { unordered: new Set(["bag"]) }; - expect(matches({ bag: [1, 2, 3] }, { bag: [3, 1, 2] }, opts)).toBe(true); - expect(matches({ bag: [1, 2] }, { bag: [1, 2, 3] }, opts)).toBe(false); + + expect(matches(new Set([1, 2, 3]), new Set([3, 1, 2]))).toBe(true); + expect(matches(new Set([1, 2]), new Set([1, 2, 3]))).toBe(false); + expect(matches(new Set([1, 2]), [1, 2])).toBe(false); // Set ≠ Array + + // Set of entities: content pairs order-independently, ids ignored. + expect( + matches(new Set([{ id: 1, x: 1 }, { id: 2, x: 2 }]), new Set([{ x: 2 }, { x: 1 }])), + ).toBe(true); + + // Map entries pair by (meaningful) key regardless of insertion order. + expect(matches(new Map([["a", 1], ["b", 2]]), new Map([["b", 2], ["a", 1]]))).toBe(true); + expect(matches(new Map([["a", 1]]), new Map([["a", 2]]))).toBe(false); + expect(matches(new Map([["a", 1]]), new Map([["b", 1]]))).toBe(false); }); describe("ref — id correspondence up to renaming", () => { @@ -47,5 +71,21 @@ describe("Match.matches", () => { expect(matches([5, 6], [ref("a"), ref("b")])).toBe(true); expect(matches([5, 5], [ref("a"), ref("b")])).toBe(false); }); + + it("corresponds across an unordered Set boundary", () => { + // `sel` points at the entity that has x:1 — whatever ecs id that entity got. + // The referenced entity lives in a Set (nondeterministic order), so the + // pairing must try candidates until the ref binding is globally consistent. + const actual = { sel: 100, items: new Set([{ id: 100, x: 1 }, { id: 200, x: 2 }]) }; + const expected = { + sel: ref("a"), + items: new Set([{ id: ref("a"), x: 1 }, { x: 2 }]), + }; + expect(matches(actual, expected)).toBe(true); + + // `sel` points at an id no item carries → no consistent pairing exists. + const dangling = { sel: 999, items: new Set([{ id: 100, x: 1 }, { id: 200, x: 2 }]) }; + expect(matches(dangling, expected)).toBe(false); + }); }); }); diff --git a/packages/data-testing/src/match/match.ts b/packages/data-testing/src/match/match.ts index 8a5ac000..ebaa8f2a 100644 --- a/packages/data-testing/src/match/match.ts +++ b/packages/data-testing/src/match/match.ts @@ -2,23 +2,26 @@ // Options controlling the tolerant structural comparison. export interface MatchOptions { - // Object keys whose array values compare as multisets (order-independent) at - // any depth — for ecs entity collections materialised in nondeterministic row - // order. Every other array stays order-sensitive (positional tuples like a - // `Vec2`, or a display-ordered list whose order a case verifies). - readonly unordered?: ReadonlySet; // Float grid that absorbs F32↔f64 storage rounding and trig epsilon. Numbers // are snapped to this grid before comparing. Default `0.01`. readonly tolerance?: number; } +// Ordered vs. unordered is carried by the value's own type, not by configuration: +// an `Array` compares positionally (tuples like a `Vec2`, or a display-ordered +// list a case verifies), while a `Set` or `Map` compares order-independently (ecs +// entity collections materialised in nondeterministic row order). This mirrors the +// `State` modelling rule — `ReadonlyArray` means order matters, `ReadonlySet` / +// `ReadonlyMap` mean it does not. + // A `ref(label)` on the EXPECTED side asserts id CORRESPONDENCE without pinning // the value: the first occurrence of a label binds to whatever actual value sits // there; later occurrences of the same label must equal that binding, and two // labels can never bind the same actual (a bijection). This checks that ecs ids // line up structurally — e.g. a `selectedId` points at the entity a case means — // even though the ecs assigns ids from its own space. For an id a case does not -// care about, use `anyNumber` instead. +// care about, simply omit it (a numeric `id` the expected side does not mention is +// ignored — see `matchesWith`), or use `anyNumber` to assert only that one exists. const REF = Symbol.for("@adobe/data-testing:ref"); export const ref = (label: string): { readonly [REF]: string } => ({ [REF]: label }); const isRef = (value: unknown): value is { readonly [REF]: string } => @@ -37,22 +40,38 @@ const quantize = (n: number, tolerance: number): number => { return Math.round(Math.fround(n) * factor) / factor + 0; // `+ 0` normalises `-0` to `0` }; -// Multiset (order-independent) match: greedy pairing, sufficient for concrete -// values. Elements compare with the ordered, matcher-aware path; `ref` bindings -// do not cross element boundaries here (ids in bags are `anyNumber`, not refs). +// Order-independent match over a fixed pair of element lists (a `Set`'s values, or +// a `Map`'s `[key, value]` entries). Finds a perfect pairing under which every +// element matches AND all `ref` bindings stay globally consistent — so a `ref` +// correspondence may cross into an unordered collection. Backtracking (rather than +// greedy) is required because one element's binding can invalidate another's +// pairing; test collections are small, so the worst case is irrelevant. `bindings` +// is snapshotted before each tentative pairing and restored on failure. const matchesUnordered = ( actual: readonly unknown[], expected: readonly unknown[], options: MatchOptions, + bindings: Map, ): boolean => { if (actual.length !== expected.length) return false; const used = new Array(actual.length).fill(false); - return expected.every((exp) => { - const index = actual.findIndex((act, i) => !used[i] && matchesWith(act, exp, options, new Map())); - if (index < 0) return false; - used[index] = true; - return true; - }); + const pair = (i: number): boolean => { + if (i === expected.length) return true; + for (let j = 0; j < actual.length; j++) { + if (used[j]) continue; + const snapshot = new Map(bindings); + if (matchesWith(actual[j], expected[i], options, bindings)) { + used[j] = true; + if (pair(i + 1)) return true; + used[j] = false; + } + // Undo any bindings the failed attempt added before trying the next candidate. + bindings.clear(); + for (const [label, value] of snapshot) bindings.set(label, value); + } + return false; + }; + return pair(0); }; const matchesWith = ( @@ -73,30 +92,51 @@ const matchesWith = ( const tolerance = options.tolerance ?? 0.01; return quantize(actual, tolerance) === quantize(expected, tolerance); } + if (expected instanceof Set) { + if (!(actual instanceof Set)) return false; + return matchesUnordered([...actual], [...expected], options, bindings); + } + if (expected instanceof Map) { + if (!(actual instanceof Map)) return false; + // Compare entries as an unordered collection of `[key, value]` pairs. Keys are + // meaningful/deterministic by convention (identity-keyed collections are Sets), + // so pairing an expected entry to the actual entry with the equal key and a + // matching value is exactly key-based comparison, and reports missing/extra + // keys as a failed pairing. + return matchesUnordered([...actual], [...expected], options, bindings); + } if (Array.isArray(expected)) { if (!Array.isArray(actual) || actual.length !== expected.length) return false; return expected.every((exp, index) => matchesWith(actual[index], exp, options, bindings)); } if (expected !== null && typeof expected === "object") { - if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; - const expectedKeys = Object.keys(expected as object); - const actualKeys = Object.keys(actual as object); + if ( + actual === null || + typeof actual !== "object" || + Array.isArray(actual) || + actual instanceof Set || + actual instanceof Map + ) + return false; + const eo = expected as Record; + const ao = actual as Record; + // A numeric `id` the case does not mention is an ecs-allocated identity a case + // cannot predict — ignore it so entity content compares without pinning ids. A + // case that DOES care pins it explicitly (`id: ref(...)` / `anyNumber`), which + // puts `id` on the expected side and takes it through the normal path below. + const ignoreId = !("id" in eo) && typeof ao.id === "number"; + const expectedKeys = Object.keys(eo); + const actualKeys = ignoreId ? Object.keys(ao).filter((k) => k !== "id") : Object.keys(ao); if (expectedKeys.length !== actualKeys.length) return false; - return expectedKeys.every((key) => { - const exp = (expected as Record)[key]; - const act = (actual as Record)[key]; - if (options.unordered?.has(key) && Array.isArray(exp) && Array.isArray(act)) { - return matchesUnordered(act, exp, options); - } - return matchesWith(act, exp, options, bindings); - }); + return expectedKeys.every((key) => matchesWith(ao[key], eo[key], options, bindings)); } return Object.is(actual, expected); }; // Tolerant structural comparison: honors asymmetric matchers and `ref` -// correspondence on the expected side, absorbs float noise, and compares arrays -// in order except where `options.unordered` names a multiset collection. Pure -// and framework-agnostic — `assert` wraps it for a throwing test assertion. +// correspondence on the expected side, absorbs float noise, compares arrays in +// order and Sets/Maps order-independently, and ignores an ecs-allocated numeric +// `id` a case does not pin. Pure and framework-agnostic — `assert` wraps it for a +// throwing test assertion. export const matches = (actual: unknown, expected: unknown, options: MatchOptions = {}): boolean => matchesWith(actual, expected, options, new Map()); diff --git a/packages/data/AGENTS.md b/packages/data/AGENTS.md index b3eba617..56add9ef 100644 --- a/packages/data/AGENTS.md +++ b/packages/data/AGENTS.md @@ -126,8 +126,9 @@ Transactions receive `(store, payload)`. Mutate via: ## Data and schemas -- **Data:** immutable, JSON-serializable values. +- **Data:** immutable, serializable values — JSON plus `ReadonlySet`, `ReadonlyMap`, and `Blob`. `ReadonlyArray` is ordered; `ReadonlySet` / `ReadonlyMap` are unordered. Serialize Set/Map-bearing Data with `Data.stringify` / `Data.parse` (plain `JSON.stringify` cannot represent them). - **Schemas:** JSON Schema with `as const` so `FromSchema` (or equivalent) can derive TypeScript types. Use schema namespaces for component definitions in ECS if we the types are numeric and we want them stored in linear memory for performance. See Vec2, Vec3 etc. Follow those patterns for numeric values. Do not use an explicit schema for resources, just use `{ default: value as Type }` since there is only one value we don't need linear memory layout. +- **Set / Map values:** a `ReadonlySet` / `ReadonlyMap` column needs no dedicated schema type — declare it with the same `{ default: value as Type }` form (it lands in a generic array buffer, not linear memory). `Data.stringify` / `Data.parse` and the store's serialize/deserialize round-trip it; `equals` compares it order-independently. ### Data modeling example diff --git a/packages/data/README.md b/packages/data/README.md index d2675b39..4bd43d54 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -20,7 +20,7 @@ We prefer composition over inheritance, avoid classes when possible and emphasiz This library uses data oriented design paradigm and prefers pure functional interfaces whenever practical. -For our purposes, `Data` is immutable `JSON` (de)serializable objects and primitives. +For our purposes, `Data` is immutable, (de)serializable objects and primitives — `JSON` extended with `ReadonlySet`, `ReadonlyMap`, and `Blob`. A `ReadonlyArray` is ordered (position is meaningful); a `ReadonlySet` / `ReadonlyMap` is unordered. Serialize Set/Map-bearing `Data` with `Data.stringify` / `Data.parse` (plain `JSON.stringify` renders a `Set`/`Map` as `{}`); `equals` compares them faithfully. ### Why immutable Data? @@ -70,6 +70,8 @@ const notNormalized = { b: 2, a: 1 }; const normalized = normalize(notNormalized); // { a: 1, b: 2 } ``` +> `normalize` operates on plain JSON structure only — it does not canonicalize `Set` or `Map` values (which have no JSON form). Don't feed Set/Map-bearing `Data` through `normalize` for cache-keying; key on a `Data.stringify` of the value, or model the collection with plain arrays/objects where a canonical cache key is required. + ## Observables An `Observable` is a subscription function that you can pass a callback function to. Your callback function can accept a single argument of type `T`. The subscription function returns a dispose function that accepts no parameters and can be called at any point in the future to cancel your subscription. diff --git a/packages/data/package.json b/packages/data/package.json index 1333ac8d..6119da4b 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.9.96", + "version": "0.9.97", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/packages/data/src/data.ts b/packages/data/src/data.ts index d6352990..f3b0006b 100644 --- a/packages/data/src/data.ts +++ b/packages/data/src/data.ts @@ -1,12 +1,19 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import * as JsonMapSet from "./functions/serialization/stringify.js"; + /** - * Data is readonly JSON. + * Data is readonly JSON, extended with `ReadonlySet`, `ReadonlyMap`, and `Blob`. * This type forms the foundation for all of our internal state models and interfaces to external APIs. * It is easy to serialize/deserialize/compare/hash/cache and validate (with JSON-Schema). * These traits make it an ideal foundation for building a robust state engine which cannot enter into invalid states. * It also allows us to strongly define contracts between our application and external services. * Validation of input arguments and output results allows strict enforcement of agreed upon contracts. + * + * Collections carry their ordering semantics in the type: a `ReadonlyArray` is + * ordered (position is meaningful), while a `ReadonlySet` / `ReadonlyMap` is + * unordered. Serialize Set/Map-bearing Data with `Data.stringify` / `Data.parse` + * (plain `JSON.stringify` cannot represent them). */ export type Data = | string @@ -14,5 +21,14 @@ export type Data = | boolean | null | ReadonlyArray + | ReadonlySet + | ReadonlyMap | { readonly [K in string]?: Data } | Blob; + +export namespace Data { + /** `JSON.stringify`, extended to round-trip `Map` and `Set`. */ + export import stringify = JsonMapSet.stringify; + /** `JSON.parse`, extended to round-trip `Map` and `Set`. */ + export import parse = JsonMapSet.parse; +} diff --git a/packages/data/src/equals.test.ts b/packages/data/src/equals.test.ts index 03145650..f8f16304 100644 --- a/packages/data/src/equals.test.ts +++ b/packages/data/src/equals.test.ts @@ -157,6 +157,49 @@ describe('equals', () => { }); }); + describe('sets', () => { + it('compares sets order-independently', () => { + expect(equals(new Set([1, 2, 3]), new Set([3, 1, 2]))).toBe(true); + expect(equals(new Set([1, 2]), new Set([1, 2, 3]))).toBe(false); + expect(equals(new Set([1, 2, 3]), new Set([1, 2, 4]))).toBe(false); + expect(equals(new Set(), new Set())).toBe(true); + }); + + it('deep-compares object elements, still order-independently', () => { + expect(equals(new Set([{ a: 1 }, { a: 2 }]), new Set([{ a: 2 }, { a: 1 }]))).toBe(true); + expect(equals(new Set([{ a: 1 }]), new Set([{ a: 2 }]))).toBe(false); + }); + + it('keeps arrays inside a set ordered', () => { + expect(equals(new Set([[1, 2]]), new Set([[1, 2]]))).toBe(true); + expect(equals(new Set([[1, 2]]), new Set([[2, 1]]))).toBe(false); + }); + + it('is not equal to a non-set', () => { + expect(equals(new Set([1, 2]), [1, 2])).toBe(false); + expect(equals([1, 2], new Set([1, 2]))).toBe(false); + }); + }); + + describe('maps', () => { + it('compares maps by entries, order-independently', () => { + expect(equals(new Map([['a', 1], ['b', 2]]), new Map([['b', 2], ['a', 1]]))).toBe(true); + expect(equals(new Map([['a', 1]]), new Map([['a', 2]]))).toBe(false); + expect(equals(new Map([['a', 1]]), new Map([['b', 1]]))).toBe(false); + expect(equals(new Map(), new Map())).toBe(true); + }); + + it('deep-compares values', () => { + expect(equals(new Map([['a', { x: 1 }]]), new Map([['a', { x: 1 }]]))).toBe(true); + expect(equals(new Map([['a', { x: 1 }]]), new Map([['a', { x: 2 }]]))).toBe(false); + }); + + it('is not equal to a non-map', () => { + expect(equals(new Map([['a', 1]]), { a: 1 })).toBe(false); + expect(equals(new Set(['a']), new Map([['a', 1]]))).toBe(false); + }); + }); + describe('typed buffers', () => { it('should return true for identical number buffers', () => { const buffer1 = createNumberBuffer({ type: 'number', precision: 1 }, 3); diff --git a/packages/data/src/equals.ts b/packages/data/src/equals.ts index e764a0d9..06bc1126 100644 --- a/packages/data/src/equals.ts +++ b/packages/data/src/equals.ts @@ -39,6 +39,20 @@ export function equals(a: unknown, b: unknown): boolean { return true; } + // 2b Sets & Maps — membership is order-independent, but each element/entry + // recurses through `equals` (so an array *inside* a Set stays ordered). A + // Map's entries compare as `[key, value]` pairs. O(n²) greedy pairing, which + // is exact because `equals` is an equivalence relation; intended for modest + // collections, not large hot-path Sets/Maps. + if (a instanceof Set || b instanceof Set) { + if (!(a instanceof Set) || !(b instanceof Set)) return false; + return multisetEquals([...a], [...b]); + } + if (a instanceof Map || b instanceof Map) { + if (!(a instanceof Map) || !(b instanceof Map)) return false; + return multisetEquals([...a], [...b]); + } + // 3 Typed-buffer fast path. Inlined here (rather than dispatched to // `typedBufferEquals`) so the recursion stays intra-module; see the // brand comment above. @@ -71,3 +85,23 @@ export function equals(a: unknown, b: unknown): boolean { return keyBalance === 0; } + +// Multiset equality: same length and every element of `aa` pairs with a distinct +// `equals` partner in `bb`. Greedy first-match is exact because `equals` is an +// equivalence relation, so equal elements are interchangeable. +function multisetEquals(aa: readonly unknown[], bb: readonly unknown[]): boolean { + if (aa.length !== bb.length) return false; + const used = new Array(bb.length).fill(false); + for (const x of aa) { + let matched = false; + for (let j = 0; j < bb.length; j++) { + if (!used[j] && equals(x, bb[j])) { + used[j] = true; + matched = true; + break; + } + } + if (!matched) return false; + } + return true; +} diff --git a/packages/data/src/functions/serialization/serialization.test.ts b/packages/data/src/functions/serialization/serialization.test.ts index f979c067..71a1afd3 100644 --- a/packages/data/src/functions/serialization/serialization.test.ts +++ b/packages/data/src/functions/serialization/serialization.test.ts @@ -237,6 +237,42 @@ describe('serialize/deserialize', () => { expect(roundTrip.buf.get(2)).toBe("a"); }); + it('round-trips a typed buffer whose column values are Maps and Sets', () => { + // A component/resource column typed only by `{ default }` stores arbitrary Data + // in an array buffer; Set/Map values survive the store serialization path. + const buf = createTypedBuffer( + { default: new Map() as ReadonlyMap }, + [new Map([['a', 1]]), new Map([['b', 2], ['c', 3]])], + ); + const roundTrip = deserialize(serialize(buf)); + expect(equals(roundTrip, buf)).toBe(true); + expect(roundTrip.get(1)).toEqual(new Map([['b', 2], ['c', 3]])); + + const setBuf = createTypedBuffer( + { default: new Set() as ReadonlySet }, + [new Set([1, 2]), new Set([3])], + ); + const setRoundTrip = deserialize(serialize(setBuf)); + expect(equals(setRoundTrip, setBuf)).toBe(true); + expect(setRoundTrip.get(0)).toEqual(new Set([1, 2])); + }); + + it('round-trips Map and Set alongside typed-array codecs', () => { + const original = { + byName: new Map([ + ['a', new Int32Array([1, 2])], + ['b', new Int32Array([3, 4])], + ]), + ids: new Set([10, 20, 30]), + }; + const payload = serialize(original); + const roundTrip = deserialize(payload); + expect(roundTrip.byName).toBeInstanceOf(Map); + expect(roundTrip.byName.get('a')).toEqual(new Int32Array([1, 2])); + expect(roundTrip.ids).toBeInstanceOf(Set); + expect(roundTrip).toEqual(original); + }); + it('round-trips boolean typed buffers', () => { const buf = createTypedBuffer({ type: "boolean" }, 65); buf.set(0, true); diff --git a/packages/data/src/functions/serialization/serialize.ts b/packages/data/src/functions/serialization/serialize.ts index 4013a29e..ace871db 100644 --- a/packages/data/src/functions/serialization/serialize.ts +++ b/packages/data/src/functions/serialization/serialize.ts @@ -1,9 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { findCodec, EncodedValue, getCodec, isEncodedValue } from "./codec.js"; +import { stringify, parse } from "./stringify.js"; export function serialize(data: T): { json: string, binary: Uint8Array[] } { const allBinaries: Uint8Array[] = []; - const json = JSON.stringify(data, (_key, value) => { + const json = stringify(data, (_key, value) => { const codec = findCodec(value); if (codec) { const { json, binary } = codec.serialize(value); @@ -19,7 +20,7 @@ export function serialize(data: T): { json: string, binary: Uint8Array(payload: { json: string, binary: Uint8Array[] }): T { - const data = JSON.parse(payload.json, (_key, value) => { + return parse(payload.json, (_key, value) => { if (isEncodedValue(value)) { const codec = getCodec(value.codec); if (codec) { @@ -29,5 +30,4 @@ export function deserialize(payload: { json: string, binary: Uint8Array { + it('round-trips plain JSON values', () => { + const original = { + s: 'hello', + n: 42, + b: true, + nil: null, + arr: [1, 'two', false, null], + nested: { a: 1, b: [2, 3] }, + }; + const roundTrip = Data.parse(Data.stringify(original)); + expect(roundTrip).toEqual(original); + }); + + it('round-trips a Map with string keys', () => { + const original = new Map([['a', 1], ['b', 2]]); + const roundTrip = Data.parse>(Data.stringify(original)); + expect(roundTrip).toBeInstanceOf(Map); + expect(roundTrip).toEqual(original); + }); + + it('round-trips a Map with non-string keys', () => { + const original = new Map([ + [1, 'one'], + [{ x: 1 }, 'obj'], + ]); + const roundTrip = Data.parse>(Data.stringify(original)); + expect(roundTrip).toBeInstanceOf(Map); + expect([...roundTrip.entries()]).toEqual([[1, 'one'], [{ x: 1 }, 'obj']]); + }); + + it('round-trips a Set', () => { + const original = new Set([1, 2, 3]); + const roundTrip = Data.parse>(Data.stringify(original)); + expect(roundTrip).toBeInstanceOf(Set); + expect(roundTrip).toEqual(original); + }); + + it('round-trips nested and interleaved Map/Set inside plain objects', () => { + const original = { + counts: new Map>([ + ['evens', new Set([2, 4])], + ['odds', new Set([1, 3])], + ]), + tags: new Set(['a', 'b']), + list: [new Map([['k', 'v']])], + }; + const roundTrip = Data.parse(Data.stringify(original)); + expect(roundTrip).toEqual(original); + expect(roundTrip.counts).toBeInstanceOf(Map); + expect(roundTrip.counts.get('evens')).toBeInstanceOf(Set); + expect(roundTrip.tags).toBeInstanceOf(Set); + expect(roundTrip.list[0]).toBeInstanceOf(Map); + }); + + it('applies an optional replacer/reviver around the Map/Set transform', () => { + const original = { big: 9007199254740993n, items: new Set([1n]) }; + const json = Data.stringify(original, (_key, value) => + typeof value === 'bigint' ? { __bigint: value.toString() } : value, + ); + const roundTrip = Data.parse(json, (_key, value) => + value && typeof value === 'object' && '__bigint' in value + ? BigInt(value.__bigint) + : value, + ); + expect(roundTrip.big).toBe(9007199254740993n); + expect(roundTrip.items).toBeInstanceOf(Set); + expect(roundTrip.items).toEqual(new Set([1n])); + }); +}); diff --git a/packages/data/src/functions/serialization/stringify.ts b/packages/data/src/functions/serialization/stringify.ts new file mode 100644 index 00000000..91b3cf31 --- /dev/null +++ b/packages/data/src/functions/serialization/stringify.ts @@ -0,0 +1,54 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +/** + * JSON serialization extended with `Map` and `Set` support in both directions. + * + * `Map` and `Set` have no JSON representation (`JSON.stringify` emits `{}` for + * both), so they are encoded as tagged wrapper objects and reconstructed on parse: + * Map -> { __type: "Map", __value: [[key, value], ...] } + * Set -> { __type: "Set", __value: [value, ...] } + * + * An optional replacer/reviver is applied around the Map/Set transform so higher + * level serializers (e.g. the codec system in serialize.ts) can compose their own + * encoding on top of this one. On stringify the caller's replacer runs first, then + * Map/Set are encoded; on parse this is mirrored — Map/Set are decoded first, then + * the caller's reviver runs. + */ + +const TYPE_KEY = "__type"; +const VALUE_KEY = "__value"; +const MAP_TYPE = "Map"; +const SET_TYPE = "Set"; + +export function stringify( + value: unknown, + replacer?: (this: any, key: string, value: any) => any, +): string { + return JSON.stringify(value, function (this: any, key, val) { + const replaced = replacer ? replacer.call(this, key, val) : val; + if (replaced instanceof Map) { + return { [TYPE_KEY]: MAP_TYPE, [VALUE_KEY]: [...replaced] }; + } + if (replaced instanceof Set) { + return { [TYPE_KEY]: SET_TYPE, [VALUE_KEY]: [...replaced] }; + } + return replaced; + }); +} + +export function parse( + text: string, + reviver?: (this: any, key: string, value: any) => any, +): T { + return JSON.parse(text, function (this: any, key, val) { + let revived = val; + if (val !== null && typeof val === "object" && !Array.isArray(val)) { + if (val[TYPE_KEY] === MAP_TYPE && Array.isArray(val[VALUE_KEY])) { + revived = new Map(val[VALUE_KEY]); + } else if (val[TYPE_KEY] === SET_TYPE && Array.isArray(val[VALUE_KEY])) { + revived = new Set(val[VALUE_KEY]); + } + } + return reviver ? reviver.call(this, key, revived) : revived; + }); +} diff --git a/packages/data/src/is-data.ts b/packages/data/src/is-data.ts index 89698645..603b8b79 100644 --- a/packages/data/src/is-data.ts +++ b/packages/data/src/is-data.ts @@ -26,6 +26,16 @@ export type IsData = ? EqualReadonly> extends true ? IsData : false + // **readonly** sets whose items are Data (a mutable `Set` is not Data) + : T extends ReadonlySet + ? EqualReadonly> extends true + ? IsData + : false + // **readonly** maps whose keys and values are Data (a mutable `Map` is not Data) + : T extends ReadonlyMap + ? EqualReadonly> extends true + ? IsData extends false ? false : IsData + : false // plain objects: 1) fully readonly, 2) every value (excluding the `| undefined` // that TypeScript adds for optional properties) is Data : T extends object @@ -54,4 +64,12 @@ interface Baz { type IsFooData = Assert, false>>; // false type IsBarData = Assert, true>>; // true -type IsBazData = Assert, true>>; // true — optional props are OK \ No newline at end of file +type IsBazData = Assert, true>>; // true — optional props are OK + +// Sets and Maps are Data when readonly and their items/keys/values are Data; a +// mutable `Set`/`Map`, or one holding non-Data, is not. +type IsReadonlySetData = Assert>, true>>; // true +type IsMutableSetData = Assert>, false>>; // false — mutable +type IsSetOfMutableData = Assert>, false>>; // false — element not Data +type IsReadonlyMapData = Assert>, true>>; // true +type IsMutableMapData = Assert>, false>>; // false — mutable \ No newline at end of file diff --git a/packages/data/src/types/types.ts b/packages/data/src/types/types.ts index 3e3963cd..cfe4314b 100644 --- a/packages/data/src/types/types.ts +++ b/packages/data/src/types/types.ts @@ -111,6 +111,10 @@ export type DeepReadonly = T extends Function | Branded | Element | Blob ? IsTuple extends true ? { readonly [K in keyof T]: DeepReadonly } // T is a tuple : ReadonlyArray> // T is an array + : T extends ReadonlyMap + ? ReadonlyMap, DeepReadonly> + : T extends ReadonlySet + ? ReadonlySet> : T extends object ? { readonly [K in keyof T]: DeepReadonly } : T;