@cyberart-io/engine 0.0.1 → 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 ADDED
@@ -0,0 +1,272 @@
1
+ # Events and router
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`), [presentation adapter](presentation-adapter.md) (`presentation.state.model`), [presentation cue](presentation-cue.md) (frame-local lifecycle, not routed unless you define a contract).
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/cyb-59-event-contract.repro.spec.ts
11
+ ```
12
+
13
+ ## When to use which
14
+
15
+ | Path | Use |
16
+ |---|---|
17
+ | Mailbox only | One cart, host `dispatch` / cart `emit`. No permissions, hops, or loop checks. |
18
+ | Router | Several carts, host reducer in the middle, Adventure-style intent vs state. |
19
+
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.
21
+
22
+ ## Mailbox (`HostChannel`)
23
+
24
+ ```ts
25
+ import { HostChannel, type HostEvent } from '@cyberart-io/engine';
26
+
27
+ // createRuntime owns one: `runtime.hostChannel`. mount({ onEvent }) also listens.
28
+ cart.dispatch({ type: 'art-project.theme', payload: 'dusk' }); // host → cart
29
+ hostChannel.consume(); // cart drains inbound (typically in update)
30
+ hostChannel.emit({ type: 'art-project.theme', payload: 'dusk' }); // cart → host
31
+ ```
32
+
33
+ `HostEvent` is `EventInput`: at least `{ type, payload? }`. Extra envelope fields are optional on an unattached mailbox.
34
+
35
+ | Member | Meaning |
36
+ |---|---|
37
+ | `dispatch(event)` | Queue inbound. Oldest dropped past **32** unconsumed events. |
38
+ | `consume()` | Return and clear the inbound batch. Empty array if none. |
39
+ | `emit(event)` | Notify `onEvent` listeners (host and, if attached, the router). |
40
+ | `onEvent(listener)` | Subscribe. Returns an unsubscribe function. |
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. |
44
+
45
+ Carts that never mention `hostChannel` ignore both directions. Guard it: the argument is undefined when the cart is not mounted through `createRuntime`.
46
+
47
+ ## Envelope
48
+
49
+ Routed events are normalized to `EventEnvelope` (`EVENT_ENVELOPE_VERSION = 1`).
50
+
51
+ | Field | Set by | Meaning |
52
+ |---|---|---|
53
+ | `schemaVersion` | router | Always `1`. Other versions are `malformed`. |
54
+ | `type` | emitter | Dotted name, e.g. `adventure.intent.exit-requested`. |
55
+ | `kind` | explicit or inferred | `intent` \| `state` \| `diagnostic`. |
56
+ | `source` | router | Participant id (or `host` / `router`). Claimed `source` is overwritten. |
57
+ | `target` | emitter | Optional **participant id**, not a domain object (put the exit id in `payload`). |
58
+ | `id` | router (`createId`) | Claimed `id` is ignored. |
59
+ | `correlationId` | emitter or `id` | Groups a request and its follow-ups. |
60
+ | `causationId` | cause / auto-link | Parent event `id`. |
61
+ | `seq` | router | Monotonic **per source**. No global order across sources. |
62
+ | `hops` | router | Remaining TTL. Claimed hop counts are ignored. |
63
+ | `idempotencyKey` | emitter | Replay-safe emit; scoped by `source`. |
64
+ | `payload` | emitter | JSON-serializable. Cycles / BigInt → `malformed`. |
65
+
66
+ `kind` on the input wins. Otherwise:
67
+
68
+ - `cyberart.state.save` / `cyberart.state.load` are **intents** (historical names).
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.
71
+
72
+ Only the host (`publish`) or an `authoritative` participant may emit `state` / `diagnostic`. Carts emit allowed `intent` patterns. Domain data belongs in `payload`.
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
+
132
+ ## `createEventRouter(options?)`
133
+
134
+ ```ts
135
+ import { createEventRouter } from '@cyberart-io/engine';
136
+
137
+ const router = createEventRouter({
138
+ now: () => cart.getClock().now,
139
+ createId: () => `evt-${seq++}`, // seed-stable in deterministic tests
140
+ validate: (event) => true, // or { reason: 'host-rejected', detail: '…' }
141
+ });
142
+
143
+ router.attach('presentation', runtime.hostChannel, {
144
+ emit: ['adventure.intent.*'],
145
+ subscribe: ['adventure.state.*', 'cyberart.diagnostic.rejected'],
146
+ });
147
+
148
+ router.subscribe(['adventure.intent.*'], (event) => {
149
+ router.publish(
150
+ { type: 'adventure.state.room-changed', kind: 'state', payload: { roomId: 'brook' } },
151
+ { cause: event },
152
+ );
153
+ });
154
+
155
+ router.turn(); // once per step / frame
156
+ ```
157
+
158
+ ### Options (`EventRouterOptions`)
159
+
160
+ | Option | Default | Meaning |
161
+ |---|---|---|
162
+ | `validate` | none | Sync hook on **cart-originated** events only. Return `true` or `{ reason: 'host-rejected', detail? }`. |
163
+ | `createId` | `evt-1`, `evt-2`, … | Envelope ids. Inject a seed-stable function for replays. |
164
+ | `now` | `Date.now` | Sliding window timestamps. Inject `() => cart.getClock().now` in deterministic mode. |
165
+ | `hostSource` | `'host'` | `source` on host `publish`. |
166
+ | `maxPerTurn` | `8` | Cart emits per source per `turn()`. Excess → `rate-limited`. |
167
+ | `maxPerWindow` | off | Cart emits per source in `windowMs`. Excess → `rate-limited`. |
168
+ | `windowMs` | `1000` | Window for `maxPerWindow`. |
169
+ | `maxCausationDepth` | `8` | Causation-chain depth / cycle → `loop-detected`. |
170
+ | `maxHops` | `DEFAULT_MAX_HOPS` (`8`) | Remaining hops on a new root. A caused follow-up gets `cause.hops - 1`. At 0 → `hop-limit`. |
171
+ | `maxCorrelationPerTurn` | `16` | Events sharing a `correlationId` per turn → `storm-detected`. |
172
+ | `maxIndex` | `256` | Bound on causation / idempotency maps (oldest keys dropped). |
173
+
174
+ ### Router methods (`EventRouter`)
175
+
176
+ | Method | Meaning |
177
+ |---|---|
178
+ | `attach(id, channel, options?)` | Bind a cart mailbox. `id` is the participant source. Re-attach replaces. |
179
+ | `detach(id)` | Unbind. In-flight targeted emits to this id fail `unknown-target`. |
180
+ | `publish(event, extras?)` | Host emit. `extras.cause` sets causation and inherited idempotency/correlation. Returns the envelope, or `undefined` if rejected. |
181
+ | `subscribe(patterns, listener)` | Host listener (not a cart). Returns unsubscribe. |
182
+ | `turn()` | Reset per-turn budgets and the auto-link causation cursor. Idempotency records **survive**. Call once per `step`. |
183
+
184
+ ### `AttachOptions`
185
+
186
+ | Option | Default | Meaning |
187
+ |---|---|---|
188
+ | `emit` | `['*.intent.*']` | Types this cart may emit. |
189
+ | `subscribe` | `[]` | Types delivered to this channel. |
190
+ | `authoritative` | `false` | If true, may emit `state` / `diagnostic` **when those types are also in `emit`**. |
191
+
192
+ Patterns are dotted segments; `*` matches one segment. `adventure.intent.*` matches `adventure.intent.exit-requested`, not `adventure.intent.exit.requested`.
193
+
194
+ ## Delivery and ordering
195
+
196
+ - FIFO **per source**, monotonic `seq`.
197
+ - No global order across sources unless the host serializes `publish`.
198
+ - Delivery is always queued `dispatch`. A cart sees routed events on the next `consume()`.
199
+ - An explicit `target` that is missing is `unknown-target`. A target that is attached but does not subscribe is `not-subscribed` (not cached; a later `attach` can retry).
200
+
201
+ Cart-to-cart still goes through the router:
202
+
203
+ ```ts
204
+ presentationChannel.emit({
205
+ type: 'adventure.intent.remark',
206
+ target: 'npc',
207
+ payload: { text: 'Anyone there?' },
208
+ idempotencyKey: 'hello-1',
209
+ });
210
+ ```
211
+
212
+ ## Hops, idempotency, storms
213
+
214
+ - New root: `hops = maxHops`. Follow-up: `cause.hops - 1`. Claimed hop counts ignored.
215
+ - At 0 the router rejects `hop-limit` without consulting the causation index (replayed logs stay bounded).
216
+ - `idempotencyKey` is scoped by `source`. Terminal outcomes (accept, or a **non-transient** reject) are remembered; a second emit with the same key returns the original envelope and does not fan out.
217
+ - Transient rejects are **not** cached: `rate-limited`, `storm-detected`, `hop-limit`, `loop-detected`, `not-subscribed`. Retry after `turn()` or a later `attach`.
218
+ - Host `publish({ cause })` and same-turn cart follow-ups inherit `${cause.idempotencyKey}::${type}` and the parent `correlationId` when the follow-up omits its own.
219
+
220
+ ## Rejections
221
+
222
+ Type `REJECTED_EVENT_TYPE` (`cyberart.diagnostic.rejected`). Payload (`RejectionPayload`):
223
+
224
+ | Field | Meaning |
225
+ |---|---|
226
+ | `reason` | See below. |
227
+ | `detail` | Optional string. |
228
+ | `eventType` | The rejected type. |
229
+ | `source` | Claimed / assigned source. |
230
+
231
+ | `reason` | When |
232
+ |---|---|
233
+ | `malformed` | Bad type, kind, schemaVersion, or non-JSON payload. |
234
+ | `unauthorized` | Cart emitted a type/kind not on its allowlist. |
235
+ | `host-rejected` | `validate` returned reject. |
236
+ | `rate-limited` | `maxPerTurn` or `maxPerWindow`. |
237
+ | `loop-detected` | Causation cycle or `maxCausationDepth`. |
238
+ | `storm-detected` | `maxCorrelationPerTurn` on one `correlationId`. |
239
+ | `unknown-target` | `target` is not attached. |
240
+ | `not-subscribed` | `target` attached but that type is not in `subscribe`. |
241
+ | `hop-limit` | Remaining hops hit 0. |
242
+
243
+ ## Helpers (exported)
244
+
245
+ | Export | Role |
246
+ |---|---|
247
+ | `EVENT_ENVELOPE_VERSION` | `1`. |
248
+ | `DEFAULT_MAX_HOPS` | `8`. |
249
+ | `REJECTED_EVENT_TYPE` | `'cyberart.diagnostic.rejected'`. |
250
+ | `inferEventKind(input)` | Kind from `kind` or first kind segment in `type`. |
251
+ | `matchEventPattern(pattern, type)` | One-segment `*` glob. |
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. |
256
+
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`.
258
+
259
+ `matchesAnyPattern` / `clonePayload` / `isEventKind` are not on the public package surface.
260
+
261
+ ## Deterministic hosts
262
+
263
+ ```ts
264
+ const router = createEventRouter({
265
+ now: () => cart.getClock().now,
266
+ createId: () => `evt-${stableCounter++}`,
267
+ });
268
+ await cart.step(1);
269
+ router.turn();
270
+ ```
271
+
272
+ See [deterministic mode](deterministic-mode.md).
@@ -0,0 +1,186 @@
1
+ # Headless harness
2
+
3
+ CI / agent wrapper around production `createRuntime({ deterministic })`. Not a second engine. Do not call this from Player or kaleidoscope.
4
+
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).
8
+
9
+ Back to the [package README](../README.md).
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
+
20
+ ## One-command reproduce (this repo)
21
+
22
+ ```bash
23
+ pnpm exec vitest run packages/engine/src/canvas/headlessHarness.spec.ts packages/engine/src/canvas/cyb-57-exports.repro.spec.ts
24
+ ```
25
+
26
+ Package consumers copy the loop below into their own test file (Vitest + jsdom or equivalent).
27
+
28
+ ## Canvas install (test-only)
29
+
30
+ ```ts
31
+ import {
32
+ installHeadlessCanvas,
33
+ HEADLESS_PNG_DATA_URL,
34
+ DEFAULT_HEADLESS_WIDTH,
35
+ DEFAULT_HEADLESS_HEIGHT,
36
+ } from '@cyberart-io/engine/headless';
37
+
38
+ installHeadlessCanvas();
39
+ ```
40
+
41
+ Mutates `HTMLCanvasElement.prototype`: `ImageData` polyfill (width/height constructor), stub `getContext` (`fillRect`, `putImageData`, `createImageData`), `toDataURL` → `HEADLESS_PNG_DATA_URL` (a real 1×1 PNG, not visual `fillRect` pixels). Idempotent. **Do not call from production playback.**
42
+
43
+ `createHeadlessHarness` calls this for you. Default container size is `DEFAULT_HEADLESS_WIDTH` × `DEFAULT_HEADLESS_HEIGHT` (320×180).
44
+
45
+ ## `createHeadlessHarness(options)`
46
+
47
+ ```ts
48
+ import { createHeadlessHarness } from '@cyberart-io/engine/headless';
49
+
50
+ const harness = createHeadlessHarness({
51
+ cart: artProject,
52
+ seed: 42,
53
+ width: 320,
54
+ height: 180,
55
+ origin: 0,
56
+ actions: [{ type: 'asset', atFrame: 0, id: 'room-map', status: 'ready' }],
57
+ initialState: { room: 'glade' },
58
+ gameManager: hostAdapter, // site injection; presentation overlay is attachPresentationAdapter
59
+ onEvent: (event) => {},
60
+ onError: (error, info) => {},
61
+ });
62
+ ```
63
+
64
+ ### Options (`CreateHeadlessHarnessOptions`)
65
+
66
+ | Option | Default | Meaning |
67
+ |---|---|---|
68
+ | `cart` | required | `AnimationCart` to mount. |
69
+ | `seed` | generated | Passed to `createRuntime` (canonicalized). |
70
+ | `width` / `height` | `320` / `180` | `clientWidth` / `clientHeight` on the container. Canvas buffer is `getDrawDimensions × devicePixelRatio`. |
71
+ | `origin` | `0` | Virtual clock origin (ms). |
72
+ | `actions` | `[]` | Constructor `ScriptedAction` tape. |
73
+ | `initialState` | none | `mount` boot overrides (`customState`). |
74
+ | `gameManager` | none | Opaque host adapter into `getDefaultState` / `update`. |
75
+ | `onEvent` | none | Also forwarded; the harness still logs outbound events. |
76
+ | `onError` | none | Also forwarded; the harness still logs structured errors. Wire happens **before** `mount`. |
77
+
78
+ Always sets `deterministic: { origin, actions }`.
79
+
80
+ ## Handle (`HeadlessHarness`)
81
+
82
+ | Member | Meaning |
83
+ |---|---|
84
+ | `runtime` | The `CyberArtRuntime`. Assigning `runtime.onError` replaces **your** handler; the harness still records errors. |
85
+ | `container` | Sized mount element (on `document.body` until `destroy`). |
86
+ | `cart` | Current `CartHandle` (updates on `remount`). |
87
+ | `events` / `errors` | **Copies** of the logs. Mutating them does not change internals. Prefer `inspect()` for a snapshot. |
88
+ | `step(frames?)` / `advance(ms)` | Deterministic ticks. Throw after `destroy()`. |
89
+ | `schedule(action)` | Pass-through `ScriptedAction`. |
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. |
92
+ | `click(x, y)` | Pointer-**down** at `getClock().framesElapsed` (next tick). Canvas pixels, not CSS. Use `schedule` for `move` / `up`. |
93
+ | `key(key)` | Same next-frame `schedule` for a key. Cart must `registerAction`. |
94
+ | `inspect()` | `{ state, events, errors, replay, clock }`. `state` is **exported**, not live `getCartState()`. |
95
+ | `captureFrame(path?)` | `cart.snapshot()`. Optional `path` writes PNG bytes (Node only). Stub-stable. |
96
+ | `remount(options?)` | `runtime.mount` again while alive. Constructor `actions` replay; prior `click`/`key`/`schedule` dropped. `'onEvent' in options` replaces the listener; `{ onEvent: undefined }` clears it. Invalid after `destroy()`. |
97
+ | `destroy()` | Unload cart, destroy runtime, remove container. Idempotent. Always removes the container even if unload throws. |
98
+
99
+ Repeated mount/destroy = a **new** `createHeadlessHarness` (or `remount` then `destroy`). Do not `remount` after `destroy()`.
100
+
101
+ ### Click / clock
102
+
103
+ After `step(5)`, `framesElapsed === 5`. `click` / `key` schedule at that index; `step(1)` applies them. Hit-test pointer against `dimensionContext` (drawing space), not the CSS box.
104
+
105
+ ### `inspect()` (`HeadlessInspect`)
106
+
107
+ | Field | Meaning |
108
+ |---|---|
109
+ | `state` | `exportState().state` / replay `state`. |
110
+ | `events` | Outbound log copy. |
111
+ | `errors` | `{ error, info: FrameErrorInfo }[]`. |
112
+ | `replay` | `getReplayMetadata()`. |
113
+ | `clock` | `getClock()`. |
114
+
115
+ First frame throw: `{ phase: 'update', consecutive: 1, stopped: false }`. See [deterministic mode](deterministic-mode.md) for the 120-frame breaker.
116
+
117
+ ### `captureFrame(path?)`
118
+
119
+ Returns `CartSnapshot`: `{ seed, metadata?, pngDataUrl }`.
120
+
121
+ - Omit `path` for in-memory snapshot (`pngDataUrl` is `HEADLESS_PNG_DATA_URL` under the stub).
122
+ - With `path`: Node `fs.promises.writeFile` of decoded PNG bytes. Throws a harness message if not Node, if `node:fs/promises` cannot load, or if the write fails (missing directory, etc.). Write under `os.tmpdir()`, not the repo.
123
+
124
+ ## Interaction loop (agent recipe)
125
+
126
+ ```ts
127
+ import {
128
+ attachPresentationAdapter,
129
+ createReferencePresentationCart,
130
+ } from '@cyberart-io/engine';
131
+ import { createHeadlessHarness } from '@cyberart-io/engine/headless';
132
+
133
+ const harness = createHeadlessHarness({
134
+ cart: createReferencePresentationCart(),
135
+ seed: 42,
136
+ });
137
+ const adapter = attachPresentationAdapter(harness);
138
+
139
+ await harness.step(1);
140
+ const canvas = harness.cart.canvas!;
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);
163
+ await harness.step(1);
164
+
165
+ const { events } = await harness.inspect();
166
+ // assert exactly one adventure.intent.exit-requested; cart state is still Glade
167
+
168
+ adapter.present({
169
+ contractVersion: 1,
170
+ phase: 'ready',
171
+ view: { title: 'Joiner Brook', regions: [] },
172
+ });
173
+ await harness.step(1);
174
+
175
+ await harness.captureFrame('/tmp/brook.png');
176
+ adapter.destroy();
177
+ harness.destroy();
178
+ ```
179
+
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.
183
+
184
+ ## Types
185
+
186
+ `CreateHeadlessHarnessOptions`, `HeadlessHarness`, `HeadlessInspect`, `HeadlessFrameError`.
@@ -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`.