@cyberart-io/engine 0.0.2 → 0.0.4

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,24 +1,32 @@
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). Prefer `createRuntimeGroup` when several carts should share one router and lockstep clock. 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`), [runtime group](runtime-group.md), [capability manifest](capability-manifest.md).
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
+
13
+
7
14
  ## When to use which
8
15
 
9
16
  | Path | Use |
10
17
  |---|---|
11
18
  | Mailbox only | One cart, host `dispatch` / cart `emit`. No permissions, hops, or loop checks. |
12
19
  | Router | Several carts, host reducer in the middle, Adventure-style intent vs state. |
20
+ | Runtime group | Same router plus lockstep `step`, pause/reset/teardown, and a routed trace. |
13
21
 
14
- `createRuntime` does **not** attach a router. You attach each cart’s channel yourself.
22
+ `createRuntime` does **not** attach a router. Attach `runtime.hostChannel` yourself, or use `createRuntimeGroup()` which calls `router.attach(id, runtime.hostChannel, options)` for each participant. Cart unload clears the inbound queue only; router listeners stay until `runtime.destroy()` (or `group.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
23
 
16
24
  ## Mailbox (`HostChannel`)
17
25
 
18
26
  ```ts
19
27
  import { HostChannel, type HostEvent } from '@cyberart-io/engine';
20
28
 
21
- // Usually you do not construct this. createRuntime owns one; mount({ onEvent }) listens.
29
+ // createRuntime owns one: `runtime.hostChannel`. mount({ onEvent }) also listens.
22
30
  cart.dispatch({ type: 'art-project.theme', payload: 'dusk' }); // host → cart
23
31
  hostChannel.consume(); // cart drains inbound (typically in update)
24
32
  hostChannel.emit({ type: 'art-project.theme', payload: 'dusk' }); // cart → host
@@ -32,7 +40,9 @@ hostChannel.emit({ type: 'art-project.theme', payload: 'dusk' }); // cart → ho
32
40
  | `consume()` | Return and clear the inbound batch. Empty array if none. |
33
41
  | `emit(event)` | Notify `onEvent` listeners (host and, if attached, the router). |
34
42
  | `onEvent(listener)` | Subscribe. Returns an unsubscribe function. |
35
- | `clear()` | Drop inbound queue and listeners. |
43
+ | `clearInbound()` | Drop the inbound queue. Listeners stay (cart unload). |
44
+ | `clear()` | Drop inbound queue and listeners (runtime teardown). |
45
+ | `close()` | Permanent. Further dispatch / emit / consume / onEvent throw. `runtime.destroy()` calls this. |
36
46
 
37
47
  Carts that never mention `hostChannel` ignore both directions. Guard it: the argument is undefined when the cart is not mounted through `createRuntime`.
38
48
 
@@ -58,15 +68,17 @@ Routed events are normalized to `EventEnvelope` (`EVENT_ENVELOPE_VERSION = 1`).
58
68
  `kind` on the input wins. Otherwise:
59
69
 
60
70
  - `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`).
71
+ - 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).
72
+ - 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.
73
+
74
+ Typed contracts (`defineIntent`, `defineStateEvent`, `defineDiagnostic`) fail at definition time with structured `{ ok: false, errors }` instead of throwing. `createContractRegistry` produces manifest JSON and a `validate` hook for `createEventRouter`. `deriveAttachOptions` / `verifyAttachOptions` keep non-authoritative carts from emitting `state` / `diagnostic` contracts. Payload schema versioning: adding optional fields with a version bump is backward-compatible; removals, type changes, and version downgrades are breaking.
63
75
 
64
76
  Only the host (`publish`) or an `authoritative` participant may emit `state` / `diagnostic`. Carts emit allowed `intent` patterns. Domain data belongs in `payload`.
65
77
 
66
78
  ## `createEventRouter(options?)`
67
79
 
68
80
  ```ts
69
- import { createEventRouter, HostChannel } from '@cyberart-io/engine';
81
+ import { createEventRouter } from '@cyberart-io/engine';
70
82
 
