@cyberart-io/engine 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/events.md CHANGED
@@ -1,9 +1,15 @@
1
1
  # Events and router
2
2
 
3
- Mailbox (one runtime) and `createEventRouter` (many carts). Carts never receive the router object or another cart’s `HostChannel`. Related: [deterministic mode](deterministic-mode.md) (`now` / `createId` / `turn` per `step`).
3
+ Mailbox (one runtime) and `createEventRouter` (many carts). Carts never receive the router object or another cart’s `HostChannel`. Related: [deterministic mode](deterministic-mode.md) (`now` / `createId` / `turn` per `step`), [presentation adapter](presentation-adapter.md) (`presentation.state.model`), [presentation cue](presentation-cue.md) (frame-local lifecycle, not routed unless you define a contract).
4
4
 
5
5
  Back to the [package README](../README.md).
6
6
 
7
+ ## One-command reproduce (this repo)
8
+
9
+ ```bash
10
+ pnpm exec vitest run packages/engine/src/canvas/cyb-59-event-contract.repro.spec.ts
11
+ ```
12
+
7
13
  ## When to use which
8
14
 
9
15
  | Path | Use |
@@ -11,14 +17,14 @@ Back to the [package README](../README.md).
11
17
  | Mailbox only | One cart, host `dispatch` / cart `emit`. No permissions, hops, or loop checks. |
12
18
  | Router | Several carts, host reducer in the middle, Adventure-style intent vs state. |
13
19
 
14
- `createRuntime` does **not** attach a router. You attach each cart’s channel yourself.
20
+ `createRuntime` does **not** attach a router. Attach `runtime.hostChannel` yourself. Cart unload clears the inbound queue only; router listeners stay until `runtime.destroy()`. If the channel is attached to a router, use `router.subscribe` for host logic — a second `mount({ onEvent })` will see every cart emit twice.
15
21
 
16
22
  ## Mailbox (`HostChannel`)
17
23
 
