From c0c62ced8cb48d6041f84c1f1888b68bf6e55295 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 15 Aug 2026 10:44:31 -0700 Subject: [PATCH 1/5] feat(data)!: drop id from ECS reads and archetype value types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit id is the entity's identity (the key), not a component value: `read(entity)`, FromArchetype/EntityReadValues/Store.EntityValues, and the typed archetype row no longer include it. The id column stays internal — still present on every archetype and typed for swap-remove and manual per-row traversal — and id is now implicit in ensureArchetype (callers never name it). getRowData is no longer exported from @adobe/data/table; public reads go through an id-excluding reader. Also removes the now-obsolete id-ignore special case from @adobe/data-testing Match. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- packages/data-ai/.claude-plugin/plugin.json | 2 +- packages/data-ai/package.json | 2 +- packages/data-gpu-hopper/package.json | 2 +- packages/data-gpu-samples/package.json | 2 +- packages/data-gpu/package.json | 2 +- .../data-lit-space-rock-game/package.json | 2 +- packages/data-lit-tictactoe/package.json | 2 +- packages/data-lit-todo/package.json | 2 +- .../main-service/conformance/projection.ts | 3 +- packages/data-lit/package.json | 2 +- packages/data-p2p-tictactoe/package.json | 2 +- packages/data-persistence/package.json | 2 +- packages/data-react-hello/package.json | 2 +- packages/data-react-pixie/package.json | 2 +- .../main-service/conformance/projection.ts | 3 +- packages/data-react/package.json | 2 +- packages/data-solid-dashboard/package.json | 2 +- packages/data-solid/package.json | 2 +- packages/data-sync/package.json | 2 +- packages/data-testing/package.json | 2 +- packages/data-testing/src/match/match.test.ts | 18 +-- packages/data-testing/src/match/match.ts | 21 +-- packages/data/package.json | 2 +- packages/data/src/ecs/archetype/archetype.ts | 31 ++-- .../src/ecs/archetype/create-archetype.ts | 24 +++- packages/data/src/ecs/archetype/delete-row.ts | 3 +- .../ecs/database/archetype-row.type-test.ts | 4 +- .../create-database-schema-test.ts | 5 +- .../database-schema/database-schema.ts | 5 +- .../database/database.partition.type-test.ts | 6 +- packages/data/src/ecs/database/database.ts | 2 +- .../database/deep-extends-chain.type-test.ts | 5 +- .../ecs/database/observe-select-entities.ts | 4 +- .../observed/create-observed-database.test.ts | 12 -- .../observed/create-observed-database.ts | 15 +- .../database/observed/observed-database.ts | 3 +- .../create-database-from-schema.test.ts | 2 - .../database/public/create-database.test.ts | 3 - .../database/public/observe-select-deep.ts | 11 ++ .../create-transactional-store.test.ts | 76 +++++----- packages/data/src/ecs/store/archetype-row.ts | 5 +- packages/data/src/ecs/store/core/core.ts | 50 ++++--- .../store/core/create-core.partition.test.ts | 40 +++--- .../src/ecs/store/core/create-core.test.ts | 134 +++++++++--------- .../data/src/ecs/store/core/create-core.ts | 56 ++++++-- .../ecs/store/core/select-entities.test.ts | 16 +-- .../src/ecs/store/create-store-schema-test.ts | 6 +- packages/data/src/ecs/store/partition.ts | 7 +- .../public/create-store.partition.test.ts | 6 +- .../create-store.partition.type-test.ts | 8 +- .../src/ecs/store/public/create-store.test.ts | 66 ++++----- .../data/src/ecs/store/public/create-store.ts | 16 +-- packages/data/src/ecs/store/store.test.ts | 4 +- packages/data/src/ecs/store/store.ts | 20 +-- .../create-undo-redo-service.test.ts | 4 +- packages/data/src/table/index.ts | 7 +- 57 files changed, 389 insertions(+), 352 deletions(-) diff --git a/package.json b/package.json index 4db6ae10..19c63130 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.9.99", + "version": "0.9.100", "private": true, "engines": { "node": ">=24" diff --git a/packages/data-ai/.claude-plugin/plugin.json b/packages/data-ai/.claude-plugin/plugin.json index 072c8435..12295d3b 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.99", + "version": "0.9.100", "description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.", "author": { "name": "Adobe" diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index b6e7ff02..436495d2 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.9.99", + "version": "0.9.100", "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 d9949a5d..58d77505 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.99", + "version": "0.9.100", "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-samples/package.json b/packages/data-gpu-samples/package.json index 658bc36c..168dac28 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.99", + "version": "0.9.100", "description": "WebGPU samples built on @adobe/data-gpu", "type": "module", "private": true, diff --git a/packages/data-gpu/package.json b/packages/data-gpu/package.json index 48fcca18..2611a9a9 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.9.99", + "version": "0.9.100", "description": "Adobe data WebGPU plugins and types for graphics and compute", "type": "module", "private": false, diff --git a/packages/data-lit-space-rock-game/package.json b/packages/data-lit-space-rock-game/package.json index fb0e7846..4ae38b39 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.99", + "version": "0.9.100", "description": "Space Rock Game sample - real-time ECS game with Lit and @adobe/data", "type": "module", "private": true, diff --git a/packages/data-lit-tictactoe/package.json b/packages/data-lit-tictactoe/package.json index 0b992734..846cdc83 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.99", + "version": "0.9.100", "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 c72da5d7..87e62ab4 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.99", + "version": "0.9.100", "description": "Todo application - Lit web components with @adobe/data ECS", "type": "module", "private": true, diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts index 0d333054..93c06c5a 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts @@ -13,7 +13,8 @@ const toData = (store: CoreDatabase.Store, entity: Entity): Todo => { const row = store.read(entity, store.archetypes.Todo); if (row === null) throw new Error("conformance projection: expected a todo entity"); - return { id: row.id, name: row.name, complete: row.complete }; + // `id` is the entity itself (reads no longer echo it back as a component). + return { id: entity, name: row.name, complete: row.complete }; }; // The test-only ecs↔`State` projection, passed to `Conformance.runFeature`. diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index 34ab43f3..869f8237 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.9.99", + "version": "0.9.100", "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 920de966..31b89fe3 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.99", + "version": "0.9.100", "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 f16a3c8c..863bfda1 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.9.99", + "version": "0.9.100", "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 7addf12e..ce0e5859 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.99", + "version": "0.9.100", "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 c362120c..3e579a19 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.99", + "version": "0.9.100", "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/services/main-service/conformance/projection.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.ts index 0bcc63d1..f209b621 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 @@ -12,7 +12,8 @@ const toData = (store: CoreDatabase.Store, entity: Entity): Sprite => { if (row === null) throw new Error("conformance projection: expected a sprite entity"); return { - id: row.id, + // `id` is the entity itself (reads no longer echo it back as a component). + id: entity, position: row.position, rotation: row.rotation, kind: row.kind, diff --git a/packages/data-react/package.json b/packages/data-react/package.json index 806254f7..f5cca163 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.9.99", + "version": "0.9.100", "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 13b184b3..5f073079 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.99", + "version": "0.9.100", "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 60c0dcec..fb8c78d8 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.9.99", + "version": "0.9.100", "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 0b34d244..a8118fc5 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.9.99", + "version": "0.9.100", "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 257ed0db..751d0de4 100644 --- a/packages/data-testing/package.json +++ b/packages/data-testing/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-testing", - "version": "0.9.99", + "version": "0.9.100", "description": "Conformance-testing utilities (Match + Conformance runners) for @adobe/data ECS features", "type": "module", "sideEffects": false, diff --git a/packages/data-testing/src/match/match.test.ts b/packages/data-testing/src/match/match.test.ts index 7f7f5b02..337d0913 100644 --- a/packages/data-testing/src/match/match.test.ts +++ b/packages/data-testing/src/match/match.test.ts @@ -23,17 +23,13 @@ describe("Match.matches", () => { expect(matches(7, expect.any(Number))).toBe(true); }); - 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). + it("treats id like any other property", () => { + // An id a case cares about is pinned and compared like any field. 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. + // An unmentioned id is an extra key and fails, exactly like any extra key. + expect(matches({ id: 7, name: "a" }, { name: "a" })).toBe(false); 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", () => { @@ -44,9 +40,9 @@ describe("Match.matches", () => { 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. + // Set of entities: content pairs order-independently. expect( - matches(new Set([{ id: 1, x: 1 }, { id: 2, x: 2 }]), new Set([{ x: 2 }, { x: 1 }])), + matches(new Set([{ x: 1 }, { x: 2 }]), new Set([{ x: 2 }, { x: 1 }])), ).toBe(true); // Map entries pair by (meaningful) key regardless of insertion order. @@ -79,7 +75,7 @@ describe("Match.matches", () => { 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 }]), + items: new Set([{ id: ref("a"), x: 1 }, { id: anyNumber, x: 2 }]), }; expect(matches(actual, expected)).toBe(true); diff --git a/packages/data-testing/src/match/match.ts b/packages/data-testing/src/match/match.ts index ebaa8f2a..8ef8a9ae 100644 --- a/packages/data-testing/src/match/match.ts +++ b/packages/data-testing/src/match/match.ts @@ -20,8 +20,7 @@ export interface MatchOptions { // 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, 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. +// want to pin to a specific number, 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 } => @@ -120,23 +119,15 @@ const matchesWith = ( 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) => matchesWith(ao[key], eo[key], options, bindings)); + if (Object.keys(eo).length !== Object.keys(ao).length) return false; + return Object.keys(eo).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, 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. +// correspondence on the expected side, absorbs float noise, and compares arrays in +// order and Sets/Maps order-independently. 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/package.json b/packages/data/package.json index 35fa7675..5fdafdcb 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.9.99", + "version": "0.9.100", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/packages/data/src/ecs/archetype/archetype.ts b/packages/data/src/ecs/archetype/archetype.ts index dbd60163..e67c9bca 100644 --- a/packages/data/src/ecs/archetype/archetype.ts +++ b/packages/data/src/ecs/archetype/archetype.ts @@ -24,7 +24,15 @@ interface BaseArchetype { readonly id: ArchetypeId; readonly components: ReadonlySet; } -export interface ReadonlyArchetype extends BaseArchetype, ReadonlyTable { + +// `C` is the archetype's *component* set and deliberately excludes the entity +// `id`. The id is the entity's identity (the key), not one of its component +// values, so it never appears in `C`, in `FromArchetype`, or in any full read. +// It remains a real, always-present COLUMN: the interfaces below re-inject it +// into `columns` (via `C & RequiredComponents`) so swap-remove and manual +// per-row traversal can still read `archetype.columns.id.get(row)` directly, +// which is required and must stay fast. +export interface ReadonlyArchetype extends BaseArchetype, ReadonlyTable { readonly components: ComponentSet>; /** * Serialize the archetype. When `copy` is true each column buffer is @@ -39,7 +47,7 @@ export interface ReadonlyArchetype extends BaseArc toData: (copy?: boolean, omit?: ReadonlySet) => unknown } -export interface Archetype extends BaseArchetype, Table { +export interface Archetype extends BaseArchetype, Table { readonly components: ComponentSet>; insert: >(rowData: Exact, T>) => Entity; /** See {@link ReadonlyArchetype.toData}. */ @@ -66,24 +74,31 @@ export namespace Archetype { * component) therefore still permits `.insert` with no narrowing — only dense * column access requires having resolved to a concrete {@link Archetype}. */ - export interface Router { + export interface Router { readonly components: ComponentSet>; insert: >(rowData: Exact, T>) => Entity; } } +// `id` is stripped explicitly: inferring `C` from an archetype can pull `id` in +// via the `columns` position (typed `C & RequiredComponents`), but `id` is never +// part of the component row. export type FromArchetype = - T extends ReadonlyArchetype ? { readonly [K in keyof C]: C[K] } : - T extends Archetype ? { readonly [K in keyof C]: C[K] } : + T extends ReadonlyArchetype ? { readonly [K in keyof Omit]: C[K] } : + T extends Archetype ? { readonly [K in keyof Omit]: C[K] } : never; // compile time type tests. -type TestFromReadonlyArchetype = Assert>, { readonly id: number, readonly a: number, readonly b: string }>>; -type TestFromArchetype = Assert>, { readonly id: number, readonly a: number, readonly b: string }>>; +// `id` is not a component: it is absent from `C` and therefore from FromArchetype… +type TestFromReadonlyArchetype = Assert>, { readonly a: number, readonly b: string }>>; +type TestFromArchetype = Assert>, { readonly a: number, readonly b: string }>>; +// …but it remains a real, typed column so swap-remove / manual traversal can read +// `columns.id` directly: `id` is present in `columns` even though it is not in `C`. +type TestIdColumnStillTyped = Assert<"id" extends keyof Archetype<{ a: number }>["columns"] ? true : false>; // Compile-time tests for Exact in insert method { - type TestArchetype = Archetype<{ id: Entity, position: [number, number, number], color: [number, number, number, number] }>; + type TestArchetype = Archetype<{ position: [number, number, number], color: [number, number, number, number] }>; type TestInsertValid = { position: [number, number, number], color: [number, number, number, number] }; type TestInsertExtra = { position: [number, number, number], color: [number, number, number, number], extra: string }; diff --git a/packages/data/src/ecs/archetype/create-archetype.ts b/packages/data/src/ecs/archetype/create-archetype.ts index b5f63788..2fd75b37 100644 --- a/packages/data/src/ecs/archetype/create-archetype.ts +++ b/packages/data/src/ecs/archetype/create-archetype.ts @@ -2,8 +2,7 @@ import { Schema } from "../../schema/index.js"; import * as TABLE from "../../table/index.js"; -import { Archetype } from "./archetype.js"; -import { RequiredComponents } from "../required-components.js"; +import { Archetype, EntityInsertValues } from "./archetype.js"; import { EntityLocationTable } from "../entity-location-table/entity-location-table.js"; import { Entity } from "../entity/entity.js"; import { StringKeyof } from "../../types/types.js"; @@ -168,7 +167,12 @@ export const createArchetype = ( components: C, id: number, entityLocationTable: EntityLocationTable, -): Archetype }> => { +): Archetype }, "id">> => { + // The archetype's public COMPONENT set excludes `id`: id is the entity's + // identity, a column but never a component value. (`table.columns` and the + // runtime `componentSet` still carry id — required for swap-remove and + // serialization — but that is asserted below where the types are narrowed.) + type PublicComponents = Omit<{ [K in keyof C]: Schema.ToType }, "id">; const table = TABLE.createTable(components); const componentSet = new Set(Object.keys(components)); @@ -181,7 +185,7 @@ export const createArchetype = ( buildGenericInsert(id, entityLocationTable); }; - const createEntity = (rowData: Omit<{ [K in keyof C]: Schema.ToType }, "id">): Entity => { + const createEntity = (rowData: EntityInsertValues): Entity => { // archetype is closure-captured here. By the time createEntity // actually runs the const has been initialized. return insertImpl(archetype as any, rowData); @@ -190,7 +194,9 @@ export const createArchetype = ( const archetype = { id, ...table, - components: componentSet as Set>, + // Runtime invariant the compiler can't see: `componentSet` DOES contain + // "id", but id is not a public component, so it is typed without id. + components: componentSet as Set>, insert: createEntity, toData: (copy = false, omit?: ReadonlySet) => ({ columns: Object.fromEntries( @@ -221,7 +227,13 @@ export const createArchetype = ( refreshInsertImpl(); // component set cannot be changed by this as the archetype components should be the same. } - } as const satisfies Archetype<{ [K in keyof C]: Schema.ToType }> as Archetype }>; + // Runtime invariant the compiler can't reduce: the `id` column is + // `TypedBuffer>`, which IS `TypedBuffer` + // (the input requires `C["id"] = typeof Entity.schema`). TS leaves + // `ToType` deferred for a generic `C`, so it can't prove that + // column matches `RequiredComponents["id"]`. Every other member matches + // structurally; assert the whole once here. + } as const as unknown as Archetype; // Initial build, after `archetype` is in scope so the specialized // insert closes over the right columns. diff --git a/packages/data/src/ecs/archetype/delete-row.ts b/packages/data/src/ecs/archetype/delete-row.ts index ad51df5b..67f7f102 100644 --- a/packages/data/src/ecs/archetype/delete-row.ts +++ b/packages/data/src/ecs/archetype/delete-row.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import * as TABLE from "../../table/index.js"; import { Archetype } from "./archetype.js"; -import { RequiredComponents } from "../required-components.js"; import { EntityLocationTable } from "../entity-location-table/entity-location-table.js"; import { Entity } from "../entity/entity.js"; @@ -13,7 +12,7 @@ import { Entity } from "../entity/entity.js"; * when the deleted row was the last row (no move). Callers surface this so * observers/persistence learn about the relocation the swap caused. */ -export const deleteRow = (archetype: Archetype, row: number, entityLocationTable: EntityLocationTable): Entity | undefined => { +export const deleteRow = (archetype: Archetype, row: number, entityLocationTable: EntityLocationTable): Entity | undefined => { const movedARowToFillHole = TABLE.deleteRow(archetype, row); if (movedARowToFillHole) { const movedId = archetype.columns.id.get(row); diff --git a/packages/data/src/ecs/database/archetype-row.type-test.ts b/packages/data/src/ecs/database/archetype-row.type-test.ts index 620d85ab..9a22d5b7 100644 --- a/packages/data/src/ecs/database/archetype-row.type-test.ts +++ b/packages/data/src/ecs/database/archetype-row.type-test.ts @@ -39,10 +39,10 @@ declare const db: MainService; const archetypes: MainService["archetypes"] = db.archetypes; void archetypes; -// 2. `RowOf` extracts the full structural row, including the implicit `id`. +// 2. `RowOf` extracts the archetype's component row. `id` is NOT a component and +// is absent from the row (it remains an implicit column, not a value). type TrackRow = Database.Archetype.RowOf; type CheckRow = Assert; declare const testDatabase: CheckDatabaseFromSchema; -type CheckDynamicParticle = Assert>>; -type CheckParticle = Assert>>; type CheckCreateParticle = Assert void; }>>; declare const testDatabase: CheckDatabaseFromSchema; -type CheckDynamicParticle = Assert>>; -type CheckParticle = Assert>>; type CheckCreateParticle = Assert | undefined; intent: "commit" | "intermediate" | "cancel"; }>; - entity(id: Entity, minArchetype?: ReadonlyArchetype | Archetype): Observe & EntityReadValues | null>; + entity(id: Entity, minArchetype?: ReadonlyArchetype | Archetype): Observe & EntityReadValues | null>; entity(id: Entity): Observe | null>; archetype(id: ArchetypeId): Observe; select< diff --git a/packages/data/src/ecs/database/deep-extends-chain.type-test.ts b/packages/data/src/ecs/database/deep-extends-chain.type-test.ts index b41d97fe..88047d19 100644 --- a/packages/data/src/ecs/database/deep-extends-chain.type-test.ts +++ b/packages/data/src/ecs/database/deep-extends-chain.type-test.ts @@ -408,8 +408,8 @@ type HasStringIndex = string extends keyof T ? true : false; import type { ReadonlyArchetype } from "../archetype/archetype.js"; import type { RequiredComponents } from "../required-components.js"; +// `id` is not a component value, so it is absent from these archetype rows. type District = Readonly<{ - id: Entity; districtType: string; zoning: string; districtLocked: boolean; @@ -417,14 +417,13 @@ type District = Readonly<{ }>; type Building = Readonly<{ - id: Entity; buildingType: string; parentDistrict: Entity; positionX: number; }>; interface CityService { - read(entity: Entity): Readonly & { id: Entity }> | null; + read(entity: Entity): Readonly> | null; read(entity: Entity, archetype: ReadonlyArchetype): District | null; read(entity: Entity, archetype: ReadonlyArchetype): Building | null; } diff --git a/packages/data/src/ecs/database/observe-select-entities.ts b/packages/data/src/ecs/database/observe-select-entities.ts index 42c62d19..bb6e4d05 100644 --- a/packages/data/src/ecs/database/observe-select-entities.ts +++ b/packages/data/src/ecs/database/observe-select-entities.ts @@ -30,7 +30,7 @@ export const observeSelectEntities = (store: ReadonlyStore, row: number) => + const matchesFilter = (archetype: ReadonlyArchetype, row: number) => rowPredicate(archetype as Parameters[0], row); // Whether an archetype qualifies for this query — its components are a @@ -41,7 +41,7 @@ export const observeSelectEntities = (store: ReadonlyStore(); - const qualifies = (archetype: ReadonlyArchetype): boolean => { + const qualifies = (archetype: ReadonlyArchetype): boolean => { let verdict = archetypeQualifies.get(archetype.id); if (verdict === undefined) { verdict = archetype.components.isSupersetOf(includeSet) diff --git a/packages/data/src/ecs/database/observed/create-observed-database.test.ts b/packages/data/src/ecs/database/observed/create-observed-database.test.ts index b4826ade..d93ea134 100644 --- a/packages/data/src/ecs/database/observed/create-observed-database.test.ts +++ b/packages/data/src/ecs/database/observed/create-observed-database.test.ts @@ -375,7 +375,6 @@ describe("createObservedDatabase", () => { const unsubscribe = observed.observe.entity(entity, observed.archetypes.Position)(observer); expect(observer).toHaveBeenCalledWith(expect.objectContaining({ - id: entity, position: { x: 1, y: 2, z: 3 }, })); @@ -394,7 +393,6 @@ describe("createObservedDatabase", () => { const unsubscribe = observed.observe.entity(entity, observed.archetypes.Position)(observer); expect(observer).toHaveBeenCalledWith(expect.objectContaining({ - id: entity, position: { x: 1, y: 2, z: 3 }, })); @@ -448,7 +446,6 @@ describe("createObservedDatabase", () => { }); expect(observer).toHaveBeenCalledWith(expect.objectContaining({ - id: entity, position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 }, })); @@ -468,7 +465,6 @@ describe("createObservedDatabase", () => { const unsubscribe = observed.observe.entity(entity, observed.archetypes.PositionHealth)(observer); expect(observer).toHaveBeenCalledWith(expect.objectContaining({ - id: entity, position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 }, })); @@ -495,7 +491,6 @@ describe("createObservedDatabase", () => { const unsubscribe = observed.observe.entity(entity, observed.archetypes.PositionHealth)(observer); expect(observer).toHaveBeenCalledWith(expect.objectContaining({ - id: entity, position: { x: 1, y: 2, z: 3 }, })); @@ -542,15 +537,12 @@ describe("createObservedDatabase", () => { const unsubscribeFull = observed.observe.entity(entity, observed.archetypes.PositionHealth)(fullObserver); expect(positionObserver).toHaveBeenCalledWith(expect.objectContaining({ - id: entity, position: { x: 1, y: 2, z: 3 }, })); expect(healthObserver).toHaveBeenCalledWith(expect.objectContaining({ - id: entity, health: { current: 100, max: 100 }, })); expect(fullObserver).toHaveBeenCalledWith(expect.objectContaining({ - id: entity, position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 }, })); @@ -561,7 +553,6 @@ describe("createObservedDatabase", () => { }); expect(positionObserver).toHaveBeenCalledWith(expect.objectContaining({ - id: entity, position: { x: 1, y: 2, z: 3 }, })); expect(healthObserver).toHaveBeenCalledWith(null); @@ -721,7 +712,6 @@ describe("createObservedDatabase", () => { expect(newObserver).toHaveBeenCalledTimes(1); expect(newObserver.mock.calls[0][0]).toEqual({ - id: newEntity, position: { x: 1, y: 2, z: 3 }, }); @@ -772,7 +762,6 @@ describe("createObservedDatabase", () => { // Verify that new components can be read back const entityData = observed.read(entity); expect(entityData).toEqual({ - id: entity, position: { x: 1, y: 2, z: 3 }, velocity: { vx: 0.1, vy: 0.2, vz: 0.3 }, }); @@ -787,7 +776,6 @@ describe("createObservedDatabase", () => { const movingEntityData = observed.read(movingEntity); expect(movingEntityData).toEqual({ - id: movingEntity, position: { x: 10, y: 20, z: 30 }, velocity: { vx: 1, vy: 2, vz: 3 }, }); diff --git a/packages/data/src/ecs/database/observed/create-observed-database.ts b/packages/data/src/ecs/database/observed/create-observed-database.ts index a4eaa9a8..ce218871 100644 --- a/packages/data/src/ecs/database/observed/create-observed-database.ts +++ b/packages/data/src/ecs/database/observed/create-observed-database.ts @@ -13,7 +13,6 @@ import { PersistenceScope, ToDataOptions } from "../../persistence-scope.js"; import { observeSelectEntities } from "../observe-select-entities.js"; import { createDerive } from "../observe-derive.js"; import { createTransactionalStore } from "../transactional-store/create-transactional-store.js"; -import { RequiredComponents } from "../../required-components.js"; import { Entity } from "../../entity/entity.js"; import { EntityReadValues, EntityUpdateValues } from "../../store/core/index.js"; import { ObservedDatabase } from "./observed-database.js"; @@ -92,7 +91,7 @@ export function createObservedDatabase< return result; }; - const observeEntity = (entity: Entity, minArchetype?: ReadonlyArchetype | Archetype) => (observer: (values: EntityReadValues | null) => void) => { + const observeEntity = (entity: Entity, minArchetype?: ReadonlyArchetype | Archetype) => (observer: (values: EntityReadValues | null) => void) => { if (minArchetype) { const originalObserver = observer; observer = (values) => { @@ -153,12 +152,14 @@ export function createObservedDatabase< changedComponents: new Set(componentObservers.keys()), changedArchetypes: new Set(archetypeObservers.keys()), changedEntities: new Map([...entityObservers.keys()].map((entity) => { + // `store.read` already excludes `id`, so the read record IS the + // full component set to report as changed. The read shape + // (optional, readonly) and EntityUpdateValues (Partial>) + // are computed differently, so bridge through `unknown`. const values = store.read(entity); - let updateValues: EntityUpdateValues | null = null; - if (values) { - const { id, ...restValues } = values; - updateValues = restValues as EntityUpdateValues; - } + const updateValues: EntityUpdateValues | null = values + ? values as unknown as EntityUpdateValues + : null; return [ entity, updateValues diff --git a/packages/data/src/ecs/database/observed/observed-database.ts b/packages/data/src/ecs/database/observed/observed-database.ts index 764fed1b..2feee1e0 100644 --- a/packages/data/src/ecs/database/observed/observed-database.ts +++ b/packages/data/src/ecs/database/observed/observed-database.ts @@ -8,7 +8,6 @@ import { StringKeyof } from "../../../types/types.js"; import { ReadonlyStore, Store } from "../../store/index.js"; import { Observe } from "../../../observe/index.js"; import { TransactionResult } from "../transactional-store/index.js"; -import { RequiredComponents } from "../../required-components.js"; import { Entity } from "../../entity/entity.js"; import { EntityReadValues } from "../../store/core/index.js"; import { Database } from "../database.js"; @@ -24,7 +23,7 @@ export interface ObservedDatabase< readonly components: { readonly [K in StringKeyof]: Observe }; readonly resources: { readonly [K in StringKeyof]: Observe }; readonly transactions: Observe>; - entity(id: Entity, minArchetype?: ReadonlyArchetype | Archetype): Observe & EntityReadValues | null>; + entity(id: Entity, minArchetype?: ReadonlyArchetype | Archetype): Observe & EntityReadValues | null>; entity(id: Entity): Observe | null>; archetype(id: ArchetypeId): Observe; select< diff --git a/packages/data/src/ecs/database/public/create-database-from-schema.test.ts b/packages/data/src/ecs/database/public/create-database-from-schema.test.ts index a57b6996..e3ba2a63 100644 --- a/packages/data/src/ecs/database/public/create-database-from-schema.test.ts +++ b/packages/data/src/ecs/database/public/create-database-from-schema.test.ts @@ -47,7 +47,6 @@ describe("Database.create from plugin", () => { const entityData = database.read(entity); expect(entityData).toEqual({ - id: entity, position: { x: 1, y: 2, z: 3 }, }); @@ -140,7 +139,6 @@ describe("Database.create from plugin", () => { const movingEntityData = extendedDatabase.read(movingEntity); expect(movingEntityData).toEqual({ - id: movingEntity, position: { x: 10, y: 20, z: 30 }, velocity: { x: 1, y: 2, z: 3 }, }); diff --git a/packages/data/src/ecs/database/public/create-database.test.ts b/packages/data/src/ecs/database/public/create-database.test.ts index 46afab28..74d30c5b 100644 --- a/packages/data/src/ecs/database/public/create-database.test.ts +++ b/packages/data/src/ecs/database/public/create-database.test.ts @@ -821,11 +821,9 @@ describe("createDatabase", () => { const restoredData1 = newStore.read(restoredEntities[0]); const restoredData2 = newStore.read(restoredEntities[1]); expect(restoredData1).toEqual({ - id: restoredEntities[0], position: { x: 1, y: 2, z: 3 } }); expect(restoredData2).toEqual({ - id: restoredEntities[1], position: { x: 4, y: 5, z: 6 }, health: { current: 100, max: 100 }, name: "TestEntity" @@ -890,7 +888,6 @@ describe("createDatabase", () => { const entityData = newStore.read(entity); expect(entityData).toEqual({ - id: entity, position: { x: 1, y: 2, z: 3 } }); diff --git a/packages/data/src/ecs/database/public/observe-select-deep.ts b/packages/data/src/ecs/database/public/observe-select-deep.ts index 1539ff6f..acf54814 100644 --- a/packages/data/src/ecs/database/public/observe-select-deep.ts +++ b/packages/data/src/ecs/database/public/observe-select-deep.ts @@ -115,6 +115,17 @@ const createObserveSelectDeep = < /** * Deeply observe entity data for an archetype query. * + * ⚠️ NOT FOR PRODUCTION USE. This has poor runtime performance by construction: + * it attaches a separate per-entity observer to every matched entity, rebuilds a + * fresh data object on every change, and deep-`equals` compares to dedupe. Cost + * scales with the size of the result set times the churn rate. It exists for + * debugging and ad-hoc inspection only — reach for `db.observe.select` (entity + * ids + membership changes) and read components on demand in real code. + * + * The result rows include `id` deliberately: unlike `read(entity)` (where you + * already hold the id), these are otherwise-anonymous query results, so `id` is + * the only thing identifying which entity each row is. + * * Unlike `db.observe.select` (which returns entity IDs and only triggers on * set membership changes), `observeSelectDeep` returns full typed entity data * and re-emits whenever ANY included component value changes on any matched entity. diff --git a/packages/data/src/ecs/database/transactional-store/create-transactional-store.test.ts b/packages/data/src/ecs/database/transactional-store/create-transactional-store.test.ts index e16ca196..3fec50b4 100644 --- a/packages/data/src/ecs/database/transactional-store/create-transactional-store.test.ts +++ b/packages/data/src/ecs/database/transactional-store/create-transactional-store.test.ts @@ -58,7 +58,7 @@ describe("createTransactionalStore", () => { const result = store.execute((transactionStore) => { // Create an entity - const archetype = transactionStore.ensureArchetype(["id", "position"]); + const archetype = transactionStore.ensureArchetype(["position"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 } }); // Update the entity @@ -89,7 +89,7 @@ describe("createTransactionalStore", () => { // Create initial entity in a transaction let entity: number = -1; store.execute((transactionStore) => { - const archetype = transactionStore.ensureArchetype(["id", "position"]); + const archetype = transactionStore.ensureArchetype(["position"]); entity = archetype.insert({ position: { x: 1, y: 2, z: 3 } }); }); @@ -123,7 +123,7 @@ describe("createTransactionalStore", () => { let entity: number = -1; store.execute((t) => { - entity = t.ensureArchetype(["id", "position"]).insert({ position: { x: 1, y: 2, z: 3 } }); + entity = t.ensureArchetype(["position"]).insert({ position: { x: 1, y: 2, z: 3 } }); }); // A transaction that ADDs the `health` column then throws must roll back @@ -137,7 +137,7 @@ describe("createTransactionalStore", () => { const finalData = store.read(entity); expect(finalData?.health).toBeUndefined(); - expect(finalData).toEqual({ id: entity, position: { x: 1, y: 2, z: 3 } }); + expect(finalData).toEqual({ position: { x: 1, y: 2, z: 3 } }); }); it("should combine multiple updates to the same entity", () => { @@ -150,7 +150,7 @@ describe("createTransactionalStore", () => { const store = createTransactionalStore(baseStore); const result = store.execute((transactionStore) => { - const archetype = transactionStore.ensureArchetype(["id", "position", "health"]); + const archetype = transactionStore.ensureArchetype(["position", "health"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } @@ -185,8 +185,8 @@ describe("createTransactionalStore", () => { const result = store.execute((transactionStore) => { // Create entities in different archetypes - const posArchetype = transactionStore.ensureArchetype(["id", "position"]); - const healthArchetype = transactionStore.ensureArchetype(["id", "health"]); + const posArchetype = transactionStore.ensureArchetype(["position"]); + const healthArchetype = transactionStore.ensureArchetype(["health"]); const entity1 = posArchetype.insert({ position: { x: 1, y: 2, z: 3 } }); const entity2 = healthArchetype.insert({ health: { current: 100, max: 100 } }); @@ -217,12 +217,12 @@ describe("createTransactionalStore", () => { expect(store.execute).toBeDefined(); // Verify we can use the store normally for read operations - const archetypes = store.queryArchetypes(["id"]); + const archetypes = store.queryArchetypes([]); expect(archetypes.length).toBeGreaterThan(0); // Verify we can create entities through actions store.execute((transactionStore) => { - const archetype = transactionStore.ensureArchetype(["id", "position"]); + const archetype = transactionStore.ensureArchetype(["position"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 } }); expect(store.read(entity)).toBeDefined(); }); @@ -239,7 +239,7 @@ describe("createTransactionalStore", () => { // Execute a regular transaction (non-transient) const regularResult = store.execute((t) => { - const archetype = t.ensureArchetype(["id", "position"]); + const archetype = t.ensureArchetype(["position"]); archetype.insert({ position: { x: 1, y: 2, z: 3 } }); }); @@ -247,7 +247,7 @@ describe("createTransactionalStore", () => { // Execute an intermediate transaction const transientResult = store.execute((t) => { - const archetype = t.ensureArchetype(["id", "position"]); + const archetype = t.ensureArchetype(["position"]); archetype.insert({ position: { x: 10, y: 20, z: 30 } }); }, { intermediate: true }); @@ -282,7 +282,7 @@ describe("createTransactionalStore", () => { const store = createTransactionalStore(baseStore); const result = store.execute((transactionStore) => { - const archetype = transactionStore.ensureArchetype(["id", "position", "health"]); + const archetype = transactionStore.ensureArchetype(["position", "health"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } @@ -317,7 +317,7 @@ describe("createTransactionalStore", () => { // First create an entity let entity: number; store.execute((transactionStore) => { - const archetype = transactionStore.ensureArchetype(["id", "position", "health"]); + const archetype = transactionStore.ensureArchetype(["position", "health"]); entity = archetype.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } @@ -344,7 +344,7 @@ describe("createTransactionalStore", () => { // First create an entity let entity: number; store.execute((transactionStore) => { - const archetype = transactionStore.ensureArchetype(["id", "position", "health"]); + const archetype = transactionStore.ensureArchetype(["position", "health"]); entity = archetype.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } @@ -373,7 +373,7 @@ describe("createTransactionalStore", () => { // First create an entity with multiple components let entity: number; store.execute((transactionStore) => { - const archetype = transactionStore.ensureArchetype(["id", "position", "health"]); + const archetype = transactionStore.ensureArchetype(["position", "health"]); entity = archetype.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } @@ -403,7 +403,7 @@ describe("createTransactionalStore", () => { // First create an entity let entity: number; store.execute((transactionStore) => { - const archetype = transactionStore.ensureArchetype(["id", "position", "health"]); + const archetype = transactionStore.ensureArchetype(["position", "health"]); entity = archetype.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } @@ -434,7 +434,7 @@ describe("createTransactionalStore", () => { let entity: number; store.execute((transactionStore) => { - const archetype = transactionStore.ensureArchetype(["id", "position", "health"]); + const archetype = transactionStore.ensureArchetype(["position", "health"]); entity = archetype.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } @@ -461,7 +461,7 @@ describe("createTransactionalStore", () => { const store = createTransactionalStore(baseStore); const result = store.execute((transactionStore) => { - const archetype = transactionStore.ensureArchetype(["id", "position", "health"]); + const archetype = transactionStore.ensureArchetype(["position", "health"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } @@ -493,7 +493,7 @@ describe("createTransactionalStore", () => { const store = createTransactionalStore(baseStore); const result = store.execute((transactionStore) => { - const archetype = transactionStore.ensureArchetype(["id", "position", "health"]); + const archetype = transactionStore.ensureArchetype(["position", "health"]); // Create first entity const entity1 = archetype.insert({ @@ -538,11 +538,11 @@ describe("createTransactionalStore", () => { const store = createTransactionalStore(baseStore); // Get the archetype before any inserts - const archetype = baseStore.ensureArchetype(["id", "position"]); + const archetype = baseStore.ensureArchetype(["position"]); const initialRows = archetype.rowCount; store.execute((transactionStore) => { - const transactionArchetype = transactionStore.ensureArchetype(["id", "position"]); + const transactionArchetype = transactionStore.ensureArchetype(["position"]); // First insert - rows should be incremented const entity1 = transactionArchetype.insert({ @@ -587,7 +587,7 @@ describe("createTransactionalStore", () => { })); const result = store.execute(t => { - const archetype = t.ensureArchetype(["id", "position", "nonPersistent"]); + const archetype = t.ensureArchetype(["position", "nonPersistent"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 }, nonPersistent: true }); expect(Entity.isNonPersistent(entity)).toBe(true); }); @@ -603,7 +603,7 @@ describe("createTransactionalStore", () => { })); const result = store.execute(t => { - const archetype = t.ensureArchetype(["id", "position"]); + const archetype = t.ensureArchetype(["position"]); archetype.insert({ position: { x: 1, y: 2, z: 3 } }); }); @@ -618,10 +618,10 @@ describe("createTransactionalStore", () => { })); const result = store.execute(t => { - const persistent = t.ensureArchetype(["id", "position"]); + const persistent = t.ensureArchetype(["position"]); persistent.insert({ position: { x: 1, y: 2, z: 3 } }); - const nonPersistent = t.ensureArchetype(["id", "position", "nonPersistent"]); + const nonPersistent = t.ensureArchetype(["position", "nonPersistent"]); nonPersistent.insert({ position: { x: 4, y: 5, z: 6 }, nonPersistent: true }); }); @@ -654,11 +654,11 @@ describe("createTransactionalStore", () => { archetypes: {} }); - const cursorArchetype = (baseStore as any).ensureArchetype(["id", "cursor", "nonPersistent"]); + const cursorArchetype = (baseStore as any).ensureArchetype(["cursor", "nonPersistent"]); const cursorEntityId = cursorArchetype.columns.id.get(0); expect(Entity.isNonPersistent(cursorEntityId)).toBe(true); - const scoreArchetype = (baseStore as any).ensureArchetype(["id", "score"]); + const scoreArchetype = (baseStore as any).ensureArchetype(["score"]); const scoreEntityId = scoreArchetype.columns.id.get(0); expect(Entity.isNonPersistent(scoreEntityId)).toBe(false); }); @@ -718,7 +718,7 @@ describe("createTransactionalStore", () => { let entity: number; store.execute(t => { - const archetype = t.ensureArchetype(["id", "position", "nonPersistent"]); + const archetype = t.ensureArchetype(["position", "nonPersistent"]); entity = archetype.insert({ position: { x: 1, y: 2, z: 3 }, nonPersistent: true }); }); @@ -738,7 +738,7 @@ describe("createTransactionalStore", () => { let entity: number; store.execute(t => { - const archetype = t.ensureArchetype(["id", "position", "nonPersistent"]); + const archetype = t.ensureArchetype(["position", "nonPersistent"]); entity = archetype.insert({ position: { x: 1, y: 2, z: 3 }, nonPersistent: true }); }); @@ -758,7 +758,7 @@ describe("createTransactionalStore", () => { let entity: number; const first = store.execute(t => { - const archetype = t.ensureArchetype(["id", "position", "nonPersistent"]); + const archetype = t.ensureArchetype(["position", "nonPersistent"]); entity = archetype.insert({ position: { x: 1, y: 2, z: 3 }, nonPersistent: true }); }); @@ -781,12 +781,12 @@ describe("createTransactionalStore", () => { })); const nonPersistentResult = store.execute(t => { - const archetype = t.ensureArchetype(["id", "position", "nonPersistent"]); + const archetype = t.ensureArchetype(["position", "nonPersistent"]); archetype.insert({ position: { x: 1, y: 2, z: 3 }, nonPersistent: true }); }); const persistentResult = store.execute(t => { - const archetype = t.ensureArchetype(["id", "position"]); + const archetype = t.ensureArchetype(["position"]); archetype.insert({ position: { x: 4, y: 5, z: 6 } }); }); @@ -811,7 +811,7 @@ describe("createTransactionalStore", () => { // Insert an entity that has the health column. let entity: Entity = 0 as Entity; store.execute(t => { - const archetype = t.ensureArchetype(["id", "position", "health"]); + const archetype = t.ensureArchetype(["position", "health"]); entity = archetype.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 }, @@ -853,7 +853,7 @@ describe("createTransactionalStore", () => { const store = make(); let a: Entity, b: Entity, c: Entity; store.execute((t) => { - const ar = t.ensureArchetype(["id", "value"]); + const ar = t.ensureArchetype(["value"]); a = ar.insert({ value: 1 }); b = ar.insert({ value: 2 }); c = ar.insert({ value: 3 }); @@ -868,7 +868,7 @@ describe("createTransactionalStore", () => { const store = make(); let b: Entity; store.execute((t) => { - const ar = t.ensureArchetype(["id", "value"]); + const ar = t.ensureArchetype(["value"]); ar.insert({ value: 1 }); b = ar.insert({ value: 2 }); }); @@ -880,11 +880,11 @@ describe("createTransactionalStore", () => { const store = make(); let x: Entity, y: Entity; store.execute((t) => { - const ar = t.ensureArchetype(["id", "value"]); + const ar = t.ensureArchetype(["value"]); x = ar.insert({ value: 1 }); y = ar.insert({ value: 2 }); }); - // Adding `tag` migrates x out of ["id","value"]; its vacated row 0 + // Adding `tag` migrates x out of ["value"]; its vacated row 0 // is backfilled by y (the last row of the old archetype). const result = store.execute((t) => { t.update(x!, { tag: 9 }); }); expect(result.relocatedEntities.has(x!)).toBe(true); @@ -895,7 +895,7 @@ describe("createTransactionalStore", () => { const store = make(); let x: Entity; store.execute((t) => { - x = t.ensureArchetype(["id", "value"]).insert({ value: 1 }); + x = t.ensureArchetype(["value"]).insert({ value: 1 }); }); const result = store.execute((t) => { t.update(x!, { value: 5 }); }); expect(result.relocatedEntities.size).toBe(0); diff --git a/packages/data/src/ecs/store/archetype-row.ts b/packages/data/src/ecs/store/archetype-row.ts index dfb0c243..bd026916 100644 --- a/packages/data/src/ecs/store/archetype-row.ts +++ b/packages/data/src/ecs/store/archetype-row.ts @@ -3,7 +3,6 @@ import type { StringKeyof } from "../../types/types.js"; import type { FromSchemas } from "../../schema/from-schemas.js"; import type { ComponentSchemas } from "../component-schemas.js"; import type { ArchetypeComponents } from "./archetype-components.js"; -import type { RequiredComponents } from "../required-components.js"; import type { OptionalComponents } from "../optional-components.js"; import type { ReadonlyArchetype } from "../archetype/archetype.js"; @@ -16,9 +15,9 @@ export type ArchetypeSchema = { export type ArchetypeRowOf< S extends ArchetypeSchema, K extends StringKeyof, -> = RequiredComponents & { +> = { readonly [Col in S["archetypes"][K][number]]: - (FromSchemas & RequiredComponents & OptionalComponents)[Col] + (FromSchemas & OptionalComponents)[Col] }; export type ArchetypeHandleOf< diff --git a/packages/data/src/ecs/store/core/core.ts b/packages/data/src/ecs/store/core/core.ts index e6f439e7..374b9cbd 100644 --- a/packages/data/src/ecs/store/core/core.ts +++ b/packages/data/src/ecs/store/core/core.ts @@ -10,8 +10,11 @@ import { OptionalComponents } from "../../optional-components.js"; import { HasPartitionKey } from "../partition.js"; import { PersistenceScope, ToDataOptions } from "../../persistence-scope.js"; -export type EntityValues = { readonly [K in (RequiredComponents & StringKeyof)]: (C & OptionalComponents)[K] } -export type EntityReadValues = RequiredComponents & { readonly [K in StringKeyof as string extends K ? never : K]?: (C & OptionalComponents)[K] } +// Entity value shapes deliberately EXCLUDE `id`: the id is the entity's identity +// (the key you already hold when reading), not one of its component values. It +// stays a real column (see the Archetype interface) but is never part of a read. +export type EntityValues = { readonly [K in StringKeyof]: (C & OptionalComponents)[K] } +export type EntityReadValues = { readonly [K in StringKeyof as string extends K ? never : K]?: (C & OptionalComponents)[K] } export type EntityUpdateValues = Partial>; export type ArchetypeQueryOptions = { @@ -32,25 +35,25 @@ export interface ReadonlyCore< readonly componentSchemas: { readonly [K in StringKeyof]: Schema }; queryArchetypes< - Include extends StringKeyof, + Include extends StringKeyof, >( include: readonly Include[] | ReadonlySet, options?: ArchetypeQueryOptions - ): readonly ReadonlyArchetype>[]; + ): readonly ReadonlyArchetype>[]; // No partition value → a concrete ReadonlyArchetype unless the key set // includes a partition component, in which case a Router (write-only). - ensureArchetype>( + ensureArchetype>( components: readonly CC[] | ReadonlySet, ): HasPartitionKey extends true - ? Archetype.Router - : ReadonlyArchetype; + ? Archetype.Router<{ [K in CC]: (C & OptionalComponents)[K] }> + : ReadonlyArchetype<{ [K in CC]: (C & OptionalComponents)[K] }>; // Partition value(s) supplied → the concrete value-child, always. - ensureArchetype>( + ensureArchetype>( components: readonly CC[] | ReadonlySet, - partitionValues: { readonly [K in Extract]: (C & RequiredComponents & OptionalComponents)[K] }, - ): ReadonlyArchetype; + partitionValues: { readonly [K in Extract]: (C & OptionalComponents)[K] }, + ): ReadonlyArchetype<{ [K in CC]: (C & OptionalComponents)[K] }>; - locate: (entity: Entity) => { archetype: ReadonlyArchetype, row: number } | null; + locate: (entity: Entity) => { archetype: ReadonlyArchetype, row: number } | null; /** * Read exactly the components of `archetype`. A membership GATE: returns * `null` unless the entity is a superset of `archetype`. The result is @@ -58,7 +61,7 @@ export interface ReadonlyCore< * `archetype` off the result is a compile error (use a wider archetype, the * component-list overload, or `read(entity)`). */ - read(entity: Entity, archetype: ReadonlyArchetype | Archetype): Readonly | null; + read(entity: Entity, archetype: ReadonlyArchetype | Archetype): Readonly | null; /** * Read a chosen subset of an entity's components. * @@ -69,8 +72,9 @@ export interface ReadonlyCore< * * Prefer this over `read(entity)` when only a few fields are needed: it * names the exact components touched, so `db.derive` can scope its - * recompute to just those fields instead of the whole entity. `id` is - * always readable; the element type is inferred as a literal union. + * recompute to just those fields instead of the whole entity. `id` is not a + * readable component (you already hold it); the element type is inferred as + * a literal union. */ read>>(entity: Entity, components: readonly K[]): Readonly, K>> | null; read(entity: Entity): EntityReadValues | null; @@ -95,21 +99,21 @@ export interface Core< PK extends string = never, > extends ReadonlyCore { queryArchetypes< - Include extends StringKeyof, + Include extends StringKeyof, >( include: readonly Include[] | ReadonlySet, options?: ArchetypeQueryOptions - ): readonly Archetype>[]; - ensureArchetype>( + ): readonly Archetype>[]; + ensureArchetype>( components: readonly CC[] | ReadonlySet, ): HasPartitionKey extends true - ? Archetype.Router - : Archetype; - ensureArchetype>( + ? Archetype.Router<{ [K in CC]: (C & OptionalComponents)[K] }> + : Archetype<{ [K in CC]: (C & OptionalComponents)[K] }>; + ensureArchetype>( components: readonly CC[] | ReadonlySet, - partitionValues: { readonly [K in Extract]: (C & RequiredComponents & OptionalComponents)[K] }, - ): Archetype; - locate: (entity: Entity) => { archetype: Archetype, row: number } | null; + partitionValues: { readonly [K in Extract]: (C & OptionalComponents)[K] }, + ): Archetype<{ [K in CC]: (C & OptionalComponents)[K] }>; + locate: (entity: Entity) => { archetype: Archetype, row: number } | null; /** * Deletes the entity. Returns the entity that was swap-moved into the * vacated row (a relocation side effect), or `undefined` when the deleted diff --git a/packages/data/src/ecs/store/core/create-core.partition.test.ts b/packages/data/src/ecs/store/core/create-core.partition.test.ts index 098179fc..0d70f300 100644 --- a/packages/data/src/ecs/store/core/create-core.partition.test.ts +++ b/packages/data/src/ecs/store/core/create-core.partition.test.ts @@ -18,7 +18,7 @@ describe("partition components", () => { describe("ensureArchetype return discrimination", () => { it("returns a Router (no dense view) when a partition key is present without a value", () => { const core = makeCore(); - const router = core.ensureArchetype(["id", "cell", "position"]); + const router = core.ensureArchetype(["cell", "position"]); expect(typeof router.insert).toBe("function"); // A family has no single dense column view. `in` (not a cast) — the // Router type has no such members, so probe presence structurally. @@ -28,14 +28,14 @@ describe("partition components", () => { it("returns a concrete Archetype when the partition value is supplied", () => { const core = makeCore(); - const arch = core.ensureArchetype(["id", "cell", "position"], { cell: 7 }); + const arch = core.ensureArchetype(["cell", "position"], { cell: 7 }); expect(arch.columns).toBeDefined(); expect(arch.rowCount).toBe(0); }); it("returns a concrete Archetype (not a Router) when no partition component is in the set", () => { const core = makeCore(); - const arch = core.ensureArchetype(["id", "position"]); + const arch = core.ensureArchetype(["position"]); expect(arch.columns).toBeDefined(); expect(typeof arch.insert).toBe("function"); }); @@ -44,13 +44,13 @@ describe("partition components", () => { describe("routing insert", () => { it("routes rows with distinct partition values into distinct archetypes", () => { const core = makeCore(); - const router = core.ensureArchetype(["id", "cell", "position"]); + const router = core.ensureArchetype(["cell", "position"]); const a = router.insert({ cell: 1, position: { x: 1, y: 1 } }); const b = router.insert({ cell: 2, position: { x: 2, y: 2 } }); const c = router.insert({ cell: 1, position: { x: 3, y: 3 } }); - const cell1 = core.ensureArchetype(["id", "cell", "position"], { cell: 1 }); - const cell2 = core.ensureArchetype(["id", "cell", "position"], { cell: 2 }); + const cell1 = core.ensureArchetype(["cell", "position"], { cell: 1 }); + const cell2 = core.ensureArchetype(["cell", "position"], { cell: 2 }); // Same value → same archetype; different value → different archetype. expect(cell1).not.toBe(cell2); @@ -64,8 +64,8 @@ describe("partition components", () => { it("stores the partition value as a const column (zero per-row bytes)", () => { const core = makeCore(); - core.ensureArchetype(["id", "cell", "position"]).insert({ cell: 42, position: { x: 0, y: 0 } }); - const arch = core.ensureArchetype(["id", "cell", "position"], { cell: 42 }); + core.ensureArchetype(["cell", "position"]).insert({ cell: 42, position: { x: 0, y: 0 } }); + const arch = core.ensureArchetype(["cell", "position"], { cell: 42 }); expect(arch.columns.cell.type).toBe(constBufferType); expect(arch.columns.cell.typedArrayElementSizeInBytes).toBe(0); expect(arch.columns.cell.get(0)).toBe(42); @@ -73,7 +73,7 @@ describe("partition components", () => { it("reads the routed partition value back through the entity", () => { const core = makeCore(); - const router = core.ensureArchetype(["id", "cell", "position"]); + const router = core.ensureArchetype(["cell", "position"]); const e = router.insert({ cell: 9, position: { x: 5, y: 6 } }); expect(core.get(e, "cell")).toBe(9); expect(core.read(e)).toMatchObject({ cell: 9, position: { x: 5, y: 6 } }); @@ -83,7 +83,7 @@ describe("partition components", () => { describe("queryArchetypes", () => { it("returns every value-child of a partitioned component set", () => { const core = makeCore(); - const router = core.ensureArchetype(["id", "cell", "position"]); + const router = core.ensureArchetype(["cell", "position"]); router.insert({ cell: 1, position: { x: 0, y: 0 } }); router.insert({ cell: 2, position: { x: 0, y: 0 } }); router.insert({ cell: 3, position: { x: 0, y: 0 } }); @@ -94,7 +94,7 @@ describe("partition components", () => { it("filters to a single value-child with a partition `where` (broad phase)", () => { const core = makeCore(); - const router = core.ensureArchetype(["id", "cell", "position"]); + const router = core.ensureArchetype(["cell", "position"]); router.insert({ cell: 1, position: { x: 0, y: 0 } }); router.insert({ cell: 2, position: { x: 0, y: 0 } }); router.insert({ cell: 2, position: { x: 0, y: 0 } }); @@ -112,11 +112,11 @@ describe("partition components", () => { describe("update migration", () => { it("migrates an entity to a new value-child when its partition value changes", () => { const core = makeCore(); - const router = core.ensureArchetype(["id", "cell", "position"]); + const router = core.ensureArchetype(["cell", "position"]); const e = router.insert({ cell: 1, position: { x: 7, y: 8 } }); - const cell1 = core.ensureArchetype(["id", "cell", "position"], { cell: 1 }); - const cell2 = core.ensureArchetype(["id", "cell", "position"], { cell: 2 }); + const cell1 = core.ensureArchetype(["cell", "position"], { cell: 1 }); + const cell2 = core.ensureArchetype(["cell", "position"], { cell: 2 }); expect(cell1.rowCount).toBe(1); expect(cell2.rowCount).toBe(0); @@ -132,7 +132,7 @@ describe("partition components", () => { it("does not migrate when a non-partition field changes", () => { const core = makeCore(); - const router = core.ensureArchetype(["id", "cell", "position"]); + const router = core.ensureArchetype(["cell", "position"]); const e = router.insert({ cell: 5, position: { x: 1, y: 1 } }); const cell5 = core.locate(e)!.archetype; @@ -144,7 +144,7 @@ describe("partition components", () => { it("does not migrate when a partition value is set to its current value", () => { const core = makeCore(); - const router = core.ensureArchetype(["id", "cell", "position"]); + const router = core.ensureArchetype(["cell", "position"]); const e = router.insert({ cell: 3, position: { x: 0, y: 0 } }); const before = core.locate(e)!.archetype; core.update(e, { cell: 3 }); @@ -155,7 +155,7 @@ describe("partition components", () => { describe("serialization", () => { it("round-trips partition children (values + rows) through toData/fromData", () => { const core = makeCore(); - const router = core.ensureArchetype(["id", "cell", "position"]); + const router = core.ensureArchetype(["cell", "position"]); const a = router.insert({ cell: 1, position: { x: 1, y: 1 } }); const b = router.insert({ cell: 2, position: { x: 2, y: 2 } }); @@ -169,7 +169,7 @@ describe("partition components", () => { expect(restored.read(a)).toMatchObject({ cell: 1, position: { x: 1, y: 1 } }); expect(restored.read(b)).toMatchObject({ cell: 2, position: { x: 2, y: 2 } }); - const cell1 = restored.ensureArchetype(["id", "cell", "position"], { cell: 1 }); + const cell1 = restored.ensureArchetype(["cell", "position"], { cell: 1 }); expect(cell1.columns.cell.type).toBe(constBufferType); expect(cell1.rowCount).toBe(1); }); @@ -183,7 +183,7 @@ describe("partition components", () => { layer: { type: "integer", partition: true }, value: { type: "number" }, }); - const keys = ["id", "cell", "layer", "value"] as const; + const keys = ["cell", "layer", "value"] as const; it("creates one archetype per distinct (cell, layer) pair, each with both const columns", () => { const core = makeGridCore(); @@ -258,7 +258,7 @@ describe("partition components", () => { describe("feature is inert without partition components", () => { it("a store with no partition component behaves exactly as before", () => { const core = createCore({ position: positionSchema }); - const arch = core.ensureArchetype(["id", "position"]); + const arch = core.ensureArchetype(["position"]); // Concrete archetype, direct insert, dense columns — no Router anywhere. expect(arch.columns).toBeDefined(); const e = arch.insert({ position: { x: 1, y: 2 } }); diff --git a/packages/data/src/ecs/store/core/create-core.test.ts b/packages/data/src/ecs/store/core/create-core.test.ts index 69a2ed7c..acdb62b7 100644 --- a/packages/data/src/ecs/store/core/create-core.test.ts +++ b/packages/data/src/ecs/store/core/create-core.test.ts @@ -57,13 +57,13 @@ export function createCoreTestSuite( }); // Create entities with different component combinations - const archetype1 = core.ensureArchetype(["id", "position"]); + const archetype1 = core.ensureArchetype(["position"]); const entity1 = archetype1.insert({ position: { x: 1, y: 2, z: 3 } }); - const archetype2 = core.ensureArchetype(["id", "health"]); + const archetype2 = core.ensureArchetype(["health"]); const entity2 = archetype2.insert({ health: { current: 100, max: 100 } }); - const archetype3 = core.ensureArchetype(["id", "position", "health"]); + const archetype3 = core.ensureArchetype(["position", "health"]); const entity3 = archetype3.insert({ position: { x: 0, y: 0, z: 0 }, health: { current: 50, max: 100 } @@ -95,13 +95,13 @@ export function createCoreTestSuite( }); // Create entities with different component combinations - const archetype1 = core.ensureArchetype(["id", "position"]); + const archetype1 = core.ensureArchetype(["position"]); const entity1 = archetype1.insert({ position: { x: 1, y: 2, z: 3 } }); - const archetype2 = core.ensureArchetype(["id", "health"]); + const archetype2 = core.ensureArchetype(["health"]); const entity2 = archetype2.insert({ health: { current: 100, max: 100 } }); - const archetype3 = core.ensureArchetype(["id", "position", "health"]); + const archetype3 = core.ensureArchetype(["position", "health"]); const entity3 = archetype3.insert({ position: { x: 0, y: 0, z: 0 }, health: { current: 50, max: 100 } @@ -122,10 +122,10 @@ export function createCoreTestSuite( }); // Create archetypes with different component combinations - const positionOnly = core.ensureArchetype(["id", "position"]); - const positionHealth = core.ensureArchetype(["id", "position", "health"]); - const positionName = core.ensureArchetype(["id", "position", "name"]); - const positionHealthName = core.ensureArchetype(["id", "position", "health", "name"]); + const positionOnly = core.ensureArchetype(["position"]); + const positionHealth = core.ensureArchetype(["position", "health"]); + const positionName = core.ensureArchetype(["position", "name"]); + const positionHealthName = core.ensureArchetype(["position", "health", "name"]); // Query for position but exclude both health and name // Should only return archetype with position only (neither health nor name) @@ -146,32 +146,35 @@ export function createCoreTestSuite( }); // First call should create new archetype - const archetype1 = core.ensureArchetype(["id", "position"]); + const archetype1 = core.ensureArchetype(["position"]); expect(archetype1).toBeDefined(); expect(archetype1.components.has("id")).toBe(true); expect(archetype1.components.has("position")).toBe(true); expect((archetype1.components as Set).has("health")).toBe(false); // Second call with same components should return same archetype - const archetype2 = core.ensureArchetype(["id", "position"]); + const archetype2 = core.ensureArchetype(["position"]); expect(archetype2).toBe(archetype1); // Different components should create new archetype - const archetype3 = core.ensureArchetype(["id", "health"]); + const archetype3 = core.ensureArchetype(["health"]); expect(archetype3).not.toBe(archetype1); expect(archetype3.components.has("id")).toBe(true); expect(archetype3.components.has("health")).toBe(true); expect(archetype3.components.has("position")).toBe(false); }); - it("should throw error when ensuring archetype without id", () => { + it("adds the implicit id column without naming id as a component", () => { const core = factory({ position: positionSchema, }); - expect(() => { - core.ensureArchetype(["position"]); - }).toThrow("id is required"); + // `id` is never named by callers; it is always present as a column, + // but not as a declared component. + const archetype = core.ensureArchetype(["position"]); + expect(archetype.columns.id).toBeDefined(); + // Resolving the same component set returns the very same archetype. + expect(core.ensureArchetype(["position"])).toBe(archetype); }); it("should locate entities correctly", () => { @@ -179,7 +182,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 } }); const location = core.locate(entity); @@ -202,12 +205,13 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 } }); const data = core.read(entity); expect(data).not.toBeNull(); - expect(data?.id).toBe(entity); + // `id` is the entity's identity, not a component value: reads never include it. + expect(data).not.toHaveProperty("id"); expect(data?.position).toEqual({ x: 1, y: 2, z: 3 }); }); @@ -234,7 +238,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 } }); // Verify entity exists @@ -264,7 +268,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 } }); // Update position @@ -281,7 +285,7 @@ export function createCoreTestSuite( health: healthSchema, }); - const archetype1 = core.ensureArchetype(["id", "position"]); + const archetype1 = core.ensureArchetype(["position"]); const entity = archetype1.insert({ position: { x: 1, y: 2, z: 3 } }); // Add health component @@ -303,7 +307,7 @@ export function createCoreTestSuite( health: healthSchema, }); - const archetype1 = core.ensureArchetype(["id", "position", "health"]); + const archetype1 = core.ensureArchetype(["position", "health"]); const entity = archetype1.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } @@ -338,7 +342,7 @@ export function createCoreTestSuite( health: healthSchema, }); - const archetype = core.ensureArchetype(["id", "position", "health"]); + const archetype = core.ensureArchetype(["position", "health"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } @@ -361,7 +365,7 @@ export function createCoreTestSuite( health: healthSchema, }); - const archetype1 = core.ensureArchetype(["id", "position"]); + const archetype1 = core.ensureArchetype(["position"]); const entity = archetype1.insert({ position: { x: 1, y: 2, z: 3 } }); // Add health and update position @@ -380,7 +384,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); const entity1 = archetype.insert({ position: { x: 1, y: 2, z: 3 } }); const entity2 = archetype.insert({ position: { x: 4, y: 5, z: 6 } }); @@ -401,7 +405,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 } }); // Update with empty object @@ -417,7 +421,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); const entities: Entity[] = []; // Create many entities @@ -453,7 +457,7 @@ export function createCoreTestSuite( health: healthSchema, }); - const ephemeralPositionTable = core.ensureArchetype(["id", "position", "nonPersistent"]); + const ephemeralPositionTable = core.ensureArchetype(["position", "nonPersistent"]); const writeId = ephemeralPositionTable.insert({ position: { x: 1, y: 2, z: 3 }, nonPersistent: true }); expect(Entity.isNonPersistent(writeId)).toBe(true); @@ -473,7 +477,7 @@ export function createCoreTestSuite( health: healthSchema, }); - const ephemeralPositionTable = core.ensureArchetype(["id", "position", "nonPersistent"]); + const ephemeralPositionTable = core.ensureArchetype(["position", "nonPersistent"]); const writeId = ephemeralPositionTable.insert({ position: { x: 1, y: 2, z: 3 }, nonPersistent: true }); expect(() => { @@ -494,12 +498,11 @@ export function createCoreTestSuite( health: healthSchema, }); - const positionArchetype = core.ensureArchetype(["id", "position"]); + const positionArchetype = core.ensureArchetype(["position"]); const entity = positionArchetype.insert({ position: { x: 1, y: 2, z: 3 } }); const data = core.read(entity, positionArchetype); expect(data).not.toBeNull(); - expect((data as any)?.id).toBe(entity); expect((data as any)?.position).toEqual({ x: 1, y: 2, z: 3 }); }); @@ -508,7 +511,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); const data = core.read(999 as Entity, archetype); expect(data).toBeNull(); }); @@ -520,11 +523,11 @@ export function createCoreTestSuite( }); // Create entity with only position - const positionArchetype = core.ensureArchetype(["id", "position"]); + const positionArchetype = core.ensureArchetype(["position"]); const entity = positionArchetype.insert({ position: { x: 1, y: 2, z: 3 } }); // Try to read from health archetype - const healthArchetype = core.ensureArchetype(["id", "health"]); + const healthArchetype = core.ensureArchetype(["health"]); const data = core.read(entity, healthArchetype); expect(data).toBeNull(); }); @@ -536,17 +539,16 @@ export function createCoreTestSuite( }); // Create entity with both position and health - const fullArchetype = core.ensureArchetype(["id", "position", "health"]); + const fullArchetype = core.ensureArchetype(["position", "health"]); const entity = fullArchetype.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } }); // Should be able to read from position-only archetype - const positionArchetype = core.ensureArchetype(["id", "position"]); + const positionArchetype = core.ensureArchetype(["position"]); const data = core.read(entity, positionArchetype); expect(data).not.toBeNull(); - expect((data as any)?.id).toBe(entity); expect((data as any)?.position).toEqual({ x: 1, y: 2, z: 3 }); }); @@ -558,14 +560,14 @@ export function createCoreTestSuite( }); // Create entity with position and health - const positionHealthArchetype = core.ensureArchetype(["id", "position", "health"]); + const positionHealthArchetype = core.ensureArchetype(["position", "health"]); const entity = positionHealthArchetype.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } }); // Try to read from name archetype - const nameArchetype = core.ensureArchetype(["id", "name"]); + const nameArchetype = core.ensureArchetype(["name"]); const data = core.read(entity, nameArchetype); expect(data).toBeNull(); }); @@ -576,7 +578,7 @@ export function createCoreTestSuite( health: healthSchema, }); - const positionArchetype = core.ensureArchetype(["id", "position"]); + const positionArchetype = core.ensureArchetype(["position"]); const entity = positionArchetype.insert({ position: { x: 1, y: 2, z: 3 } }); // Update position @@ -592,7 +594,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 } }); // Verify entity exists @@ -610,7 +612,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); const entity1 = archetype.insert({ position: { x: 1, y: 2, z: 3 } }); const entity2 = archetype.insert({ position: { x: 4, y: 5, z: 6 } }); @@ -628,7 +630,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); const data = core.read(-1, archetype); expect(data).toBeNull(); }); @@ -641,8 +643,8 @@ export function createCoreTestSuite( health: healthSchema, }); - const archetype1 = core.ensureArchetype(["id", "position"]); - const archetype2 = core.ensureArchetype(["id", "health"]); + const archetype1 = core.ensureArchetype(["position"]); + const archetype2 = core.ensureArchetype(["health"]); // Add entities to both archetypes const entity1 = archetype1.insert({ position: { x: 1, y: 2, z: 3 } }); @@ -678,7 +680,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); const entity1 = archetype.insert({ position: { x: 10, y: 20, z: 30 } }); const entity2 = archetype.insert({ position: { x: 40, y: 50, z: 60 } }); const entity3 = archetype.insert({ position: { x: 70, y: 80, z: 90 } }); @@ -698,7 +700,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); // Add many entities const ids: Entity[] = []; @@ -733,7 +735,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); expect(() => core.compact()).not.toThrow(); expect(archetype.rowCount).toBe(0); @@ -745,7 +747,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const archetype = core.ensureArchetype(["id", "position"]); + const archetype = core.ensureArchetype(["position"]); archetype.insert({ position: { x: 1, y: 2, z: 3 } }); archetype.insert({ position: { x: 4, y: 5, z: 6 } }); @@ -764,7 +766,7 @@ export function createCoreTestSuite( position: positionSchema, }); - const ephemeralArchetype = core.ensureArchetype(["id", "position", "nonPersistent"]); + const ephemeralArchetype = core.ensureArchetype(["position", "nonPersistent"]); const ephemeralEntity = ephemeralArchetype.insert({ position: { x: 1, y: 2, z: 3 }, nonPersistent: true @@ -790,7 +792,7 @@ export function createCoreTestSuite( }); // Create an nonPersistent entity with just position - const ephemeralArchetype1 = core.ensureArchetype(["id", "position", "nonPersistent"]); + const ephemeralArchetype1 = core.ensureArchetype(["position", "nonPersistent"]); const ephemeralEntity = ephemeralArchetype1.insert({ position: { x: 1, y: 2, z: 3 }, nonPersistent: true @@ -816,10 +818,10 @@ export function createCoreTestSuite( it("partitions entities into four disjoint quadrants by persistence × sharing", () => { const core = factory({ position: positionSchema }); - const doc = core.ensureArchetype(["id", "position"]).insert({ position: { x: 0, y: 0, z: 0 } }); - const settings = core.ensureArchetype(["id", "position", "nonShared"]).insert({ position: { x: 0, y: 0, z: 0 }, nonShared: true }); - const presence = core.ensureArchetype(["id", "position", "nonPersistent"]).insert({ position: { x: 0, y: 0, z: 0 }, nonPersistent: true }); - const session = core.ensureArchetype(["id", "position", "nonPersistent", "nonShared"]).insert({ position: { x: 0, y: 0, z: 0 }, nonPersistent: true, nonShared: true }); + const doc = core.ensureArchetype(["position"]).insert({ position: { x: 0, y: 0, z: 0 } }); + const settings = core.ensureArchetype(["position", "nonShared"]).insert({ position: { x: 0, y: 0, z: 0 }, nonShared: true }); + const presence = core.ensureArchetype(["position", "nonPersistent"]).insert({ position: { x: 0, y: 0, z: 0 }, nonPersistent: true }); + const session = core.ensureArchetype(["position", "nonPersistent", "nonShared"]).insert({ position: { x: 0, y: 0, z: 0 }, nonPersistent: true, nonShared: true }); expect(Entity.isPersistent(doc) && Entity.isShared(doc)).toBe(true); expect(Entity.isPersistent(settings) && Entity.isNonShared(settings)).toBe(true); @@ -832,9 +834,9 @@ export function createCoreTestSuite( it("serializes persistent quadrants and resets non-persistent ones on load", () => { const core = factory({ position: positionSchema }); - const doc = core.ensureArchetype(["id", "position"]).insert({ position: { x: 1, y: 0, z: 0 } }); - const settings = core.ensureArchetype(["id", "position", "nonShared"]).insert({ position: { x: 2, y: 0, z: 0 }, nonShared: true }); - core.ensureArchetype(["id", "position", "nonPersistent"]).insert({ position: { x: 3, y: 0, z: 0 }, nonPersistent: true }); + const doc = core.ensureArchetype(["position"]).insert({ position: { x: 1, y: 0, z: 0 } }); + const settings = core.ensureArchetype(["position", "nonShared"]).insert({ position: { x: 2, y: 0, z: 0 }, nonShared: true }); + core.ensureArchetype(["position", "nonPersistent"]).insert({ position: { x: 3, y: 0, z: 0 }, nonPersistent: true }); const data = core.toData(); @@ -845,19 +847,19 @@ export function createCoreTestSuite( expect(restored.read(doc)?.position).toEqual({ x: 1, y: 0, z: 0 }); expect(restored.read(settings)?.position).toEqual({ x: 2, y: 0, z: 0 }); // presence (non-persistent) is not serialized: its archetype loads empty. - expect(restored.ensureArchetype(["id", "position", "nonPersistent"]).rowCount).toBe(0); + expect(restored.ensureArchetype(["position", "nonPersistent"]).rowCount).toBe(0); }); it("should throw when trying to update nonShared component", () => { const core = factory({ position: positionSchema }); - const entity = core.ensureArchetype(["id", "position", "nonShared"]).insert({ position: { x: 0, y: 0, z: 0 }, nonShared: true }); + const entity = core.ensureArchetype(["position", "nonShared"]).insert({ position: { x: 0, y: 0, z: 0 }, nonShared: true }); expect(() => core.update(entity, { nonShared: true } as never)).toThrow("Cannot update nonShared component"); }); it("scopes toData/fromData to selected persistent quadrants", () => { const core = factory({ position: positionSchema }); - const doc = core.ensureArchetype(["id", "position"]).insert({ position: { x: 1, y: 0, z: 0 } }); - const settings = core.ensureArchetype(["id", "position", "nonShared"]).insert({ position: { x: 2, y: 0, z: 0 }, nonShared: true }); + const doc = core.ensureArchetype(["position"]).insert({ position: { x: 1, y: 0, z: 0 } }); + const settings = core.ensureArchetype(["position", "nonShared"]).insert({ position: { x: 2, y: 0, z: 0 }, nonShared: true }); // One scoped snapshot per persistent quadrant. const docData = core.toData({ scope: { shared: true } }); @@ -889,7 +891,7 @@ export function createCoreTestSuite( const make = () => factory({ position: positionSchema, cache: cacheSchema, derived: derivedSchema }); const core = make(); - const e = core.ensureArchetype(["id", "position", "cache", "derived"]).insert({ + const e = core.ensureArchetype(["position", "cache", "derived"]).insert({ position: { x: 1, y: 2, z: 3 }, cache: 99, derived: 42, }); @@ -922,7 +924,7 @@ export function createCoreTestSuite( const make = () => factory({ position: positionSchema, gpuRef: gpuRefSchema, maybe: maybeSchema, counter: counterSchema }); const core = make(); - const e = core.ensureArchetype(["id", "position", "gpuRef", "maybe", "counter"]).insert({ + const e = core.ensureArchetype(["position", "gpuRef", "maybe", "counter"]).insert({ position: { x: 1, y: 2, z: 3 }, gpuRef: 12345, maybe: 999, counter: 99, }); diff --git a/packages/data/src/ecs/store/core/create-core.ts b/packages/data/src/ecs/store/core/create-core.ts index 0e756767..6772b27a 100644 --- a/packages/data/src/ecs/store/core/create-core.ts +++ b/packages/data/src/ecs/store/core/create-core.ts @@ -3,7 +3,12 @@ import { Schema } from "../../../schema/index.js"; import { createEntityLocationTable } from "../../entity-location-table/index.js"; import * as ARCHETYPE from "../../archetype/index.js"; -import { Table, getRowData, addRow, updateRow } from "../../../table/index.js"; +import { Table, addRow, updateRow } from "../../../table/index.js"; +// getRowData returns the WHOLE row (incl. the internal `id` column); it is only +// used here for the archetype-migration copy that must carry id into the new +// row. Public reads use `readRowExcludingId` below. It is not part of the +// public `@adobe/data/table` surface, so it is imported from the file directly. +import { getRowData } from "../../../table/get-row-data.js"; import { Archetype, ReadonlyArchetype } from "../../archetype/archetype.js"; import { RequiredComponents } from "../../required-components.js"; import { Entity } from "../../entity/entity.js"; @@ -116,14 +121,14 @@ export function createCore( }; const queryArchetypes = < - Include extends StringKeyof, + Include extends StringKeyof, >( include: readonly Include[] | ReadonlySet, options?: ArchetypeQueryOptions - ): readonly Archetype>[] => { + ): readonly Archetype>[] => { const includeArray = Array.from(include); const where = options?.where as Record | undefined; - const results: Archetype>[] = []; + const results: Archetype>[] = []; for (const archetype of archetypes) { const hasAllRequired = includeArray.every(comp => archetype.columns[comp] !== undefined); const hasNoExcluded = !options?.exclude || options.exclude.every(comp => archetype.columns[comp] === undefined); @@ -141,7 +146,7 @@ export function createCore( } } if (hasAllRequired && hasNoExcluded && matchesWhere) { - results.push(archetype as unknown as Archetype>); + results.push(archetype as unknown as Archetype>); } } return results; @@ -157,8 +162,15 @@ export function createCore( componentNames: readonly string[] | ReadonlySet, partitionValues?: Record, ): Archetype => { + // `id` is never a declared component — callers name only real components, + // and the public `ensureArchetype` type rejects "id". The id COLUMN is + // added structurally below (to the schema map), never to this name list or + // the identity key. `id` can still appear in `componentNames` when a + // snapshot restore replays the serialized component set; the key filters it + // and the schema loop skips it, so a restore resolves the same archetype a + // fresh `ensureArchetype([...])` would. const namesArr = Array.from(componentNames); - const sorted = namesArr.slice().sort(); + const sorted = namesArr.filter((n) => n !== "id").sort(); const partitionNames = partitionNamesIn(sorted); for (const n of partitionNames) { if (partitionValues?.[n] === undefined) { @@ -170,12 +182,14 @@ export function createCore( if (existing) return existing; const id = archetypes.length; - const archetypeComponentSchemas: Record = {}; - let hasId = false; + // Every archetype carries the implicit `id` column. Seed the schema with it + // structurally — this is the id column's definition, not a component the + // caller asked for. + const archetypeComponentSchemas: Record = { id: componentSchemas.id }; let isNonPersistent = false; let isNonShared = false; for (const comp of namesArr) { - if (comp === "id") hasId = true; + if (comp === "id") continue; if (comp === "nonPersistent") isNonPersistent = true; if (comp === "nonShared") isNonShared = true; const base = componentSchemas[comp as StringKeyof]; @@ -183,9 +197,6 @@ export function createCore( ? { ...base, const: partitionValues![comp] } : base; } - if (!hasId) { - throw new Error("id is required"); - } const archetype = ARCHETYPE.createArchetype( archetypeComponentSchemas as any, id, @@ -235,6 +246,23 @@ export function createCore( return getLocationTable(entity).locate(entity); } + // Build an entity's value record, excluding the always-present `id` column. + // `read(entity)` is handed the id already, so echoing it back is redundant — + // and `id` is the entity's identity (the key), not one of its component + // values. Single forward pass over the columns (never add-then-delete), so + // this stays as cheap as a full-row copy minus one field on this hot path. + const readRowExcludingId = ( + archetype: { columns: { readonly [k: string]: { get(row: number): unknown } } }, + row: number, + ): Record => { + const values: Record = {}; + for (const name in archetype.columns) { + if (name === "id") continue; + values[name] = archetype.columns[name]!.get(row); + } + return values; + } + const readEntity = ( entity: Entity, archetypeOrComponents?: ReadonlyArchetype | Archetype | readonly string[] @@ -264,7 +292,9 @@ export function createCore( if (archetypeArg && location.archetype !== archetypeArg.id && !archetype.components.isSupersetOf(archetypeArg.components)) { return null; } - return getRowData(archetype, location.row); + // Full read (and the archetype-gated read): return the entity's component + // values WITHOUT `id` (see readRowExcludingId). + return readRowExcludingId(archetype, location.row); } const deleteEntity = (entity: Entity): Entity | undefined => { diff --git a/packages/data/src/ecs/store/core/select-entities.test.ts b/packages/data/src/ecs/store/core/select-entities.test.ts index 5120ece7..ef7fa65c 100644 --- a/packages/data/src/ecs/store/core/select-entities.test.ts +++ b/packages/data/src/ecs/store/core/select-entities.test.ts @@ -28,13 +28,13 @@ describe("selectEntities", () => { }); // Create multiple archetypes with different component combinations - const positionArchetype = core.ensureArchetype(["id", "position"]); - const healthArchetype = core.ensureArchetype(["id", "health"]); - const nameArchetype = core.ensureArchetype(["id", "name"]); - const positionHealthArchetype = core.ensureArchetype(["id", "position", "health"]); - const positionNameArchetype = core.ensureArchetype(["id", "position", "name"]); - const healthNameArchetype = core.ensureArchetype(["id", "health", "name"]); - const fullArchetype = core.ensureArchetype(["id", "position", "health", "name", "score", "active"]); + const positionArchetype = core.ensureArchetype(["position"]); + const healthArchetype = core.ensureArchetype(["health"]); + const nameArchetype = core.ensureArchetype(["name"]); + const positionHealthArchetype = core.ensureArchetype(["position", "health"]); + const positionNameArchetype = core.ensureArchetype(["position", "name"]); + const healthNameArchetype = core.ensureArchetype(["health", "name"]); + const fullArchetype = core.ensureArchetype(["position", "health", "name", "score", "active"]); // Create test entities across different archetypes const entities = { @@ -382,7 +382,7 @@ describe("selectEntities", () => { describe("Performance characteristics", () => { it("should handle large number of entities efficiently", () => { // Create additional entities to test performance - const largeArchetype = core.ensureArchetype(["id", "score"]); + const largeArchetype = core.ensureArchetype(["score"]); const largeEntities: number[] = []; for (let i = 0; i < 100; i++) { diff --git a/packages/data/src/ecs/store/create-store-schema-test.ts b/packages/data/src/ecs/store/create-store-schema-test.ts index 1e613022..71195d6e 100644 --- a/packages/data/src/ecs/store/create-store-schema-test.ts +++ b/packages/data/src/ecs/store/create-store-schema-test.ts @@ -2,7 +2,7 @@ import { Assert } from "../../types/assert.js"; import { Equal } from "../../types/equal.js"; -import { Archetype, RequiredComponents } from "../index.js"; +import { Archetype } from "../index.js"; import { Store } from "./store.js"; const storeSchema = Store.Schema.create({ @@ -21,10 +21,10 @@ const storeSchema = Store.Schema.create({ }) type TestStore = Store.FromSchema; -type CheckParticle = Assert>>; -type CheckDynamicParticle = Assert>>; diff --git a/packages/data/src/ecs/store/partition.ts b/packages/data/src/ecs/store/partition.ts index ad05b722..ab3f5482 100644 --- a/packages/data/src/ecs/store/partition.ts +++ b/packages/data/src/ecs/store/partition.ts @@ -2,7 +2,6 @@ import { StringKeyof } from "../../types/types.js"; import { ComponentSchemas } from "../component-schemas.js"; -import { RequiredComponents } from "../required-components.js"; import { Archetype } from "../archetype/archetype.js"; /** @@ -51,7 +50,7 @@ export type HasPartitionKey = * caller). `Has` is a *naked* type parameter so a `boolean` (from `PK = any`) * distributes to `Archetype.Router | Concrete`. */ -export type ArchetypeOrRouter = +export type ArchetypeOrRouter = Has extends true ? Archetype.Router : Concrete; /** @@ -68,7 +67,7 @@ export type ArchetypeOrRouter = HasPartitionKey extends true ? Archetype.Router : Archetype; diff --git a/packages/data/src/ecs/store/public/create-store.partition.test.ts b/packages/data/src/ecs/store/public/create-store.partition.test.ts index 819ae33b..6eb97052 100644 --- a/packages/data/src/ecs/store/public/create-store.partition.test.ts +++ b/packages/data/src/ecs/store/public/create-store.partition.test.ts @@ -99,18 +99,18 @@ describe("Store partition integration", () => { it("store.ensureArchetype routes (no value) and resolves concrete (with value)", () => { const store = makeStore(); - const router = store.ensureArchetype(["id", "cell", "name"]); + const router = store.ensureArchetype(["cell", "name"]); expect("columns" in router).toBe(false); router.insert({ cell: 5, name: "x" }); - const concrete = store.ensureArchetype(["id", "cell", "name"], { cell: 5 }); + const concrete = store.ensureArchetype(["cell", "name"], { cell: 5 }); expect(concrete.columns).toBeDefined(); expect(concrete.rowCount).toBe(1); }); it("index find works when inserting via the concrete-child path too", () => { const store = makeStore(); - const concrete = store.ensureArchetype(["id", "cell", "name"], { cell: 9 }); + const concrete = store.ensureArchetype(["cell", "name"], { cell: 9 }); const e = concrete.insert({ cell: 9, name: "zed" }); expect(store.indexes.byName.get({ name: "zed" })).toBe(e); }); diff --git a/packages/data/src/ecs/store/public/create-store.partition.type-test.ts b/packages/data/src/ecs/store/public/create-store.partition.type-test.ts index 31e9ee37..9d8663e0 100644 --- a/packages/data/src/ecs/store/public/create-store.partition.type-test.ts +++ b/packages/data/src/ecs/store/public/create-store.partition.type-test.ts @@ -43,22 +43,22 @@ function _archetypesMap() { // store.ensureArchetype(keys, values?) — discriminated function _ensureArchetype() { // Partition key present, no value → Router (insert only). - const router = store.ensureArchetype(["id", "cell", "position"]); + const router = store.ensureArchetype(["cell", "position"]); router.insert({ cell: 1, position: 2 }); // @ts-expect-error - a partition family (Router) has no columns router.columns; // Partition value supplied → concrete Archetype (dense access). - const concrete = store.ensureArchetype(["id", "cell", "position"], { cell: 7 }); + const concrete = store.ensureArchetype(["cell", "position"], { cell: 7 }); concrete.columns; concrete.rowCount; // No partition component in the set → concrete Archetype. - const plain = store.ensureArchetype(["id", "position"]); + const plain = store.ensureArchetype(["position"]); plain.columns; // @ts-expect-error - partition value must match the component's type (cell is number) - store.ensureArchetype(["id", "cell", "position"], { cell: "nope" }); + store.ensureArchetype(["cell", "position"], { cell: "nope" }); } // queryArchetypes where — keyed to partition components only diff --git a/packages/data/src/ecs/store/public/create-store.test.ts b/packages/data/src/ecs/store/public/create-store.test.ts index 2ccd6e16..698b1eb3 100644 --- a/packages/data/src/ecs/store/public/create-store.test.ts +++ b/packages/data/src/ecs/store/public/create-store.test.ts @@ -33,7 +33,7 @@ describe("createStore", () => { }, resources: {}, archetypes: {} }); // Create entities in a single archetype - const archetype = store.ensureArchetype(["id", "position", "health"]); + const archetype = store.ensureArchetype(["position", "health"]); const entity1 = archetype.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } @@ -59,7 +59,7 @@ describe("createStore", () => { }, resources: {}, archetypes: {} }); // Create entities in different archetypes - const positionOnlyArchetype = store.ensureArchetype(["id", "position"]); + const positionOnlyArchetype = store.ensureArchetype(["position"]); const entity1 = positionOnlyArchetype.insert({ position: { x: 1, y: 2, z: 3 } }); @@ -67,18 +67,18 @@ describe("createStore", () => { position: { x: 4, y: 5, z: 6 } }); - const healthOnlyArchetype = store.ensureArchetype(["id", "health"]); + const healthOnlyArchetype = store.ensureArchetype(["health"]); const entity3 = healthOnlyArchetype.insert({ health: { current: 100, max: 100 } }); - const positionHealthArchetype = store.ensureArchetype(["id", "position", "health"]); + const positionHealthArchetype = store.ensureArchetype(["position", "health"]); const entity4 = positionHealthArchetype.insert({ position: { x: 0, y: 0, z: 0 }, health: { current: 50, max: 100 } }); - const velocityArchetype = store.ensureArchetype(["id", "velocity"]); + const velocityArchetype = store.ensureArchetype(["velocity"]); const entity5 = velocityArchetype.insert({ velocity: { x: 1, y: 0, z: 0 } }); @@ -119,12 +119,12 @@ describe("createStore", () => { }, resources: {}, archetypes: {} }); // Create entities in different archetypes - const positionOnlyArchetype = store.ensureArchetype(["id", "position"]); + const positionOnlyArchetype = store.ensureArchetype(["position"]); const entity1 = positionOnlyArchetype.insert({ position: { x: 1, y: 2, z: 3 } }); - const positionHealthArchetype = store.ensureArchetype(["id", "position", "health"]); + const positionHealthArchetype = store.ensureArchetype(["position", "health"]); const entity2 = positionHealthArchetype.insert({ position: { x: 0, y: 0, z: 0 }, health: { current: 50, max: 100 } @@ -144,7 +144,7 @@ describe("createStore", () => { }, resources: {}, archetypes: {} }); // Create entity with only position - const archetype = store.ensureArchetype(["id", "position"]); + const archetype = store.ensureArchetype(["position"]); archetype.insert({ position: { x: 1, y: 2, z: 3 } }); // Select entities with health (should be empty) @@ -161,14 +161,14 @@ describe("createStore", () => { }, resources: {}, archetypes: {} }); // Create entities across many different archetypes - const archetype1 = store.ensureArchetype(["id", "position"]); + const archetype1 = store.ensureArchetype(["position"]); const entity1 = archetype1.insert({ position: { x: 1, y: 2, z: 3 } }); const entity2 = archetype1.insert({ position: { x: 4, y: 5, z: 6 } }); - const archetype2 = store.ensureArchetype(["id", "health"]); + const archetype2 = store.ensureArchetype(["health"]); const entity3 = archetype2.insert({ health: { current: 100, max: 100 } }); - const archetype3 = store.ensureArchetype(["id", "position", "health"]); + const archetype3 = store.ensureArchetype(["position", "health"]); const entity4 = archetype3.insert({ position: { x: 0, y: 0, z: 0 }, health: { current: 50, max: 100 } @@ -178,16 +178,16 @@ describe("createStore", () => { health: { current: 75, max: 100 } }); - const archetype4 = store.ensureArchetype(["id", "name"]); + const archetype4 = store.ensureArchetype(["name"]); const entity6 = archetype4.insert({ name: "Player1" }); - const archetype5 = store.ensureArchetype(["id", "position", "name"]); + const archetype5 = store.ensureArchetype(["position", "name"]); const entity7 = archetype5.insert({ position: { x: 100, y: 200, z: 300 }, name: "Player2" }); - const archetype6 = store.ensureArchetype(["id", "position", "health", "name"]); + const archetype6 = store.ensureArchetype(["position", "health", "name"]); const entity8 = archetype6.insert({ position: { x: 500, y: 600, z: 700 }, health: { current: 25, max: 100 }, @@ -237,14 +237,14 @@ describe("createStore", () => { }, resources: {}, archetypes: {} }); // Create entities in different archetypes - const archetype1 = store.ensureArchetype(["id", "position"]); + const archetype1 = store.ensureArchetype(["position"]); const entity1 = archetype1.insert({ position: { x: 1, y: 2, z: 3 } }); const entity2 = archetype1.insert({ position: { x: 4, y: 5, z: 6 } }); - const archetype2 = store.ensureArchetype(["id", "health"]); + const archetype2 = store.ensureArchetype(["health"]); const entity3 = archetype2.insert({ health: { current: 100, max: 100 } }); - const archetype3 = store.ensureArchetype(["id", "position", "health"]); + const archetype3 = store.ensureArchetype(["position", "health"]); const entity4 = archetype3.insert({ position: { x: 0, y: 0, z: 0 }, health: { current: 50, max: 100 } @@ -280,7 +280,7 @@ describe("createStore", () => { }, archetypes: {} }); // Create some entities - const archetype = store.ensureArchetype(["id", "position"]); + const archetype = store.ensureArchetype(["position"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 } }); // Select entities with time component (should include the resource entity) @@ -544,7 +544,7 @@ describe("createStore", () => { // Create entity with current timestamp const now = Date.now(); - const archetype = store.ensureArchetype(["id", "timestamp"]); + const archetype = store.ensureArchetype(["timestamp"]); const entity = archetype.insert({ timestamp: now }); // Query for the entity @@ -571,7 +571,7 @@ describe("createStore", () => { }, archetypes: {} }); // Add some entities - const archetype = store.ensureArchetype(["id", "position", "health"]); + const archetype = store.ensureArchetype(["position", "health"]); const entity1 = archetype.insert({ position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } @@ -606,12 +606,10 @@ describe("createStore", () => { const restoredData1 = newStore.read(restoredEntities[0]); const restoredData2 = newStore.read(restoredEntities[1]); expect(restoredData1).toEqual({ - id: restoredEntities[0], position: { x: 1, y: 2, z: 3 }, health: { current: 100, max: 100 } }); expect(restoredData2).toEqual({ - id: restoredEntities[1], position: { x: 4, y: 5, z: 6 }, health: { current: 50, max: 100 } }); @@ -734,7 +732,7 @@ describe("createStore", () => { // the load. fromData must clear it — the non-persistent quadrants are // never serialized, so a load resets them exactly as reset() would. const target = makeStore(); - const selectionArchetype = target.ensureArchetype(["id", "selection", "nonPersistent"]); + const selectionArchetype = target.ensureArchetype(["selection", "nonPersistent"]); const selectionEntity = selectionArchetype.insert({ selection: true, nonPersistent: true }); expect(target.read(selectionEntity)).not.toBeNull(); @@ -755,12 +753,12 @@ describe("createStore", () => { const store = makeStore(); // Ad-hoc nonPersistent entity archetype created FIRST → lower id. - const selectionArchetype = store.ensureArchetype(["id", "selection", "nonPersistent"]); + const selectionArchetype = store.ensureArchetype(["selection", "nonPersistent"]); selectionArchetype.insert({ selection: true, nonPersistent: true }); // Persistent entity archetype created AFTER → higher id, referenced // by the persistent entity-location table by that id. - const positionArchetype = store.ensureArchetype(["id", "position"]); + const positionArchetype = store.ensureArchetype(["position"]); const positionEntity = positionArchetype.insert({ position: 42 }); const serializedData = store.toData({ copy: true }); @@ -771,12 +769,12 @@ describe("createStore", () => { // The persistent entity must still resolve to the right archetype // after reload — it would not if the nonPersistent archetype's slot // were dropped and later ids shifted down. - expect(newStore.read(positionEntity)).toEqual({ id: positionEntity, position: 42 }); + expect(newStore.read(positionEntity)).toEqual({ position: 42 }); }); it("stamps a version and skips (warns, does not throw) snapshots of an incompatible or legacy format", () => { const store = createStore({ components: { position: positionSchema }, resources: {}, archetypes: {} }); - const entity = store.ensureArchetype(["id", "position"]).insert({ position: { x: 1, y: 2, z: 3 } }); + const entity = store.ensureArchetype(["position"]).insert({ position: { x: 1, y: 2, z: 3 } }); const snapshot: any = store.toData({ copy: true }); expect(snapshot.version).toBe(ECS_SNAPSHOT_VERSION); @@ -798,7 +796,7 @@ describe("createStore", () => { // The correctly-versioned snapshot loads normally. const target = createStore({ components: { position: positionSchema }, resources: {}, archetypes: {} }); target.fromData(snapshot); - expect(target.read(entity)).toEqual({ id: entity, position: { x: 1, y: 2, z: 3 } }); + expect(target.read(entity)).toEqual({ position: { x: 1, y: 2, z: 3 } }); } finally { warn.mockRestore(); } @@ -807,7 +805,7 @@ describe("createStore", () => { it("toData(true) detaches the snapshot from later store mutation; toData() references live buffers", () => { const makePopulatedStore = () => { const store = createStore({ components: { health: healthSchema }, resources: {}, archetypes: {} }); - const archetype = store.ensureArchetype(["id", "health"]); + const archetype = store.ensureArchetype(["health"]); const entity = archetype.insert({ health: { current: 100, max: 100 } }); return { store, entity }; }; @@ -841,7 +839,7 @@ describe("createStore", () => { }, archetypes: {} }); // Add some entities and update resource - const archetype = originalStore.ensureArchetype(["id", "position"]); + const archetype = originalStore.ensureArchetype(["position"]); archetype.insert({ position: { x: 1, y: 2, z: 3 } }); originalStore.resources.time = { delta: 0.033, elapsed: 1.5 }; @@ -893,7 +891,7 @@ describe("createStore", () => { }, archetypes: {} }); // Add entities and update resources - const archetype = originalStore.ensureArchetype(["id", "position"]); + const archetype = originalStore.ensureArchetype(["position"]); const entity = archetype.insert({ position: { x: 1, y: 2, z: 3 } }); originalStore.resources.time = { delta: 0.033, elapsed: 1.5 }; originalStore.resources.config = { debug: true, volume: 0.5 }; @@ -925,7 +923,6 @@ describe("createStore", () => { expect(restoredEntities).toHaveLength(1); const restoredData = newStore.read(restoredEntities[0]); expect(restoredData).toEqual({ - id: restoredEntities[0], position: { x: 1, y: 2, z: 3 } }); }); @@ -995,7 +992,7 @@ describe("createStore", () => { store.resources.complex = updatedComplex; // Add some entities - const archetype = store.ensureArchetype(["id", "position"]); + const archetype = store.ensureArchetype(["position"]); archetype.insert({ position: { x: 1, y: 2, z: 3 } }); // Serialize and deserialize @@ -1025,7 +1022,7 @@ describe("createStore", () => { }, archetypes: {} }); // Add entities and update resource - const archetype = store.ensureArchetype(["id", "position"]); + const archetype = store.ensureArchetype(["position"]); archetype.insert({ position: { x: 1, y: 2, z: 3 } }); store.resources.time = { delta: 0.033, elapsed: 1.5 }; @@ -1045,7 +1042,6 @@ describe("createStore", () => { expect(restoredEntities).toHaveLength(1); const restoredData = newStore.read(restoredEntities[0]); expect(restoredData).toEqual({ - id: restoredEntities[0], position: { x: 1, y: 2, z: 3 } }); diff --git a/packages/data/src/ecs/store/public/create-store.ts b/packages/data/src/ecs/store/public/create-store.ts index faed3256..2414c7e1 100644 --- a/packages/data/src/ecs/store/public/create-store.ts +++ b/packages/data/src/ecs/store/public/create-store.ts @@ -72,13 +72,9 @@ export function createStore< // re-reads the full record to recompute the key. Seeding goes through // `seedIndexFromArchetypes` instead (see below). const indexRegistry = createIndexRegistry( - (entity) => { - const values = core.read(entity); - if (!values) return null; - // Strip `id` — it is never a useful index key. - const { id: _id, ...rest } = values as { id: Entity } & Record; - return rest; - }, + // `core.read` already excludes `id` (the identity is never a useful index + // key), so the read record is handed to the registry as-is. + (entity) => core.read(entity) as Record | null, // Resolve an `archetype`-scoped index to that archetype's declared // component set (archetypes are registered before indexes in `extend`). (archetypeName) => (archetypeComponentNames as Record)[archetypeName], @@ -177,7 +173,8 @@ export function createStore< const resourceId = name as StringKeyof; const isNonPersistent = resourceSchema.nonPersistent; const isNonShared = resourceSchema.nonShared; - const componentNames: StringKeyof[] = ["id" as StringKeyof, resourceId]; + // `id` is implicit (added by resolveArchetype); name only real components. + const componentNames: StringKeyof[] = [resourceId]; if (isNonPersistent) componentNames.push("nonPersistent" as StringKeyof); if (isNonShared) componentNames.push("nonShared" as StringKeyof); const archetype = core.ensureArchetype(componentNames); @@ -300,7 +297,8 @@ export function createStore< archetypeComponentNames[name as keyof typeof archetypeComponentNames] = newComponents as any; // Insert is already index-maintaining (decorated at creation), and a // partitioned archetype resolves to a Router — both surfaced directly. - (archetypes as any)[name] = core.ensureArchetype(["id", ...(newComponents as any)]); + // `id` is implicit (added by resolveArchetype); pass only real components. + (archetypes as any)[name] = core.ensureArchetype([...(newComponents as any)]); } // indexes: registry enforces (===)-or-throw on same name and diff --git a/packages/data/src/ecs/store/store.test.ts b/packages/data/src/ecs/store/store.test.ts index 97d9b15d..d2767c24 100644 --- a/packages/data/src/ecs/store/store.test.ts +++ b/packages/data/src/ecs/store/store.test.ts @@ -140,13 +140,13 @@ describe("Store.Schema.create", () => { it("should merge archetypes from dependencies", () => { const baseSchema = Store.Schema.create({ archetypes: { - BaseEntity: ["id"] as any + BaseEntity: [] as any } }); const extendedSchema = Store.Schema.create({ archetypes: { - ExtendedEntity: ["id", "name"] as any + ExtendedEntity: ["name"] as any } }, [baseSchema]); diff --git a/packages/data/src/ecs/store/store.ts b/packages/data/src/ecs/store/store.ts index 38744137..4ebd17eb 100644 --- a/packages/data/src/ecs/store/store.ts +++ b/packages/data/src/ecs/store/store.ts @@ -50,8 +50,8 @@ export interface ReadonlyStore< readonly resources: { readonly [K in StringKeyof]: R[K] }; readonly archetypes: { readonly [K in StringKeyof]: ArchetypeOrRouter< HasPartitionKey, - RequiredComponents & { [Col in A[K][number]]: (C & RequiredComponents & OptionalComponents)[Col] }, - ReadonlyArchetype + { [Col in A[K][number]]: (C & OptionalComponents)[Col] }, + ReadonlyArchetype<{ [Col in A[K][number]]: (C & OptionalComponents)[Col] }> > } readonly indexes: { readonly [K in keyof IX]: Index.Handle }; } @@ -83,8 +83,8 @@ export interface Store< readonly resources: { -readonly [K in StringKeyof]: R[K] }; readonly archetypes: { -readonly [K in StringKeyof]: ArchetypeOrRouter< HasPartitionKey, - RequiredComponents & { [Col in A[K][number]]: (C & RequiredComponents & OptionalComponents)[Col] }, - Archetype + { [Col in A[K][number]]: (C & OptionalComponents)[Col] }, + Archetype<{ [Col in A[K][number]]: (C & OptionalComponents)[Col] }> > } /** * Index handles keyed by user-chosen name. Returned handles are the @@ -104,7 +104,7 @@ export namespace Store { export type Components = S extends Store ? C : never; export type Resources = S extends Store ? R : never; export type Archetypes = S extends Store ? A : never; - export type EntityValues, K extends S extends Store ? StringKeyof : never> = Simplify[0] & RequiredComponents>; + export type EntityValues, K extends S extends Store ? StringKeyof : never> = Simplify[0]>; export type InsertValues, K extends S extends Store ? StringKeyof : never> = Parameters[0]; export type Schema< @@ -221,8 +221,8 @@ export namespace Store { } type Foo = Store<{ a: number, b: string }, {}, { one: ["a", "b"] }> +// `id` is no longer part of an archetype's entity values — only its components. type CheckEntityValues = Assert, { - id: number; a: number; b: string; }>>; @@ -243,11 +243,11 @@ type CheckStoreFromSchema = Store.FromSchema>; declare const testStore: CheckStoreFromSchema; type A = typeof testStore.archetypes.DynamicParticle; -type CheckDynamicParticle = Assert>>; -type CheckParticle = Assert>>; @@ -257,10 +257,10 @@ const checkRowSchema = { components: { particle: { type: "boolean" }, velocity: { type: "number" } }, archetypes: { Particle: ["particle"], DynamicParticle: ["particle", "velocity"] }, } as const; -type CheckSchemaRowDynamic = Assert, RequiredComponents & { +type CheckSchemaRowDynamic = Assert, { readonly particle: boolean; readonly velocity: number; }>>; -type CheckSchemaRowParticle = Assert, RequiredComponents & { +type CheckSchemaRowParticle = Assert, { readonly particle: boolean; }>>; diff --git a/packages/data/src/ecs/undo-redo-service/create-undo-redo-service.test.ts b/packages/data/src/ecs/undo-redo-service/create-undo-redo-service.test.ts index 98409fa8..289356dd 100644 --- a/packages/data/src/ecs/undo-redo-service/create-undo-redo-service.test.ts +++ b/packages/data/src/ecs/undo-redo-service/create-undo-redo-service.test.ts @@ -370,7 +370,7 @@ describe("createUndoRedoService", () => { const after = database.read(entity); expect(after?.name).toBeUndefined(); - expect(after).toEqual({ id: entity, position: { x: 1, y: 2, z: 3 } }); + expect(after).toEqual({ position: { x: 1, y: 2, z: 3 } }); // Redo re-adds the column, and a second undo must remove it again — // the recorded op must not be consumed/emptied by the first replay. @@ -378,6 +378,6 @@ describe("createUndoRedoService", () => { expect(database.read(entity)?.name).toBe("hello"); undoRedo.undo(); expect(database.read(entity)?.name).toBeUndefined(); - expect(database.read(entity)).toEqual({ id: entity, position: { x: 1, y: 2, z: 3 } }); + expect(database.read(entity)).toEqual({ position: { x: 1, y: 2, z: 3 } }); }); }); \ No newline at end of file diff --git a/packages/data/src/table/index.ts b/packages/data/src/table/index.ts index 5cd89f5e..5ffea957 100644 --- a/packages/data/src/table/index.ts +++ b/packages/data/src/table/index.ts @@ -1,10 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export * from "./table.js"; export * from "./create-table.js"; -export * from "./get-row-data.js"; export * from "./update-row.js"; export * from "./add-row.js"; export * from "./delete-row.js"; -export * from "./get-row-data.js"; export * from "./ensure-capacity.js"; export * from "./copy-column-to-gpu-buffer.js"; +// NOTE: `get-row-data.js` is intentionally NOT re-exported here. `getRowData` +// returns the WHOLE row including the internal `id` column and is an +// implementation detail of archetype migration/serialization; ECS reads must +// go through the id-excluding reader instead. Import it directly from +// "./get-row-data.js" where an internal full-row copy is genuinely needed. From 77cbe13746786456de2ae907bef341ec27bc152f Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 15 Aug 2026 11:11:05 -0700 Subject: [PATCH 2/5] feat(data): reserve id/nonPersistent/nonShared names; single-source the id name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Throw if a component schema defines a reserved built-in name (id, nonPersistent, nonShared) — at createCore and at store.extend — instead of silently clobbering the built-in (which post-id-removal corrupts the entity-id column, since resolveArchetype seeds it from componentSchemas[ID]). Covered by a unit test that runs for both the core and store factories. - Abstract the "id" component name to a single source of truth: `ID` (runtime) and `IdComponent` (type) in required-components.ts, with `RequiredComponents = Record`. All ECS id-column access (data + data-persistence) now goes through these, so the name could be changed in one place. Co-Authored-By: Claude Opus 4.8 --- .../create-incremental-persistence-service.ts | 10 ++++---- .../src/service/internal-access.ts | 3 ++- packages/data/src/ecs/archetype/archetype.ts | 10 ++++---- .../src/ecs/archetype/create-archetype.ts | 13 +++++----- packages/data/src/ecs/archetype/delete-row.ts | 3 ++- .../observed/create-observed-database.ts | 7 +++--- .../transactional-store/apply-operations.ts | 3 ++- .../create-transactional-store.ts | 9 ++++--- packages/data/src/ecs/required-components.ts | 24 ++++++++++++++++-- packages/data/src/ecs/store/core/core.ts | 4 +-- .../src/ecs/store/core/create-core.test.ts | 6 +++++ .../data/src/ecs/store/core/create-core.ts | 25 +++++++++++++------ .../src/ecs/store/core/select-entities.ts | 10 ++++---- .../data/src/ecs/store/public/create-store.ts | 8 ++++-- 14 files changed, 90 insertions(+), 45 deletions(-) diff --git a/packages/data-persistence/src/service/create-incremental-persistence-service.ts b/packages/data-persistence/src/service/create-incremental-persistence-service.ts index a458112c..9429f7d5 100644 --- a/packages/data-persistence/src/service/create-incremental-persistence-service.ts +++ b/packages/data-persistence/src/service/create-incremental-persistence-service.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { ECS_SNAPSHOT_VERSION, Entity, serializedEntityLocationTables, type Archetype, type EntityLocationEntry } from "@adobe/data/ecs"; +import { ECS_SNAPSHOT_VERSION, Entity, ID, serializedEntityLocationTables, type Archetype, type EntityLocationEntry } from "@adobe/data/ecs"; import { createColumnEncoder } from "../encoder/create-column-encoder.js"; import { decodeJournalSnapshot, @@ -133,7 +133,7 @@ export const createIncrementalPersistenceService = async ( // Skip the implicit `id` column — entity ids are recovered // from the per-quadrant entity-location files, so storing them // again per archetype row would be redundant. - if (component === "id") continue; + if (component === ID) continue; // Skip nonPersistent-schema components — their values are never // saved; on load they're reset to default or the component is // stripped (see store.reconstructNonPersistentColumns). @@ -146,7 +146,7 @@ export const createIncrementalPersistenceService = async ( const componentIds = new Map(); for (const component of archetype.components) { - if (component === "id" || isNonPersistentComponent(component)) continue; + if (component === ID || isNonPersistentComponent(component)) continue; componentIds.set(component, internComponent(component)); } @@ -670,7 +670,7 @@ export const createIncrementalPersistenceService = async ( const colMan = aMan.columns[componentName]!; // The implicit `id` column is reconstructed from the entity // location table on a separate pass below. - if (colMan.component === "id") continue; + if (colMan.component === ID) continue; await restoreColumn(aMan, colMan, liveArchetype); } @@ -932,7 +932,7 @@ export const createIncrementalPersistenceService = async ( if (entry.componentId === 0) return; const componentName = manifest.components[entry.componentId]; if (componentName === undefined) return; - if (componentName === "id") return; + if (componentName === ID) return; const colMan = aMan.columns[componentName]; if (colMan === undefined) return; diff --git a/packages/data-persistence/src/service/internal-access.ts b/packages/data-persistence/src/service/internal-access.ts index 0c84f36c..6ef13e43 100644 --- a/packages/data-persistence/src/service/internal-access.ts +++ b/packages/data-persistence/src/service/internal-access.ts @@ -6,6 +6,7 @@ // what runtime contract makes it sound. import type { Archetype, Database, ReadonlyArchetype } from "@adobe/data/ecs"; +import { ID } from "@adobe/data/ecs"; import type { Store } from "@adobe/data/ecs"; import type { TypedBuffer } from "@adobe/data/typed-buffer"; @@ -70,5 +71,5 @@ export const getColumn = ( export const getIdColumn = ( archetype: ReadonlyArchetype, ): TypedBuffer | undefined => { - return getColumn(archetype, "id") as TypedBuffer | undefined; + return getColumn(archetype, ID) as TypedBuffer | undefined; }; diff --git a/packages/data/src/ecs/archetype/archetype.ts b/packages/data/src/ecs/archetype/archetype.ts index e67c9bca..e0581fca 100644 --- a/packages/data/src/ecs/archetype/archetype.ts +++ b/packages/data/src/ecs/archetype/archetype.ts @@ -1,12 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { RequiredComponents } from "../required-components.js"; +import { RequiredComponents, IdComponent } from "../required-components.js"; import { Entity } from "../entity/entity.js"; import { Table, ReadonlyTable } from "../../table/index.js"; import { Assert } from "../../types/assert.js"; import { Equal } from "../../types/equal.js"; import { Exact, StringKeyof } from "../../types/types.js"; -export type EntityInsertValues = Omit; +export type EntityInsertValues = Omit; export type ArchetypeId = number; /** @@ -84,8 +84,8 @@ export namespace Archetype { // via the `columns` position (typed `C & RequiredComponents`), but `id` is never // part of the component row. export type FromArchetype = - T extends ReadonlyArchetype ? { readonly [K in keyof Omit]: C[K] } : - T extends Archetype ? { readonly [K in keyof Omit]: C[K] } : + T extends ReadonlyArchetype ? { readonly [K in keyof Omit]: C[K] } : + T extends Archetype ? { readonly [K in keyof Omit]: C[K] } : never; // compile time type tests. @@ -94,7 +94,7 @@ type TestFromReadonlyArchetype = Assert>, { readonly a: number, readonly b: string }>>; // …but it remains a real, typed column so swap-remove / manual traversal can read // `columns.id` directly: `id` is present in `columns` even though it is not in `C`. -type TestIdColumnStillTyped = Assert<"id" extends keyof Archetype<{ a: number }>["columns"] ? true : false>; +type TestIdColumnStillTyped = Assert["columns"] ? true : false>; // Compile-time tests for Exact in insert method { diff --git a/packages/data/src/ecs/archetype/create-archetype.ts b/packages/data/src/ecs/archetype/create-archetype.ts index 2fd75b37..27284524 100644 --- a/packages/data/src/ecs/archetype/create-archetype.ts +++ b/packages/data/src/ecs/archetype/create-archetype.ts @@ -3,6 +3,7 @@ import { Schema } from "../../schema/index.js"; import * as TABLE from "../../table/index.js"; import { Archetype, EntityInsertValues } from "./archetype.js"; +import { ID, IdComponent } from "../required-components.js"; import { EntityLocationTable } from "../entity-location-table/entity-location-table.js"; import { Entity } from "../entity/entity.js"; import { StringKeyof } from "../../types/types.js"; @@ -91,7 +92,7 @@ const buildSpecializedInsert = ( const componentParamValues: TypedBuffer[] = []; const sets: string[] = []; for (const name of componentNames) { - if (name === "id") continue; + if (name === ID) continue; const local = `_${name}`; componentParamNames.push(local); componentParamValues.push(columns[name]); @@ -142,7 +143,7 @@ ${sets.join("\n")} archetypeId, ensureCapacity, entityLocationTable, - columns.id as TypedBuffer, + columns[ID] as TypedBuffer, ...componentParamValues, ); }; @@ -158,21 +159,21 @@ const buildGenericInsert = ( return (archetype: any, rowData: any) => { const row = TABLE.addRow(archetype, rowData); const entity = entityLocationTable.create({ archetype: archetypeId, row }); - archetype.columns.id.set(row, entity); + archetype.columns[ID].set(row, entity); return entity; }; }; -export const createArchetype = ( +export const createArchetype = >( components: C, id: number, entityLocationTable: EntityLocationTable, -): Archetype }, "id">> => { +): Archetype }, IdComponent>> => { // The archetype's public COMPONENT set excludes `id`: id is the entity's // identity, a column but never a component value. (`table.columns` and the // runtime `componentSet` still carry id — required for swap-remove and // serialization — but that is asserted below where the types are narrowed.) - type PublicComponents = Omit<{ [K in keyof C]: Schema.ToType }, "id">; + type PublicComponents = Omit<{ [K in keyof C]: Schema.ToType }, IdComponent>; const table = TABLE.createTable(components); const componentSet = new Set(Object.keys(components)); diff --git a/packages/data/src/ecs/archetype/delete-row.ts b/packages/data/src/ecs/archetype/delete-row.ts index 67f7f102..f2562495 100644 --- a/packages/data/src/ecs/archetype/delete-row.ts +++ b/packages/data/src/ecs/archetype/delete-row.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import * as TABLE from "../../table/index.js"; import { Archetype } from "./archetype.js"; +import { ID } from "../required-components.js"; import { EntityLocationTable } from "../entity-location-table/entity-location-table.js"; import { Entity } from "../entity/entity.js"; @@ -15,7 +16,7 @@ import { Entity } from "../entity/entity.js"; export const deleteRow = (archetype: Archetype, row: number, entityLocationTable: EntityLocationTable): Entity | undefined => { const movedARowToFillHole = TABLE.deleteRow(archetype, row); if (movedARowToFillHole) { - const movedId = archetype.columns.id.get(row); + const movedId = archetype.columns[ID].get(row); entityLocationTable.update(movedId, { archetype: archetype.id, row }); return movedId; } diff --git a/packages/data/src/ecs/database/observed/create-observed-database.ts b/packages/data/src/ecs/database/observed/create-observed-database.ts index ce218871..0afd59a9 100644 --- a/packages/data/src/ecs/database/observed/create-observed-database.ts +++ b/packages/data/src/ecs/database/observed/create-observed-database.ts @@ -14,6 +14,7 @@ import { observeSelectEntities } from "../observe-select-entities.js"; import { createDerive } from "../observe-derive.js"; import { createTransactionalStore } from "../transactional-store/create-transactional-store.js"; import { Entity } from "../../entity/entity.js"; +import { ID } from "../../required-components.js"; import { EntityReadValues, EntityUpdateValues } from "../../store/core/index.js"; import { ObservedDatabase } from "./observed-database.js"; @@ -117,7 +118,7 @@ export function createObservedDatabase< const resourceArchetypeComponents = (resource: string): StringKeyof[] => { const schema = (store.componentSchemas as any)[resource]; - const names: StringKeyof[] = ["id" as StringKeyof, resource as unknown as StringKeyof]; + const names: StringKeyof[] = [ID as StringKeyof, resource as unknown as StringKeyof]; if (schema?.nonPersistent) names.push("nonPersistent" as StringKeyof); if (schema?.nonShared) names.push("nonShared" as StringKeyof); return names; @@ -126,7 +127,7 @@ export function createObservedDatabase< const observeResource = Object.fromEntries( Object.entries(store.resources).map(([resource]) => { const archetype = store.ensureArchetype(resourceArchetypeComponents(resource)); - const resourceId = archetype.columns.id.get(0); + const resourceId = archetype.columns[ID].get(0); return [resource, Observe.withMap(observeEntity(resourceId), (values) => (values as any)?.[resource] ?? null)]; }) ) as { [K in StringKeyof]: Observe; }; @@ -200,7 +201,7 @@ export function createObservedDatabase< (observe as any).resources = Object.fromEntries( Object.entries(store.resources).map(([resource]) => { const archetype = store.ensureArchetype(resourceArchetypeComponents(resource)); - const resourceId = archetype.columns.id.get(0); + const resourceId = archetype.columns[ID].get(0); return [resource, Observe.withMap(observeEntity(resourceId), (values) => (values as any)?.[resource] ?? null)]; }) ); diff --git a/packages/data/src/ecs/database/transactional-store/apply-operations.ts b/packages/data/src/ecs/database/transactional-store/apply-operations.ts index 8d8905de..66317cac 100644 --- a/packages/data/src/ecs/database/transactional-store/apply-operations.ts +++ b/packages/data/src/ecs/database/transactional-store/apply-operations.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Store } from "../../store/index.js"; +import { ID } from "../../required-components.js"; import { TransactionWriteOperation } from "./transactional-store.js"; import { StringKeyof } from "../../../types/types.js"; @@ -21,7 +22,7 @@ export const applyOperations = ( for (const operation of operations) { switch (operation.type) { case "insert": { - const componentNames = ["id", ...Object.keys(operation.values)] as StringKeyof[]; + const componentNames = [ID, ...Object.keys(operation.values)] as StringKeyof[]; const archetype = store.ensureArchetype(componentNames); archetype.insert(operation.values as never); break; diff --git a/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts b/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts index 463957f7..3dedf85d 100644 --- a/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts +++ b/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts @@ -3,6 +3,7 @@ import { Archetype, ArchetypeId, EntityInsertValues } from "../../archetype/inde import { ResourceComponents } from "../../store/resource-components.js"; import { Store } from "../../store/index.js"; import { Entity } from "../../entity/entity.js"; +import { ID } from "../../required-components.js"; import { EntityUpdateValues } from "../../store/core/index.js"; import { TransactionalStore, TransactionResult, TransactionWriteOperation } from "./transactional-store.js"; import { StringKeyof } from "../../../types/types.js"; @@ -154,7 +155,7 @@ export function createTransactionalStore< throw new Error(`Entity not found: ${entity}`); } - const { id: _ignore, ...oldValuesWithoutId } = oldValues as any; + const { [ID]: _ignore, ...oldValuesWithoutId } = oldValues as any; for (const key in oldValuesWithoutId) { changed.components.add(key); } @@ -171,7 +172,7 @@ export function createTransactionalStore< const resourceComponentNames = (name: string): StringKeyof[] => { const schema = (store.componentSchemas as any)[name]; - const names = ["id", name] as StringKeyof[]; + const names = [ID, name] as StringKeyof[]; if (schema?.nonPersistent) names.push("nonPersistent" as StringKeyof); if (schema?.nonShared) names.push("nonShared" as StringKeyof); return names; @@ -182,7 +183,7 @@ export function createTransactionalStore< const resourceId = name as keyof C; const componentNames = resourceComponentNames(name); const archetype = store.ensureArchetype(componentNames); - const entityId = archetype.columns.id.get(0); + const entityId = archetype.columns[ID].get(0); Object.defineProperty(resources, name, { get: Object.getOwnPropertyDescriptor(store.resources, name)!.get, set: (newValue) => { @@ -290,7 +291,7 @@ export function createTransactionalStore< const resourceId = name as keyof C; const componentNames = resourceComponentNames(name); const archetype = store.ensureArchetype(componentNames); - const entityId = archetype.columns.id.get(0); + const entityId = archetype.columns[ID].get(0); Object.defineProperty(resources, name, { get: Object.getOwnPropertyDescriptor(store.resources, name)!.get, set: (newValue: any) => { diff --git a/packages/data/src/ecs/required-components.ts b/packages/data/src/ecs/required-components.ts index 02122397..6f879feb 100644 --- a/packages/data/src/ecs/required-components.ts +++ b/packages/data/src/ecs/required-components.ts @@ -1,5 +1,25 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Entity } from "./entity/entity.js"; -// © 2026 Adobe. MIT License. See /LICENSE for details. -export type RequiredComponents = { id: Entity }; +/** + * The reserved component name for an entity's identity — the single source of + * truth for the name. Every archetype carries this column; it is the entity's + * key, never a component value. All ECS access to the identity column goes + * through this constant (runtime) and {@link IdComponent} (type), so the name + * could be changed here in one place (e.g. to `"entity"`) and the whole ECS + * would follow. + */ +export const ID = "id" as const; + +/** The identity component's name as a type — mirrors {@link ID}. */ +export type IdComponent = typeof ID; + +/** The always-present identity column every entity row is keyed by. */ +export type RequiredComponents = Record; + +/** + * Component names reserved by the ECS. User schemas may not define these — the + * store/core throw if a schema does. `id` is the entity identity; `nonPersistent` + * / `nonShared` are the built-in quadrant markers (see entity/persistence-sharing). + */ +export const RESERVED_COMPONENT_NAMES: readonly string[] = [ID, "nonPersistent", "nonShared"]; diff --git a/packages/data/src/ecs/store/core/core.ts b/packages/data/src/ecs/store/core/core.ts index 374b9cbd..61bef23d 100644 --- a/packages/data/src/ecs/store/core/core.ts +++ b/packages/data/src/ecs/store/core/core.ts @@ -3,7 +3,7 @@ import { Entity } from "../../entity/entity.js"; import { Archetype, ReadonlyArchetype } from "../../archetype/archetype.js"; import { Schema } from "../../../schema/index.js"; -import { RequiredComponents } from "../../required-components.js"; +import { RequiredComponents, IdComponent } from "../../required-components.js"; import { StringKeyof } from "../../../types/index.js"; import { Components } from "../components.js"; import { OptionalComponents } from "../../optional-components.js"; @@ -15,7 +15,7 @@ import { PersistenceScope, ToDataOptions } from "../../persistence-scope.js"; // stays a real column (see the Archetype interface) but is never part of a read. export type EntityValues = { readonly [K in StringKeyof]: (C & OptionalComponents)[K] } export type EntityReadValues = { readonly [K in StringKeyof as string extends K ? never : K]?: (C & OptionalComponents)[K] } -export type EntityUpdateValues = Partial>; +export type EntityUpdateValues = Partial>; export type ArchetypeQueryOptions = { exclude?: readonly StringKeyof[]; diff --git a/packages/data/src/ecs/store/core/create-core.test.ts b/packages/data/src/ecs/store/core/create-core.test.ts index acdb62b7..df60c968 100644 --- a/packages/data/src/ecs/store/core/create-core.test.ts +++ b/packages/data/src/ecs/store/core/create-core.test.ts @@ -177,6 +177,12 @@ export function createCoreTestSuite( expect(core.ensureArchetype(["position"])).toBe(archetype); }); + it("throws when a schema defines a reserved component name", () => { + for (const reserved of ["id", "nonPersistent", "nonShared"]) { + expect(() => factory({ [reserved]: positionSchema } as any)).toThrow(/reserved/); + } + }); + it("should locate entities correctly", () => { const core = factory({ position: positionSchema, diff --git a/packages/data/src/ecs/store/core/create-core.ts b/packages/data/src/ecs/store/core/create-core.ts index 6772b27a..8ed9660e 100644 --- a/packages/data/src/ecs/store/core/create-core.ts +++ b/packages/data/src/ecs/store/core/create-core.ts @@ -10,7 +10,7 @@ import { Table, addRow, updateRow } from "../../../table/index.js"; // public `@adobe/data/table` surface, so it is imported from the file directly. import { getRowData } from "../../../table/get-row-data.js"; import { Archetype, ReadonlyArchetype } from "../../archetype/archetype.js"; -import { RequiredComponents } from "../../required-components.js"; +import { RequiredComponents, ID, RESERVED_COMPONENT_NAMES } from "../../required-components.js"; import { Entity } from "../../entity/entity.js"; import { QUADRANT_COUNT, isPersistentQuadrant, quadrantFor, quadrantOf } from "../../entity/persistence-sharing.js"; import { PersistenceScope, ToDataOptions } from "../../persistence-scope.js"; @@ -75,8 +75,17 @@ export function createCore( ): Core]: Schema.ToType }>, PartitionKeysOf> { type C = RequiredComponents & { [K in StringKeyof]: Schema.ToType }; + // Reserved names (`id`, `nonPersistent`, `nonShared`) are the ECS's own + // built-ins; a user schema defining one would silently clobber it, so reject + // it loudly instead. + for (const name of Object.keys(newComponentSchemas)) { + if (RESERVED_COMPONENT_NAMES.includes(name)) { + throw new Error(`Component name "${name}" is reserved by the ECS and cannot be defined.`); + } + } + const componentSchemas: { readonly [K in StringKeyof]: Schema } = { - id: Entity.schema, + [ID]: Entity.schema, nonPersistent: True.schema, // Built-in sharing tag, mirror of nonPersistent. Together they place an // archetype's entities into one of four quadrants (persistence × sharing); @@ -170,7 +179,7 @@ export function createCore( // and the schema loop skips it, so a restore resolves the same archetype a // fresh `ensureArchetype([...])` would. const namesArr = Array.from(componentNames); - const sorted = namesArr.filter((n) => n !== "id").sort(); + const sorted = namesArr.filter((n) => n !== ID).sort(); const partitionNames = partitionNamesIn(sorted); for (const n of partitionNames) { if (partitionValues?.[n] === undefined) { @@ -185,11 +194,11 @@ export function createCore( // Every archetype carries the implicit `id` column. Seed the schema with it // structurally — this is the id column's definition, not a component the // caller asked for. - const archetypeComponentSchemas: Record = { id: componentSchemas.id }; + const archetypeComponentSchemas: Record = { [ID]: componentSchemas[ID] }; let isNonPersistent = false; let isNonShared = false; for (const comp of namesArr) { - if (comp === "id") continue; + if (comp === ID) continue; if (comp === "nonPersistent") isNonPersistent = true; if (comp === "nonShared") isNonShared = true; const base = componentSchemas[comp as StringKeyof]; @@ -257,7 +266,7 @@ export function createCore( ): Record => { const values: Record = {}; for (const name in archetype.columns) { - if (name === "id") continue; + if (name === ID) continue; values[name] = archetype.columns[name]!.get(row); } return values; @@ -438,7 +447,7 @@ export function createCore( const nonPersistentComponents = (): Set => { const names = new Set(); for (const name in componentSchemas) { - if (name === "id" || name === "nonPersistent" || name === "nonShared") continue; + if (name === ID || name === "nonPersistent" || name === "nonShared") continue; if ((componentSchemas as Record)[name]?.nonPersistent === true) names.add(name); } return names; @@ -477,7 +486,7 @@ export function createCore( if (present.length === 0) continue; const removal = Object.fromEntries(present.map((n) => [n, undefined])) as EntityUpdateValues; while (archetype.rowCount > 0) { - updateEntity(archetype.columns.id!.get(0), removal); + updateEntity(archetype.columns[ID]!.get(0), removal); } } } diff --git a/packages/data/src/ecs/store/core/select-entities.ts b/packages/data/src/ecs/store/core/select-entities.ts index cac4053a..9a03cf2e 100644 --- a/packages/data/src/ecs/store/core/select-entities.ts +++ b/packages/data/src/ecs/store/core/select-entities.ts @@ -3,7 +3,7 @@ import { selectRows } from "../../../table/select-rows.js"; import { compare } from "../../../functions/compare.js"; import { StringKeyof } from "../../../types/types.js"; -import { RequiredComponents } from "../../required-components.js"; +import { RequiredComponents, ID } from "../../required-components.js"; import { Entity } from "../../entity/entity.js"; import { EntitySelectOptions } from "../entity-select-options.js"; import { Core } from "./core.js"; @@ -31,7 +31,7 @@ export const selectEntities = < const entities = new Array(length); let index = 0; for (const archetype of archetypes) { - const idTypedArray = archetype.columns.id.getTypedArray(); + const idTypedArray = archetype.columns[ID].getTypedArray(); for (let i = 0; i < archetype.rowCount; i++) { entities[index++] = idTypedArray[i]; } @@ -41,7 +41,7 @@ export const selectEntities = < if (options?.where && !options.order) { const entities = new Array(); for (const archetype of archetypes) { - const idTypedArray = archetype.columns.id.getTypedArray(); + const idTypedArray = archetype.columns[ID].getTypedArray(); for (const row of selectRows>(archetype as any, options.where)) { entities.push(idTypedArray[row]); } @@ -94,7 +94,7 @@ export const selectEntities = < if (sortKeys.length === 0) { const ids: Entity[] = []; for (const archetype of archs) { - const idTypedArray = archetype.columns.id.getTypedArray(); + const idTypedArray = archetype.columns[ID].getTypedArray(); for (const row of selectRows>(archetype as any, options.where)) { ids.push(idTypedArray[row]); } @@ -103,7 +103,7 @@ export const selectEntities = < } const rows: { id: Entity; [k: string]: Comparable }[] = []; for (const archetype of archs) { - const idTypedArray = archetype.columns.id.getTypedArray(); + const idTypedArray = archetype.columns[ID].getTypedArray(); for (const row of selectRows>(archetype as any, options.where)) { const value: { id: Entity; [k: string]: Comparable } = { id: idTypedArray[row] }; for (const key of sortKeys) value[key] = archetype.columns[key]!.get(row) as Comparable; diff --git a/packages/data/src/ecs/store/public/create-store.ts b/packages/data/src/ecs/store/public/create-store.ts index 2414c7e1..12633d38 100644 --- a/packages/data/src/ecs/store/public/create-store.ts +++ b/packages/data/src/ecs/store/public/create-store.ts @@ -2,7 +2,7 @@ import { ComponentSchemas } from "../../component-schemas.js"; import { StringKeyof } from "../../../types/types.js"; -import { RequiredComponents } from "../../required-components.js"; +import { RequiredComponents, ID, RESERVED_COMPONENT_NAMES } from "../../required-components.js"; import { Store } from "../store.js"; import { PersistenceScope, ToDataOptions } from "../../persistence-scope.js"; import { Schema } from "../../../schema/index.js"; @@ -101,7 +101,7 @@ export function createStore< if (required.length === 0) return; const archetypes = core.queryArchetypes(required as readonly StringKeyof[]); for (const archetype of archetypes) { - const idCol = archetype.columns.id; + const idCol = archetype.columns[ID]; for (let row = 0; row < archetype.rowCount; row++) { const values: Record = {}; for (const c of required) { @@ -258,6 +258,10 @@ export function createStore< } = schema; // components: existing must be identical if present for (const [name, newComponentSchema] of Object.entries(schemaComponents)) { + // Reserved built-ins (id / nonPersistent / nonShared) can't be redefined. + if (RESERVED_COMPONENT_NAMES.includes(name)) { + throw new Error(`Component name "${name}" is reserved by the ECS and cannot be defined.`); + } if (name in componentAndResourceSchemas) { if (componentAndResourceSchemas[name as keyof typeof componentAndResourceSchemas] !== newComponentSchema) { throw new Error(`Component schema for "${name}" must be identical when extending.`); From 764dd888d13eaf33f9e2caa0d3fd6cfcffe58a0f Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 15 Aug 2026 14:27:57 -0700 Subject: [PATCH 3/5] docs(data-ai)+samples: entities-map State pattern; conform all samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State spec pattern for the feature architecture, aligned with the id-removal: - State = named singletons (→ resources) + one `entities: ReadonlyMap` keyed by a plain numeric id; entity value types carry NO id (identity is the key), are structural with a composing `is` guard, and property names are unique feature-wide (one name → one component); queries return ids (Set unordered / Array ordered by an `order` component). Rules: state.md (rewritten), flipped data-modelling.md's identity-collection guidance, fixed conformance.md. - @adobe/data-testing: `Match.ref` re-typed to fit numeric/map-key positions so a case's `after`/`samples` entity map keys use distinct `Match.ref` labels (`Match.anyNumber` is a shared singleton that would collapse duplicate keys). - Migrated every entity-bearing sample to the pattern (todo, pixie, space-rock, gpu-hopper): entity types lose id, State uses one `entities` map, transitions + conformance projections + derivations updated; all suites green. Singleton features (tictactoe, dashboard, p2p-tictactoe) need no change. Co-Authored-By: Claude Opus 4.8 --- .../data-ai/.claude/rules/data-modelling.md | 31 ++-- .../.claude/rules/features/data/state.md | 159 +++++++++++++---- .../services/main-service/conformance.md | 24 +-- .../src/features/main/data/hazard/is.ts | 19 +++ .../src/features/main/data/hazard/public.ts | 1 + .../features/main/data/state/create.test.ts | 4 +- .../src/features/main/data/state/create.ts | 10 +- .../main/data/state/frog-outcome.test.ts | 4 +- .../features/main/data/state/frog-outcome.ts | 6 +- .../src/features/main/data/state/hop.ts | 2 +- .../src/features/main/data/state/lose-life.ts | 2 +- .../src/features/main/data/state/new-game.ts | 16 +- .../src/features/main/data/state/samples.ts | 26 ++- .../src/features/main/data/state/state.ts | 9 +- .../src/features/main/data/state/step.ts | 52 +++--- .../src/features/main/data/state/win-goal.ts | 2 +- .../conformance/conformance.test.ts | 5 +- .../main-service/conformance/projection.ts | 17 +- .../system-database/outcome-selection.test.ts | 14 +- .../system-database/system-database.ts | 6 +- .../system-database/tick-loop.test.ts | 5 +- .../transactions/new-game.ts | 2 +- .../src/features/main/data/asteroid/is.ts | 15 ++ .../src/features/main/data/asteroid/public.ts | 1 + .../src/features/main/data/bullet/is.ts | 14 ++ .../src/features/main/data/bullet/public.ts | 1 + .../main/data/state/create-initial.ts | 34 ++-- .../features/main/data/state/create.test.ts | 3 +- .../src/features/main/data/state/create.ts | 11 +- .../features/main/data/state/fire-bullet.ts | 37 ++-- .../main/data/state/resolve-bullet-hits.ts | 161 +++++++++--------- .../main/data/state/resolve-ship-hits.ts | 55 +++--- .../src/features/main/data/state/samples.ts | 29 ++-- .../main/data/state/spawn-random-wave.ts | 39 +++-- .../features/main/data/state/spawn-wave.ts | 39 +++-- .../src/features/main/data/state/state.ts | 6 +- .../main/data/state/step-asteroids.ts | 65 +++---- .../features/main/data/state/step-bullets.ts | 76 +++++---- .../src/features/main/data/state/step.ts | 57 +++---- .../conformance/conformance.test.ts | 6 +- .../main-service/conformance/projection.ts | 43 +++-- .../collision-detection.test.ts | 91 +++++----- .../system-database/tick-loop.test.ts | 10 +- .../transactions/fire-bullet.ts | 14 +- .../transactions/new-game.ts | 8 +- .../transactions/spawn-random-wave.ts | 8 +- .../features/main/data/state/append-todo.ts | 21 ++- .../main/data/state/create-bulk-todos.ts | 56 +++--- .../main/data/state/create-random-todo.ts | 38 ++--- .../features/main/data/state/create-todo.ts | 54 +++--- .../src/features/main/data/state/create.ts | 6 +- .../main/data/state/delete-all-todos.ts | 29 ++-- .../features/main/data/state/delete-todo.ts | 62 +++---- .../features/main/data/state/reorder-todo.ts | 98 ++++++----- .../src/features/main/data/state/samples.ts | 30 ++-- .../src/features/main/data/state/state.ts | 8 +- .../main/data/state/toggle-complete.ts | 66 +++---- .../data/state/toggle-display-completed.ts | 13 +- .../features/main/data/state/visible-todos.ts | 47 ++--- .../src/features/main/data/todo/is.ts | 15 ++ .../src/features/main/data/todo/public.ts | 2 +- .../src/features/main/data/todo/todo.ts | 9 +- .../computed-database/computed/state.ts | 25 +-- .../main-service/conformance/projection.ts | 42 ++--- .../src/features/main/data/sprite/is.ts | 20 +++ .../src/features/main/data/sprite/public.ts | 2 +- .../src/features/main/data/sprite/sprite.ts | 7 +- .../features/main/data/state/create-sprite.ts | 115 +++++++------ .../src/features/main/data/state/create.ts | 4 +- .../src/features/main/data/state/samples.ts | 77 ++++----- .../main/data/state/set-sprite-active.ts | 44 ++--- .../main/data/state/set-sprite-hovered.ts | 44 ++--- .../src/features/main/data/state/state.ts | 7 +- .../src/features/main/data/state/tick.ts | 34 ++-- .../main/data/state/toggle-sprite-active.ts | 52 +++--- .../main-service/conformance/projection.ts | 22 ++- packages/data-testing/src/match/match.ts | 7 +- 77 files changed, 1313 insertions(+), 982 deletions(-) create mode 100644 packages/data-gpu-hopper/src/features/main/data/hazard/is.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/asteroid/is.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/bullet/is.ts create mode 100644 packages/data-lit-todo/src/features/main/data/todo/is.ts create mode 100644 packages/data-react-pixie/src/features/main/data/sprite/is.ts diff --git a/packages/data-ai/.claude/rules/data-modelling.md b/packages/data-ai/.claude/rules/data-modelling.md index 8674b619..bc2da80a 100644 --- a/packages/data-ai/.claude/rules/data-modelling.md +++ b/packages/data-ai/.claude/rules/data-modelling.md @@ -41,21 +41,32 @@ function record(m: unknown) { 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). +- **`ReadonlyArray`** — order is meaningful. A display list of scalar values, a + positional tuple (`Vec2 = readonly [number, number]`), or an **ordered query + result of entity ids** (`ReadonlyArray`, sorted by an `order` component — + see below). Not for the entity *store* itself. +- **`ReadonlySet`** — an unordered bag / membership set. A set of entity + references is `ReadonlySet` (a set of ids), and an unordered entity + *query result* is `ReadonlySet`. +- **`ReadonlyMap`** — a keyed lookup. This is how **identity-keyed entities** + are modelled: `entities: ReadonlyMap` — the `number` id is the + key, and the value carries **no `id` of its own** (identity is the key, never a + field). Also used for deterministic-key lookups (an enum, a name, a stable string). + +**Entities are keyed, never id-bearing values.** An entity's identity is its map +key, so entity value types (`Todo`, `Bullet`) have no `id` field, and there is no +`ReadonlySet`/`ReadonlyArray` *of entity values* — the single +`ReadonlyMap` store is the only home for entities, and queries return +their **ids** (`ReadonlySet` unordered, `ReadonlyArray` ordered). +See `features/data/state.md` for the full `State` shape. 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. +order-independently. Entity identity is the map key (resolved to the allocated ECS +entity during conformance); the id is never a value field, so there is nothing to +"ignore" when comparing entity content. ## Shape of keyed collections diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index 8ebdd979..7927ea3b 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -15,18 +15,89 @@ State Specification is authoritative and the ECS conforms to it. A feature witho 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 }; +// data/todo/todo.ts — an entity value type: plain readonly data, NO id. +export type Todo = { readonly name: string; readonly complete: boolean; readonly order: number }; +export * as Todo from "./public.js"; + +// data/todo/is.ts — a structural type guard, re-exported so it reads as `Todo.is`. +// `"k" in v` narrows `v` so each field reads without a cast. +export const is = (v: unknown): v is Todo => + typeof v === "object" && v !== null && + "name" in v && typeof v.name === "string" && + "complete" in v && typeof v.complete === "boolean" && + "order" in v && typeof v.order === "number"; + +// data/state/state.ts — singletons + one identity-keyed entity map. +export type State = { + readonly displayCompleted: boolean; // a SINGLETON → an ECS resource of the same name + readonly entities: ReadonlyMap; // ALL entities, keyed by id; the value has no id +}; export * as State from "./public.js"; ``` -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. +Every **state-based** feature owns a `State`. An ECS-based feature has none. + +## The standard `State` shape + +A `State` has two kinds of field: + +- **Singletons** — every non-`entities` field (`displayCompleted`, a `score`, a + `board`). Each becomes an ECS **resource** of the same name (see + `../services/main-service/resources.md`). +- **`entities`** — a single `ReadonlyMap` holding *every* entity, + keyed by a numeric id, where `EntityValue` is the union of the feature's entity value + types (`Todo`, or `Bullet | Asteroid`). Omit `entities` entirely for a feature with + no entities (`tictactoe`, `dashboard`) — it is then all singletons. + +This maps 1:1 to the ECS with **zero dependency on it** — the key is a plain `number`, +never the ECS `Entity`, so `data/` provably cannot reach ECS machinery. Singletons ↔ +resources; each `entities` value ↔ one entity whose **component set is the value's own +keys**, so `fromState` inserts a value into the archetype named by `Object.keys(value)` +and `toState` reads every entity back into the map. Identity lives in the key, never a +field — see `../../data-modelling.md` (Entities are keyed, never id-bearing values). + +### Entity value types — structural, id-less, one `is` guard each -**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`. +Each entity type is its own `data//` namespace (`data/index.md`): a plain +readonly type with **no `id`** plus a structural `is` guard (`is.ts`, re-exported so it +reads `Todo.is`). A **sub-archetype** is an intersection — `Bar = Foo & { baz }` — and +its guard **composes the base guard**: `Bar.is` calls `Foo.is(v)` first, then checks the +added fields (`"baz" in v && typeof v.baz === "boolean"`). So `Bar.is ⟹ Foo.is` by +construction. + +**Model structurally, like the ECS — no tags by default.** ECS systems match purely on +structure (a mover runs on anything with `position` + `velocity`, regardless of +"kind"), and `State` mirrors that: + +- **A property name means one thing feature-wide.** No two entity types may declare the + same property name with a different type/semantics — that is what lets every property + collapse to exactly one ECS component and keeps structural matching sound. +- **No marker / `kind` / tag field unless a genuine modelling need demands it.** Prefer + discriminating on the presence of real components; add a tag only when structure alone + cannot express the distinction. + +### Querying entities — `State.getXEntities`, returning ids + +Entity queries are `state/` derivations that return entity **ids** (look the value up +with `state.entities.get(id)`), mirroring the ECS `select`. Perf is irrelevant here +(full-map scan + guard); the ECS realises them as archetype queries. + +- **Unordered** → `ReadonlySet`: keys whose value passes the guard. It is a + **superset** match — `getFooEntities` (via `Foo.is`) also includes `Bar` entities, + just as `queryArchetypes(["…Foo's cols"])` returns the `Foo` *and* `Bar` archetypes. +- **Ordered** → `ReadonlyArray`: the matching ids sorted by an explicit `order` + component, exactly like an ECS `select(cols, { order })`. Order is **never** carried + by the `entities` map (it is identity-keyed / unordered); a meaningful order is a + component on the value and the derivation returns the sorted ids. + +```ts +// data/state/get-todos.ts — an ordered membership derivation returning ids +export const getTodos = (state: State): readonly number[] => + [...state.entities] + .filter(([, v]) => Todo.is(v)) + .sort(([, a], [, b]) => a.order - b.order) + .map(([id]) => id); +``` **`State` has a standard shape.** Two exports are conventional and drive conformance: @@ -106,21 +177,28 @@ export const entity = ConformanceApi.entity; ```ts // 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 +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 } & Pick, -): Pick => { + state: Pick, + { name, analytics }: { name: string } & Pick, +): Pick => { analytics.todoCreated({ name }); - return { todos: [...state.todos, { name, complete: complete ?? false }] }; // writes patch only + // The spec mints the id (the map key) and the order; the value carries neither an id + // nor anything the ECS allocates — identity is the key. + const id = Math.max(0, ...state.entities.keys()) + 1; + const order = state.entities.size; + return { entities: new Map(state.entities).set(id, { name, complete: false, order }) }; }; export const cases: Conformance = [ - { name: "appends the first todo", - before: {}, // empty delta — the default State.create() + { name: "adds the first todo", + before: {}, // empty delta — the default State.create() (empty entities) args: { name: "a", analytics: AnalyticsService.createFake() }, - after: { todos: [{ id: Match.anyNumber, name: "a", complete: false }] }, // only the changed field + // The map key is an id the ECS mints — use `Match.ref("label")` (a DISTINCT + // label per entity). The value is id-less so content compares directly. (Maps + // compare entry-wise / order-independently — see conformance.md.) + after: { entities: new Map([[Match.ref("a"), { name: "a", complete: false, order: 0 }]]) }, effects: { analytics: [["todoCreated", { name: "a" }]] } }, ]; ``` @@ -155,12 +233,17 @@ 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 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 + per-feature `matchers.ts` anymore. An entity's identity is the `entities` map **key**, + not a value field, so entity content compares directly with nothing to ignore. The + key is an ECS-minted id, so use **`Match.ref("label")` with a DISTINCT label per map + entry** (`[Match.ref("a"), value]`) — `ref` returns a fresh object so distinct labels + are distinct keys, and its injective binding both keeps entities distinct and lets an + entity **correlate** with a reference elsewhere in the case (reuse the label, e.g. a + `selectedId: Match.ref("a")` singleton). Do **not** use `Match.anyNumber` as a map key + — it is a shared singleton, so two entries keyed by it collapse to one. `entity(specId)` + is for **`args`** (it resolves via the seed map), **not** `after` keys — a case's `after` + entities may be freshly created, with no seed mapping. Match by content, not by a 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 @@ -168,12 +251,24 @@ export const cases: Conformance = [ not a pinned value, so the two occurrences of the label must resolve to the same actual id and two labels can't collide. `anyNumber`/`anyString` are for an id a case does not pin at all; `ref` for one that must be consistent across the case. -- **Entity-addressed cases use `entity(specId)`.** A transition that addresses an - entity by id writes it as `args: { id: entity(2) }` — `entity` imported from the - feature's `conformance-case.ts` (re-exported from `@adobe/data-testing`). It types - as the id it stands for (like `Match.anyNumber`), so it slots into the transform's - own arg type. `runSpec` unwraps it to the plain data-id for the pure side; the ECS - runners resolve it to the seeded entity (see `conformance.md`). +- **Entity-addressed cases use `entity(specId)` in `args`.** A transition that + addresses an entity by id writes it as `args: { id: entity(2) }` — `entity` imported + from the feature's `conformance-case.ts` (re-exported from `@adobe/data-testing`). It + types as the id it stands for, so it slots into the transform's own arg type. `runSpec` + unwraps it to the plain data-id for the pure side; the ECS runners resolve it (via the + `fromState` seed map) to the seeded entity (see `conformance.md`). +- **The `entities` map key convention across a case, in one place:** + - **`before`** (the seed): **plain spec-id numbers** (`new Map([[1, …], [2, …]])`). + `fromState` seeds from these and returns the `spec-id → entity` map, so `args: + { id: entity(1) }` resolves to the entity seeded for `1`. + - **`after`** (the expectation, compared against the ECS by content): **`Match.ref` + with a distinct label per entry** (`[[Match.ref("a"), …], [Match.ref("b"), …]]`) — + the ECS mints its own ids, so keys must be open; `ref` is a fresh object (distinct + keys don't collapse) and injective (entities stay distinct, and a label reused in a + singleton reference correlates). Never `entity(specId)` here — a case's `after` may + hold freshly created entities with no seed mapping. + - **`samples`** (round-tripped `toState ∘ fromState`): same as `after` — **`Match.ref` + distinct labels** (they compare against ECS-minted ids). - No per-transform test. The single **`spec.test.ts`** is one call — `Conformance.runSpec({ state: State, transitions })` importing `transitions` from the test-only `./transitions.js` (above) — that auto-discovers every module @@ -220,8 +315,10 @@ state change and the service calls. ## Derivations — `(state) => value`, cases `{ input, value }` Pure selectors that **compose the aggregate** — a value drawn from **two or more -`State` fields** (`visibleTodos` from `todos` + `displayCompleted`; -`currentPlayer` from `board` + `firstPlayer`). A value computed from a **single** +`State` fields** (`visibleTodos` from `entities` + `displayCompleted`; +`currentPlayer` from `board` + `firstPlayer`). An entity query like `visibleTodos` +returns entity **ids** (`ReadonlyArray` / `ReadonlySet`), never the +values (look those up in `entities`). A value computed from a **single** `State` field is that field's own type math and lives on its `data/` namespace (`winner`/`status` from `board` → `data/board-state`), tested there — **not** in `state/`. A feature may therefore have zero `state/` derivations. 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 a6cb48f9..1e889edb 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 @@ -41,10 +41,11 @@ installing `@adobe/data` never pulls in a `vitest` peer dependency): `{ 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. + **order-independently** — there is no `unordered` option. Entity identity is the + key of `State.entities` (a `ReadonlyMap`), resolved to the allocated ECS + entity via `resolver` (below); entity *values* carry no `id`, so there is no id to + ignore when comparing content. 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 @@ -144,10 +145,10 @@ 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` 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`). +`data-lit-space-rock-game` holds its `bullet` / `asteroid` entities in the single +`entities: ReadonlyMap`, keyed by id, so they compare +order-independently by the map — 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` @@ -205,9 +206,10 @@ 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). +- **Entity identity is the `State.entities` key, not a value field.** Entity values + carry no `id`, so there is nothing to omit or ignore — content compares directly. + The map key is a spec-domain id the runner resolves to the allocated ECS entity via + `resolver`; assert a *reference* between entities with `Match.ref` (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 diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard/is.ts b/packages/data-gpu-hopper/src/features/main/data/hazard/is.ts new file mode 100644 index 00000000..ec6fabe1 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/hazard/is.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Hazard } from "./hazard.js"; + +// A structural type guard, re-exported so it reads as `Hazard.is`. Discriminates a +// hazard entity value by the presence and primitive shape of its own components — +// no id, no tag (identity is the `State.entities` map key). +export const is = (v: unknown): v is Hazard => + typeof v === "object" && + v !== null && + "kind" in v && + typeof v.kind === "string" && + "lane" in v && + typeof v.lane === "number" && + "x" in v && + typeof v.x === "number" && + "width" in v && + typeof v.width === "number" && + "velocity" in v && + typeof v.velocity === "number"; diff --git a/packages/data-gpu-hopper/src/features/main/data/hazard/public.ts b/packages/data-gpu-hopper/src/features/main/data/hazard/public.ts index c23ef81e..73315152 100644 --- a/packages/data-gpu-hopper/src/features/main/data/hazard/public.ts +++ b/packages/data-gpu-hopper/src/features/main/data/hazard/public.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { schema } from "./schema.js"; +export { is } from "./is.js"; export { covers } from "./covers.js"; export { coversAt } from "./covers-at.js"; export { advance } from "./advance.js"; 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 a8613e09..62d5b4e3 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 @@ -24,7 +24,7 @@ describe("State.create", () => { }); it("places cars only on roads and logs only on rivers", () => { - for (const hazard of state.hazards) { + for (const hazard of state.entities.values()) { const kind = State.laneAt(state, hazard.lane)?.kind; const expected = hazard.kind === "car" ? "road" : "river"; expect(kind).toBe(expected); @@ -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.entities.values()].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 e3693d03..c972c88f 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: ReadonlySet = new Set([ +const hazardList: readonly Hazard[] = [ { kind: "car", lane: 1, x: 0, width: 1, velocity: 1.5 }, { kind: "car", lane: 1, x: 5, width: 1, velocity: 1.5 }, { kind: "car", lane: 2, x: 2, width: 1, velocity: -2 }, @@ -36,14 +36,18 @@ const hazards: ReadonlySet = new Set([ { kind: "log", lane: 6, x: 7, width: 2, velocity: -1 }, { kind: "log", lane: 7, x: 1, width: 3, velocity: 2 }, { kind: "log", lane: 7, x: 6, width: 3, velocity: 2 }, -]); +]; + +// Each hazard is a per-entity ECS entity; the spec mints its own numeric ids (the +// map keys). The values are id-less — identity is the key. +const entities: ReadonlyMap = new Map(hazardList.map((hazard, index) => [index + 1, hazard])); // The initial, full game state: frog at the bottom, three lives, nothing scored. export const create = (): State => ({ width, height, lanes, - hazards, + entities, frog: startPosition({ width }), lives: 3, score: 0, 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 1550d37a..9f7f14c9 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,9 @@ const lanes: readonly Lane[] = [ ]; const board = (hazards: readonly Hazard[], x: number, y: number): StateType => ({ - width: 5, height: 4, lanes, hazards: new Set(hazards), frog: { x, y }, lives: 3, score: 0, status: "playing", + width: 5, height: 4, lanes, + entities: new Map(hazards.map((hazard, index) => [index + 1, hazard])), + 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 b62cc8ca..edec736d 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 @@ -10,7 +10,7 @@ import { laneAt } from "./lane-at.js"; // composes them with hazard coverage and an on-board test. A frog carried off // the board edge is no longer "covered" by any hazard, so open water drowns it. export const frogOutcome = < - T extends Pick, + T extends Pick, >( state: T, ): Outcome => { @@ -19,6 +19,8 @@ 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.entities.values()].some( + (hazard) => Hazard.is(hazard) && hazard.lane === state.frog.y && Hazard.covers(hazard, state.frog.x), + ); return covered ? LaneKind.coveredOutcome[lane.kind] : LaneKind.emptyOutcome[lane.kind]; }; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/hop.ts b/packages/data-gpu-hopper/src/features/main/data/state/hop.ts index 734b9974..d581dd1e 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: new Set(), + entities: new Map(), 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 c7ac8183..41284271 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: new Set(), + entities: new Map(), 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 e86d8c92..1d070118 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 @@ -1,4 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { create } from "./create.js"; @@ -10,6 +11,17 @@ import { create } from "./create.js"; // the co-located conformance cases derive from. export const newGame = (_state: State): State => create(); +// The initial game with its hazard-entity keys left OPEN: the ecs `newGame` +// transaction mints its own ids, so `after` must not pin the concrete keys +// `create()` uses (a distinct `Match.ref` per hazard, injective) — see conformance.md. +const initial = create(); +const initialWithOpenKeys: State = { + ...initial, + entities: new Map( + [...initial.entities.values()].map((hazard, index) => [Match.ref(`hazard-${index}`), hazard]), + ), +}; + // Spec-owned cases, shared with the ecs `newGame` transaction. `before` is a // fully-divergent mid-run state (dimensions, terrain, hazards, frog, lives, score, // status all differ) so the reset is proven total. @@ -24,12 +36,12 @@ export const cases: Conformance = [ { row: 1, kind: "river" }, { row: 2, kind: "goal" }, ], - hazards: new Set([{ kind: "log", lane: 1, x: 0, width: 2, velocity: 1 }]), + entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 2, velocity: 1 }]]), frog: { x: 1, y: 2 }, lives: 0, score: 7, status: "gameOver", }, - after: create(), + after: initialWithOpenKeys, }, ]; 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 d9ae8888..a5eb0db9 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 @@ -1,13 +1,25 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; +import type { Hazard } from "../hazard/hazard.js"; import { create } from "./create.js"; -// Representative full states for the projection round-trip (toState ∘ fromState ≡ -// identity). The initial game, a mid-run state with a fractional log-carried frog -// and depleted lives, and a minimal empty board — together exercising the whole -// ecs↔State map (resources, the frog entity, and the hazard bag). +// The projection round-trip (toState ∘ fromState ≡ identity) compares a sample +// against the store the ecs re-materialises, which mints its own entity ids. So the +// `entities` keys must stay OPEN: a DISTINCT `Match.ref` label per hazard (a fresh +// object, injective), never a pinned number (which would demand the ecs reproduce +// that exact id) — see conformance.md. +const entities = (hazards: readonly Hazard[]): ReadonlyMap => + new Map(hazards.map((hazard, index) => [Match.ref(`hazard-${index}`), hazard])); + +const initial = create(); + +// Representative full states for the projection round-trip. The initial game, a +// mid-run state with a fractional log-carried frog and depleted lives, and a +// minimal empty board — together exercising the whole ecs↔State map (resources, +// the frog entity, and the hazard entities). export const samples: readonly State[] = [ - create(), + { ...initial, entities: entities([...initial.entities.values()]) }, { width: 5, height: 3, @@ -16,7 +28,7 @@ export const samples: readonly State[] = [ { row: 1, kind: "river" }, { row: 2, kind: "goal" }, ], - hazards: new Set([ + entities: entities([ { kind: "log", lane: 1, x: 1.5, width: 3, velocity: 1 }, { kind: "log", lane: 1, x: 4, width: 2, velocity: 1 }, ]), @@ -32,7 +44,7 @@ export const samples: readonly State[] = [ { row: 0, kind: "grass" }, { row: 1, kind: "goal" }, ], - hazards: new Set(), + entities: entities([]), frog: { x: 1, y: 0 }, lives: 3, score: 0, diff --git a/packages/data-gpu-hopper/src/features/main/data/state/state.ts b/packages/data-gpu-hopper/src/features/main/data/state/state.ts index d2371593..589d470a 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 @@ -5,13 +5,16 @@ import type { Frog } from "../frog/frog.js"; import type { GameStatus } from "../game-status/game-status.js"; // The whole game modelled as one immutable value — the specification the ECS -// implementation is verified against. `lanes` is the static board terrain; -// `hazards` and `frog` are the moving pieces. +// implementation is verified against. `width`/`height`/`frog`/`lives`/`score`/ +// `status` are singletons (ECS resources / the single frog entity); `lanes` is the +// fixed board terrain (an ECS resource, not per-entity). `entities` is the one +// identity-keyed map of the game's per-entity ECS entities — the moving hazards — +// keyed by a plain numeric id, the value id-less. export type State = { readonly width: number; readonly height: number; readonly lanes: readonly Lane[]; - readonly hazards: ReadonlySet; + readonly entities: ReadonlyMap; 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 7e2ea10e..cbc6c6dd 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 @@ -1,4 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { Match } from "@adobe/data-testing"; import { Hazard } from "../hazard/hazard.js"; import { LaneKind } from "../lane-kind/lane-kind.js"; import { Outcome } from "../outcome/outcome.js"; @@ -13,19 +14,19 @@ import { loseLife } from "./lose-life.js"; // The slice `step` writes. Every branch supplies all five keys; a composer layers // the outcome sub-patch (`winGoal` / `loseLife`) over the movement fields. -type StepPatch = Pick; +type StepPatch = Pick; // Advance the simulation by `dt` seconds: scroll the hazards, carry the frog if // it is riding a log, then resolve its fate — score a win, or on a fatal outcome // spend a life and respawn (or end the game once the last life is gone). A no-op // once the game has ended, keeping it idempotent. export const step = ( - state: Pick, + state: Pick, dt: number, ): StepPatch => { if (!GameStatus.isPlaying(state.status)) { return { - hazards: state.hazards, + entities: state.entities, frog: state.frog, score: state.score, status: state.status, @@ -33,7 +34,10 @@ export const step = ( }; } - const hazards = new Set([...state.hazards].map((hazard) => Hazard.advance(hazard, dt, state.width))); + // Advance every hazard, preserving its identity (the map key). Values are id-less. + const entities = new Map( + [...state.entities].map(([id, hazard]) => [id, Hazard.advance(hazard, dt, state.width)] as const), + ); const lane = laneAt(state, state.frog.y); // Ride a log: on a carrying lane, the log the frog is standing on drags it @@ -41,7 +45,7 @@ export const step = ( // log the frog was actually on this frame. const carrier = lane && LaneKind.coveredOutcome[lane.kind] === "ride" - ? [...state.hazards].find( + ? [...state.entities.values()].find( (hazard) => hazard.lane === state.frog.y && Hazard.covers(hazard, state.frog.x), ) : undefined; @@ -49,12 +53,12 @@ export const step = ( ? { x: state.frog.x + carrier.velocity * dt, y: state.frog.y } : state.frog; - // The moved world (full read slice + updated hazards/frog), fed to the outcome + // The moved world (full read slice + updated entities/frog), fed to the outcome // derivation and the winGoal / loseLife sub-transitions. - const moved = { ...state, hazards, frog }; + const moved = { ...state, entities, frog }; // The movement-only patch the outcome sub-patch layers over. const movedPatch: StepPatch = { - hazards, + entities, frog, score: state.score, status: state.status, @@ -89,58 +93,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: new Set([{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]), + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), frog: { x: 2, y: 0 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { hazards: new Set([{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }]), + after: { entities: new Map([[Match.ref("hazard"), { 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: new Set([{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]), + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { hazards: new Set([{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }]), + after: { entities: new Map([[Match.ref("hazard"), { 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: new Set([{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]), + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), frog: { x: 1, y: 1 }, lives: 1, score: 0, status: "playing" }, args: 1, - after: { hazards: new Set([{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }]), + after: { entities: new Map([[Match.ref("hazard"), { 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: new Set([{ kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }]), + entities: new Map([[1, { kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }]]), frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { hazards: new Set([{ kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }]), + after: { entities: new Map([[Match.ref("hazard"), { 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: new Set([{ kind: "log", lane: 1, x: 0, width: 3, velocity: 1 }]), + entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 3, velocity: 1 }]]), frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { hazards: new Set([{ kind: "log", lane: 1, x: 1, width: 3, velocity: 1 }]), + after: { entities: new Map([[Match.ref("hazard"), { 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: new Set([{ kind: "log", lane: 1, x: 3, width: 2, velocity: 2 }]), + entities: new Map([[1, { kind: "log", lane: 1, x: 3, width: 2, velocity: 2 }]]), frog: { x: 4, y: 1 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { hazards: new Set([{ kind: "log", lane: 1, x: 0, width: 2, velocity: 2 }]), + after: { entities: new Map([[Match.ref("hazard"), { 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: new Set([{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]), + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), frog: { x: 2, y: 2 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { hazards: new Set([{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }]), + after: { entities: new Map([[Match.ref("hazard"), { 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: new Set([{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]), + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), frog: { x: 2, y: 0 }, lives: 0, score: 0, status: "gameOver" }, args: 1, - after: { hazards: new Set([{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]), + after: { entities: new Map([[Match.ref("hazard"), { 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 a867b6ec..1c895ba1 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: new Set(), + entities: new Map(), 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 b75ba98d..b876531d 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,8 +9,9 @@ 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 is a `ReadonlySet`, so the comparator matches it order-independently -// (the ecs materialises it in nondeterministic row order). `step` has no +// hazards live in the identity-keyed `entities` `ReadonlyMap`, so the comparator +// matches them order-independently (the ecs materialises them in nondeterministic +// row order, and mints its own ids the `after`/`samples` refs leave open). `step` has no // transaction — the per-frame system loop conforms it in // `system-database/tick-loop.test.ts` (see systems.md) — so it is simply skipped // here. Hopper has no derivations, so no `computedPlugin`. 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 e2249a92..88c5b836 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,14 @@ const readFrog = (store: CoreDatabase.Store): Frog => { throw new Error("frog entity missing from store"); }; -const readHazards = (store: CoreDatabase.Store): Set => { - const hazards = new Set(); +// Read every hazard entity back into the identity-keyed `entities` map. The key is +// the allocated ecs entity id (the runner leaves it open via `Match.ref`); the +// value is id-less. +const readEntities = (store: CoreDatabase.Store): Map => { + const entities = new Map(); for (const arch of store.queryArchetypes(store.archetypes.Hazard.components)) { for (let row = 0; row < arch.rowCount; row++) { - hazards.add({ + entities.set(arch.columns.id.get(row), { kind: arch.columns.kind.get(row), lane: arch.columns.lane.get(row), x: arch.columns.x.get(row), @@ -28,7 +31,7 @@ const readHazards = (store: CoreDatabase.Store): Set => { }); } } - return hazards; + return entities; }; // The test-only ecs↔`State` projection, passed to `Conformance.runFeature` (and @@ -59,17 +62,17 @@ export const projection = { store.resources.lanes = state.lanes; store.archetypes.Frog.insert({ x: state.frog.x, y: state.frog.y }); - for (const hazard of state.hazards) { + for (const hazard of state.entities.values()) { store.archetypes.Hazard.insert(hazard); } }, // Read a store back into a `data/` `State` — the inverse of `fromState`. The - // scalar resources join the frog and the hazard entities. + // scalar resources join the singleton frog and the identity-keyed hazard entities. toState: (store: CoreDatabase.Store): State => ({ width: store.resources.width, height: store.resources.height, lanes: store.resources.lanes, - hazards: readHazards(store), + entities: readEntities(store), frog: readFrog(store), lives: store.resources.lives, score: store.resources.score, 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 705bf0f1..fb9ed71c 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: new Set(), + entities: new Map(), 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: new Set([{ kind: "car", lane: 1, x: 2, width: 1, velocity: 0 }]), + entities: new Map([[1, { 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: new Set([{ kind: "car", lane: 1, x: 0, width: 1, velocity: 0 }]), + entities: new Map([[1, { 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: new Set([{ kind: "car", lane: 1, x: 2, width: 1, velocity: 0 }]), + entities: new Map([[1, { 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: new Set([{ kind: "log", lane: 1, x: 0, width: 3, velocity: 0 }]), + entities: new Map([[1, { 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: new Set() })); + const after = detect(base({ lanes: riverLanes, frog: { x: 2, y: 1 }, entities: new Map() })); expect(after.lives).toBe(2); expect(after.frog).toEqual({ x: 2, y: 0 }); }); @@ -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: new Set([{ kind: "log", lane: 1, x: 0, width: 3, velocity: 0 }]), + entities: new Map([[1, { 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 95021bf1..ccd1af1b 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 = new Set(); + const entities = new Map(); for (const arch of db.store.queryArchetypes(["kind", "lane", "x", "width", "velocity"])) { for (let i = 0; i < arch.rowCount; i++) { - hazards.add({ + entities.set(arch.columns.id.get(i), { kind: arch.columns.kind.get(i), lane: arch.columns.lane.get(i), x: arch.columns.x.get(i), @@ -149,7 +149,7 @@ const systemDatabasePlugin = Database.Plugin.create({ const outcome = State.frogOutcome({ lanes: resources.lanes, - hazards, + entities, frog: { x: frogX, y: frogY }, width: resources.width, }); 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 a02b9a91..6cf93593 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,8 +14,9 @@ // 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 is a `ReadonlySet`, so the -// comparator matches it order-independently. +// keeping the shared case honest. The hazards live in the identity-keyed +// `entities` `ReadonlyMap`, so the comparator matches them order-independently (the +// ecs mints its own ids the `after` refs leave open). import { describe, it } from "vitest"; import { Match } from "@adobe/data-testing"; import { State } from "../../../data/state/state.js"; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/new-game.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/new-game.ts index 9a594f1c..75d80819 100644 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/new-game.ts +++ b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/new-game.ts @@ -23,7 +23,7 @@ export const newGame = (t: CoreDatabase.Store) => { t.resources.lanes = initial.lanes; t.archetypes.Frog.insert({ x: initial.frog.x, y: initial.frog.y }); - for (const hazard of initial.hazards) { + for (const hazard of initial.entities.values()) { t.archetypes.Hazard.insert(hazard); } }; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/is.ts b/packages/data-lit-space-rock-game/src/features/main/data/asteroid/is.ts new file mode 100644 index 00000000..ddf9115d --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/asteroid/is.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Asteroid } from "./asteroid.js"; +import { Size } from "../size/size.js"; + +// Structural guard, re-exported so it reads as `Asteroid.is`. Within +// `State.entities` a value is a `Bullet | Asteroid`; an asteroid is the one that +// carries a `size` tier — the bullet carries `age` instead — so `size` is the +// distinguishing member. Every entity value also carries `position` + `velocity`. +export const is = (v: unknown): v is Asteroid => + typeof v === "object" && + v !== null && + "position" in v && + "velocity" in v && + "size" in v && + Size.is(v.size); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/asteroid/public.ts index 972e5f6a..8c28bb8e 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/asteroid/public.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/asteroid/public.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { schema } from "./schema.js"; +export { is } from "./is.js"; export { radius } from "./radius.js"; export { score } from "./score.js"; export { split } from "./split.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/bullet/is.ts b/packages/data-lit-space-rock-game/src/features/main/data/bullet/is.ts new file mode 100644 index 00000000..cdf47261 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/bullet/is.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Bullet } from "./bullet.js"; + +// Structural guard, re-exported so it reads as `Bullet.is`. Within `State.entities` +// a value is a `Bullet | Asteroid`; a bullet is the one that carries an `age` +// (a number) — the asteroid carries `size` instead — so `age` is the +// distinguishing member. Every entity value also carries `position` + `velocity`. +export const is = (v: unknown): v is Bullet => + typeof v === "object" && + v !== null && + "position" in v && + "velocity" in v && + "age" in v && + typeof v.age === "number"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/bullet/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/bullet/public.ts index 17f601d5..93612f42 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/bullet/public.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/bullet/public.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { schema } from "./schema.js"; +export { is } from "./is.js"; export { speed } from "./speed.js"; export { radius } from "./radius.js"; export { lifetime } from "./lifetime.js"; 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 b472418a..50381fd2 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 @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; +import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { Ship } from "../ship/ship.js"; @@ -19,13 +20,12 @@ export const createInitial = ( const fresh: State = { bounds, ship: Ship.spawn(Vec2.scale(bounds, 0.5)), - bullets: new Set(), - asteroids: new Set(), + entities: new Map(), score: 0, lives: 3, wave: 0, }; - // spawnWave returns only { asteroids, wave }; layer it over the fresh game. + // spawnWave returns only { entities, wave }; layer it over the fresh game. return { ...fresh, ...spawnWave(fresh) }; }; @@ -38,8 +38,10 @@ export const createInitial = ( const dirty: State = { bounds: [1, 1], ship: { position: [10, 10], velocity: [5, 5], rotation: 1 }, - bullets: new Set([{ position: [1, 1], velocity: [0, 0], age: 0.5 }]), - asteroids: new Set([{ position: [9, 9], velocity: [0, 0], size: "small" }]), + entities: new Map([ + [1, { position: [1, 1], velocity: [0, 0], age: 0.5 }], + [2, { position: [9, 9], velocity: [0, 0], size: "small" }], + ]), score: 99, lives: 1, wave: 7, @@ -53,12 +55,11 @@ export const cases: Conformance = [ after: { bounds: [200, 200], ship: { position: [100, 100], velocity: [0, 0], rotation: -Math.PI / 2 }, - 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" }, + entities: new Map([ + [Match.ref("a1"), { position: [180, 100], velocity: [0, 60], size: "large" }], + [Match.ref("a2"), { position: [100, 180], velocity: [-60, 0], size: "large" }], + [Match.ref("a3"), { position: [20, 100], velocity: [0, -60], size: "large" }], + [Match.ref("a4"), { position: [100, 20], velocity: [60, 0], size: "large" }], ]), score: 0, lives: 3, @@ -72,12 +73,11 @@ export const cases: Conformance = [ after: { bounds: [400, 400], ship: { position: [200, 200], velocity: [0, 0], rotation: -Math.PI / 2 }, - 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" }, + entities: new Map([ + [Match.ref("a1"), { position: [360, 200], velocity: [0, 60], size: "large" }], + [Match.ref("a2"), { position: [200, 360], velocity: [-60, 0], size: "large" }], + [Match.ref("a3"), { position: [40, 200], velocity: [0, -60], size: "large" }], + [Match.ref("a4"), { position: [200, 40], velocity: [60, 0], size: "large" }], ]), score: 0, lives: 3, 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 2d1dacc5..661be785 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,7 @@ describe("State.create", () => { const state = State.create(); expect(state.bounds).toEqual([0, 0]); expect(state.ship.velocity).toEqual([0, 0]); - expect(state.bullets).toEqual(new Set()); - expect(state.asteroids).toEqual(new Set()); + expect(state.entities).toEqual(new Map()); 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 acf610ea..c76653ad 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 @@ -3,15 +3,14 @@ import type { State } from "./state.js"; import { Ship } from "../ship/ship.js"; // A blank neutral `State`: no play-field, a centred-at-origin idle ship, no -// bullets or asteroids, full lives, zero score, wave 0. Unlike `createInitial` -// it spawns no wave — it is the base every transform test builds a `before` -// from (`{ ...State.create(), …overrides }`), so each case names only the -// fields it exercises. +// entities, full lives, zero score, wave 0. Unlike `createInitial` it spawns no +// wave — it is the base every transform test builds a `before` from +// (`{ ...State.create(), …overrides }`), so each case names only the fields it +// exercises. export const create = (): State => ({ bounds: [0, 0], ship: Ship.spawn([0, 0]), - bullets: new Set(), - asteroids: new Set(), + entities: new Map(), score: 0, lives: 3, wave: 0, diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts index 19e6533c..0007729d 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 @@ -1,4 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { create } from "./create.js"; @@ -6,13 +7,15 @@ import { Bullet } from "../bullet/bullet.js"; import { Ship } from "../ship/ship.js"; // Fire one bullet from the ship's nose, inheriting its momentum. Composes the -// ship's muzzle kinematics with the bullet's own speed constant. +// ship's muzzle kinematics with the bullet's own speed constant, minting a fresh +// entity id (the map key) for it — the bullet value itself carries no id. export const fireBullet = ( - state: Pick, -): Pick => { + state: Pick, +): Pick => { const { position, velocity } = Ship.muzzle(state.ship, Bullet.speed); const bullet: Bullet = { position, velocity, age: 0 }; - return { bullets: new Set(state.bullets).add(bullet) }; + const id = Math.max(0, ...state.entities.keys()) + 1; + return { entities: new Map(state.entities).set(id, bullet) }; }; // Spec-owned cases, shared with the ecs `fireBullet` transaction. A bullet leaves @@ -27,13 +30,15 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: new Set(), + entities: new Map(), }, args: undefined, after: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: new Set([{ position: [112, 100], velocity: [400, 0], age: 0 }]), + entities: new Map([ + [Match.ref("fired"), { position: [112, 100], velocity: [400, 0], age: 0 }], + ]), }, }, { @@ -41,13 +46,15 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [100, 100], velocity: [10, 20], rotation: 0 }, - bullets: new Set(), + entities: new Map(), }, args: undefined, after: { ...field, ship: { position: [100, 100], velocity: [10, 20], rotation: 0 }, - bullets: new Set([{ position: [112, 100], velocity: [410, 20], age: 0 }]), + entities: new Map([ + [Match.ref("fired"), { position: [112, 100], velocity: [410, 20], age: 0 }], + ]), }, }, { @@ -55,15 +62,15 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: new Set([{ position: [0, 0], velocity: [1, 0], age: 0.2 }]), + entities: new Map([[1, { position: [0, 0], velocity: [1, 0], age: 0.2 }]]), }, args: undefined, after: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: new Set([ - { position: [0, 0], velocity: [1, 0], age: 0.2 }, - { position: [112, 100], velocity: [400, 0], age: 0 }, + entities: new Map([ + [Match.ref("existing"), { position: [0, 0], velocity: [1, 0], age: 0.2 }], + [Match.ref("fired"), { position: [112, 100], velocity: [400, 0], age: 0 }], ]), }, }, @@ -72,13 +79,15 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: -Math.PI / 2 }, - bullets: new Set(), + entities: new Map(), }, args: undefined, after: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: -Math.PI / 2 }, - bullets: new Set([{ position: [100, 88], velocity: [0, -400], age: 0 }]), + entities: new Map([ + [Match.ref("fired"), { 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 31310421..c385c5e1 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 @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; +import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { create } from "./create.js"; @@ -18,22 +19,29 @@ import { Collision } from "../collision/collision.js"; // asteroid. Asteroids are treated as stationary at their current position: they // drift ~1px/frame, negligible against the bullet's sweep. export const resolveBulletHits = ( - state: Pick, + state: Pick, dt: number, -): Pick => { - const asteroids: Asteroid[] = [...state.asteroids]; - // Children spawned this pass are collected separately and appended only after - // every bullet has resolved — a bullet may hit an asteroid that existed at the - // start of the pass, never one that a split just created this same frame. +): Pick => { + const entities = new Map(state.entities); + // The asteroids that existed at the start of the pass, with their ids — a + // bullet may hit one of these, never a split child created this same frame. + const asteroids: [number, Asteroid][] = []; + const bullets: [number, Bullet][] = []; + for (const [id, value] of state.entities) { + if (Bullet.is(value)) bullets.push([id, value]); + else asteroids.push([id, value]); + } + // Children spawned this pass are collected separately and inserted only after + // every bullet has resolved, each under a freshly minted id. const spawned: Asteroid[] = []; - const survivors: Bullet[] = []; + let nextId = Math.max(0, ...state.entities.keys()) + 1; let score = state.score; - for (const bullet of state.bullets) { + for (const [bulletId, bullet] of bullets) { const prev = Vec2.subtract( bullet.position, Vec2.scale(bullet.velocity, dt), ); - const hit = asteroids.findIndex((a) => + const hit = asteroids.findIndex(([, a]) => Collision.segmentCircleOverlap( prev, bullet.position, @@ -42,18 +50,18 @@ export const resolveBulletHits = ( ), ); if (hit < 0) { - survivors.push(bullet); continue; } - const [asteroid] = asteroids.splice(hit, 1); + const [[asteroidId, asteroid]] = asteroids.splice(hit, 1); + entities.delete(bulletId); + entities.delete(asteroidId); score += Asteroid.score(asteroid); spawned.push(...Asteroid.split(asteroid)); } - return { - bullets: new Set(survivors), - asteroids: new Set([...asteroids, ...spawned]), - score, - }; + for (const child of spawned) { + entities.set(nextId++, child); + } + return { entities, score }; }; // Spec-owned cases, shared with the ecs `hitAsteroid` transaction (dispatched @@ -72,19 +80,18 @@ export const cases: Conformance = [ name: "destroys bullet + asteroid, scores, and spawns split children (large → 2 medium)", before: { ...field, - bullets: new Set([{ position: [50, 50], velocity: [0, 0], age: 0 }]), - asteroids: new Set([ - { position: [50, 50], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "large" }], ]), score: 0, }, args: 1 / 60, after: { ...field, - bullets: new Set(), - asteroids: new Set([ - { position: [50, 50], velocity: [0, 0], size: "medium" }, - { position: [50, 50], velocity: [0, 0], size: "medium" }, + entities: new Map([ + [Match.ref("m1"), { position: [50, 50], velocity: [0, 0], size: "medium" }], + [Match.ref("m2"), { position: [50, 50], velocity: [0, 0], size: "medium" }], ]), score: 20, }, @@ -93,19 +100,18 @@ export const cases: Conformance = [ name: "medium splits into two small", before: { ...field, - bullets: new Set([{ position: [50, 50], velocity: [0, 0], age: 0 }]), - asteroids: new Set([ - { position: [50, 50], velocity: [0, 0], size: "medium" }, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], ]), score: 5, }, args: 1 / 60, after: { ...field, - bullets: new Set(), - asteroids: new Set([ - { position: [50, 50], velocity: [0, 0], size: "small" }, - { position: [50, 50], velocity: [0, 0], size: "small" }, + entities: new Map([ + [Match.ref("s1"), { position: [50, 50], velocity: [0, 0], size: "small" }], + [Match.ref("s2"), { position: [50, 50], velocity: [0, 0], size: "small" }], ]), score: 55, }, @@ -114,31 +120,31 @@ export const cases: Conformance = [ name: "the smallest tier is destroyed outright — no children", before: { ...field, - bullets: new Set([{ position: [50, 50], velocity: [0, 0], age: 0 }]), - asteroids: new Set([ - { position: [50, 50], velocity: [0, 0], size: "small" }, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "small" }], ]), score: 0, }, args: 1 / 60, - after: { ...field, bullets: new Set(), asteroids: new Set(), score: 100 }, + after: { ...field, entities: new Map(), score: 100 }, }, { name: "a bullet that hits nothing is left untouched", before: { ...field, - bullets: new Set([{ position: [10, 10], velocity: [0, 0], age: 0 }]), - asteroids: new Set([ - { position: [500, 500], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], age: 0 }], + [2, { position: [500, 500], velocity: [0, 0], size: "large" }], ]), score: 7, }, args: 1 / 60, after: { ...field, - bullets: new Set([{ position: [10, 10], velocity: [0, 0], age: 0 }]), - asteroids: new Set([ - { position: [500, 500], velocity: [0, 0], size: "large" }, + entities: new Map([ + [Match.ref("b"), { position: [10, 10], velocity: [0, 0], age: 0 }], + [Match.ref("a"), { position: [500, 500], velocity: [0, 0], size: "large" }], ]), score: 7, }, @@ -147,21 +153,20 @@ export const cases: Conformance = [ name: "only the overlapping asteroid is hit; distant ones remain", before: { ...field, - 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" }, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "large" }], + [3, { position: [500, 500], velocity: [0, 0], size: "small" }], ]), score: 0, }, args: 1 / 60, after: { ...field, - 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" }, + entities: new Map([ + [Match.ref("m1"), { position: [50, 50], velocity: [0, 0], size: "medium" }], + [Match.ref("m2"), { position: [50, 50], velocity: [0, 0], size: "medium" }], + [Match.ref("s"), { position: [500, 500], velocity: [0, 0], size: "small" }], ]), score: 20, }, @@ -170,29 +175,25 @@ export const cases: Conformance = [ name: "two bullets each destroy their own asteroid", before: { ...field, - bullets: new Set([ - { position: [50, 50], velocity: [0, 0], age: 0 }, - { position: [500, 500], velocity: [0, 0], age: 0 }, - ]), - asteroids: new Set([ - { position: [50, 50], velocity: [0, 0], size: "small" }, - { position: [500, 500], velocity: [0, 0], size: "small" }, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [500, 500], velocity: [0, 0], age: 0 }], + [3, { position: [50, 50], velocity: [0, 0], size: "small" }], + [4, { position: [500, 500], velocity: [0, 0], size: "small" }], ]), score: 0, }, args: 1 / 60, - after: { ...field, bullets: new Set(), asteroids: new Set(), score: 200 }, + after: { ...field, entities: new Map(), score: 200 }, }, { name: "split children are not hittable by another bullet in the same pass", before: { ...field, - bullets: new Set([ - { position: [50, 50], velocity: [0, 0], age: 0 }, - { position: [50, 50], velocity: [0, 0], age: 0 }, - ]), - asteroids: new Set([ - { position: [50, 50], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], age: 0 }], + [3, { position: [50, 50], velocity: [0, 0], size: "large" }], ]), score: 0, }, @@ -202,10 +203,10 @@ 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: 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" }, + entities: new Map([ + [Match.ref("b"), { position: [50, 50], velocity: [0, 0], age: 0 }], + [Match.ref("m1"), { position: [50, 50], velocity: [0, 0], size: "medium" }], + [Match.ref("m2"), { position: [50, 50], velocity: [0, 0], size: "medium" }], ]), score: 20, }, @@ -214,19 +215,18 @@ export const cases: Conformance = [ name: "boundary: distance exactly equal to the radius sum still overlaps", before: { ...field, - bullets: new Set([{ position: [0, 0], velocity: [0, 0], age: 0 }]), - asteroids: new Set([ - { position: [42, 0], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [0, 0], velocity: [0, 0], age: 0 }], + [2, { position: [42, 0], velocity: [0, 0], size: "large" }], ]), score: 0, }, args: 1 / 60, after: { ...field, - bullets: new Set(), - asteroids: new Set([ - { position: [42, 0], velocity: [0, 0], size: "medium" }, - { position: [42, 0], velocity: [0, 0], size: "medium" }, + entities: new Map([ + [Match.ref("m1"), { position: [42, 0], velocity: [0, 0], size: "medium" }], + [Match.ref("m2"), { position: [42, 0], velocity: [0, 0], size: "medium" }], ]), score: 20, }, @@ -239,19 +239,18 @@ 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: new Set([{ position: [0, 0], velocity: [-3000, 0], age: 0 }]), - asteroids: new Set([ - { position: [25, 0], velocity: [0, 0], size: "medium" }, + entities: new Map([ + [1, { position: [0, 0], velocity: [-3000, 0], age: 0 }], + [2, { position: [25, 0], velocity: [0, 0], size: "medium" }], ]), score: 0, }, args: 1 / 60, after: { ...field, - bullets: new Set(), - asteroids: new Set([ - { position: [25, 0], velocity: [0, 0], size: "small" }, - { position: [25, 0], velocity: [0, 0], size: "small" }, + entities: new Map([ + [Match.ref("s1"), { position: [25, 0], velocity: [0, 0], size: "small" }], + [Match.ref("s2"), { 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 dbb665f1..a1bca326 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 @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; +import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { create } from "./create.js"; @@ -10,15 +11,17 @@ import { Collision } from "../collision/collision.js"; // If any asteroid is touching the ship, it costs a life and the ship respawns // at the centre. No collision leaves the state untouched (idempotent). export const resolveShipHits = ( - state: Pick, + state: Pick, ): Pick => { - const struck = [...state.asteroids].some((a) => - Collision.circlesOverlap( - state.ship.position, - Ship.radius, - a.position, - Asteroid.radius(a), - ), + const struck = [...state.entities.values()].some( + (v) => + Asteroid.is(v) && + Collision.circlesOverlap( + state.ship.position, + Ship.radius, + v.position, + Asteroid.radius(v), + ), ); if (!struck) { return { ship: state.ship, lives: state.lives }; @@ -44,8 +47,8 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [10, 10], velocity: [5, 5], rotation: 1 }, - asteroids: new Set([ - { position: [10, 10], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], size: "large" }], ]), lives: 3, }, @@ -53,8 +56,8 @@ export const cases: Conformance = [ after: { ...field, ship: respawned, - asteroids: new Set([ - { position: [10, 10], velocity: [0, 0], size: "large" }, + entities: new Map([ + [Match.ref("a"), { position: [10, 10], velocity: [0, 0], size: "large" }], ]), lives: 2, }, @@ -64,8 +67,8 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: new Set([ - { position: [500, 500], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [500, 500], velocity: [0, 0], size: "large" }], ]), lives: 3, }, @@ -73,8 +76,8 @@ export const cases: Conformance = [ after: { ...field, ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: new Set([ - { position: [500, 500], velocity: [0, 0], size: "large" }, + entities: new Map([ + [Match.ref("a"), { position: [500, 500], velocity: [0, 0], size: "large" }], ]), lives: 3, }, @@ -84,8 +87,8 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: new Set([ - { position: [10, 10], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], size: "large" }], ]), lives: 0, }, @@ -93,8 +96,8 @@ export const cases: Conformance = [ after: { ...field, ship: respawned, - asteroids: new Set([ - { position: [10, 10], velocity: [0, 0], size: "large" }, + entities: new Map([ + [Match.ref("a"), { position: [10, 10], velocity: [0, 0], size: "large" }], ]), lives: 0, }, @@ -104,8 +107,8 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [0, 0], velocity: [0, 0], rotation: 0 }, - asteroids: new Set([ - { position: [52, 0], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [52, 0], velocity: [0, 0], size: "large" }], ]), lives: 3, }, @@ -113,8 +116,8 @@ export const cases: Conformance = [ after: { ...field, ship: respawned, - asteroids: new Set([ - { position: [52, 0], velocity: [0, 0], size: "large" }, + entities: new Map([ + [Match.ref("a"), { position: [52, 0], velocity: [0, 0], size: "large" }], ]), lives: 2, }, @@ -124,14 +127,14 @@ export const cases: Conformance = [ before: { ...field, ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: new Set(), + entities: new Map(), lives: 3, }, args: undefined, after: { ...field, ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: new Set(), + entities: new Map(), 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 e469446f..878d1738 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 @@ -1,21 +1,22 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; // Representative full states for the projection round-trip (toState ∘ fromState ≡ // identity). Varied ships / bullets / asteroids + non-default counters exercise the // whole ecs↔State map, including a multiset case (identical same-size asteroids). +// Entity values are concrete (`fromState` inserts them); the map KEYS use +// `Match.ref` distinct labels since the ecs mints its own ids on read-back. export const samples: readonly State[] = [ { bounds: [800, 600], ship: { position: [400, 300], velocity: [12, -7], rotation: 1.25 }, - bullets: new Set([ - { position: [100, 100], velocity: [400, 0], age: 0.1 }, - { position: [220, 340], velocity: [-100, 200], age: 0.9 }, - ]), - 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" }, + entities: new Map([ + [Match.ref("b1"), { position: [100, 100], velocity: [400, 0], age: 0.1 }], + [Match.ref("b2"), { position: [220, 340], velocity: [-100, 200], age: 0.9 }], + [Match.ref("a1"), { position: [50, 60], velocity: [10, 20], size: "large" }], + [Match.ref("a2"), { position: [700, 80], velocity: [-30, 5], size: "medium" }], + [Match.ref("a3"), { position: [640, 540], velocity: [0, -15], size: "small" }], ]), score: 240, lives: 2, @@ -24,8 +25,7 @@ export const samples: readonly State[] = [ { bounds: [320, 240], ship: { position: [160, 120], velocity: [0, 0], rotation: -Math.PI / 2 }, - bullets: new Set(), - asteroids: new Set(), + entities: new Map(), score: 0, lives: 3, wave: 0, @@ -33,11 +33,10 @@ export const samples: readonly State[] = [ { bounds: [500, 500], ship: { position: [250, 250], velocity: [0, 0], rotation: 0 }, - 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" }, + entities: new Map([ + [Match.ref("a1"), { position: [250, 250], velocity: [0, 0], size: "medium" }], + [Match.ref("a2"), { position: [250, 250], velocity: [0, 0], size: "medium" }], + [Match.ref("a3"), { position: [250, 250], velocity: [0, 0], size: "medium" }], ]), score: 90, lives: 1, 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 959b214b..d40ea3b4 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 @@ -1,9 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; +import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { create } from "./create.js"; -import type { Asteroid } from "../asteroid/asteroid.js"; +import { 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"; @@ -28,29 +29,31 @@ const asteroidsFor = (wave: number): number => 3 + wave; * asteroids remain (draws nothing, returns the same reference). */ export const spawnRandomWave = ( - state: Pick, + state: Pick, { random }: Pick, -): Pick => { - if (state.asteroids.size > 0) { - return { asteroids: state.asteroids, wave: state.wave }; +): Pick => { + const hasAsteroid = [...state.entities.values()].some((v) => Asteroid.is(v)); + if (hasAsteroid) { + return { entities: state.entities, wave: state.wave }; } const wave = state.wave + 1; const count = asteroidsFor(wave); const center = Vec2.scale(state.bounds, 0.5); const ring = Math.min(state.bounds[0], state.bounds[1]) * 0.4; - const asteroids: Asteroid[] = []; + const entities = new Map(state.entities); + let nextId = Math.max(0, ...state.entities.keys()) + 1; for (let i = 0; i < count; i++) { const angle = (Math.PI * 2 * i) / count; const outward = Motion.rotate([1, 0], angle); const tangent = Motion.rotate([1, 0], angle + Math.PI / 2); const speed = waveSpeed * (0.5 + random.next()); - asteroids.push({ + entities.set(nextId++, { position: Vec2.add(center, Vec2.scale(outward, ring)), velocity: Vec2.scale(tangent, speed), size: Size.largest, }); } - return { wave, asteroids: new Set(asteroids) }; + return { wave, entities }; }; // Spec-owned cases, shared with the ecs `spawnRandomWave` transaction. Each case @@ -70,16 +73,16 @@ 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: new Set(), wave: 0 }, + before: { ...field, entities: new Map(), wave: 0 }, args: { random: RandomService.createFake(randoms) }, after: { ...field, wave: 1, - 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" }, + entities: new Map([ + [Match.ref("a1"), { position: [180, 100], velocity: [0, 30], size: "large" }], + [Match.ref("a2"), { position: [100, 180], velocity: [-60, 0], size: "large" }], + [Match.ref("a3"), { position: [20, 100], velocity: [0, -45], size: "large" }], + [Match.ref("a4"), { position: [100, 20], velocity: [75, 0], size: "large" }], ]), }, }, @@ -88,16 +91,16 @@ export const cases: Conformance = [ before: { ...field, wave: 1, - asteroids: new Set([ - { position: [10, 10], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], size: "large" }], ]), }, args: { random: RandomService.createFake(randoms) }, after: { ...field, wave: 1, - asteroids: new Set([ - { position: [10, 10], velocity: [0, 0], size: "large" }, + entities: new Map([ + [Match.ref("a"), { 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 ef5ef935..26d9536c 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 @@ -1,9 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; +import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { create } from "./create.js"; -import type { Asteroid } from "../asteroid/asteroid.js"; +import { Asteroid } from "../asteroid/asteroid.js"; import { Size } from "../size/size.js"; import { Motion } from "../motion/motion.js"; @@ -19,27 +20,29 @@ const asteroidsFor = (wave: number): number => 3 + wave; // The randomized sibling `spawnRandomWave` injects a `random` service for the // varied refill waves the tick loop spawns. export const spawnWave = ( - state: Pick, -): Pick => { - if (state.asteroids.size > 0) { - return { asteroids: state.asteroids, wave: state.wave }; + state: Pick, +): Pick => { + const hasAsteroid = [...state.entities.values()].some((v) => Asteroid.is(v)); + if (hasAsteroid) { + return { entities: state.entities, wave: state.wave }; } const wave = state.wave + 1; const count = asteroidsFor(wave); const center = Vec2.scale(state.bounds, 0.5); const ring = Math.min(state.bounds[0], state.bounds[1]) * 0.4; - const asteroids: Asteroid[] = []; + const entities = new Map(state.entities); + let nextId = Math.max(0, ...state.entities.keys()) + 1; for (let i = 0; i < count; i++) { const angle = (Math.PI * 2 * i) / count; const outward = Motion.rotate([1, 0], angle); const tangent = Motion.rotate([1, 0], angle + Math.PI / 2); - asteroids.push({ + entities.set(nextId++, { position: Vec2.add(center, Vec2.scale(outward, ring)), velocity: Vec2.scale(tangent, waveSpeed), size: Size.largest, }); } - return { wave, asteroids: new Set(asteroids) }; + return { wave, entities }; }; // Spec-owned cases for the deterministic `spawnWave` (no args) — the fixed FIRST @@ -53,16 +56,16 @@ 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: new Set(), wave: 0 }, + before: { ...field, entities: new Map(), wave: 0 }, args: undefined, after: { ...field, wave: 1, - 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" }, + entities: new Map([ + [Match.ref("a1"), { position: [180, 100], velocity: [0, 60], size: "large" }], + [Match.ref("a2"), { position: [100, 180], velocity: [-60, 0], size: "large" }], + [Match.ref("a3"), { position: [20, 100], velocity: [0, -60], size: "large" }], + [Match.ref("a4"), { position: [100, 20], velocity: [60, 0], size: "large" }], ]), }, }, @@ -71,16 +74,16 @@ export const cases: Conformance = [ before: { ...field, wave: 1, - asteroids: new Set([ - { position: [10, 10], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], size: "large" }], ]), }, args: undefined, after: { ...field, wave: 1, - asteroids: new Set([ - { position: [10, 10], velocity: [0, 0], size: "large" }, + entities: new Map([ + [Match.ref("a"), { position: [10, 10], velocity: [0, 0], size: "large" }], ]), }, }, 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 0735eae2..f33eef63 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,10 @@ 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: ReadonlySet; - readonly asteroids: ReadonlySet; + // Every bullet and asteroid, in ONE identity-keyed map — the value carries no + // id (identity is the key), and each entry is discriminated by its own shape + // (`Bullet.is` / `Asteroid.is`). Membership queries scan + guard. + readonly entities: ReadonlyMap; readonly score: number; readonly lives: number; readonly wave: number; 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 b0f26739..90be26b5 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 @@ -1,25 +1,30 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { create } from "./create.js"; +import { Asteroid } from "../asteroid/asteroid.js"; import { Motion } from "../motion/motion.js"; import { Size } from "../size/size.js"; -// Drift every asteroid one tick by its constant velocity, wrapping at edges. +// Drift every asteroid one tick by its constant velocity, wrapping at edges +// (keeping each asteroid's entity id). Non-asteroid entities pass through. export const stepAsteroids = ( - state: Pick, + state: Pick, dt: number, -): Pick => { - const asteroids = new Set( - [...state.asteroids].map((a) => ({ - ...a, +): Pick => { + const entities = new Map(state.entities); + for (const [id, value] of state.entities) { + if (!Asteroid.is(value)) continue; + entities.set(id, { + ...value, position: Motion.wrap( - Motion.advance(a.position, a.velocity, dt), + Motion.advance(value.position, value.velocity, dt), state.bounds, ), - })), - ); - return { asteroids }; + }); + } + return { entities }; }; // Spec-owned cases, shared with the ecs system conformance (the asteroid half of @@ -32,15 +37,15 @@ export const cases: Conformance = [ name: "drifts an asteroid by its velocity", before: { ...field, - asteroids: new Set([ - { position: [10, 10], velocity: [30, 0], size: Size.largest }, + entities: new Map([ + [1, { position: [10, 10], velocity: [30, 0], size: Size.largest }], ]), }, args: 1, after: { ...field, - asteroids: new Set([ - { position: [40, 10], velocity: [30, 0], size: Size.largest }, + entities: new Map([ + [Match.ref("a"), { position: [40, 10], velocity: [30, 0], size: Size.largest }], ]), }, }, @@ -48,15 +53,15 @@ export const cases: Conformance = [ name: "wraps an asteroid around the toroidal field", before: { ...field, - asteroids: new Set([ - { position: [80, 80], velocity: [50, 50], size: Size.largest }, + entities: new Map([ + [1, { position: [80, 80], velocity: [50, 50], size: Size.largest }], ]), }, args: 1, after: { ...field, - asteroids: new Set([ - { position: [30, 30], velocity: [50, 50], size: Size.largest }, + entities: new Map([ + [Match.ref("a"), { position: [30, 30], velocity: [50, 50], size: Size.largest }], ]), }, }, @@ -64,15 +69,15 @@ export const cases: Conformance = [ name: "wraps negatively across the left edge", before: { ...field, - asteroids: new Set([ - { position: [10, 10], velocity: [-50, 0], size: "medium" }, + entities: new Map([ + [1, { position: [10, 10], velocity: [-50, 0], size: "medium" }], ]), }, args: 1, after: { ...field, - asteroids: new Set([ - { position: [60, 10], velocity: [-50, 0], size: "medium" }, + entities: new Map([ + [Match.ref("a"), { position: [60, 10], velocity: [-50, 0], size: "medium" }], ]), }, }, @@ -80,24 +85,24 @@ export const cases: Conformance = [ name: "advances several asteroids of different sizes independently", before: { ...field, - asteroids: new Set([ - { position: [10, 10], velocity: [10, 0], size: "large" }, - { position: [20, 20], velocity: [0, 10], size: "small" }, + entities: new Map([ + [1, { position: [10, 10], velocity: [10, 0], size: "large" }], + [2, { position: [20, 20], velocity: [0, 10], size: "small" }], ]), }, args: 1, after: { ...field, - asteroids: new Set([ - { position: [20, 10], velocity: [10, 0], size: "large" }, - { position: [20, 30], velocity: [0, 10], size: "small" }, + entities: new Map([ + [Match.ref("a1"), { position: [20, 10], velocity: [10, 0], size: "large" }], + [Match.ref("a2"), { position: [20, 30], velocity: [0, 10], size: "small" }], ]), }, }, { name: "an empty field stays empty", - before: { ...field, asteroids: new Set() }, + before: { ...field, entities: new Map() }, args: 1, - after: { ...field, asteroids: new Set() }, + after: { ...field, entities: new Map() }, }, ]; 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 5f8cc58a..faa104ba 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 @@ -1,4 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { create } from "./create.js"; @@ -6,24 +7,29 @@ import { Bullet } from "../bullet/bullet.js"; import { Motion } from "../motion/motion.js"; // Advance every bullet one tick: drop the ones that expire this tick, and move -// + age + wrap the survivors. +// + age + wrap the survivors (keeping each survivor's entity id). Non-bullet +// entities pass through untouched. export const stepBullets = ( - state: Pick, + state: Pick, dt: number, -): Pick => { - 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 }; +): Pick => { + const entities = new Map(state.entities); + for (const [id, value] of state.entities) { + if (!Bullet.is(value)) continue; + if (Bullet.isExpired(value.age, dt)) { + entities.delete(id); + continue; + } + entities.set(id, { + ...value, + position: Motion.wrap( + Motion.advance(value.position, value.velocity, dt), + state.bounds, + ), + age: value.age + dt, + }); + } + return { entities }; }; // Spec-owned cases, shared with the ecs system conformance (the `lifetime` @@ -37,68 +43,76 @@ export const cases: Conformance = [ name: "moves and ages a live bullet", before: { ...field, - bullets: new Set([{ position: [10, 50], velocity: [100, 0], age: 0 }]), + entities: new Map([[1, { position: [10, 50], velocity: [100, 0], age: 0 }]]), }, args: 0.1, after: { ...field, - bullets: new Set([{ position: [20, 50], velocity: [100, 0], age: 0.1 }]), + entities: new Map([ + [Match.ref("b"), { position: [20, 50], velocity: [100, 0], age: 0.1 }], + ]), }, }, { name: "wraps a bullet across the right edge", before: { ...field, - bullets: new Set([{ position: [95, 50], velocity: [100, 0], age: 0 }]), + entities: new Map([[1, { position: [95, 50], velocity: [100, 0], age: 0 }]]), }, args: 0.1, after: { ...field, - bullets: new Set([{ position: [5, 50], velocity: [100, 0], age: 0.1 }]), + entities: new Map([ + [Match.ref("b"), { position: [5, 50], velocity: [100, 0], age: 0.1 }], + ]), }, }, { name: "drops a bullet that expires this tick (age + dt ≥ lifetime)", before: { ...field, - bullets: new Set([ - { position: [10, 50], velocity: [100, 0], age: Bullet.lifetime }, + entities: new Map([ + [1, { position: [10, 50], velocity: [100, 0], age: Bullet.lifetime }], ]), }, args: 0.1, - after: { ...field, bullets: new Set() }, + after: { ...field, entities: new Map() }, }, { name: "keeps and ages a bullet still under its lifetime", before: { ...field, - bullets: new Set([{ position: [10, 50], velocity: [0, 0], age: 1.0 }]), + entities: new Map([[1, { position: [10, 50], velocity: [0, 0], age: 1.0 }]]), }, args: 0.1, after: { ...field, - bullets: new Set([{ position: [10, 50], velocity: [0, 0], age: 1.1 }]), + entities: new Map([ + [Match.ref("b"), { position: [10, 50], velocity: [0, 0], age: 1.1 }], + ]), }, }, { name: "advances survivors and drops only the expired bullet", before: { ...field, - bullets: new Set([ - { position: [10, 50], velocity: [100, 0], age: 0 }, - { position: [10, 60], velocity: [100, 0], age: Bullet.lifetime }, + entities: new Map([ + [1, { position: [10, 50], velocity: [100, 0], age: 0 }], + [2, { position: [10, 60], velocity: [100, 0], age: Bullet.lifetime }], ]), }, args: 0.1, after: { ...field, - bullets: new Set([{ position: [20, 50], velocity: [100, 0], age: 0.1 }]), + entities: new Map([ + [Match.ref("b"), { position: [20, 50], velocity: [100, 0], age: 0.1 }], + ]), }, }, { name: "an empty list stays empty", - before: { ...field, bullets: new Set() }, + before: { ...field, entities: new Map() }, args: 0.1, - after: { ...field, bullets: new Set() }, + after: { ...field, entities: new Map() }, }, ]; 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 441bbe98..fff2c850 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 @@ -1,4 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { Input } from "../input/input.js"; @@ -69,9 +70,8 @@ export const cases: Conformance = [ before: { bounds: [200, 200], ship: { position: [190, 100], velocity: [30, 0], rotation: 0 }, - bullets: new Set(), - asteroids: new Set([ - { position: [190, 180], velocity: [30, 30], size: "large" }, + entities: new Map([ + [1, { position: [190, 180], velocity: [30, 30], size: "large" }], ]), score: 0, lives: 3, @@ -81,9 +81,8 @@ export const cases: Conformance = [ after: { bounds: [200, 200], ship: { position: [20, 100], velocity: [30, 0], rotation: 0 }, - bullets: new Set(), - asteroids: new Set([ - { position: [20, 10], velocity: [30, 30], size: "large" }, + entities: new Map([ + [Match.ref("a"), { position: [20, 10], velocity: [30, 30], size: "large" }], ]), score: 0, lives: 3, @@ -95,9 +94,8 @@ export const cases: Conformance = [ before: { bounds: [400, 400], ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: new Set(), - asteroids: new Set([ - { position: [350, 350], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [350, 350], velocity: [0, 0], size: "large" }], ]), score: 0, lives: 3, @@ -111,9 +109,9 @@ export const cases: Conformance = [ after: { bounds: [400, 400], ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: new Set([{ position: [152, 100], velocity: [400, 0], age: 0.1 }]), - asteroids: new Set([ - { position: [350, 350], velocity: [0, 0], size: "large" }, + entities: new Map([ + [Match.ref("b"), { position: [152, 100], velocity: [400, 0], age: 0.1 }], + [Match.ref("a"), { position: [350, 350], velocity: [0, 0], size: "large" }], ]), score: 0, lives: 3, @@ -125,9 +123,9 @@ export const cases: Conformance = [ before: { bounds: [800, 600], ship: { position: [700, 500], velocity: [0, 0], rotation: 0 }, - bullets: new Set([{ position: [100, 100], velocity: [0, 0], age: 0 }]), - asteroids: new Set([ - { position: [100, 100], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [100, 100], velocity: [0, 0], age: 0 }], + [2, { position: [100, 100], velocity: [0, 0], size: "large" }], ]), score: 0, lives: 3, @@ -137,10 +135,9 @@ export const cases: Conformance = [ after: { bounds: [800, 600], ship: { position: [700, 500], velocity: [0, 0], rotation: 0 }, - bullets: new Set(), - asteroids: new Set([ - { position: [100, 100], velocity: [0, 0], size: "medium" }, - { position: [100, 100], velocity: [0, 0], size: "medium" }, + entities: new Map([ + [Match.ref("m1"), { position: [100, 100], velocity: [0, 0], size: "medium" }], + [Match.ref("m2"), { position: [100, 100], velocity: [0, 0], size: "medium" }], ]), score: 20, lives: 3, @@ -152,9 +149,8 @@ export const cases: Conformance = [ before: { bounds: [200, 200], ship: Ship.spawn([100, 100]), - bullets: new Set(), - asteroids: new Set([ - { position: [100, 100], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [100, 100], velocity: [0, 0], size: "large" }], ]), score: 0, lives: 3, @@ -164,9 +160,8 @@ export const cases: Conformance = [ after: { bounds: [200, 200], ship: Ship.spawn([100, 100]), - bullets: new Set(), - asteroids: new Set([ - { position: [100, 100], velocity: [0, 0], size: "large" }, + entities: new Map([ + [Match.ref("a"), { position: [100, 100], velocity: [0, 0], size: "large" }], ]), score: 0, lives: 2, @@ -178,9 +173,9 @@ export const cases: Conformance = [ before: { bounds: [200, 200], ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, - bullets: new Set([{ position: [60, 60], velocity: [0, 0], age: 0.5 }]), - asteroids: new Set([ - { position: [100, 100], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [60, 60], velocity: [0, 0], age: 0.5 }], + [2, { position: [100, 100], velocity: [0, 0], size: "large" }], ]), score: 40, lives: 0, @@ -194,9 +189,9 @@ export const cases: Conformance = [ after: { bounds: [200, 200], ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, - bullets: new Set([{ position: [60, 60], velocity: [0, 0], age: 0.5 }]), - asteroids: new Set([ - { position: [100, 100], velocity: [0, 0], size: "large" }, + entities: new Map([ + [Match.ref("b"), { position: [60, 60], velocity: [0, 0], age: 0.5 }], + [Match.ref("a"), { position: [100, 100], velocity: [0, 0], size: "large" }], ]), score: 40, lives: 0, 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 9cd96e76..6eb3c9b4 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 @@ -9,9 +9,9 @@ import { projection } from "./projection.js"; // transactions/actions off `MainService.plugin`, seeds each case's `before` (a // 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`) are typed `ReadonlySet`, so the comparator matches them -// order-independently. There is no `computedPlugin` — space-rock has no `state/` +// spec.test. The entities the ecs materialises in nondeterministic row order live in +// one `entities: ReadonlyMap`, so the comparator matches +// them order-independently. There is no `computedPlugin` — space-rock has no `state/` // derivations. Conformance.runFeature({ state: State, 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 44d0f058..10cfb8ac 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 @@ -2,8 +2,8 @@ import type { Entity } from "@adobe/data/ecs"; import type { State } from "../../../data/state/state.js"; import type { Ship } from "../../../data/ship/ship.js"; -import type { Bullet } from "../../../data/bullet/bullet.js"; -import type { Asteroid } from "../../../data/asteroid/asteroid.js"; +import { Bullet } from "../../../data/bullet/bullet.js"; +import { Asteroid } from "../../../data/asteroid/asteroid.js"; import type { CoreDatabase } from "../core-database/core-database.js"; // Read one entity back into its `data/` value — the per-entity projection @@ -49,11 +49,13 @@ const toData = ( // production code. export const projection = { // Seed a store to exactly match a `data/` `State`: clear every entity, set the - // scalar resources, then insert the ship, bullets, and asteroids. The inverse - // of `toState`. Clearing iterates tail→head so each delete is from the tail (no - // hole-fill shift). Every entity carries `position`, so one query covers all - // three archetypes. Row shapes equal their `data/` types (no stored broad-phase - // column), so each value inserts directly. + // scalar resources, then insert the ship and every `entities` value into its own + // archetype by its structural guard. The inverse of `toState`. Clearing iterates + // tail→head so each delete is from the tail (no hole-fill shift). Every entity + // carries `position`, so one query covers all three archetypes. Row shapes equal + // their `data/` types (no stored broad-phase column), so each value inserts + // directly. Space-rock is not id-addressed (no `entity()` markers in cases), so + // `fromState` returns `void`. fromState: (store: CoreDatabase.Store, state: State): void => { for (const arch of store.queryArchetypes(["position"])) { for (let row = arch.rowCount - 1; row >= 0; row--) { @@ -65,30 +67,26 @@ export const projection = { store.resources.lives = state.lives; store.resources.wave = state.wave; store.archetypes.Ship.insert(state.ship); - for (const bullet of state.bullets) { - store.archetypes.Bullet.insert(bullet); - } - for (const asteroid of state.asteroids) { - store.archetypes.Asteroid.insert(asteroid); + for (const value of state.entities.values()) { + if (Bullet.is(value)) store.archetypes.Bullet.insert(value); + else store.archetypes.Asteroid.insert(value); } }, // Read a store back into a `data/` `State` — the inverse of `fromState`, built on // the per-entity `toData` projection. Every entity carries `position`, so one - // query covers all three archetypes; each entity is projected through `toData` - // and sorted into the ship, bullets, or asteroids slot by its distinguishing - // member (`rotation` → ship, `age` → bullet, `size` → asteroid). Row order across - // archetypes is arbitrary, but the entity collections compare as multisets, so it - // need not be stable. + // query covers all three archetypes; each entity is projected through `toData`. + // The ship (its `rotation` distinguishes it) seeds the singleton `ship` field; + // every bullet and asteroid goes into the ONE identity-keyed `entities` map under + // a fresh key. Row/key order is arbitrary — the map compares order-independently. toState: (store: CoreDatabase.Store): State => { let ship: Ship | undefined; - const bullets: Bullet[] = []; - const asteroids: Asteroid[] = []; + const entities = new Map(); + let nextKey = 1; for (const arch of store.queryArchetypes(["position"])) { for (let row = 0; row < arch.rowCount; row++) { const value = toData(store, arch.columns.id.get(row)); if ("rotation" in value) ship = value; - else if ("age" in value) bullets.push(value); - else asteroids.push(value); + else entities.set(nextKey++, value); } } if (ship === undefined) @@ -96,8 +94,7 @@ export const projection = { return { bounds: store.resources.bounds, ship, - bullets: new Set(bullets), - asteroids: new Set(asteroids), + entities, 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/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 5334512b..e562e22b 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 @@ -13,6 +13,8 @@ import { describe, it, expect } from "vitest"; import type { State } from "../../../data/state/state.js"; import { Ship } from "../../../data/ship/ship.js"; +import { Bullet } from "../../../data/bullet/bullet.js"; +import { Asteroid } from "../../../data/asteroid/asteroid.js"; import { Input } from "../../../data/input/input.js"; import { Size } from "../../../data/size/size.js"; import { createSystemDatabase } from "../conformance/create-system-database.js"; @@ -22,14 +24,20 @@ 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: new Set(), - asteroids: new Set(), + entities: new Map(), score: 0, lives: 3, wave: 1, ...overrides, }); +// The bullets / asteroids projected out of the unified `entities` map by their +// structural guards — the collision selection assertions read these back. +const bulletsOf = (state: State): readonly Bullet[] => + [...state.entities.values()].filter(Bullet.is); +const asteroidsOf = (state: State): readonly Asteroid[] => + [...state.entities.values()].filter(Asteroid.is); + // Seed the geometry, run exactly one detection-only frame (dt 0), project back. const detect = (state: State): State => { const db = createSystemDatabase(); @@ -44,17 +52,17 @@ describe("collision detection — bullet ↔ asteroid selection", () => { it("destroys only the asteroid the bullet overlaps, scoring it", () => { const after = detect( base({ - 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 + entities: new Map([ + [1, { position: [100, 100], velocity: [0, 0], age: 0 }], + [2, { position: [100, 100], velocity: [0, 0], size: "large" }], // overlapped + [3, { position: [400, 300], velocity: [0, 0], size: "large" }], // far away ]), }), ); expect(after.score).toBe(Size.score.large); - expect(after.bullets.size).toBe(0); + expect(bulletsOf(after)).toHaveLength(0); // The struck large became two mediums; the distant large is untouched. - const asteroids = [...after.asteroids]; + const asteroids = asteroidsOf(after); expect(asteroids.filter((a) => a.size === "medium")).toHaveLength(2); expect(asteroids.filter((a) => a.size === "large")).toHaveLength(1); }); @@ -64,56 +72,57 @@ 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: new Set([{ position: [79, 100], velocity: [0, 0], age: 0 }]), - asteroids: new Set([ - { position: [81, 100], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [79, 100], velocity: [0, 0], age: 0 }], + [2, { position: [81, 100], velocity: [0, 0], size: "large" }], ]), }), ); expect(after.score).toBe(Size.score.large); - expect(after.bullets.size).toBe(0); + expect(bulletsOf(after)).toHaveLength(0); }); it("registers a hit exactly at the radius-sum boundary (distance == r₁+r₂)", () => { const after = detect( base({ - 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 + entities: new Map([ + [1, { position: [0, 0], velocity: [0, 0], age: 0 }], + [2, { position: [42, 0], velocity: [0, 0], size: "large" }], // 42 == 2+40 ]), }), ); expect(after.score).toBe(Size.score.large); - expect(after.bullets.size).toBe(0); + expect(bulletsOf(after)).toHaveLength(0); }); it("does NOT register just beyond the radius sum (no false positive)", () => { const after = detect( base({ - bullets: new Set([{ position: [0, 0], velocity: [0, 0], age: 0 }]), - asteroids: new Set([ - { position: [43, 0], velocity: [0, 0], size: "large" }, // 43 > 42 + entities: new Map([ + [1, { position: [0, 0], velocity: [0, 0], age: 0 }], + [2, { position: [43, 0], velocity: [0, 0], size: "large" }], // 43 > 42 ]), }), ); expect(after.score).toBe(0); - expect(after.bullets.size).toBe(1); - expect(after.asteroids.size).toBe(1); - expect([...after.asteroids][0].size).toBe("large"); + expect(bulletsOf(after)).toHaveLength(1); + const asteroids = asteroidsOf(after); + expect(asteroids).toHaveLength(1); + expect(asteroids[0].size).toBe("large"); }); it("leaves a bullet that overlaps nothing untouched", () => { const after = detect( base({ - bullets: new Set([{ position: [10, 10], velocity: [0, 0], age: 0 }]), - asteroids: new Set([ - { position: [400, 300], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], age: 0 }], + [2, { position: [400, 300], velocity: [0, 0], size: "large" }], ]), }), ); expect(after.score).toBe(0); - expect(after.bullets.size).toBe(1); - expect(after.asteroids.size).toBe(1); + expect(bulletsOf(after)).toHaveLength(1); + expect(asteroidsOf(after)).toHaveLength(1); }); it("does not let a second bullet hit a child the first spawned this same frame", () => { @@ -121,20 +130,18 @@ describe("collision detection — bullet ↔ asteroid selection", () => { // find no ORIGINAL target and survive — never chain onto a fresh medium. const after = detect( base({ - bullets: new Set([ - { position: [100, 100], velocity: [0, 0], age: 0 }, - { position: [100, 100], velocity: [0, 0], age: 0 }, - ]), - asteroids: new Set([ - { position: [100, 100], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [100, 100], velocity: [0, 0], age: 0 }], + [2, { position: [100, 100], velocity: [0, 0], age: 0 }], + [3, { position: [100, 100], velocity: [0, 0], size: "large" }], ]), }), ); expect(after.score).toBe(Size.score.large); - const asteroids = [...after.asteroids]; + const asteroids = asteroidsOf(after); expect(asteroids.filter((a) => a.size === "medium")).toHaveLength(2); expect(asteroids.filter((a) => a.size === "small")).toHaveLength(0); - expect(after.bullets.size).toBe(1); + expect(bulletsOf(after)).toHaveLength(1); }); }); @@ -143,8 +150,8 @@ describe("collision detection — ship ↔ asteroid selection", () => { const after = detect( base({ ship: Ship.spawn([400, 300]), - asteroids: new Set([ - { position: [400, 300], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [400, 300], velocity: [0, 0], size: "large" }], ]), lives: 3, }), @@ -157,9 +164,9 @@ describe("collision detection — ship ↔ asteroid selection", () => { const after = detect( base({ ship: Ship.spawn([400, 300]), - asteroids: new Set([ - { position: [400, 300], velocity: [0, 0], size: "large" }, - { position: [410, 300], velocity: [0, 0], size: "large" }, + entities: new Map([ + [1, { position: [400, 300], velocity: [0, 0], size: "large" }], + [2, { position: [410, 300], velocity: [0, 0], size: "large" }], ]), lives: 3, }), @@ -171,8 +178,8 @@ describe("collision detection — ship ↔ asteroid selection", () => { const after = detect( base({ ship: Ship.spawn([400, 300]), - asteroids: new Set([ - { position: [460, 300], velocity: [0, 0], size: "large" }, // 60 > 52 + entities: new Map([ + [1, { 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 e9594a96..4e6f1d74 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 @@ -24,6 +24,7 @@ import { describe, it, expect } from "vitest"; import { State } from "../../../data/state/state.js"; import { Ship } from "../../../data/ship/ship.js"; +import { Asteroid } from "../../../data/asteroid/asteroid.js"; import { Input } from "../../../data/input/input.js"; import { cases } from "../../../data/state/step.js"; import { Match } from "@adobe/data-testing"; @@ -62,7 +63,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: new Set(), + entities: new Map(), wave: 0, }); db.store.resources.frameDelta = 0.1; @@ -70,10 +71,11 @@ describe("ECS system tick loop conforms to State.step (one frame = one step)", ( driveFrame(db); const after = projection.toState(db.store); + const asteroids = [...after.entities.values()].filter(Asteroid.is); expect(after.wave).toBe(1); - expect(after.asteroids.size).toBe(4); - expect([...after.asteroids].every((a) => a.size === "large")).toBe(true); - const positions = [...after.asteroids].map((a) => [ + expect(asteroids.length).toBe(4); + expect(asteroids.every((a) => a.size === "large")).toBe(true); + const positions = asteroids.map((a) => [ Math.round(a.position[0]), Math.round(a.position[1]), ]); 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 9b6027d2..9f3fe651 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 @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { State } from "../../../../data/state/state.js"; +import { Bullet } from "../../../../data/bullet/bullet.js"; import type { CoreDatabase } from "../../core-database/core-database.js"; import { readShip } from "./read-ship.js"; @@ -10,12 +11,9 @@ 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 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); + const { entities } = State.fireBullet({ ship: found.ship, entities: new Map() }); + // The seed's entities were empty, so the patch holds exactly the fired bullet. + for (const value of entities.values()) { + if (Bullet.is(value)) t.archetypes.Bullet.insert(value); + } }; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.ts index c1be62db..c503aeb4 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { State } from "../../../../data/state/state.js"; +import { Bullet } from "../../../../data/bullet/bullet.js"; import type { CoreDatabase } from "../../core-database/core-database.js"; import { clearEntities } from "./clear-entities.js"; @@ -14,7 +15,10 @@ export const newGame = (t: CoreDatabase.Store): void => { t.resources.lives = initial.lives; t.resources.wave = initial.wave; t.archetypes.Ship.insert(initial.ship); - for (const asteroid of initial.asteroids) { - t.archetypes.Asteroid.insert(asteroid); + // Insert each entity into its archetype by its structural guard (createInitial + // spawns only asteroids, but this mirrors the projection's fromState). + for (const value of initial.entities.values()) { + if (Bullet.is(value)) t.archetypes.Bullet.insert(value); + else t.archetypes.Asteroid.insert(value); } }; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/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 52aac5db..e351e738 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 @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { State } from "../../../../data/state/state.js"; +import { Asteroid } from "../../../../data/asteroid/asteroid.js"; import type { CoreDatabase } from "../../core-database/core-database.js"; import { RandomService } from "../../../random-service/random-service.js"; import { readAsteroids } from "./read-asteroids.js"; @@ -20,11 +21,12 @@ 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: new Set(), wave: t.resources.wave, bounds: t.resources.bounds }, + { entities: new Map(), wave: t.resources.wave, bounds: t.resources.bounds }, { random }, ); t.resources.wave = after.wave; - for (const asteroid of after.asteroids) { - t.archetypes.Asteroid.insert(asteroid); + // The seed's entities were empty, so the patch holds exactly the spawned rocks. + for (const value of after.entities.values()) { + if (Asteroid.is(value)) t.archetypes.Asteroid.insert(value); } }; diff --git a/packages/data-lit-todo/src/features/main/data/state/append-todo.ts b/packages/data-lit-todo/src/features/main/data/state/append-todo.ts index d9248d0b..03fd9f41 100644 --- a/packages/data-lit-todo/src/features/main/data/state/append-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/append-todo.ts @@ -5,17 +5,20 @@ import type { State } from "./state.js"; // re-exported through `public.ts`, so it is not a public `State.` transform and // carries no conformance cases. Shared by the `createTodo` and `createRandomTodo` // transitions so the pure append stays single-sourced while each fires its own -// analytics side effect. Reads the todos, writes the todos — a `{ todos }` patch. +// analytics side effect. Reads the entities, writes the entities — an `{ entities }` +// patch. Mints the next id (the map key) and sets `order` to the current size so +// the todo sorts after every existing one. export const appendTodo = ( - state: Pick, + state: Pick, input: { readonly name: string; readonly complete?: boolean }, -): Pick => { - const nextId = - state.todos.reduce((max, todo) => Math.max(max, todo.id), 0) + 1; +): Pick => { + const id = Math.max(0, ...state.entities.keys()) + 1; + const order = state.entities.size; return { - todos: [ - ...state.todos, - { id: nextId, name: input.name, complete: input.complete ?? false }, - ], + entities: new Map(state.entities).set(id, { + name: input.name, + complete: input.complete ?? false, + order, + }), }; }; 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 9375da67..831749ed 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 @@ -6,52 +6,54 @@ import type { Conformance } from "./conformance-case.js"; import { appendTodo } from "./append-todo.js"; import { Match } from "@adobe/data-testing"; /** Adds numbered placeholder todos for demos and performance testing. Reads and - * writes the todos — a `{ todos }` patch. */ + * writes the entities — an `{ entities }` patch. */ export const createBulkTodos = ( - state: Pick, + state: Pick, { count, analytics, }: { readonly count: number } & Pick, -): Pick => { +): Pick => { analytics.bulkTodosCreated({ count }); const total = Math.max(0, Math.floor(count)); - let next: Pick = state; + let next: Pick = state; for (let index = 0; index < total; index++) { - next = appendTodo(next, { name: `Todo ${state.todos.length + index}` }); + next = appendTodo(next, { name: `Todo ${state.entities.size + index}` }); } return next; }; // Spec-owned cases, shared with the ecs `createBulkTodos` transaction. `before` -// is a delta over `State.create()`; `after` lists only the written todos. -// `count` (floored, clamped at 0) numbered todos are appended; the transition -// logs `bulkTodosCreated` with the raw count (as the action does), even on a -// no-op. Minted ids are left open (`Match.anyNumber`) — the ecs assigns its own. +// is a delta over `State.create()` (plain spec-id keys); `after` lists only the +// written entities with distinct `Match.ref` keys. `count` (floored, clamped at 0) +// numbered todos are appended; the transition logs `bulkTodosCreated` with the raw +// count (as the action does), even on a no-op. export const cases: Conformance = [ { name: "appends count numbered todos to an empty list", before: {}, args: { count: 3, analytics: AnalyticsService.createFake() }, after: { - todos: [ - { id: Match.anyNumber, name: "Todo 0", complete: false }, - { id: Match.anyNumber, name: "Todo 1", complete: false }, - { id: Match.anyNumber, name: "Todo 2", complete: false }, - ], + entities: new Map([ + [Match.ref("a"), { name: "Todo 0", complete: false, order: 0 }], + [Match.ref("b"), { name: "Todo 1", complete: false, order: 1 }], + [Match.ref("c"), { name: "Todo 2", complete: false, order: 2 }], + ]), }, effects: { analytics: [["bulkTodosCreated", { count: 3 }]] }, }, { name: "continues names after existing todos", - before: { todos: [{ id: 1, name: "a", complete: false }] }, + before: { + entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), + }, args: { count: 2, analytics: AnalyticsService.createFake() }, after: { - todos: [ - { id: Match.anyNumber, name: "a", complete: false }, - { id: Match.anyNumber, name: "Todo 1", complete: false }, - { id: Match.anyNumber, name: "Todo 2", complete: false }, - ], + entities: new Map([ + [Match.ref("a"), { name: "a", complete: false, order: 0 }], + [Match.ref("b"), { name: "Todo 1", complete: false, order: 1 }], + [Match.ref("c"), { name: "Todo 2", complete: false, order: 2 }], + ]), }, effects: { analytics: [["bulkTodosCreated", { count: 2 }]] }, }, @@ -60,22 +62,24 @@ export const cases: Conformance = [ before: {}, args: { count: 2.9, analytics: AnalyticsService.createFake() }, after: { - todos: [ - { id: Match.anyNumber, name: "Todo 0", complete: false }, - { id: Match.anyNumber, name: "Todo 1", complete: false }, - ], + entities: new Map([ + [Match.ref("a"), { name: "Todo 0", complete: false, order: 0 }], + [Match.ref("b"), { name: "Todo 1", complete: false, order: 1 }], + ]), }, effects: { analytics: [["bulkTodosCreated", { count: 2.9 }]] }, }, { name: "is a no-op for count 0 but still logs the request", before: { - todos: [{ id: 1, name: "a", complete: false }], + entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), displayCompleted: true, }, args: { count: 0, analytics: AnalyticsService.createFake() }, after: { - todos: [{ id: Match.anyNumber, name: "a", complete: false }], + entities: new Map([ + [Match.ref("a"), { name: "a", complete: false, order: 0 }], + ]), }, effects: { analytics: [["bulkTodosCreated", { count: 0 }]] }, }, 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 0668a7bc..7957e452 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 @@ -10,17 +10,17 @@ import { Match } from "@adobe/data-testing"; * Async, service-injected transition: brackets the slow name generation with * analytics timing, then appends the todo via the shared {@link appendTodo} (so * it does NOT fire `todoCreated` — it logs its own `randomTodoAdded`). Reads and - * writes the todos — a `{ todos }` patch. Awaiting an async port makes it - * `Promise>`, but it stays deterministic given its injected - * services — which is how it is unit-tested. + * writes the entities — an `{ entities }` patch. Awaiting an async port makes it + * `Promise>`, but it stays deterministic given its + * injected services — which is how it is unit-tested. */ export const createRandomTodo = async ( - state: Pick, + state: Pick, { nameGenerator, analytics, }: Pick, -): Promise> => { +): Promise> => { const timing = await analytics.randomTodoRequested(); const name = await nameGenerator.generateName(); const next = appendTodo(state, { name }); @@ -29,13 +29,13 @@ export const createRandomTodo = async ( }; // Spec-owned cases. `before` is a delta over `State.create()`; `after` lists only -// the written todos. Each injects deterministic doubles with the exact responses -// it needs and authors `after` + `effects` against those self-owned values (the -// name it schedules, the fixed `{ startedAt: 0 }` timing the analytics double -// resolves). The value-returning reads (`randomTodoRequested`, `generateName`) -// are still calls on `analytics`, so — analytics being a declared service — its -// full call sequence is listed; `nameGenerator` is not declared, so its read is -// ignored. +// the written entities with a distinct `Match.ref` key. Each injects deterministic +// doubles with the exact responses it needs and authors `after` + `effects` against +// those self-owned values (the name it schedules, the fixed `{ startedAt: 0 }` +// timing the analytics double resolves). The value-returning reads +// (`randomTodoRequested`, `generateName`) are still calls on `analytics`, so — it +// being a declared service — its full call sequence is listed; `nameGenerator` is +// not declared, so its read is ignored. export const cases: Conformance = [ { name: "names the new todo from the generator and logs the timed add", @@ -45,13 +45,9 @@ export const cases: Conformance = [ analytics: AnalyticsService.createFake(), }, after: { - todos: [ - { - id: Match.anyNumber, - name: "random task", - complete: false, - }, - ], + entities: new Map([ + [Match.ref("a"), { name: "random task", complete: false, order: 0 }], + ]), }, effects: { analytics: [ @@ -74,7 +70,9 @@ export const cases: Conformance = [ analytics: AnalyticsService.createFake(), }, after: { - todos: [{ id: Match.anyNumber, name: "only name", complete: false }], + entities: new Map([ + [Match.ref("a"), { name: "only name", complete: false, order: 0 }], + ]), }, effects: { analytics: [ 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 2c9f2610..a389f441 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 @@ -6,10 +6,10 @@ import type { Conformance } from "./conformance-case.js"; import { appendTodo } from "./append-todo.js"; import { Match } from "@adobe/data-testing"; -// Reads the todos, writes the todos — a `{ todos }` patch — by delegating to the -// shared `appendTodo`; also logs `todoCreated`. +// Reads the entities, writes the entities — an `{ entities }` patch — by +// delegating to the shared `appendTodo`; also logs `todoCreated`. export const createTodo = ( - state: Pick, + state: Pick, { name, complete, @@ -18,59 +18,65 @@ export const createTodo = ( readonly name: string; readonly complete?: boolean; } & Pick, -): Pick => { +): Pick => { analytics.todoCreated({ name }); return appendTodo(state, { name, complete }); }; // Spec-owned cases, shared with the ecs `createTodo` transaction. `before` is a -// delta over `State.create()` (no todos, completed hidden); `after` lists only -// what the transition writes — the todos (minted id left open as `Match.anyNumber`, -// the ecs assigns its own). `complete` defaults to false; it logs `todoCreated`. +// delta over `State.create()` (empty entities, completed hidden); the `before` +// map is keyed by PLAIN spec-id numbers. `after` lists only what the transition +// writes — the entities — keyed by `Match.ref` with a DISTINCT label per entry +// (the ecs mints its own ids, so keys stay open; values are id-less and compare by +// content). `complete` defaults to false; it logs `todoCreated`. export const cases: Conformance = [ { name: "appends the first todo to an empty list", before: {}, args: { name: "a", analytics: AnalyticsService.createFake() }, after: { - todos: [{ id: Match.anyNumber, name: "a", complete: false }], + entities: new Map([ + [Match.ref("a"), { name: "a", complete: false, order: 0 }], + ]), }, effects: { analytics: [["todoCreated", { name: "a" }]] }, }, { name: "appends a complete todo", - before: { todos: [{ id: 1, name: "a", complete: false }] }, + before: { + entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), + }, args: { name: "b", complete: true, analytics: AnalyticsService.createFake(), }, after: { - todos: [ - { id: Match.anyNumber, name: "a", complete: false }, - { id: Match.anyNumber, name: "b", complete: true }, - ], + entities: new Map([ + [Match.ref("a"), { name: "a", complete: false, order: 0 }], + [Match.ref("b"), { name: "b", complete: true, order: 1 }], + ]), }, effects: { analytics: [["todoCreated", { name: "b" }]] }, }, { name: "appends onto a longer list", before: { - todos: [ - { id: 1, name: "a", complete: false }, - { id: 2, name: "b", complete: true }, - { id: 3, name: "c", complete: false }, - ], + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), displayCompleted: true, }, args: { name: "d", analytics: AnalyticsService.createFake() }, after: { - todos: [ - { id: Match.anyNumber, name: "a", complete: false }, - { id: Match.anyNumber, name: "b", complete: true }, - { id: Match.anyNumber, name: "c", complete: false }, - { id: Match.anyNumber, name: "d", complete: false }, - ], + entities: new Map([ + [Match.ref("a"), { name: "a", complete: false, order: 0 }], + [Match.ref("b"), { name: "b", complete: true, order: 1 }], + [Match.ref("c"), { name: "c", complete: false, order: 2 }], + [Match.ref("d"), { name: "d", complete: false, order: 3 }], + ]), }, effects: { analytics: [["todoCreated", { name: "d" }]] }, }, diff --git a/packages/data-lit-todo/src/features/main/data/state/create.ts b/packages/data-lit-todo/src/features/main/data/state/create.ts index 3517e7c8..011d8697 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create.ts +++ b/packages/data-lit-todo/src/features/main/data/state/create.ts @@ -1,7 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Todo } from "../todo/todo.js"; // The default application state: no todos, completed items hidden. It is the // baseline the conformance cases author their `before`/`input` as deltas over // (passed to the runners as `initial`), and the state a fresh app starts in. -export const create = (): State => ({ todos: [], displayCompleted: false }); +export const create = (): State => ({ + displayCompleted: false, + entities: new Map(), +}); 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 230b9b5f..26054979 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 @@ -2,42 +2,43 @@ import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; +import type { Todo } from "../todo/todo.js"; import type { Conformance } from "./conformance-case.js"; -// Reads the todos, writes the todos — a `{ todos }` patch — clearing them; -// `displayCompleted` is untouched. Logs `allTodosCleared`. +// Reads the entities, writes the entities — an `{ entities }` patch — clearing +// them; `displayCompleted` is untouched. Logs `allTodosCleared`. export const deleteAllTodos = ( - state: Pick, + state: Pick, { analytics }: Pick, -): Pick => { +): Pick => { analytics.allTodosCleared(); - return { todos: [] }; + return { entities: new Map() }; }; // Spec-owned cases, shared with the ecs `deleteAllTodos` transaction. `before` is -// a delta over `State.create()`; `after` lists only the written todos. Every todo -// is removed and `displayCompleted` is untouched; the transition logs +// a delta over `State.create()`; `after` lists only the written entities (empty). +// Every todo is removed and `displayCompleted` is untouched; the transition logs // `allTodosCleared` (as the action does). export const cases: Conformance = [ { name: "empties a populated list, preserving displayCompleted", before: { - todos: [ - { id: 1, name: "a", complete: false }, - { id: 2, name: "b", complete: true }, - { id: 3, name: "c", complete: false }, - ], + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), displayCompleted: true, }, args: { analytics: AnalyticsService.createFake() }, - after: { todos: [] }, + after: { entities: new Map() }, effects: { analytics: [["allTodosCleared"]] }, }, { name: "is a no-op on an already empty list but still logs the clear", before: {}, args: { analytics: AnalyticsService.createFake() }, - after: { todos: [] }, + after: { entities: new Map() }, effects: { analytics: [["allTodosCleared"]] }, }, ]; 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 4fb8b089..853af205 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 @@ -2,68 +2,72 @@ import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; +import type { Todo } from "../todo/todo.js"; import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data-testing"; -// Reads the todos, writes the todos — a `{ todos }` patch — dropping the -// addressed id; also logs `todoDeleted`. +// Reads the entities, writes the entities — an `{ entities }` patch — dropping the +// addressed id; also logs `todoDeleted`. Surviving todos keep their `order` (the +// ecs `deleteTodo` does not renumber either). export const deleteTodo = ( - state: Pick, + state: Pick, { id, analytics, }: { readonly id: number } & Pick, -): Pick => { +): Pick => { analytics.todoDeleted(); - return { todos: state.todos.filter((todo) => todo.id !== id) }; + const entities = new Map(state.entities); + entities.delete(id); + return { entities }; }; -const three = [ - { id: 1, name: "a", complete: false }, - { id: 2, name: "b", complete: true }, - { id: 3, name: "c", complete: false }, +const three: readonly (readonly [number, Todo])[] = [ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], ]; // Spec-owned cases, shared with the ecs `deleteTodo` transaction. `before` is a -// delta over `State.create()`; `after` lists only the written todos. The -// addressed todo is removed; an unknown id is a no-op. The transition logs -// `todoDeleted`. `before` ids are concrete (they address the delete); surviving -// `after` ids are left open (`Match.anyNumber`) — the ecs assigns its own. +// delta over `State.create()` keyed by PLAIN spec-ids (so `entity(2)` resolves via +// the seed map); `after` lists the surviving entities with distinct `Match.ref` +// keys. The addressed todo is removed; an unknown id is a no-op. The transition +// logs `todoDeleted`. export const cases: Conformance = [ { name: "removes a middle todo", - before: { todos: [...three] }, + before: { entities: new Map(three) }, args: { id: entity(2), analytics: AnalyticsService.createFake() }, after: { - todos: [ - { id: Match.anyNumber, name: "a", complete: false }, - { id: Match.anyNumber, name: "c", complete: false }, - ], + entities: new Map([ + [Match.ref("a"), { name: "a", complete: false, order: 0 }], + [Match.ref("c"), { name: "c", complete: false, order: 2 }], + ]), }, effects: { analytics: [["todoDeleted"]] }, }, { name: "removes the first todo", - before: { todos: [...three], displayCompleted: true }, + before: { entities: new Map(three), displayCompleted: true }, args: { id: entity(1), analytics: AnalyticsService.createFake() }, after: { - todos: [ - { id: Match.anyNumber, name: "b", complete: true }, - { id: Match.anyNumber, name: "c", complete: false }, - ], + entities: new Map([ + [Match.ref("b"), { name: "b", complete: true, order: 1 }], + [Match.ref("c"), { name: "c", complete: false, order: 2 }], + ]), }, effects: { analytics: [["todoDeleted"]] }, }, { name: "is a no-op for an unknown id but still logs the delete", - before: { todos: [...three] }, + before: { entities: new Map(three) }, args: { id: entity(99), analytics: AnalyticsService.createFake() }, after: { - todos: [ - { id: Match.anyNumber, name: "a", complete: false }, - { id: Match.anyNumber, name: "b", complete: true }, - { id: Match.anyNumber, name: "c", complete: false }, - ], + entities: new Map([ + [Match.ref("a"), { name: "a", complete: false, order: 0 }], + [Match.ref("b"), { name: "b", complete: true, order: 1 }], + [Match.ref("c"), { name: "c", complete: false, order: 2 }], + ]), }, effects: { analytics: [["todoDeleted"]] }, }, diff --git a/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts b/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts index a4dbc045..3869971a 100644 --- a/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts @@ -1,89 +1,97 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Todo } from "../todo/todo.js"; import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data-testing"; /** - * Moves the todo with the given id to `toIndex` within the list, preserving the - * relative order of every other todo. Reads the todos, writes the todos — a - * `{ todos }` patch. Out-of-range indices are clamped and an unknown id is a - * no-op. A pure reorder — no side effects. + * Moves the todo with the given id to `toIndex` within the display order, + * preserving the relative order of every other todo, then recomputes every + * `order` to a contiguous 0,1,2,… sequence so the moved todo lands at the target + * rank (mirroring the ecs `dragTodo` drop + `normalizeOrder`). Reads the entities, + * writes the entities — an `{ entities }` patch. Out-of-range indices are clamped + * and an unknown id is a no-op. A pure reorder — no side effects. */ export const reorderTodo = ( - state: Pick, + state: Pick, input: { readonly id: number; readonly toIndex: number }, -): Pick => { - const fromIndex = state.todos.findIndex((todo) => todo.id === input.id); - if (fromIndex === -1) return { todos: state.todos }; +): Pick => { + if (!state.entities.has(input.id)) return { entities: state.entities }; - const moved = state.todos[fromIndex]; - const without = state.todos.filter((todo) => todo.id !== input.id); - const toIndex = Math.max(0, Math.min(input.toIndex, without.length)); + const ordered = [...state.entities].sort( + ([, a], [, b]) => a.order - b.order, + ); + const fromIndex = ordered.findIndex(([id]) => id === input.id); + const [moved] = ordered.splice(fromIndex, 1); + const toIndex = Math.max(0, Math.min(input.toIndex, ordered.length)); + ordered.splice(toIndex, 0, moved); return { - todos: [...without.slice(0, toIndex), moved, ...without.slice(toIndex)], + entities: new Map( + ordered.map(([id, todo], index) => [id, { ...todo, order: index }]), + ), }; }; -const three = [ - { id: 1, name: "a", complete: false }, - { id: 2, name: "b", complete: false }, - { id: 3, name: "c", complete: false }, +const three: readonly (readonly [number, Todo])[] = [ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: false, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], ]; // Spec-owned cases, shared with the ecs `dragTodo` transaction (its final drop is -// the same move — `finalIndex` is `toIndex`). `before` is a delta over -// `State.create()`; `after` lists only the written todos. Every case keeps all -// todos incomplete with `displayCompleted` true, so the visible list `dragTodo` -// indexes equals the full list. `before` ids address the move; `after` ids are -// open (`Match.anyNumber`) but their *order* is verified. The unknown-id no-op is +// the same move — `finalIndex` is `toIndex`, followed by `normalizeOrder`). +// `before` is a delta over `State.create()` keyed by PLAIN spec-ids; `after` lists +// the entities with distinct `Match.ref` keys and their RECOMPUTED contiguous +// `order`. Every case keeps all todos incomplete with `displayCompleted` true, so +// the visible list `dragTodo` indexes equals the full list. The unknown-id no-op is // exercised only by the pure transform — `dragTodo` has no such guard. export const cases: Conformance = [ { name: "moves the first todo to the end", - before: { todos: [...three], displayCompleted: true }, + before: { entities: new Map(three), displayCompleted: true }, args: { id: entity(1), toIndex: 2 }, after: { - todos: [ - { id: Match.anyNumber, name: "b", complete: false }, - { id: Match.anyNumber, name: "c", complete: false }, - { id: Match.anyNumber, name: "a", complete: false }, - ], + entities: new Map([ + [Match.ref("b"), { name: "b", complete: false, order: 0 }], + [Match.ref("c"), { name: "c", complete: false, order: 1 }], + [Match.ref("a"), { name: "a", complete: false, order: 2 }], + ]), }, }, { name: "moves the last todo to the front", - before: { todos: [...three], displayCompleted: true }, + before: { entities: new Map(three), displayCompleted: true }, args: { id: entity(3), toIndex: 0 }, after: { - todos: [ - { id: Match.anyNumber, name: "c", complete: false }, - { id: Match.anyNumber, name: "a", complete: false }, - { id: Match.anyNumber, name: "b", complete: false }, - ], + entities: new Map([ + [Match.ref("c"), { name: "c", complete: false, order: 0 }], + [Match.ref("a"), { name: "a", complete: false, order: 1 }], + [Match.ref("b"), { name: "b", complete: false, order: 2 }], + ]), }, }, { name: "clamps an out-of-range index to the end", - before: { todos: [...three], displayCompleted: true }, + before: { entities: new Map(three), displayCompleted: true }, args: { id: entity(1), toIndex: 99 }, after: { - todos: [ - { id: Match.anyNumber, name: "b", complete: false }, - { id: Match.anyNumber, name: "c", complete: false }, - { id: Match.anyNumber, name: "a", complete: false }, - ], + entities: new Map([ + [Match.ref("b"), { name: "b", complete: false, order: 0 }], + [Match.ref("c"), { name: "c", complete: false, order: 1 }], + [Match.ref("a"), { name: "a", complete: false, order: 2 }], + ]), }, }, { name: "keeps the order when moving to the same index", - before: { todos: [...three], displayCompleted: true }, + before: { entities: new Map(three), displayCompleted: true }, args: { id: entity(2), toIndex: 1 }, after: { - todos: [ - { id: Match.anyNumber, name: "a", complete: false }, - { id: Match.anyNumber, name: "b", complete: false }, - { id: Match.anyNumber, name: "c", complete: false }, - ], + entities: new Map([ + [Match.ref("a"), { name: "a", complete: false, order: 0 }], + [Match.ref("b"), { name: "b", complete: false, order: 1 }], + [Match.ref("c"), { name: "c", complete: false, order: 2 }], + ]), }, }, ]; diff --git a/packages/data-lit-todo/src/features/main/data/state/samples.ts b/packages/data-lit-todo/src/features/main/data/state/samples.ts index 78ed12ad..a8f31be2 100644 --- a/packages/data-lit-todo/src/features/main/data/state/samples.ts +++ b/packages/data-lit-todo/src/features/main/data/state/samples.ts @@ -1,27 +1,29 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; +import type { Todo } from "../todo/todo.js"; // Representative full states for the projection round-trip (toState ∘ fromState ≡ -// identity). Todo ids are authored as `anyNumber`: the ecs reassigns ids from its -// own id-space, so the round-trip leaves them open. Varied lists (mixed -// complete/incomplete, empty, duplicate names) exercise the whole ecs↔State map. +// identity). Entity keys are authored as `Match.ref` with distinct labels: the ecs +// reassigns ids from its own id-space, so the round-trip leaves the keys open while +// the id-less values compare by content. Varied lists (mixed complete/incomplete, +// empty, duplicate names) exercise the whole ecs↔State map. export const samples: readonly State[] = [ { - todos: [ - { id: Match.anyNumber, name: "buy milk", complete: false }, - { id: Match.anyNumber, name: "walk dog", complete: true }, - { id: Match.anyNumber, name: "write tests", complete: false }, - ], + entities: new Map([ + [Match.ref("a"), { name: "buy milk", complete: false, order: 0 }], + [Match.ref("b"), { name: "walk dog", complete: true, order: 1 }], + [Match.ref("c"), { name: "write tests", complete: false, order: 2 }], + ]), displayCompleted: true, }, - { todos: [], displayCompleted: false }, + { entities: new Map(), displayCompleted: false }, { - todos: [ - { id: Match.anyNumber, name: "task", complete: false }, - { id: Match.anyNumber, name: "task", complete: false }, - { id: Match.anyNumber, name: "task", complete: true }, - ], + entities: new Map([ + [Match.ref("a"), { name: "task", complete: false, order: 0 }], + [Match.ref("b"), { name: "task", complete: false, order: 1 }], + [Match.ref("c"), { name: "task", complete: true, order: 2 }], + ]), displayCompleted: false, }, ]; diff --git a/packages/data-lit-todo/src/features/main/data/state/state.ts b/packages/data-lit-todo/src/features/main/data/state/state.ts index eb3ee23b..9676f3ec 100644 --- a/packages/data-lit-todo/src/features/main/data/state/state.ts +++ b/packages/data-lit-todo/src/features/main/data/state/state.ts @@ -2,10 +2,12 @@ import type { Todo } from "../todo/todo.js"; // The full persistent application state as one immutable object — the -// specification the ECS implementation is verified against. `todos` is in -// display order. +// specification the ECS implementation is verified against. `entities` holds every +// todo keyed by a numeric id (identity is the key, never a value field); each +// value carries its own `order` for display sorting. `displayCompleted` is a +// singleton toggle. export type State = { - readonly todos: readonly Todo[]; readonly displayCompleted: boolean; + readonly entities: ReadonlyMap; }; export * as State from "./public.js"; diff --git a/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts b/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts index 3a6f75af..b02e8a94 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 @@ -2,75 +2,81 @@ import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; +import type { Todo } from "../todo/todo.js"; import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data-testing"; -// Reads the todos, writes the todos — a `{ todos }` patch — flipping the +// Reads the entities, writes the entities — an `{ entities }` patch — flipping the // addressed todo's `complete`; also logs `todoToggled`. export const toggleComplete = ( - state: Pick, + state: Pick, { id, analytics, }: { readonly id: number } & Pick, -): Pick => { +): Pick => { analytics.todoToggled(); + const target = state.entities.get(id); + if (target === undefined) return { entities: state.entities }; return { - todos: state.todos.map((todo) => - todo.id === id ? { ...todo, complete: !todo.complete } : todo, - ), + entities: new Map(state.entities).set(id, { + ...target, + complete: !target.complete, + }), }; }; +const two: readonly (readonly [number, Todo])[] = [ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: false, order: 1 }], +]; + // Spec-owned cases, shared with the ecs `toggleComplete` transaction. `before` is -// a delta over `State.create()`; `after` lists only the written todos. Only the -// addressed todo's `complete` flips; an unknown id is a no-op. The transition -// logs `todoToggled` unconditionally (as the action does). `before` ids address -// the toggle; `after` ids are left open (`Match.anyNumber`). +// a delta over `State.create()` keyed by PLAIN spec-ids; `after` lists the written +// entities with distinct `Match.ref` keys. Only the addressed todo's `complete` +// flips; an unknown id is a no-op. The transition logs `todoToggled` +// unconditionally (as the action does). export const cases: Conformance = [ { name: "marks an incomplete todo complete", - before: { - todos: [ - { id: 1, name: "a", complete: false }, - { id: 2, name: "b", complete: false }, - ], - }, + before: { entities: new Map(two) }, args: { id: entity(1), analytics: AnalyticsService.createFake() }, after: { - todos: [ - { id: Match.anyNumber, name: "a", complete: true }, - { id: Match.anyNumber, name: "b", complete: false }, - ], + entities: new Map([ + [Match.ref("a"), { name: "a", complete: true, order: 0 }], + [Match.ref("b"), { name: "b", complete: false, order: 1 }], + ]), }, effects: { analytics: [["todoToggled"]] }, }, { name: "marks a complete todo incomplete", before: { - todos: [ - { id: 1, name: "a", complete: true }, - { id: 2, name: "b", complete: false }, - ], + entities: new Map([ + [1, { name: "a", complete: true, order: 0 }], + [2, { name: "b", complete: false, order: 1 }], + ]), displayCompleted: true, }, args: { id: entity(1), analytics: AnalyticsService.createFake() }, after: { - todos: [ - { id: Match.anyNumber, name: "a", complete: false }, - { id: Match.anyNumber, name: "b", complete: false }, - ], + entities: new Map([ + [Match.ref("a"), { name: "a", complete: false, order: 0 }], + [Match.ref("b"), { name: "b", complete: false, order: 1 }], + ]), }, effects: { analytics: [["todoToggled"]] }, }, { name: "is a no-op for an unknown id but still logs the toggle", before: { - todos: [{ id: 1, name: "a", complete: false }], + entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), }, args: { id: entity(99), analytics: AnalyticsService.createFake() }, after: { - todos: [{ id: Match.anyNumber, name: "a", complete: false }], + entities: new Map([ + [Match.ref("a"), { name: "a", complete: false, order: 0 }], + ]), }, effects: { analytics: [["todoToggled"]] }, }, 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 f0bd43c8..44f39243 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 @@ -17,7 +17,7 @@ export const toggleDisplayCompleted = ( // Spec-owned cases, shared with the ecs `toggleDisplayCompleted` transaction. // `before` is a delta over `State.create()`; `after` lists only the written -// `displayCompleted`. Only the flag flips; todos are untouched; the transition +// `displayCompleted`. Only the flag flips; entities are untouched; the transition // logs `displayCompletedToggled` (as the action does). export const cases: Conformance = [ { @@ -30,15 +30,16 @@ export const cases: Conformance = [ { name: "turns the completed view off, leaving todos intact", before: { - todos: [{ id: 1, name: "a", complete: true }], + entities: new Map([[1, { name: "a", complete: true, order: 0 }]]), displayCompleted: true, }, args: { analytics: AnalyticsService.createFake() }, - // Only `displayCompleted` is written, but the carried-through todo holds an - // ecs-minted id, so it is restated with `Match.anyNumber` to bridge the - // seeded data id (1) and the id the ecs assigns on the round-trip. + // Only `displayCompleted` is written; the carried-through entity is restated + // with a `Match.ref` key so the round-trip's ecs-minted id stays open. after: { - todos: [{ id: Match.anyNumber, name: "a", complete: true }], + entities: new Map([ + [Match.ref("a"), { name: "a", complete: true, order: 0 }], + ]), displayCompleted: false, }, effects: { analytics: [["displayCompletedToggled"]] }, diff --git a/packages/data-lit-todo/src/features/main/data/state/visible-todos.ts b/packages/data-lit-todo/src/features/main/data/state/visible-todos.ts index a5966814..1d5f71c4 100644 --- a/packages/data-lit-todo/src/features/main/data/state/visible-todos.ts +++ b/packages/data-lit-todo/src/features/main/data/state/visible-todos.ts @@ -2,47 +2,48 @@ import type { State } from "./state.js"; import type { Todo } from "../todo/todo.js"; import type { Derivation } from "./conformance-case.js"; -import { Match } from "@adobe/data-testing"; -// The todos the user should see, in display order: all of them when -// `displayCompleted`, otherwise only the incomplete ones. +// The todos the user should see, in display order (ascending `order`): all of them +// when `displayCompleted`, otherwise only the incomplete ones. Yields id-less +// values — identity is the `entities` key, so the visible list compares by content. export const visibleTodos = ( - state: Pick, + state: Pick, ): readonly Todo[] => - state.displayCompleted - ? state.todos - : state.todos.filter((todo) => !todo.complete); + [...state.entities.values()] + .filter((todo) => state.displayCompleted || !todo.complete) + .sort((a, b) => a.order - b.order); -// Spec-owned cases, shared with the ecs `visibleTodos` computed. A derivation -// case is `{ input, value }`; `value` leaves ids open (`Match.anyNumber`) and its order -// is significant (display order). +// Spec-owned cases, shared with the ecs `visibleTodos` computed (an entity-id-list +// computed the runner hydrates through `toData` into these id-less values). A +// derivation case is `{ input, value }`; `input` is keyed by PLAIN spec-ids; +// `value` is id-less content in significant display order. export const cases: Derivation = [ { name: "hides completed todos unless the completed view is on", input: { - todos: [ - { id: 1, name: "a", complete: false }, - { id: 2, name: "b", complete: true }, - { id: 3, name: "c", complete: false }, - ], + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), displayCompleted: false, }, value: [ - { id: Match.anyNumber, name: "a", complete: false }, - { id: Match.anyNumber, name: "c", complete: false }, + { name: "a", complete: false, order: 0 }, + { name: "c", complete: false, order: 2 }, ], }, { name: "shows every todo when the completed view is on", input: { - todos: [ - { id: 1, name: "a", complete: false }, - { id: 2, name: "b", complete: true }, - ], + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + ]), displayCompleted: true, }, value: [ - { id: Match.anyNumber, name: "a", complete: false }, - { id: Match.anyNumber, name: "b", complete: true }, + { name: "a", complete: false, order: 0 }, + { name: "b", complete: true, order: 1 }, ], }, ]; diff --git a/packages/data-lit-todo/src/features/main/data/todo/is.ts b/packages/data-lit-todo/src/features/main/data/todo/is.ts new file mode 100644 index 00000000..d6dd0490 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/todo/is.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Todo } from "./todo.js"; + +// A structural type guard, re-exported through `public.ts` so it reads `Todo.is`. +// Matches purely on structure (name/complete/order), mirroring how the ECS matches +// on components — there is no `id` to check because identity is the map key. +export const is = (v: unknown): v is Todo => + typeof v === "object" && + v !== null && + "name" in v && + typeof v.name === "string" && + "complete" in v && + typeof v.complete === "boolean" && + "order" in v && + typeof v.order === "number"; diff --git a/packages/data-lit-todo/src/features/main/data/todo/public.ts b/packages/data-lit-todo/src/features/main/data/todo/public.ts index 12e95997..f0c37fea 100644 --- a/packages/data-lit-todo/src/features/main/data/todo/public.ts +++ b/packages/data-lit-todo/src/features/main/data/todo/public.ts @@ -1,2 +1,2 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export {}; +export { is } from "./is.js"; diff --git a/packages/data-lit-todo/src/features/main/data/todo/todo.ts b/packages/data-lit-todo/src/features/main/data/todo/todo.ts index 6084b735..4a7a2781 100644 --- a/packages/data-lit-todo/src/features/main/data/todo/todo.ts +++ b/packages/data-lit-todo/src/features/main/data/todo/todo.ts @@ -1,11 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -// A single todo entity in the logical application State. The array position in -// `State.todos` is its display order; the ECS materialises that ordering with -// an `order` component (an implementation detail absent from the spec). +// A single todo entity value in the logical application State — plain readonly +// data with NO id: identity is the key of `State.entities`, never a field. The +// `order` component carries the display order (the ECS materialises it the same +// way), so entity values compare by content across the spec↔ecs boundary. export type Todo = { - readonly id: number; readonly name: string; readonly complete: boolean; + readonly order: number; }; export * as Todo from "./public.js"; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/state.ts b/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/state.ts index b7d8b52f..d80e7f4e 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/state.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/computed-database/computed/state.ts @@ -2,25 +2,30 @@ import { cached } from "@adobe/data/cache"; import { Observe } from "@adobe/data/observe"; import type { State } from "../../../../data/state/state.js"; +import type { Todo } from "../../../../data/todo/todo.js"; import type { IndexDatabase } from "../../index-database/index-database.js"; // The full logical `State` projected from the ECS — the conformance anchor -// between the data-layer spec and this implementation. The `order` component -// collapses into array position and the transient `dragPosition` is dropped; -// neither exists in the spec. +// between the data-layer spec and this implementation. Each entity is keyed by its +// id in `entities` and carries its `order`; the transient `dragPosition` is +// dropped (it has no place in the spec). export const state = cached((db: IndexDatabase) => Observe.withCache(db.derive((read): State => { const rows = read .select(db.archetypes.Todo.components) .map((id) => ({ id, values: read.read(id) })) - .filter((row) => row.values !== undefined) - .sort((a, b) => (a.values!.order ?? 0) - (b.values!.order ?? 0)); + .filter((row) => row.values !== undefined); return { - todos: rows.map(({ id, values }) => ({ - id, - name: values!.name ?? "", - complete: values!.complete ?? false, - })), + entities: new Map( + rows.map(({ id, values }) => [ + id, + { + name: values!.name ?? "", + complete: values!.complete ?? false, + order: values!.order ?? 0, + }, + ]), + ), displayCompleted: read.resources.displayCompleted, }; })), diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts index 93c06c5a..3528459c 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts @@ -4,30 +4,30 @@ import type { State } from "../../../data/state/state.js"; import type { Todo } from "../../../data/todo/todo.js"; import type { CoreDatabase } from "../core-database/core-database.js"; -// Read one entity back into its `data/` value — the per-entity projection +// Read one entity back into its id-less `data/` value — the per-entity projection // `toState` is built on, and the single place the ecs↔data mapping for a todo // lives. Reused by the computed conformance to hydrate id-based computed outputs // (e.g. `visibleTodos` returns entity ids) into the value shape a derivation -// yields, so those computeds need no bespoke projection. Test-only. +// yields, so those computeds need no bespoke projection. Identity is the map key, +// so no `id` is projected. Test-only. const toData = (store: CoreDatabase.Store, entity: Entity): Todo => { const row = store.read(entity, store.archetypes.Todo); if (row === null) throw new Error("conformance projection: expected a todo entity"); - // `id` is the entity itself (reads no longer echo it back as a component). - return { id: entity, name: row.name, complete: row.complete }; + return { name: row.name, complete: row.complete, order: row.order }; }; // The test-only ecs↔`State` projection, passed to `Conformance.runFeature`. // `fromState` seeds a store to a `State` (clearing every todo, setting the -// `displayCompleted` resource, then inserting the todos in display order with -// `order` = the index; the implementation-only slots (`dragPosition`, -// `assignees`) are seeded empty). Clearing iterates tail→head so each delete is -// from the tail (no hole-fill shift). The ecs assigns entity ids from its own -// id-space, unrelated to the spec's domain `id`; `fromState` returns the `spec id -// → seeded entity` map so the runners resolve id-addressed operations generically. -// `toState` reads it back (todos in ascending `order`, each through its full -// archetype so the row shape never aliases; only the spec fields are projected); -// `toData` reads one entity. +// `displayCompleted` resource, then inserting each entity with its own `order`; +// the implementation-only slots (`dragPosition`, `assignees`) and the `todo` tag +// are seeded here). Clearing iterates tail→head so each delete is from the tail +// (no hole-fill shift). The ecs assigns entity ids from its own id-space, unrelated +// to the spec's domain id; `fromState` returns the `spec id → seeded entity` map so +// the runners resolve id-addressed operations generically. `toState` reads it back +// (todos keyed by the assigned entity, each through its full archetype so the row +// shape never aliases; only the spec fields are projected); `toData` reads one +// entity. export const projection = { fromState: ( store: CoreDatabase.Store, @@ -42,13 +42,13 @@ export const projection = { } store.resources.displayCompleted = state.displayCompleted; return new Map( - state.todos.map((todo, index) => [ - todo.id, + [...state.entities].map(([specId, todo]) => [ + specId, store.archetypes.Todo.insert({ todo: true, name: todo.name, complete: todo.complete, - order: index, + order: todo.order, dragPosition: null, assignees: [], }), @@ -56,12 +56,12 @@ export const projection = { ); }, toState: (store: CoreDatabase.Store): State => ({ - todos: [ - ...store.select(store.archetypes.Todo.components, { - order: { order: true }, - }), - ].map((entity) => toData(store, entity)), displayCompleted: store.resources.displayCompleted, + entities: new Map( + store + .select(store.archetypes.Todo.components, { order: { order: true } }) + .map((entity) => [entity, toData(store, entity)]), + ), }), toData, }; diff --git a/packages/data-react-pixie/src/features/main/data/sprite/is.ts b/packages/data-react-pixie/src/features/main/data/sprite/is.ts new file mode 100644 index 00000000..83d420ab --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/sprite/is.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Sprite } from "./sprite.js"; + +// A structural type guard, re-exported so it reads as `Sprite.is`. A value is a +// Sprite when it carries the sprite component shape (a superset match, mirroring +// the ECS archetype query). +export const is = (v: unknown): v is Sprite => + typeof v === "object" && + v !== null && + "position" in v && + Array.isArray((v as Sprite).position) && + (v as Sprite).position.length === 2 && + "rotation" in v && + typeof (v as Sprite).rotation === "number" && + "kind" in v && + typeof (v as Sprite).kind === "string" && + "hovered" in v && + typeof (v as Sprite).hovered === "boolean" && + "active" in v && + typeof (v as Sprite).active === "boolean"; diff --git a/packages/data-react-pixie/src/features/main/data/sprite/public.ts b/packages/data-react-pixie/src/features/main/data/sprite/public.ts index 12e95997..f0c37fea 100644 --- a/packages/data-react-pixie/src/features/main/data/sprite/public.ts +++ b/packages/data-react-pixie/src/features/main/data/sprite/public.ts @@ -1,2 +1,2 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export {}; +export { is } from "./is.js"; diff --git a/packages/data-react-pixie/src/features/main/data/sprite/sprite.ts b/packages/data-react-pixie/src/features/main/data/sprite/sprite.ts index 3e315bc5..68b229d1 100644 --- a/packages/data-react-pixie/src/features/main/data/sprite/sprite.ts +++ b/packages/data-react-pixie/src/features/main/data/sprite/sprite.ts @@ -2,11 +2,10 @@ import type { Vec2, F32 } from "@adobe/data/math"; import type { SpriteKind } from "../sprite-kind/sprite-kind.js"; -// One sprite entity in the logical application State. `id` is the sprite's -// domain identity; the ECS materialises each sprite as an entity whose id is -// drawn from its own id-space (conformance compares ignoring ids). +// One sprite entity value in the logical application State — plain readonly +// data with NO id. Identity lives in the `State.entities` map key, never in the +// value; the ECS materialises each sprite as an entity keyed by that id. export type Sprite = { - readonly id: number; readonly position: Vec2; readonly rotation: F32; readonly kind: SpriteKind; 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 dd01fef4..8c8a011c 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 @@ -5,84 +5,93 @@ import type { State } from "./state.js"; 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; - -// Append a sprite to the scene. Returns only the field it writes (`sprites`). +// Append a sprite to the scene. The spec mints the id (the map key); the value +// carries none. Returns only the field it writes (`entities`). export const createSprite = ( - state: Pick, + state: Pick, input: { readonly position: Vec2; readonly rotation?: number; readonly kind: SpriteKind; }, -): Pick => ({ - sprites: new Set(state.sprites).add({ - id: nextSpriteId(state), - position: input.position, - rotation: input.rotation ?? 0, - kind: input.kind, - hovered: false, - active: false, - }), -}); +): Pick => { + const id = Math.max(0, ...state.entities.keys()) + 1; + return { + entities: new Map(state.entities).set(id, { + position: input.position, + rotation: input.rotation ?? 0, + kind: input.kind, + hovered: false, + active: false, + }), + }; +}; // Spec-owned cases, shared with the ecs `createSprite` transaction. A sprite is -// appended (minted id left open as `anyNumber` — the ecs assigns its own) with -// rotation defaulting to 0 and hovered/active to false; existing sprites are -// untouched. `before` is a delta over `State.create()`; `after` is the writes patch. +// appended with rotation defaulting to 0 and hovered/active to false; existing +// sprites are untouched. `before` is a delta over `State.create()`; `after` is +// the writes patch — map keys are `Match.ref` (a DISTINCT label per entry) since +// the ecs mints its own ids. export const cases: Conformance = [ { name: "appends the first sprite to an empty scene", before: {}, args: { position: [100, 100], kind: "bunny" }, after: { - sprites: new Set([ - { - id: Match.anyNumber, - position: [100, 100], - rotation: 0, - kind: "bunny", - hovered: false, - active: false, - }, + entities: new Map([ + [ + Match.ref("a"), + { + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, + }, + ], ]), }, }, { name: "appends a fox with the next id and an explicit rotation", before: { - sprites: new Set([ - { - id: 1, - position: [100, 100], - rotation: 0, - kind: "bunny", - hovered: false, - active: false, - }, + entities: new Map([ + [ + 1, + { + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, + }, + ], ]), filter: "sepia", }, args: { position: [300, 200], rotation: 1, kind: "fox" }, after: { - sprites: new Set([ - { - id: Match.anyNumber, - position: [100, 100], - rotation: 0, - kind: "bunny", - hovered: false, - active: false, - }, - { - id: Match.anyNumber, - position: [300, 200], - rotation: 1, - kind: "fox", - hovered: false, - active: false, - }, + entities: new Map([ + [ + Match.ref("a"), + { + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, + }, + ], + [ + Match.ref("b"), + { + position: [300, 200], + rotation: 1, + kind: "fox", + 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 621e5c20..051eac24 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 @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; -// The default scene state: no sprites, no filter. It is the baseline the +// The default scene state: no filter, no entities. 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: new Set(), filter: "none" }); +export const create = (): State => ({ filter: "none", entities: new Map() }); 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 08f82f12..5f8f5f8d 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 @@ -1,64 +1,45 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Match } from "@adobe/data-testing"; +import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; // Representative full states for the projection round-trip (toState ∘ fromState ≡ -// identity). Sprite ids are authored as `anyNumber`: the ecs reassigns ids from -// its own id-space, so the round-trip leaves them open. Varied sprite collections -// + scene filters exercise the whole ecs↔State map. +// identity). Entity keys are `Match.ref` distinct labels: the ecs reassigns ids +// from its own id-space, so the round-trip leaves them open. Varied sprite +// collections + scene filters exercise the whole ecs↔State map. export const samples: readonly State[] = [ { - sprites: new Set([ - { - id: Match.anyNumber, - position: [100, 100], - rotation: 0, - kind: "bunny", - hovered: false, - active: false, - }, - { - id: Match.anyNumber, - position: [300, 200], - rotation: 1, - kind: "fox", - hovered: true, - active: false, - }, - { - id: Match.anyNumber, - position: [150, 250], - rotation: 0.5, - kind: "bunny", - hovered: false, - active: true, - }, - ]), filter: "sepia", + entities: new Map([ + [ + Match.ref("a"), + { position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }, + ], + [ + Match.ref("b"), + { position: [300, 200], rotation: 1, kind: "fox", hovered: true, active: false }, + ], + [ + Match.ref("c"), + { position: [150, 250], rotation: 0.5, kind: "bunny", hovered: false, active: true }, + ], + ]), }, { - sprites: new Set(), filter: "none", + entities: new Map(), }, { - sprites: new Set([ - { - id: Match.anyNumber, - position: [10, 10], - rotation: 0, - kind: "fox", - hovered: false, - active: false, - }, - { - id: Match.anyNumber, - position: [20, 20], - rotation: 0, - kind: "fox", - hovered: false, - active: false, - }, - ]), filter: "blur", + entities: new Map([ + [ + Match.ref("a"), + { position: [10, 10], rotation: 0, kind: "fox", hovered: false, active: false }, + ], + [ + Match.ref("b"), + { position: [20, 20], rotation: 0, kind: "fox", hovered: false, active: false }, + ], + ]), }, ]; 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 41cd8b12..d6ebad44 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 @@ -4,47 +4,51 @@ import type { State } from "./state.js"; import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data-testing"; -// Set the addressed sprite's `active` flag. Writes only `sprites`. +// Set the addressed sprite's `active` flag. Writes only `entities`. export const setSpriteActive = ( - state: Pick, + state: Pick, input: { readonly id: number; readonly active: boolean }, -): Pick => ({ - sprites: new Set( - [...state.sprites].map((sprite) => - sprite.id === input.id ? { ...sprite, active: input.active } : sprite, - ), - ), -}); +): Pick => { + const sprite = state.entities.get(input.id); + if (sprite === undefined) return { entities: state.entities }; + return { + entities: new Map(state.entities).set(input.id, { + ...sprite, + active: input.active, + }), + }; +}; const bunny: Sprite = { - id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, + position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, }; const fox: Sprite = { - id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, + position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, }; // Spec-owned cases, shared with the ecs `setSpriteActive` transaction. `before` -// ids address the sprite (`entity(2)`); `after` ids are left open (`anyNumber`). +// keys are plain spec-ids the `args` address via `entity(2)`; `after` keys are +// `Match.ref` distinct labels (the ecs mints its own ids). export const cases: Conformance = [ { name: "sets active true on the addressed sprite only", - before: { sprites: new Set([bunny, fox]) }, + before: { entities: new Map([[1, bunny], [2, fox]]) }, args: { id: entity(2), active: true }, after: { - sprites: new Set([ - { ...bunny, id: Match.anyNumber }, - { ...fox, id: Match.anyNumber, active: true }, + entities: new Map([ + [Match.ref("a"), bunny], + [Match.ref("b"), { ...fox, active: true }], ]), }, }, { name: "is a no-op for an unknown id", - before: { sprites: new Set([bunny, fox]) }, + before: { entities: new Map([[1, bunny], [2, fox]]) }, args: { id: entity(99), active: true }, after: { - sprites: new Set([ - { ...bunny, id: Match.anyNumber }, - { ...fox, id: Match.anyNumber }, + entities: new Map([ + [Match.ref("a"), bunny], + [Match.ref("b"), fox], ]), }, }, 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 a6e9eabd..7a316ed0 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 @@ -4,47 +4,51 @@ import type { State } from "./state.js"; import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data-testing"; -// Set the addressed sprite's `hovered` flag. Writes only `sprites`. +// Set the addressed sprite's `hovered` flag. Writes only `entities`. export const setSpriteHovered = ( - state: Pick, + state: Pick, input: { readonly id: number; readonly hovered: boolean }, -): Pick => ({ - sprites: new Set( - [...state.sprites].map((sprite) => - sprite.id === input.id ? { ...sprite, hovered: input.hovered } : sprite, - ), - ), -}); +): Pick => { + const sprite = state.entities.get(input.id); + if (sprite === undefined) return { entities: state.entities }; + return { + entities: new Map(state.entities).set(input.id, { + ...sprite, + hovered: input.hovered, + }), + }; +}; const bunny: Sprite = { - id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, + position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, }; const fox: Sprite = { - id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, + position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, }; // Spec-owned cases, shared with the ecs `setSpriteHovered` transaction. `before` -// ids address the sprite (`entity(1)`); `after` ids are left open (`anyNumber`). +// keys are plain spec-ids the `args` address via `entity(1)`; `after` keys are +// `Match.ref` distinct labels (the ecs mints its own ids). export const cases: Conformance = [ { name: "sets hovered true on the addressed sprite only", - before: { sprites: new Set([bunny, fox]) }, + before: { entities: new Map([[1, bunny], [2, fox]]) }, args: { id: entity(1), hovered: true }, after: { - sprites: new Set([ - { ...bunny, id: Match.anyNumber, hovered: true }, - { ...fox, id: Match.anyNumber }, + entities: new Map([ + [Match.ref("a"), { ...bunny, hovered: true }], + [Match.ref("b"), fox], ]), }, }, { name: "is a no-op for an unknown id", - before: { sprites: new Set([bunny, fox]) }, + before: { entities: new Map([[1, bunny], [2, fox]]) }, args: { id: entity(99), hovered: true }, after: { - sprites: new Set([ - { ...bunny, id: Match.anyNumber }, - { ...fox, id: Match.anyNumber }, + entities: new Map([ + [Match.ref("a"), bunny], + [Match.ref("b"), fox], ]), }, }, 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 b39b2a9b..4f02181d 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 @@ -3,10 +3,11 @@ import type { Sprite } from "../sprite/sprite.js"; import type { FilterKind } from "../filter-kind/filter-kind.js"; // The full application state as one immutable object — the specification the -// ECS implementation is verified against. `sprites` is an unordered collection; -// `filter` is the scene-wide colour filter. +// ECS implementation is verified against. `filter` is the scene-wide colour +// filter (a singleton → an ECS resource); `entities` holds every sprite keyed +// by a numeric id, the value carrying no id (identity is the key). export type State = { - readonly sprites: ReadonlySet; readonly filter: FilterKind; + readonly entities: ReadonlyMap; }; export * as State from "./public.js"; diff --git a/packages/data-react-pixie/src/features/main/data/state/tick.ts b/packages/data-react-pixie/src/features/main/data/state/tick.ts index 4c62ba67..251789a8 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 @@ -6,44 +6,44 @@ import { Match } from "@adobe/data-testing"; // Advance one animation frame: every sprite rotates by `delta * 0.1` radians. // `delta` is the frame time step, supplied by the caller (the render loop). -// Writes only `sprites`. +// Writes only `entities`. export const tick = ( - state: Pick, + state: Pick, input: { readonly delta: number }, -): Pick => ({ - sprites: new Set( - [...state.sprites].map((sprite) => ({ - ...sprite, - rotation: sprite.rotation + input.delta * 0.1, - })), +): Pick => ({ + entities: new Map( + [...state.entities].map(([id, sprite]): [number, Sprite] => [ + id, + { ...sprite, rotation: sprite.rotation + input.delta * 0.1 }, + ]), ), }); const bunny: Sprite = { - id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, + position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, }; const fox: Sprite = { - id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, + position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, }; // Spec-owned cases, shared with the ecs `tick` transaction. Every sprite's -// rotation advances by delta * 0.1; ids are left open (`anyNumber`). +// rotation advances by delta * 0.1; `after` keys are `Match.ref` distinct labels. export const cases: Conformance = [ { name: "advances every sprite's rotation by delta * 0.1", - before: { sprites: new Set([bunny, fox]) }, + before: { entities: new Map([[1, bunny], [2, fox]]) }, args: { delta: 10 }, after: { - sprites: new Set([ - { ...bunny, id: Match.anyNumber, rotation: 1 }, - { ...fox, id: Match.anyNumber, rotation: 2 }, + entities: new Map([ + [Match.ref("a"), { ...bunny, rotation: 1 }], + [Match.ref("b"), { ...fox, rotation: 2 }], ]), }, }, { name: "is a no-op on an empty scene", - before: { sprites: new Set(), filter: "blur" }, + before: { entities: new Map(), filter: "blur" }, args: { delta: 5 }, - after: { sprites: new Set() }, + after: { entities: new Map() }, }, ]; 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 ae9acbbe..77d957df 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 @@ -4,58 +4,62 @@ import type { State } from "./state.js"; import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data-testing"; -// Flip the addressed sprite's `active` flag. Writes only `sprites`. +// Flip the addressed sprite's `active` flag. Writes only `entities`. export const toggleSpriteActive = ( - state: Pick, + state: Pick, input: { readonly id: number }, -): Pick => ({ - sprites: new Set( - [...state.sprites].map((sprite) => - sprite.id === input.id ? { ...sprite, active: !sprite.active } : sprite, - ), - ), -}); +): Pick => { + const sprite = state.entities.get(input.id); + if (sprite === undefined) return { entities: state.entities }; + return { + entities: new Map(state.entities).set(input.id, { + ...sprite, + active: !sprite.active, + }), + }; +}; const bunny: Sprite = { - id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, + position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, }; const activeFox: Sprite = { - id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: true, + position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: true, }; // Spec-owned cases, shared with the ecs `toggleSpriteActive` transaction. `before` -// ids address the sprite; `after` ids are left open (`anyNumber`). +// keys are plain spec-ids the `args` address via `entity()`; `after` keys are +// `Match.ref` distinct labels (the ecs mints its own ids). export const cases: Conformance = [ { name: "toggles a sprite from inactive to active", - before: { sprites: new Set([bunny, activeFox]) }, + before: { entities: new Map([[1, bunny], [2, activeFox]]) }, args: { id: entity(1) }, after: { - sprites: new Set([ - { ...bunny, id: Match.anyNumber, active: true }, - { ...activeFox, id: Match.anyNumber }, + entities: new Map([ + [Match.ref("a"), { ...bunny, active: true }], + [Match.ref("b"), activeFox], ]), }, }, { name: "toggles a sprite from active to inactive", - before: { sprites: new Set([bunny, activeFox]) }, + before: { entities: new Map([[1, bunny], [2, activeFox]]) }, args: { id: entity(2) }, after: { - sprites: new Set([ - { ...bunny, id: Match.anyNumber }, - { ...activeFox, id: Match.anyNumber, active: false }, + entities: new Map([ + [Match.ref("a"), bunny], + [Match.ref("b"), { ...activeFox, active: false }], ]), }, }, { name: "is a no-op for an unknown id", - before: { sprites: new Set([bunny, activeFox]) }, + before: { entities: new Map([[1, bunny], [2, activeFox]]) }, args: { id: entity(99) }, after: { - sprites: new Set([ - { ...bunny, id: Match.anyNumber }, - { ...activeFox, id: Match.anyNumber }, + entities: new Map([ + [Match.ref("a"), bunny], + [Match.ref("b"), activeFox], ]), }, }, 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 f209b621..e40a7bb8 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 @@ -4,16 +4,14 @@ import type { Sprite } from "../../../data/sprite/sprite.js"; import type { State } from "../../../data/state/state.js"; import type { CoreDatabase } from "../core-database/core-database.js"; -// Read one entity back into its `data/` value — the per-entity projection +// Read one entity back into its id-less `data/` value — the per-entity projection // `toState` folds over, and the single place the ecs↔data mapping for a sprite -// lives. The projected `id` is the entity id (the ecs's own id-space). +// lives. Identity is the `State.entities` key (the entity itself), never a field. const toData = (store: CoreDatabase.Store, entity: Entity): Sprite => { const row = store.read(entity, store.archetypes.Sprite); if (row === null) throw new Error("conformance projection: expected a sprite entity"); return { - // `id` is the entity itself (reads no longer echo it back as a component). - id: entity, position: row.position, rotation: row.rotation, kind: row.kind, @@ -25,8 +23,8 @@ const toData = (store: CoreDatabase.Store, entity: Entity): Sprite => { // The test-only ecs↔`State` projection, passed to `Conformance.runFeature`. // `fromState` seeds a store to a `State` (clear every sprite, set `filter`, then // insert the sprites) and returns the `spec id → seeded entity` map so the -// runners resolve id-addressed operations generically; `toState` reads it back; -// `toData` reads one entity. +// runners resolve id-addressed operations generically; `toState` reads it back +// into the identity-keyed `entities` map; `toData` reads one entity. export const projection = { fromState: ( store: CoreDatabase.Store, @@ -41,8 +39,8 @@ export const projection = { } store.resources.filter = state.filter; return new Map( - [...state.sprites].map((sprite) => [ - sprite.id, + [...state.entities].map(([id, sprite]): [number, Entity] => [ + id, store.archetypes.Sprite.insert({ position: sprite.position, rotation: sprite.rotation, @@ -54,12 +52,12 @@ export const projection = { ); }, toState: (store: CoreDatabase.Store): State => ({ - sprites: new Set( - [...store.select(store.archetypes.Sprite.components)].map((entity) => - toData(store, entity), + filter: store.resources.filter, + entities: new Map( + [...store.select(store.archetypes.Sprite.components)].map( + (entity): [number, Sprite] => [entity, toData(store, entity)], ), ), - filter: store.resources.filter, }), toData, }; diff --git a/packages/data-testing/src/match/match.ts b/packages/data-testing/src/match/match.ts index 8ef8a9ae..59f9a4c5 100644 --- a/packages/data-testing/src/match/match.ts +++ b/packages/data-testing/src/match/match.ts @@ -22,7 +22,12 @@ export interface MatchOptions { // even though the ecs assigns ids from its own space. For an id a case does not // want to pin to a specific number, 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 }); +// Typed as the value it stands in for (defaulting to `number`, the common entity-id +// case), like `anyNumber`, so it slots into a pinned `number` position — including a +// `ReadonlyMap` KEY. Each call returns a fresh object, so distinct labels +// are distinct keys (a shared matcher like `anyNumber` would collapse duplicate map +// keys); same-label occurrences still assert the same actual id (correspondence). +export const ref = (label: string): T => ({ [REF]: label }) as unknown as T; const isRef = (value: unknown): value is { readonly [REF]: string } => typeof value === "object" && value !== null && REF in value; From b91f27ed98ebb8f07679ba1a725b3a0cbfd131be Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 15 Aug 2026 15:06:19 -0700 Subject: [PATCH 4/5] fix(data): close reserved-name guard gap on resources; drop dead id-strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Objective review of the remove-id branch surfaced two defects: - The public `extend` path guarded reserved names (id/nonPersistent/nonShared) on components but NOT on resources. Since createStore constructs the core with an empty schema, the extend loop is the only guard, so a reserved-named resource would silently clobber the built-in quadrant marker / identity column. Add the same throw to the resources loop; cover both paths with a create-store test (component + resource). - The transactional-store delete path still destructured `{ [ID]: _ignore, ... }` off the read values, but reads no longer return id, so it was a no-op masked by an `as any`. Read values directly; bridge the all-optional read type to the insert type with one commented runtime-invariant cast. Also harden a sample spec against a latent stack risk: resolve-bullet-hits minted ids via `Math.max(0, ...keys)` (spreads every key as an argument) — use a running max instead. Co-Authored-By: Claude Opus 4.8 --- .../main/data/state/resolve-bullet-hits.ts | 6 ++++- .../create-transactional-store.ts | 10 ++++++--- .../src/ecs/store/public/create-store.test.ts | 22 +++++++++++++++++++ .../data/src/ecs/store/public/create-store.ts | 6 +++++ 4 files changed, 40 insertions(+), 4 deletions(-) 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 c385c5e1..d61b77df 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 @@ -34,7 +34,11 @@ export const resolveBulletHits = ( // Children spawned this pass are collected separately and inserted only after // every bullet has resolved, each under a freshly minted id. const spawned: Asteroid[] = []; - let nextId = Math.max(0, ...state.entities.keys()) + 1; + // Running max (not `Math.max(...keys)`, which would blow the argument stack for + // a large entity set) to mint ids above every existing one. + let maxId = 0; + for (const id of state.entities.keys()) if (id > maxId) maxId = id; + let nextId = maxId + 1; let score = state.score; for (const [bulletId, bullet] of bullets) { const prev = Vec2.subtract( diff --git a/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts b/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts index 3dedf85d..a1677e82 100644 --- a/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts +++ b/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts @@ -155,8 +155,9 @@ export function createTransactionalStore< throw new Error(`Entity not found: ${entity}`); } - const { [ID]: _ignore, ...oldValuesWithoutId } = oldValues as any; - for (const key in oldValuesWithoutId) { + // `store.read` already excludes the identity column, so `oldValues` carries + // only the entity's components — exactly what the undo `insert` restores. + for (const key in oldValues) { changed.components.add(key); } @@ -166,7 +167,10 @@ export function createTransactionalStore< changed.moves.add(swapped); } redoOperations.push({ type: "delete", entity }); - undoOperationsInReverseOrder.push({ type: "insert", values: oldValuesWithoutId }); + // A live entity carries every component its archetype requires, so the + // id-excluded read values form a complete insert payload — a runtime + // invariant the checker can't derive from the all-optional read type. + undoOperationsInReverseOrder.push({ type: "insert", values: oldValues as unknown as EntityInsertValues }); return swapped; }; diff --git a/packages/data/src/ecs/store/public/create-store.test.ts b/packages/data/src/ecs/store/public/create-store.test.ts index 698b1eb3..48862b6a 100644 --- a/packages/data/src/ecs/store/public/create-store.test.ts +++ b/packages/data/src/ecs/store/public/create-store.test.ts @@ -14,6 +14,28 @@ describe("createStore", () => { createStore({ components: componentSchemas, resources: {}, archetypes: {} }) as any ); + describe("reserved names", () => { + it("throws when a schema defines a reserved component name", () => { + for (const reserved of ["id", "nonPersistent", "nonShared"]) { + expect(() => + createStore({ components: { [reserved]: positionSchema }, resources: {}, archetypes: {} } as any), + ).toThrow(/reserved/); + } + }); + + it("throws when a schema defines a reserved resource name", () => { + for (const reserved of ["id", "nonPersistent", "nonShared"]) { + expect(() => + createStore({ + components: {}, + resources: { [reserved]: { default: { x: 0, y: 0, z: 0 } } }, + archetypes: {}, + } as any), + ).toThrow(/reserved/); + } + }); + }); + // Select function tests describe("Select functionality", () => { const velocitySchema = { diff --git a/packages/data/src/ecs/store/public/create-store.ts b/packages/data/src/ecs/store/public/create-store.ts index 12633d38..f353b5be 100644 --- a/packages/data/src/ecs/store/public/create-store.ts +++ b/packages/data/src/ecs/store/public/create-store.ts @@ -276,6 +276,12 @@ export function createStore< // resources: existing must be identical if present const newResourceNames: string[] = []; for (const [name, newResourceSchema] of Object.entries(schemaResources)) { + // Reserved built-ins (id / nonPersistent / nonShared) can't be redefined + // as resources either — otherwise a reserved-named resource would clobber + // the built-in quadrant marker / identity column in the shared schema map. + if (RESERVED_COMPONENT_NAMES.includes(name)) { + throw new Error(`Resource name "${name}" is reserved by the ECS and cannot be defined.`); + } if (name in resourceSchemas) { if (resourceSchemas[name as keyof typeof resourceSchemas] !== newResourceSchema) { throw new Error(`Resource schema for "${name}" must be identical when extending.`); From 7cf030fda3be12d0ebec0e6af84be0e959b8b9c7 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 15 Aug 2026 17:38:25 -0700 Subject: [PATCH 5/5] feat(data-testing): Match.refMap for identity-keyed entity collections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building the expected side of an entities map (a `samples` entry, a created-entity `after`) meant hand-rolling `new Map([[Match.ref("a"), v], ...])` in every sample — one distinct label per entry so the open keys don't collapse the way a shared `anyNumber` would. Factor that into `Match.refMap(values)`: an iterable of id-less values → `ReadonlyMap` keyed by process-unique open refs. Reserve a hand-written `Match.ref(label)` for keys that must CORRESPOND to a reference elsewhere in the case. Conform all four entity samples to it (drops gpu-hopper's per-package `entities` helper and the inline `Match.ref` bookkeeping in todo/pixie/space-rock); document in state.md + conformance.md. All suites green; clean monorepo typecheck. Co-Authored-By: Claude Opus 4.8 --- .../.claude/rules/features/data/state.md | 12 +++++- .../services/main-service/conformance.md | 5 ++- .../src/features/main/data/state/samples.ts | 17 +++------ .../src/features/main/data/state/samples.ts | 26 ++++++------- .../src/features/main/data/state/samples.ts | 25 ++++++------ .../src/features/main/data/state/samples.ts | 38 ++++++------------- packages/data-testing/src/match/match.test.ts | 28 +++++++++++++- packages/data-testing/src/match/match.ts | 13 +++++++ packages/data-testing/src/match/public.ts | 2 +- 9 files changed, 97 insertions(+), 69 deletions(-) diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index 7927ea3b..b3e48170 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -267,8 +267,16 @@ export const cases: Conformance = [ keys don't collapse) and injective (entities stay distinct, and a label reused in a singleton reference correlates). Never `entity(specId)` here — a case's `after` may hold freshly created entities with no seed mapping. - - **`samples`** (round-tripped `toState ∘ fromState`): same as `after` — **`Match.ref` - distinct labels** (they compare against ECS-minted ids). + - **`samples`** (round-tripped `toState ∘ fromState`): same as `after` — open, + distinct keys against ECS-minted ids. + - **Building the open-keyed map — `Match.refMap(values)`.** When the entries come + from a list rather than hand-authored labels (a `samples` entry, a case `after` + whose entities you don't cross-reference), don't hand-roll the `Match.ref` keys: + `Match.refMap(iterableOfValues)` returns a `ReadonlyMap` keyed by + process-unique open `ref`s — the standard way to build an identity-keyed collection + for comparison. Reach for a **hand-written `Match.ref(label)`** only when a key must + **correspond** to a reference elsewhere in the same case (a `selectedId`), where the + shared label is the whole point. - No per-transform test. The single **`spec.test.ts`** is one call — `Conformance.runSpec({ state: State, transitions })` importing `transitions` from the test-only `./transitions.js` (above) — that auto-discovers every module 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 1e889edb..cae2f1ab 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 @@ -37,7 +37,10 @@ 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 + `Match.anyNumber` / `Match.anyString` / `Match.ref(label)`, and the collection + builder `Match.refMap(values)` (an identity-keyed `ReadonlyMap` of + id-less values with distinct open keys — the standard way to author a `samples` + entry or a created-entity `after`; see `../../../data/state.md`). Options are `{ 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` 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 a5eb0db9..54782223 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 @@ -1,17 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; -import type { Hazard } from "../hazard/hazard.js"; import { create } from "./create.js"; // The projection round-trip (toState ∘ fromState ≡ identity) compares a sample -// against the store the ecs re-materialises, which mints its own entity ids. So the -// `entities` keys must stay OPEN: a DISTINCT `Match.ref` label per hazard (a fresh -// object, injective), never a pinned number (which would demand the ecs reproduce -// that exact id) — see conformance.md. -const entities = (hazards: readonly Hazard[]): ReadonlyMap => - new Map(hazards.map((hazard, index) => [Match.ref(`hazard-${index}`), hazard])); - +// against the store the ecs re-materialises, which mints its own entity ids — +// `Match.refMap` keys each id-less hazard with a distinct open matcher so the keys +// don't pin the ecs to specific ids (see conformance.md). const initial = create(); // Representative full states for the projection round-trip. The initial game, a @@ -19,7 +14,7 @@ const initial = create(); // minimal empty board — together exercising the whole ecs↔State map (resources, // the frog entity, and the hazard entities). export const samples: readonly State[] = [ - { ...initial, entities: entities([...initial.entities.values()]) }, + { ...initial, entities: Match.refMap(initial.entities.values()) }, { width: 5, height: 3, @@ -28,7 +23,7 @@ export const samples: readonly State[] = [ { row: 1, kind: "river" }, { row: 2, kind: "goal" }, ], - entities: entities([ + entities: Match.refMap([ { kind: "log", lane: 1, x: 1.5, width: 3, velocity: 1 }, { kind: "log", lane: 1, x: 4, width: 2, velocity: 1 }, ]), @@ -44,7 +39,7 @@ export const samples: readonly State[] = [ { row: 0, kind: "grass" }, { row: 1, kind: "goal" }, ], - entities: entities([]), + entities: Match.refMap([]), frog: { x: 1, y: 0 }, lives: 3, score: 0, 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 878d1738..c14a3c38 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 @@ -5,18 +5,18 @@ import type { State } from "./state.js"; // Representative full states for the projection round-trip (toState ∘ fromState ≡ // identity). Varied ships / bullets / asteroids + non-default counters exercise the // whole ecs↔State map, including a multiset case (identical same-size asteroids). -// Entity values are concrete (`fromState` inserts them); the map KEYS use -// `Match.ref` distinct labels since the ecs mints its own ids on read-back. +// Entity values are concrete (`fromState` inserts them); `Match.refMap` leaves the +// map KEYS open since the ecs mints its own ids on read-back. export const samples: readonly State[] = [ { bounds: [800, 600], ship: { position: [400, 300], velocity: [12, -7], rotation: 1.25 }, - entities: new Map([ - [Match.ref("b1"), { position: [100, 100], velocity: [400, 0], age: 0.1 }], - [Match.ref("b2"), { position: [220, 340], velocity: [-100, 200], age: 0.9 }], - [Match.ref("a1"), { position: [50, 60], velocity: [10, 20], size: "large" }], - [Match.ref("a2"), { position: [700, 80], velocity: [-30, 5], size: "medium" }], - [Match.ref("a3"), { position: [640, 540], velocity: [0, -15], size: "small" }], + entities: Match.refMap([ + { position: [100, 100], velocity: [400, 0], age: 0.1 }, + { position: [220, 340], velocity: [-100, 200], age: 0.9 }, + { 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, @@ -25,7 +25,7 @@ export const samples: readonly State[] = [ { bounds: [320, 240], ship: { position: [160, 120], velocity: [0, 0], rotation: -Math.PI / 2 }, - entities: new Map(), + entities: Match.refMap([]), score: 0, lives: 3, wave: 0, @@ -33,10 +33,10 @@ export const samples: readonly State[] = [ { bounds: [500, 500], ship: { position: [250, 250], velocity: [0, 0], rotation: 0 }, - entities: new Map([ - [Match.ref("a1"), { position: [250, 250], velocity: [0, 0], size: "medium" }], - [Match.ref("a2"), { position: [250, 250], velocity: [0, 0], size: "medium" }], - [Match.ref("a3"), { position: [250, 250], velocity: [0, 0], size: "medium" }], + entities: Match.refMap([ + { 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, diff --git a/packages/data-lit-todo/src/features/main/data/state/samples.ts b/packages/data-lit-todo/src/features/main/data/state/samples.ts index a8f31be2..3b74cac5 100644 --- a/packages/data-lit-todo/src/features/main/data/state/samples.ts +++ b/packages/data-lit-todo/src/features/main/data/state/samples.ts @@ -1,28 +1,27 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Match } from "@adobe/data-testing"; import type { State } from "./state.js"; -import type { Todo } from "../todo/todo.js"; // Representative full states for the projection round-trip (toState ∘ fromState ≡ -// identity). Entity keys are authored as `Match.ref` with distinct labels: the ecs -// reassigns ids from its own id-space, so the round-trip leaves the keys open while -// the id-less values compare by content. Varied lists (mixed complete/incomplete, +// identity). `Match.refMap` keys each id-less value with a distinct open matcher: +// the ecs reassigns ids from its own id-space, so the round-trip leaves the keys +// open while the values compare by content. Varied lists (mixed complete/incomplete, // empty, duplicate names) exercise the whole ecs↔State map. export const samples: readonly State[] = [ { - entities: new Map([ - [Match.ref("a"), { name: "buy milk", complete: false, order: 0 }], - [Match.ref("b"), { name: "walk dog", complete: true, order: 1 }], - [Match.ref("c"), { name: "write tests", complete: false, order: 2 }], + entities: Match.refMap([ + { name: "buy milk", complete: false, order: 0 }, + { name: "walk dog", complete: true, order: 1 }, + { name: "write tests", complete: false, order: 2 }, ]), displayCompleted: true, }, - { entities: new Map(), displayCompleted: false }, + { entities: Match.refMap([]), displayCompleted: false }, { - entities: new Map([ - [Match.ref("a"), { name: "task", complete: false, order: 0 }], - [Match.ref("b"), { name: "task", complete: false, order: 1 }], - [Match.ref("c"), { name: "task", complete: true, order: 2 }], + entities: Match.refMap([ + { name: "task", complete: false, order: 0 }, + { name: "task", complete: false, order: 1 }, + { name: "task", complete: true, order: 2 }, ]), displayCompleted: false, }, 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 5f8f5f8d..3866b1b2 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 @@ -1,45 +1,29 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Match } from "@adobe/data-testing"; -import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; // Representative full states for the projection round-trip (toState ∘ fromState ≡ -// identity). Entity keys are `Match.ref` distinct labels: the ecs reassigns ids -// from its own id-space, so the round-trip leaves them open. Varied sprite -// collections + scene filters exercise the whole ecs↔State map. +// identity). `Match.refMap` keys each id-less sprite with a distinct open matcher: +// the ecs reassigns ids from its own id-space, so the round-trip leaves them open. +// Varied sprite collections + scene filters exercise the whole ecs↔State map. export const samples: readonly State[] = [ { filter: "sepia", - entities: new Map([ - [ - Match.ref("a"), - { position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }, - ], - [ - Match.ref("b"), - { position: [300, 200], rotation: 1, kind: "fox", hovered: true, active: false }, - ], - [ - Match.ref("c"), - { position: [150, 250], rotation: 0.5, kind: "bunny", hovered: false, active: true }, - ], + entities: Match.refMap([ + { position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }, + { position: [300, 200], rotation: 1, kind: "fox", hovered: true, active: false }, + { position: [150, 250], rotation: 0.5, kind: "bunny", hovered: false, active: true }, ]), }, { filter: "none", - entities: new Map(), + entities: Match.refMap([]), }, { filter: "blur", - entities: new Map([ - [ - Match.ref("a"), - { position: [10, 10], rotation: 0, kind: "fox", hovered: false, active: false }, - ], - [ - Match.ref("b"), - { position: [20, 20], rotation: 0, kind: "fox", hovered: false, active: false }, - ], + entities: Match.refMap([ + { position: [10, 10], rotation: 0, kind: "fox", hovered: false, active: false }, + { position: [20, 20], rotation: 0, kind: "fox", hovered: false, active: false }, ]), }, ]; diff --git a/packages/data-testing/src/match/match.test.ts b/packages/data-testing/src/match/match.test.ts index 337d0913..58ab5dae 100644 --- a/packages/data-testing/src/match/match.test.ts +++ b/packages/data-testing/src/match/match.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, it, expect } from "vitest"; -import { matches, ref, anyNumber, anyString } from "./public.js"; +import { matches, ref, refMap, anyNumber, anyString } from "./public.js"; describe("Match.matches", () => { it("compares plain structures deeply", () => { @@ -84,4 +84,30 @@ describe("Match.matches", () => { expect(matches(dangling, expected)).toBe(false); }); }); + + describe("refMap", () => { + it("matches an identity-keyed entity collection regardless of the actual ids", () => { + const actual = new Map([ + [10, { name: "a" }], + [20, { name: "b" }], + ]); + // Same values, ecs-minted keys — refMap leaves the keys open. + expect(matches(actual, refMap([{ name: "a" }, { name: "b" }]))).toBe(true); + // A value mismatch still fails. + expect(matches(actual, refMap([{ name: "a" }, { name: "c" }]))).toBe(false); + // Cardinality mismatch fails. + expect(matches(actual, refMap([{ name: "a" }]))).toBe(false); + }); + + it("keys are distinct — entries never collapse the way a shared matcher would", () => { + const actual = new Map([ + [1, { v: 1 }], + [2, { v: 1 }], + ]); + // Two equal values in two entries: refMap keeps two entries (distinct keys), + // so both must pair. A single shared `anyNumber` key would collapse to one. + expect(matches(actual, refMap([{ v: 1 }, { v: 1 }]))).toBe(true); + expect(matches(new Map([[1, { v: 1 }]]), refMap([{ v: 1 }, { v: 1 }]))).toBe(false); + }); + }); }); diff --git a/packages/data-testing/src/match/match.ts b/packages/data-testing/src/match/match.ts index 59f9a4c5..684806f9 100644 --- a/packages/data-testing/src/match/match.ts +++ b/packages/data-testing/src/match/match.ts @@ -31,6 +31,19 @@ export const ref = (label: string): T => ({ [REF]: label }) as unkno const isRef = (value: unknown): value is { readonly [REF]: string } => typeof value === "object" && value !== null && REF in value; +// Build the EXPECTED side of an identity-keyed entity collection — a +// `ReadonlyMap` of id-less values whose keys are DISTINCT open `ref`s. +// This is the common shape of a `samples` entry or a case `after` whose entities +// were freshly created: the ecs mints the real ids, so each key must be an open, +// injective matcher rather than a pinned number, and two entries must not share a +// matcher (a shared `anyNumber` would collapse to one map key). Each value gets its +// own process-unique label, so refMap maps never collide with each other or with a +// case's own hand-authored `ref` labels. Use `ref(label)` by hand only when a key +// must CORRESPOND to a reference elsewhere in the same comparison (a `selectedId`). +let refMapCounter = 0; +export const refMap = (values: Iterable): ReadonlyMap => + new Map([...values].map((value): [number, V] => [ref(`@refMap-${refMapCounter++}`), value])); + // An asymmetric matcher (this module's `anyNumber`/`anyString`, or vitest's // `expect.any(...)`): honored on the EXPECTED side so a case asserts a shape it // does not pin. Recognised structurally, so no test framework is imported. diff --git a/packages/data-testing/src/match/public.ts b/packages/data-testing/src/match/public.ts index 6d7c2d91..09215574 100644 --- a/packages/data-testing/src/match/public.ts +++ b/packages/data-testing/src/match/public.ts @@ -1,4 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export { matches, ref, type MatchOptions } from "./match.js"; +export { matches, ref, refMap, type MatchOptions } from "./match.js"; export { assert } from "./assert.js"; export { anyNumber, anyString } from "./matchers.js";