71
83
  const router = createEventRouter({
72
84
  now: () => cart.getClock().now,
@@ -74,7 +86,7 @@ const router = createEventRouter({
74
86
  validate: (event) => true, // or { reason: 'host-rejected', detail: '…' }
75
87
  });
76
88
 
77
- router.attach('presentation', presentationChannel, {
89
+ router.attach('presentation', runtime.hostChannel, {
78
90
  emit: ['adventure.intent.*'],
79
91
  subscribe: ['adventure.state.*', 'cyberart.diagnostic.rejected'],
80
92
  });
@@ -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). Several carts: [runtime group](runtime-group.md) (`createHeadlessMultiCartHarness`).
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, assets, presentation adapter) |
16
+ | Vitest / jsdom / CI capture | `@cyberart-io/engine/headless` (`createHeadlessHarness`, `createHeadlessMultiCartHarness`, `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,88 @@
1
+ # Normalized geometry
2
+
3
+ Shared 0–1 content-box coordinates, anchors, hit regions, and resize-stable
4
+ contain/cover/crop layouts. Pixel `PresentationRegion` on the presentation
5
+ adapter is unchanged; hosts can adopt this contract later.
6
+
7
+ Back to the [package README](../README.md).
8
+
9
+ ## One-command reproduce (this repo)
10
+
11
+ ```bash
12
+ pnpm exec vitest run packages/engine/src/canvas/cyb-60-normalized-geometry.repro.spec.ts
13
+ ```
14
+
15
+ ## Contract (`GEOMETRY_CONTRACT_VERSION = 1`)
16
+
17
+ | Field | Meaning |
18
+ |---|---|
19
+ | `version` | Always `1`. Other versions fail `validateGeometry`. |
20
+ | `landmarks` | Named anchors (`id` + normalized point + optional origin keyword). |
21
+ | `regions` | Hitboxes: axis-aligned `rect` and/or `polygon` in normalized space. |
22
+ | `padding` | Normalized insets `{ top, right, bottom, left }`. |
23
+ | `safeArea` | Same inset shape; authoring hint, not applied by the layout math. |
24
+
25
+ Documents are JSON-serializable (`JSON.parse(JSON.stringify(doc))` round-trips). Import from **`@cyberart-io/engine`** (`createPresentationLayout`, `pointerToRegion`, `serializeGeometry`, …).
26
+
27
+ If a region sets both `rect` and `polygon`, hit-testing uses the polygon and overlay/AABB helpers use the rect. Prefer one shape per region.
28
+
29
+ ## Coordinate spaces
30
+
31
+ Normalized is **0–1 of the content box** (intrinsic artwork), not the letterboxed viewport.
32
+
33
+ | Space | Unit | Box |
34
+ |---|---|---|
35
+ | `normalized` | 0–1 | Full content (`contentWidth` × `contentHeight`) |
36
+ | `asset` | px | Same content, intrinsic pixels |
37
+ | `css` / `viewport` | CSS px | Layout (`viewportWidth` × `viewportHeight`) |
38
+ | `canvas` | buffer px | `css * devicePixelRatio` (default dpr `1`) |
39
+
40
+ `pointerToRegion` defaults to **canvas** space (same as `PointerManager` / harness `click`). Pass `{ space: 'css' }` for layout pixels.
41
+
42
+ ## Layout (`createPresentationLayout`)
43
+
44
+ ```ts
45
+ import { createPresentationLayout } from '@cyberart-io/engine';
46
+
47
+ const layout = createPresentationLayout({
48
+ contentWidth: 1920,
49
+ contentHeight: 1080,
50
+ viewportWidth: 1280,
51
+ viewportHeight: 800,
52
+ mode: 'contain', // or 'cover' | 'crop'
53
+ });
54
+ ```
55
+
56
+ | Mode | Scale | Offsets |
57
+ |---|---|---|
58
+ | `contain` | `min(vw/cw, vh/ch)` | Letterbox ≥ 0 |
59
+ | `cover` | `max(vw/cw, vh/ch)` | Crop ≤ 0 (centered) |
60
+ | `crop` | Same as `cover` | Same as `cover` |
61
+
62
+ Resize-stable under **contain**: a normalized point stays on the same content location when only the viewport changes. Cover/crop keep the content centered and report `visibleNormalizedRect` for the uncropped slice.
63
+
64
+ ## Round-trip tolerance
65
+
66
+ | Constant | Value | Use |
67
+ |---|---|---|
68
+ | `ROUND_TRIP_TOLERANCE` | `1e-6` | Max \|Δ\| in normalized units after canvas/css round-trip |
69
+ | `ROUND_TRIP_TOLERANCE_CANVAS_PX` | `0.5` | Max \|Δ\| in canvas pixels after normalized round-trip |
70
+
71
+ ## Helpers
72
+
73
+ | Export | Role |
74
+ |---|---|
75
+ | `normalizedToCanvas` / `canvasToNormalized` | Content box ↔ drawing buffer |
76
+ | `normalizedToCss` / `cssToNormalized` | Content box ↔ layout pixels |
77
+ | `normalizedToAsset` / `assetToNormalized` | Content box ↔ intrinsic pixels |
78
+ | `pointerToRegion(pointer, layout, doc)` | First matching region (document order) |
79
+ | `regionToViewport(region, layout)` | CSS rect (polygon AABB) |
80
+ | `createLandmarkRegistry()` | `register` / `get` / `list`; duplicate ids → `{ ok: false, error: 'duplicate-id' }` |
81
+ | `validateGeometry(doc)` | Overlap, out-of-bounds, duplicate id, invalid shape; diagnostics include region ids |
82
+ | `drawGeometryDebug(ctx, layout, doc, diagnostics)` | Canvas2D `fillRect` / `strokeRect` / `fillText` when present; always returns recorded calls with region ids |
83
+
84
+ Origin keywords: `center`, `top-left`, `top-right`, `bottom-left`, `bottom-right`, `top`, `bottom`, `left`, `right`. `pointFromOrigin` yields the matching unit-square point.
85
+
86
+ ## Fixture (CYB-60)
87
+
88
+ Content **1920×1080**. Hotspot point `(0.25, 0.4)` inside region `hotspot` `{ x: 0.2, y: 0.35, width: 0.1, height: 0.1 }`. Contain viewports **1280×800** and **390×844**.
@@ -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). Normalized 0–1 regions: [normalized geometry](normalized-geometry.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 on `PresentationRegion` are drawing-space pixels, the same space as `PointerManager` and harness `click`. Convert 0–1 content-box geometry with `createPresentationLayout` / `pointerToRegion` from [normalized geometry](normalized-geometry.md).
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).
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`.
@@ -0,0 +1,120 @@
1
+ # Runtime group
2
+
3
+ Host helper that mounts several production `createRuntime` carts, attaches each mailbox with `router.attach(id, runtime.hostChannel, attachOptions)`, and locksteps one shared deterministic clock. Carts never receive the router. Not a second engine. Do not call the headless wrapper from Player or kaleidoscope.
4
+
5
+ Capability-manifest integration is optional and structural (`capability?: { emit, subscribe, authoritative }`). This module does not import the manifest.
6
+
7
+ Related: [events](events.md), [headless harness](headless-harness.md), [deterministic mode](deterministic-mode.md).
8
+
9
+ Back to the [package README](../README.md).
10
+
11
+ ## One-command reproduce (this repo)
12
+
13
+ ```bash
14
+ pnpm exec vitest run packages/engine/src/canvas/cyb-61-runtime-group.repro.spec.ts
15
+ ```
16
+
17
+ ## When to use which
18
+
19
+ | Surface | Import | Use |
20
+ |---|---|---|
21
+ | Production host | `createRuntimeGroup` from `@cyberart-io/engine` | Several carts in one page; host injects containers or lets the group create them. |
22
+ | Vitest / jsdom | `createHeadlessMultiCartHarness` from `@cyberart-io/engine/headless` | Same group API after `installHeadlessCanvas()`. |
23
+
24
+ `createRuntime` still does **not** attach a router by itself. The group is the attach + lockstep layer.
25
+
26
+ ## `createRuntimeGroup(options)`
27
+
28
+ ```ts
29
+ import { createRuntimeGroup } from '@cyberart-io/engine';
30
+
31
+ const group = createRuntimeGroup({
32
+ origin: 0,
33
+ createId: (() => {
34
+ let n = 0;
35
+ return () => `g-${++n}`;
36
+ })(),
37
+ participants: [
38
+ {
39
+ id: 'effects',
40
+ cart: effectsCart,
41
+ seed: `0x${'61'.repeat(32)}`,
42
+ emit: ['ambience.intent.*'],
43
+ subscribe: ['adventure.state.*'],
44
+ },
45
+ {
46
+ id: 'ambience',
47
+ cart: ambienceCart,
48
+ kind: 'render',
49
+ seed: `0x${'62'.repeat(32)}`,
50
+ subscribe: ['ambience.intent.*'],
51
+ },
52
+ ],
53
+ });
54
+
55
+ group.publish({
56
+ type: 'adventure.state.loon-whistle',
57
+ kind: 'state',
58
+ payload: { habitat: 'pond' },
59
+ });
60
+ await group.step(2);
61
+ const { participants, trace, diagnostics } = await group.inspect();
62
+ group.destroy();
63
+ ```
64
+
65
+ Adventure-style mapping (fixture, not Adventure Kit): an accepted `adventure.state.loon-whistle` is consumed by the effects cart, which emits `ambience.intent.play`; the router delivers that cue to the ambience cart. No project-specific bridge.
66
+
67
+ ### Options (`CreateRuntimeGroupOptions`)
68
+
69
+ | Option | Default | Meaning |
70
+ |---|---|---|
71
+ | `participants` | required | One entry per cart. Ids must be unique. Sorted lexicographically for lockstep. |
72
+ | `origin` | `0` | Passed to every `createRuntime({ deterministic: { origin } })`. |
73
+ | `width` / `height` | `320` / `180` | Used when the group creates a container. |
74
+ | `createId` / `now` | `evt-1`… / first cart clock | Injected into the **one** shared router. |
75
+ | `validate` | none | Router `validate` for cart-originated events. |
76
+ | `router` | `{}` | Extra `createEventRouter` options (`maxHops`, `maxPerTurn`, …). |
77
+
78
+ ### Participant config
79
+
80
+ | Field | Meaning |
81
+ |---|---|
82
+ | `id` | Router participant id. Reserved: `host`, `router`. |
83
+ | `cart` | `AnimationCart` mounted through `createRuntime`. |
84
+ | `kind` | `'render'` (default) or `'calculation'` (no-op/minimal render; still a real cart). |
85
+ | `seed` | `0x` + 64 hex, or any seed `createRuntime` accepts. Default: derived from `id`. |
86
+ | `container` | Injected mount node. If omitted, the group creates and owns a sized `div`. |
87
+ | `emit` / `subscribe` / `authoritative` | Passed to `router.attach`. |
88
+ | `capability` | Structural fallback for those three fields when the explicit ones are omitted. |
89
+ | `initialState` / `gameManager` / `onEvent` | `mount` options. The group still records outbound events. |
90
+
91
+ ### Handle (`RuntimeGroup`)
92
+
93
+ | Member | Meaning |
94
+ |---|---|
95
+ | `router` | The shared `EventRouter`. Do not create a second one. |
96
+ | `step(n)` | For each frame: `router.turn()` once, then `await cart.step(1)` in sorted id order. No-op while paused. Throws after `destroy()`. |
97
+ | `pause` / `resume` | Group flag plus every cart. `step` does not advance while paused. |
98
+ | `reset` | Remounts every cart to its initial state, clears traces / logs, `router.turn()`. Does not change `paused`; call `resume()` if you need lockstep after a paused reset. |
99
+ | `dispatch(id, event)` | Mailbox inbound on that participant. |
100
+ | `publish(event)` | Host `router.publish`. Unknown `target` → `unknown-target` rejection. |
101
+ | `inspect()` | `{ participants: Record<id, { state, events, errors, kind, clock }>, trace, diagnostics }`. |
102
+ | `destroy()` | Detach router listeners, `runtime.destroy()` each cart, remove owned containers. Idempotent. |
103
+
104
+ Idempotency, correlation, causation, unauthorized emit, hop/loop checks are the existing router. The group only attaches and locksteps.
105
+
106
+ ## `createHeadlessMultiCartHarness(options)`
107
+
108
+ ```ts
109
+ import { createHeadlessMultiCartHarness } from '@cyberart-io/engine/headless';
110
+
111
+ const harness = createHeadlessMultiCartHarness({
112
+ participants: [effects, ambience, telemetry],
113
+ });
114
+ await harness.step(2);
115
+ harness.destroy();
116
+ ```
117
+
118
+ Calls `installHeadlessCanvas()` then `createRuntimeGroup`. Same handle. Three-cart CI fixture: effects + ambience + a calculation telemetry cart.
119
+
120
+ Cleanup: `afterEach(() => group.destroy())` so a failed assertion does not leak nodes. After `destroy`, `step` throws and owned containers are gone from `document.body`.