18
24
  ```ts
19
25
  import { HostChannel, type HostEvent } from '@cyberart-io/engine';
20
26
 
21
- // Usually you do not construct this. createRuntime owns one; mount({ onEvent }) listens.
27
+ // createRuntime owns one: `runtime.hostChannel`. mount({ onEvent }) also listens.
22
28
  cart.dispatch({ type: 'art-project.theme', payload: 'dusk' }); // host → cart
23
29
  hostChannel.consume(); // cart drains inbound (typically in update)
24
30
  hostChannel.emit({ type: 'art-project.theme', payload: 'dusk' }); // cart → host
@@ -32,7 +38,9 @@ hostChannel.emit({ type: 'art-project.theme', payload: 'dusk' }); // cart → ho
32
38
  | `consume()` | Return and clear the inbound batch. Empty array if none. |
33
39
  | `emit(event)` | Notify `onEvent` listeners (host and, if attached, the router). |
34
40
  | `onEvent(listener)` | Subscribe. Returns an unsubscribe function. |
35
- | `clear()` | Drop inbound queue and listeners. |
41
+ | `clearInbound()` | Drop the inbound queue. Listeners stay (cart unload). |
42
+ | `clear()` | Drop inbound queue and listeners (runtime teardown). |
43
+ | `close()` | Permanent. Further dispatch / emit / consume / onEvent throw. `runtime.destroy()` calls this. |
36
44
 
37
45
  Carts that never mention `hostChannel` ignore both directions. Guard it: the argument is undefined when the cart is not mounted through `createRuntime`.
38
46
 
@@ -58,15 +66,73 @@ Routed events are normalized to `EventEnvelope` (`EVENT_ENVELOPE_VERSION = 1`).
58
66
  `kind` on the input wins. Otherwise:
59
67
 
60
68
  - `cyberart.state.save` / `cyberart.state.load` are **intents** (historical names).
61
- - Else the second dotted segment of `type` if it is `intent`, `state`, or `diagnostic`.
62
- - Else `malformed` (`cannot infer kind`).
69
+ - Else the first dotted segment of `type` that is `intent`, `state`, or `diagnostic` (not necessarily the second segment — `adventure.presentation.intent.cue-started` is an intent).
70
+ - Else `malformed` (`cannot infer kind`). `adventure.presentation.cue.started` does not infer a kind; define it with `defineIntent` / `defineStateEvent` / `defineDiagnostic` so the kind is in the name.
63
71
 
64
72
  Only the host (`publish`) or an `authoritative` participant may emit `state` / `diagnostic`. Carts emit allowed `intent` patterns. Domain data belongs in `payload`.
65
73
 
74
+ ## Typed contracts
75
+
76
+ Define each routed type once. That object is the TypeScript payload shape (`InferredPayload`), the runtime validator, the router permission source, and the JSON manifest row.
77
+
78
+ ```ts
79
+ import {
80
+ defineIntent,
81
+ defineStateEvent,
82
+ createContractRegistry,
83
+ deriveAttachOptions,
84
+ createEventRouter,
85
+ } from '@cyberart-io/engine';
86
+
87
+ const exit = defineIntent('adventure.intent.exit-requested', {
88
+ version: 1,
89
+ fields: { exitId: { type: 'string' } },
90
+ });
91
+ const room = defineStateEvent('adventure.state.room-changed', {
92
+ version: 1,
93
+ fields: { roomId: { type: 'string' } },
94
+ });
95
+ if (!exit.ok || !room.ok) {
96
+ // structured errors — these helpers do not throw
97
+ }
98
+ const contracts = [exit.contract, room.contract];
99
+ const registry = createContractRegistry(contracts);
100
+ const router = createEventRouter({ validate: registry.asRouterValidate });
101
+ router.attach('presentation', runtime.hostChannel, deriveAttachOptions(contracts, 'cart'));
102
+ ```
103
+
104
+ `adventure.presentation.cue.started` has no `intent` / `state` / `diagnostic` segment, so `inferEventKind` returns undefined and `defineIntent` returns `{ ok: false, errors }` with `kind-mismatch`. Use `adventure.presentation.intent.cue-started` (kind anywhere after the namespace, not only the second segment). Cue timeline names (`cue.started`, …) are **not** routed envelopes until you wrap them in a contract.
105
+
106
+ | Helper | Kind stamped | Name rule |
107
+ |---|---|---|
108
+ | `defineIntent(type, schema)` | `intent` | `type` must contain an `intent` dotted segment |
109
+ | `defineStateEvent(type, schema)` | `state` | `type` must contain a `state` dotted segment |
110
+ | `defineDiagnostic(type, schema)` | `diagnostic` | `type` must contain a `diagnostic` dotted segment |
111
+
112
+ `schema` is `{ version: integer >= 1, fields: { name: { type: 'string' \| 'number' \| 'boolean' \| 'object' \| 'array', optional?: true } } }`. Extra payload keys are allowed. Missing required fields and wrong JSON types return `{ ok: false, errors: ContractDiagnostic[] }` (`code`, `detail`, optional `path`). Invalid definitions return the same shape; they never throw.
113
+
114
+ | Export | Role |
115
+ |---|---|
116
+ | `createContractRegistry(contracts)` | `get`, `manifest()` (JSON-serializable), `validateEnvelope`, `asRouterValidate` (safe to pass as `EventRouterOptions.validate`; does not rely on `this`) |
117
+ | `deriveAttachOptions(contracts, 'cart' \| 'authoritative')` | Cart: emit intent family patterns only. Authoritative: emit every contract family. Subscribe to non-intent families. |
118
+ | `verifyAttachOptions(options, contracts)` | `{ ok: true }` or unauthorized-emit errors when a non-authoritative attach can emit a `state` / `diagnostic` contract |
119
+ | `comparePayloadSchemas(from, to)` | `'identical'` / `'backward-compatible'` / `'breaking'` |
120
+ | `kindSegmentInType(type)` / `familyPatternForType(type)` | First kind segment; last-segment `*` glob (`adventure.presentation.intent.*`) |
121
+
122
+ Default router `emit: ['*.intent.*']` is **three** segments. Four-segment names such as `adventure.presentation.intent.cue-started` need `deriveAttachOptions` (or an explicit four-segment pattern). Duplicate `type`s in a registry last-win.
123
+
124
+ ### Payload versioning (`comparePayloadSchemas`)
125
+
126
+ | Change | Result |
127
+ |---|---|
128
+ | Same version, same fields | `identical` |
129
+ | Version bump, only new **optional** fields (or unchanged fields) | `backward-compatible` |
130
+ | Removed field, type change, optional → required, version downgrade, optional add **without** a version bump | `breaking` |
131
+
66
132
  ## `createEventRouter(options?)`
67
133
 
68
134
  ```ts
69
- import { createEventRouter, HostChannel } from '@cyberart-io/engine';
135
+ import { createEventRouter } from '@cyberart-io/engine';
70
136
 
71
137
  const router = createEventRouter({
72
138
  now: () => cart.getClock().now,
@@ -74,7 +140,7 @@ const router = createEventRouter({
74
140
  validate: (event) => true, // or { reason: 'host-rejected', detail: '…' }
75
141
  });
76
142
 
77
- router.attach('presentation', presentationChannel, {
143
+ router.attach('presentation', runtime.hostChannel, {
78
144
  emit: ['adventure.intent.*'],
79
145
  subscribe: ['adventure.state.*', 'cyberart.diagnostic.rejected'],
80
146
  });
@@ -181,11 +247,14 @@ Type `REJECTED_EVENT_TYPE` (`cyberart.diagnostic.rejected`). Payload (`Rejection
181
247
  | `EVENT_ENVELOPE_VERSION` | `1`. |
182
248
  | `DEFAULT_MAX_HOPS` | `8`. |
183
249
  | `REJECTED_EVENT_TYPE` | `'cyberart.diagnostic.rejected'`. |
184
- | `inferEventKind(input)` | Kind from `kind` or type name. |
250
+ | `inferEventKind(input)` | Kind from `kind` or first kind segment in `type`. |
185
251
  | `matchEventPattern(pattern, type)` | One-segment `*` glob. |
186
252
  | `normalizeEvent(input, context)` | Build an envelope or `{ ok: false, reason: 'malformed', detail }`. The router calls this; hosts rarely need it. |
253
+ | `defineIntent` / `defineStateEvent` / `defineDiagnostic` | Contract constructors (see Typed contracts). |
254
+ | `createContractRegistry` / `deriveAttachOptions` / `verifyAttachOptions` / `comparePayloadSchemas` | Registry, permissions, schema compatibility. |
255
+ | `kindSegmentInType` / `familyPatternForType` | Name helpers used by contracts. |
187
256
 
188
- Types: `EventEnvelope`, `EventInput`, `EventKind`, `NormalizeContext`, `NormalizeResult`, `RejectionPayload`, `RejectionReason`, `AttachOptions`, `EventRouter`, `EventRouterOptions`, `PublishExtras`, `ValidateResult`.
257
+ Types: `EventEnvelope`, `EventInput`, `EventKind`, `NormalizeContext`, `NormalizeResult`, `RejectionPayload`, `RejectionReason`, `AttachOptions`, `EventRouter`, `EventRouterOptions`, `PublishExtras`, `ValidateResult`, `EventContract`, `EventContractManifest`, `ContractRegistry`, `DefineContractResult`, `PayloadSchema`, `PayloadFieldSpec`, `PayloadValidation`, `ContractDiagnostic`, `ContractFieldType`, `InferredPayload`, `SchemaCompatibility`.
189
258
 
190
259
  `matchesAnyPattern` / `clonePayload` / `isEventKind` are not on the public package surface.
191
260
 
@@ -2,14 +2,25 @@
2
2
 
3
3
  CI / agent wrapper around production `createRuntime({ deterministic })`. Not a second engine. Do not call this from Player or kaleidoscope.
4
4
 
5
- Depends on [deterministic mode](deterministic-mode.md). Host reducers: [events](events.md).
5
+ Import from **`@cyberart-io/engine/headless`**. Production carts and browser hosts keep using `@cyberart-io/engine` so Vite never resolves `node:fs/promises`.
6
+
7
+ Depends on [deterministic mode](deterministic-mode.md). Host reducers: [events](events.md). Presentation overlay: [presentation adapter](presentation-adapter.md).
6
8
 
7
9
  Back to the [package README](../README.md).
8
10
 
11
+ ## Migration
12
+
13
+ | Surface | Import |
14
+ |---|---|
15
+ | Carts, Player, kaleidoscope, `/art` | `@cyberart-io/engine` (`createRuntime`, events, contracts, assets, presentation adapter, presentation cue) |
16
+ | Vitest / jsdom / CI capture | `@cyberart-io/engine/headless` (`createHeadlessHarness`, `installHeadlessCanvas`, `captureFrame`) |
17
+
18
+ The main export does not re-export the harness. A browser bundle that only imports `@cyberart-io/engine` must not warn about `node:fs/promises`.
19
+
9
20
  ## One-command reproduce (this repo)
10
21
 
11
22
  ```bash
12
- pnpm exec vitest run packages/engine/src/canvas/headlessHarness.spec.ts
23
+ pnpm exec vitest run packages/engine/src/canvas/headlessHarness.spec.ts packages/engine/src/canvas/cyb-57-exports.repro.spec.ts
13
24
  ```
14
25
 
15
26
  Package consumers copy the loop below into their own test file (Vitest + jsdom or equivalent).
@@ -22,7 +33,7 @@ import {
22
33
  HEADLESS_PNG_DATA_URL,
23
34
  DEFAULT_HEADLESS_WIDTH,
24
35
  DEFAULT_HEADLESS_HEIGHT,
25
- } from '@cyberart-io/engine';
36
+ } from '@cyberart-io/engine/headless';
26
37
 
27
38
  installHeadlessCanvas();
28
39
  ```
@@ -34,7 +45,7 @@ Mutates `HTMLCanvasElement.prototype`: `ImageData` polyfill (width/height constr
34
45
  ## `createHeadlessHarness(options)`
35
46
 
36
47
  ```ts
37
- import { createHeadlessHarness } from '@cyberart-io/engine';
48
+ import { createHeadlessHarness } from '@cyberart-io/engine/headless';
38
49
 
39
50
  const harness = createHeadlessHarness({
40
51
  cart: artProject,
@@ -44,7 +55,7 @@ const harness = createHeadlessHarness({
44
55
  origin: 0,
45
56
  actions: [{ type: 'asset', atFrame: 0, id: 'room-map', status: 'ready' }],
46
57
  initialState: { room: 'glade' },
47
- gameManager: hostAdapter, // existing mount injection; not a new adapter contract
58
+ gameManager: hostAdapter, // site injection; presentation overlay is attachPresentationAdapter
48
59
  onEvent: (event) => {},
49
60
  onError: (error, info) => {},
50
61
  });
@@ -77,6 +88,7 @@ Always sets `deterministic: { origin, actions }`.
77
88
  | `step(frames?)` / `advance(ms)` | Deterministic ticks. Throw after `destroy()`. |
78
89
  | `schedule(action)` | Pass-through `ScriptedAction`. |
79
90
  | `dispatch(event)` | Immediate mailbox enqueue. Cart `consume()`s on the next `update`. |
91
+ | `start()` / `pause()` / `resume()` / `paused` | Pass-through on the current `cart`. Attach the harness to `attachPresentationAdapter` so `remount` stays live. |
80
92
  | `click(x, y)` | Pointer-**down** at `getClock().framesElapsed` (next tick). Canvas pixels, not CSS. Use `schedule` for `move` / `up`. |
81
93
  | `key(key)` | Same next-frame `schedule` for a key. Cart must `registerAction`. |
82
94
  | `inspect()` | `{ state, events, errors, replay, clock }`. `state` is **exported**, not live `getCartState()`. |
@@ -112,32 +124,62 @@ Returns `CartSnapshot`: `{ seed, metadata?, pngDataUrl }`.
112
124
  ## Interaction loop (agent recipe)
113
125
 
114
126
  ```ts
127
+ import {
128
+ attachPresentationAdapter,
129
+ createReferencePresentationCart,
130
+ } from '@cyberart-io/engine';
131
+ import { createHeadlessHarness } from '@cyberart-io/engine/headless';
132
+
115
133
  const harness = createHeadlessHarness({
116
- cart,
134
+ cart: createReferencePresentationCart(),
117
135
  seed: 42,
118
- gameManager: adapter,
119
- actions: [{ type: 'asset', atFrame: 0, id: 'room-map', status: 'ready' }],
120
136
  });
137
+ const adapter = attachPresentationAdapter(harness);
121
138
 
122
- await harness.step(5);
139
+ await harness.step(1);
123
140
  const canvas = harness.cart.canvas!;
124
- harness.click(canvas.width / 2, canvas.height * 0.1); // canvas pixels
141
+ adapter.present({
142
+ contractVersion: 1,
143
+ phase: 'ready',
144
+ view: {
145
+ title: 'Glade',
146
+ regions: [
147
+ {
148
+ id: 'north-trail',
149
+ x: 0,
150
+ y: 0,
151
+ width: canvas.width,
152
+ height: canvas.height * 0.25,
153
+ intent: {
154
+ type: 'adventure.intent.exit-requested',
155
+ payload: { exitId: 'brook' },
156
+ },
157
+ },
158
+ ],
159
+ },
160
+ });
161
+ await harness.step(1);
162
+ harness.click(canvas.width / 2, canvas.height * 0.1);
125
163
  await harness.step(1);
126
164
 
127
- const { events, state } = await harness.inspect();
128
- // assert exactly one intent, e.g. room.exit.requested
165
+ const { events } = await harness.inspect();
166
+ // assert exactly one adventure.intent.exit-requested; cart state is still Glade
129
167
 
130
- harness.dispatch({
131
- type: 'adventure.state.room-changed',
132
- payload: { roomId: 'brook' },
168
+ adapter.present({
169
+ contractVersion: 1,
170
+ phase: 'ready',
171
+ view: { title: 'Joiner Brook', regions: [] },
133
172
  });
134
173
  await harness.step(1);
135
174
 
136
175
  await harness.captureFrame('/tmp/brook.png');
176
+ adapter.destroy();
137
177
  harness.destroy();
138
178
  ```
139
179
 
140
- Cleanup: cart `teardown` runs on unload; after `destroy`, `step` throws and the container is gone. `afterEach(() => harness.destroy())` so a failed assertion does not leak nodes.
180
+ Cleanup: cart `teardown` runs on unload; after `destroy`, `step` throws and the container is gone. `afterEach(() => harness.destroy())` so a failed assertion does not leak nodes. `attachPresentationAdapter(harness)` then `adapter.destroy()` tears down the harness too; `attachPresentationAdapter(harness.cart)` only unloads the cart.
181
+
182
+ Host-owned rooms / hotspots use this adapter (`present` + `adventure.intent.*`). `gameManager` is site multiplayer, not this contract.
141
183
 
142
184
  ## Types
143
185
 
@@ -0,0 +1,150 @@
1
+ # Presentation adapter
2
+
3
+ Host-owned render models in, interaction intents out. Cyberart is not authoritative for game rules. Depends on [events](events.md). CI: [headless harness](headless-harness.md).
4
+
5
+ Back to the [package README](../README.md).
6
+
7
+ ## One-command reproduce (this repo)
8
+
9
+ ```bash
10
+ pnpm exec vitest run packages/engine/src/canvas/presentationAdapter.spec.ts
11
+ ```
12
+
13
+ ## Contract (`PRESENTATION_ADAPTER_VERSION = 1`)
14
+
15
+ The host keeps the canonical world. It projects a **render model** into Cyberart, maps its lifecycle onto a cart handle, and validates intents before mutating anything.
16
+
17
+ | Host | Adapter | Cyberart |
18
+ |---|---|---|
19
+ | attach / open | `mountPresentationAdapter` or `attachPresentationAdapter` | `runtime.mount` |
20
+ | start loop | `start()` | `cart.start()` (live). Deterministic hosts `step` instead; `pause` does not block `step`. |
21
+ | push view | `present(model)` | `dispatch({ type: presentation.state.model, kind: 'state' })` |
22
+ | pause / resume | `pause()` / `resume()` | `cart.pause` / `cart.resume` |
23
+ | tear down | `destroy()` | `cart.destroy`. Idempotent. Later `present` / `pause` / `resume` / `start` throw. With a harness, still call `harness.destroy()` so the container goes away. |
24
+
25
+ `gameManager` on `mount` is unchanged (site multiplayer). Do not use it as this contract.
26
+
27
+ `attachPresentationAdapter(cart)` does **not** dispatch. Pass `{ model }` to push immediately. `mountPresentationAdapter` boots `model` (or `initialState.model`, or loading) through `initialState` and does not queue a second copy. `present()` throws `INVALID_PRESENTATION_MODEL_MESSAGE` if the model is invalid or not JSON-serializable — `adapter.phase` is unchanged.
28
+
29
+ Attach the **harness** (not `harness.cart`) if you `remount`; dispatch follows the current cart. After `runtime.mount` again, call `adapter.retarget(newHandle)`.
30
+
31
+ If you `router.attach('presentation', runtime.hostChannel)`, use `router.subscribe` for host logic and omit `mount({ onEvent })` — both would see every intent.
32
+
33
+ A `present()` from `onEvent` during the click's `update` applies **this frame** (the reference cart consumes once more after emitting).
34
+
35
+ ### Model
36
+
37
+ ```ts
38
+ import {
39
+ PRESENTATION_ADAPTER_VERSION,
40
+ type PresentationModel,
41
+ } from '@cyberart-io/engine';
42
+
43
+ const model: PresentationModel = {
44
+ contractVersion: PRESENTATION_ADAPTER_VERSION, // 1
45
+ phase: 'ready', // 'loading' | 'ready' | 'error' | 'unsupported'
46
+ reason: undefined, // string when not ready
47
+ view: {
48
+ background: '#1b3a4a',
49
+ title: 'Joiner Brook',
50
+ regions: [
51
+ {
52
+ id: 'north-trail',
53
+ x: 0,
54
+ y: 0,
55
+ width: 320,
56
+ height: 45,
57
+ intent: {
58
+ type: 'adventure.intent.exit-requested',
59
+ payload: { exitId: 'brook' },
60
+ },
61
+ },
62
+ ],
63
+ },
64
+ };
65
+ ```
66
+
67
+ `view` is host-owned. The reference cart hit-tests `regions` and paints `background`. `title` is inspectable state, not drawn. Extra fields (characters, exits, inventory) are ignored — map clickable things onto `regions`. Coordinates are drawing-space pixels, the same space as `PointerManager` and harness `click`. A shared normalized space is a later ticket.
68
+
69
+ `adapter.model` / `adapter.phase` are what this session last presented (or the boot model). The getter returns a **copy**. After `step`, `inspect().state.model` is what the cart applied.
70
+
71
+ `phase`:
72
+
73
+ | Phase | Clicks |
74
+ |---|---|
75
+ | `loading` | Ignored |
76
+ | `ready` | First matching `view.regions[]` emits its `intent` (`kind` is always `intent`) |
77
+ | `error` | Ignored |
78
+ | `unsupported` | Ignored (unknown `contractVersion`, non-serializable model, or a capability the host cannot present) |
79
+
80
+ The reference cart never writes the host’s world. An unknown `contractVersion` (or a model that cannot be JSON-cloned) becomes `{ phase: 'unsupported', reason }` in cart state and emits `presentation.intent.unsupported`. Listen on `onEvent` / the router. `adapter.phase` is only what this session successfully `present()`ed (or the boot model); cart-side rejects do not change it.
81
+
82
+ ### Intents
83
+
84
+ Region `intent.type` should be routable: `*.intent.*` (for example `adventure.intent.exit-requested`). The reference cart always sets `kind: 'intent'` on clicks (a hotspot cannot emit `state`).
85
+
86
+ Default router `emit` is `['*.intent.*']`, which does **not** match `room.exit.requested`. Prefer `adventure.intent.*`.
87
+
88
+ ## Reference cart + session
89
+
90
+ ```ts
91
+ import {
92
+ attachPresentationAdapter,
93
+ createReferencePresentationCart,
94
+ } from '@cyberart-io/engine';
95
+ import { createHeadlessHarness } from '@cyberart-io/engine/headless';
96
+
97
+ const harness = createHeadlessHarness({
98
+ cart: createReferencePresentationCart(),
99
+ seed: 42,
100
+ onEvent: (event) => hostReduce(event),
101
+ });
102
+ const adapter = attachPresentationAdapter(harness);
103
+
104
+ adapter.present(model);
105
+ await harness.step(1);
106
+ harness.click(x, y); // canvas pixels
107
+ await harness.step(1);
108
+ await adapter.start(); // live hosts; optional in deterministic mode
109
+ adapter.pause();
110
+ adapter.resume();
111
+ adapter.destroy();
112
+ ```
113
+
114
+ Already have a runtime:
115
+
116
+ ```ts
117
+ const adapter = mountPresentationAdapter(runtime, {
118
+ onEvent: (event) => hostReduce(event),
119
+ model: { contractVersion: 1, phase: 'loading' },
120
+ });
121
+ await adapter.start();
122
+ ```
123
+
124
+ A host with a custom view shape passes `cart:` into `mountPresentationAdapter` and still `present()`s the same event. Do not import Adventure Kit types into engine carts.
125
+
126
+ ## Router
127
+
128
+ ```ts
129
+ router.attach('presentation', runtime.hostChannel, {
130
+ emit: ['*.intent.*'],
131
+ subscribe: [...PRESENTATION_SUBSCRIBE_PATTERNS],
132
+ });
133
+
134
+ router.subscribe(['adventure.intent.*', 'presentation.intent.unsupported'], (event) => {
135
+ const next = reduce(world, event); // host validates; ignore invalid intents
136
+ if (next) {
137
+ // Do not pass `{ cause: event }` — a second `presentation.state.model`
138
+ // from `host` in that chain is `loop-detected`.
139
+ router.publish(createPresentationModelEvent(project(next)));
140
+ }
141
+ });
142
+ ```
143
+
144
+ `PRESENTATION_SUBSCRIBE_PATTERNS` is `presentation.state.*` plus `cyberart.diagnostic.rejected`. Only the host (or an `authoritative` participant) may emit `presentation.state.model`. `adapter.present` writes this cart’s mailbox only; `router.publish` fans out to every subscriber.
145
+
146
+ ## Types and helpers
147
+
148
+ `PRESENTATION_ADAPTER_VERSION`, `PRESENTATION_MODEL_EVENT`, `PRESENTATION_UNSUPPORTED_EVENT`, `PRESENTATION_PHASES`, `PRESENTATION_SUBSCRIBE_PATTERNS`, `INVALID_PRESENTATION_MODEL_MESSAGE`, `isPresentationModel`, `isPresentationPhase`, `createPresentationModelEvent`, `createReferencePresentationCart`, `attachPresentationAdapter`, `mountPresentationAdapter`.
149
+
150
+ Types: `PresentationModel`, `PresentationView`, `PresentationRegion`, `PresentationPhase`, `PresentationCartState`, `PresentationAdapter`, `PresentationAdapterTarget`, `AttachPresentationAdapterOptions`, `MountPresentationAdapterOptions`.
@@ -0,0 +1,82 @@
1
+ # Presentation cue / timeline
2
+
3
+ Deterministic cue/effect primitive. Carts call `step(frames)` with the same clock they use for CYB-23; there are no `setTimeout` / rAF timers. Gold checkmarks, ripples, room transitions, weather pulses, and NPC beats should share this lifecycle instead of ad-hoc frame counters.
4
+
5
+ Back to the [package README](../README.md). Related: [deterministic mode](deterministic-mode.md), [events](events.md) (routed contracts vs local `cue.*` names).
6
+
7
+ ## One-command reproduce (this repo)
8
+
9
+ ```bash
10
+ pnpm exec vitest run packages/engine/src/canvas/cyb-58-presentation-cue.repro.spec.ts
11
+ ```
12
+
13
+ ## `createPresentationTimeline(options?)`
14
+
15
+ ```ts
16
+ import {
17
+ createPresentationTimeline,
18
+ CUE_STARTED_EVENT,
19
+ CUE_COMPLETED_EVENT,
20
+ CUE_CANCELLED_EVENT,
21
+ CUE_REPLACED_EVENT,
22
+ } from '@cyberart-io/engine';
23
+
24
+ const timeline = createPresentationTimeline({
25
+ originFrame: 0,
26
+ reducedMotion: false, // host flag, not a CSS media query
27
+ });
28
+
29
+ timeline.play({
30
+ name: 'checkmark',
31
+ idempotencyKey: 'gold',
32
+ durationFrames: 90,
33
+ delayFrames: 0,
34
+ easing: 'ease-out', // or 'linear'
35
+ repeat: { count: 0 },
36
+ reducedMotion: 'complete', // 'skip' | 'complete' | { durationFrames: n }
37
+ onDuplicate: 'replace', // 'ignore' | 'replace' | 'reject'
38
+ });
39
+
40
+ timeline.step(90);
41
+ ```
42
+
43
+ | Member | Meaning |
44
+ |---|---|
45
+ | `play(spec)` | Schedule or start a cue. `{ ok: true, cue }` or `{ ok: false, reason: 'duplicate' \| 'invalid' }`. |
46
+ | `step(frames?)` | Advance the frame index (default 1). Returns lifecycle events emitted during those frames. |
47
+ | `cancel(key)` | Cancel an active/scheduled cue (`cue.cancelled`). |
48
+ | `reset()` | Cancel remaining cues, drop dedupe, rewind to `originFrame`, clear the event log. |
49
+ | `snapshot()` | JSON-serializable `{ frame, reducedMotion, cues, events }`. |
50
+ | `get(key)` | Copy of the live cue, or `undefined`. |
51
+
52
+ `startFrame` defaults to the play frame. If `play` happens after `startFrame + delayFrames`, the timeline catches up on that call: mid-cue progress is applied, and a cue whose duration is already over completes immediately (`cue.started` then `cue.completed`). Progress is `0…1` after easing. `step` ignores non-finite counts (no infinite loop). Two identical `play` / `step` tapes produce identical snapshots.
53
+
54
+ ## Duplicate policy
55
+
56
+ Keyed by `idempotencyKey` among scheduled and active cues.
57
+
58
+ | `onDuplicate` | Effect |
59
+ |---|---|
60
+ | `replace` (default) | Emit `cue.replaced` for the old cue, start the new spec. |
61
+ | `ignore` | Keep the existing cue. |
62
+ | `reject` | `{ ok: false, reason: 'duplicate' }`. Unrelated cues keep running. |
63
+
64
+ ## Reduced motion
65
+
66
+ Pass `reducedMotion: true` on the timeline (host-owned). Per-cue `reducedMotion`:
67
+
68
+ - `complete` / `skip` — duration 0; `cue.started` then `cue.completed` at the play frame
69
+ - `{ durationFrames: n }` — use `n` instead of the authored duration
70
+
71
+ ## Lifecycle events (stable names)
72
+
73
+ | `type` | When |
74
+ |---|---|
75
+ | `cue.started` | Cue becomes active |
76
+ | `cue.completed` | Duration elapsed (or reduced-motion complete) |
77
+ | `cue.cancelled` | `cancel` or `reset` |
78
+ | `cue.replaced` | Duplicate with `replace` |
79
+
80
+ Each event: `{ type, atFrame, name, idempotencyKey, progress }`. Repeat `{ count: n }` restarts immediately after complete (`repeatIndex` increments). `{ forever: true }` with `durationFrames: 0` completes once (no infinite loop).
81
+
82
+ Do not `await` wall-clock time inside cart `update` / `render`. Drive the timeline from `cart.step` / `getClock().framesElapsed`.
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "@cyberart-io/engine",
3
- "version": "0.0.2",
4
- "description": "Embeddable CyberArt host engine: mount a cart, pause, snapshot, and save/load state.",
3
+ "version": "0.0.3",
4
+ "description": "CyberArt host engine: mount a cart, assets, events, deterministic cues, and a Node/jsdom headless entry.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "files": [
8
8
  "dist/index.js",
9
9
  "dist/index.d.ts",
10
+ "dist/headless.js",
11
+ "dist/headless.d.ts",
10
12
  "docs"
11
13
  ],
12
14
  "main": "./dist/index.js",
@@ -16,6 +18,15 @@
16
18
  ".": {
17
19
  "types": "./dist/index.d.ts",
18
20
  "import": "./dist/index.js"
21
+ },
22
+ "./headless": {
23
+ "types": "./dist/headless.d.ts",
24
+ "import": "./dist/headless.js"
25
+ }
26
+ },
27
+ "typesVersions": {
28
+ "*": {
29
+ "headless": ["./dist/headless.d.ts"]
19
30
  }
20
31
  },
21
32
  "sideEffects": false,