@cyberart-io/engine 0.0.4 → 0.0.6

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.
@@ -1,6 +1,6 @@
1
1
  # Asset resolver / preloader
2
2
 
3
- Host-owned URL policy. The engine caches, dedupes, tracks progress, applies timeouts and fallbacks, and (in live mode) dispatches `ASSET_READY_EVENT` / `ASSET_FAILED_EVENT`. Authored carts keep logical refs (`moltazine:post/<id>#primary-image`, `world:asset/…`, `library:…`, or ordinary URLs). Adventure Kit, Cyberart, and a local preview each supply a different `AssetResolver`.
3
+ Host-owned URL policy. The engine caches, dedupes, tracks progress, applies timeouts and fallbacks, and (in live mode) dispatches `ASSET_READY_EVENT` / `ASSET_FAILED_EVENT`. Authored carts keep logical refs (`moltazine:post/<id>#primary-image`, `world:asset/…`, `library:…`, or ordinary URLs). the host, Cyberart, and a local preview each supply a different `AssetResolver`.
4
4
 
5
5
  Deterministic timing: [deterministic mode](deterministic-mode.md). CI: [headless harness](headless-harness.md). Event mailbox: [events](events.md).
6
6
 
@@ -43,7 +43,7 @@ const runtime = createRuntime({
43
43
  });
44
44
 
45
45
  await runtime.assets!.preload([
46
- { id: 'room-bg', ref: 'moltazine:post/porch-1#primary-image', type: 'image' },
46
+ { id: 'backdrop', ref: 'moltazine:post/porch-1#primary-image', type: 'image' },
47
47
  {
48
48
  id: 'ambience',
49
49
  ref: 'world:asset/stream-loop',
@@ -69,7 +69,7 @@ Under `deterministic`, emit and wall-clock timeout are off so scripted `{ type:
69
69
  import type { AssetDeclaration } from '@cyberart-io/engine';
70
70
 
71
71
  const roomBg: AssetDeclaration = {
72
- id: 'room-bg',
72
+ id: 'backdrop',
73
73
  ref: 'moltazine:post/porch-1#primary-image',
74
74
  type: 'image', // 'image' | 'audio' | 'font' | 'spritesheet'
75
75
  integrity: 'sha256-…', // optional; mismatch → `invalid`
@@ -135,7 +135,7 @@ const runtime = createRuntime({
135
135
  seed: 42,
136
136
  deterministic: {
137
137
  actions: [
138
- { type: 'asset', atFrame: 2, id: 'room-bg', status: 'ready' },
138
+ { type: 'asset', atFrame: 2, id: 'backdrop', status: 'ready' },
139
139
  {
140
140
  type: 'asset',
141
141
  atFrame: 3,
package/docs/audio.md ADDED
@@ -0,0 +1,152 @@
1
+ # Audio unlock broker and cues
2
+
3
+ Page-level unlock, channel mix, and frame-stepped cue scheduling. One user gesture unlocks authorized carts onto a single audio graph. Cue traces are deterministic; **PCM output is not**.
4
+
5
+ Back to the [package README](../README.md). Related: [presentation cue](presentation-cue.md) (scheduling), [asset resolver](asset-resolver.md) (`ASSET_READY_EVENT` / `ASSET_FAILED_EVENT`), [runtime group](runtime-group.md), [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-67-audio.repro.spec.ts
11
+ ```
12
+
13
+ ## Why this exists
14
+
15
+ Browser autoplay policy, load timing, and several carts on one page make it easy to start extra `AudioContext`s or invent per-cart mute/timing rules. Hosts create **one** `createAudioBroker` and pass it into `createRuntime` / `createRuntimeGroup`. Carts that only set `metadata.audio: 'tone'` keep the existing `start()` / `unlockAudio()` path when the broker is omitted.
16
+
17
+ Silent carts never import Tone. Destroying a cart tears down that participant’s channels; it does not close Tone for remaining carts. There is still **one audio graph per page**.
18
+
19
+ ## `createAudioBroker(options?)`
20
+
21
+ ```ts
22
+ import { createAudioBroker, createRuntime, createRuntimeGroup } from '@cyberart-io/engine';
23
+
24
+ const broker = createAudioBroker({
25
+ // tests inject a no-op; default dynamically loads the Tone adapter
26
+ toneStart: async () => undefined,
27
+ reducedSensory: false,
28
+ });
29
+
30
+ await broker.unlock(); // first call runs toneStart; later calls share status
31
+ broker.authorize('effects');
32
+ broker.authorize('ambience');
33
+
34
+ const group = createRuntimeGroup({
35
+ audioBroker: broker,
36
+ participants: [/* … */],
37
+ });
38
+ ```
39
+
40
+ | Member | Meaning |
41
+ |---|---|
42
+ | `unlock()` | Idempotent. First call runs `toneStart`. Default dynamically loads the Tone adapter: `load()` when `navigator.userAgent` matches `/Headless/i` (same as `AnimationManager.unlockAudio`), otherwise `unlock()`. Returns `{ state, error? }`. |
43
+ | `status()` | `locked` / `unlocking` / `unlocked` / `failed`. |
44
+ | `authorize(id)` / `revoke(id)` / `isAuthorized(id)` | Runtime-group participant ids. Empty set = all cues allowed (single-cart host). |
45
+ | `setChannelGain(id, gain)` | Channel gain in `0…1`. |
46
+ | `setPriority(id, n)` | Higher-priority active cues duck lower-priority channels (`DEFAULT_DUCK_GAIN` = `0.25`). |
47
+ | `mute()` / `unmute()` | Page mute. Cues still record; playback is skipped (`audio.cue.skipped`, reason `muted`). |
48
+ | `muteChannel(id)` / `unmuteChannel(id)` | Per-channel mute. |
49
+ | `duck(id, gain?)` / `unduck(id)` | Manual duck; `unduck` restores auto priority ducking. |
50
+ | `effectiveGain(id)` | `0` when page/channel muted, else `gain * duckGain`. |
51
+ | `handleHostEvent(event)` | Records `ASSET_READY_EVENT` / `ASSET_FAILED_EVENT` by payload `id`. |
52
+ | `teardown(participantId)` | Drop that cart’s authorization, channels, and active cues. Does **not** close Tone. |
53
+ | `inspect()` | JSON-serializable `{ status, reducedSensory, muted, authorized, channels, assets }`. |
54
+ | `destroy()` | Tear down all participants and listeners. Unlock status stays `unlocked` if it was; the shared context is left running. |
55
+
56
+ `reducedSensory: true` skips playback and still records cue events (`audio.cue.skipped`, reason `reduced-sensory`). It does **not** pass presentation `reducedMotion`, so authored `delayFrames` still appear on `atFrame`.
57
+
58
+ `createAudioCueTimeline()` with **no** broker starts cues (no unauthorized skip). Pass a broker when you need authorization, mute, or asset readiness.
59
+
60
+ Repeat (`CueSpec.repeat`) keeps the audio record across cycles: each presentation `cue.started` can emit another `audio.cue.started`, and `noteCueEnded` runs at the end of a cycle so ducking does not stick after the first loop.
61
+
62
+ `createRuntime({ audioBroker, audioParticipantId })` authorizes that id, routes mailbox `ASSET_*` events into the broker, and `teardown`s the id on `runtime.destroy()`. `createRuntimeGroup({ audioBroker })` passes each participant id. Carts that only declare `metadata.audio: 'tone'` still unlock on `start()` / `unlockAudio()` when **no** broker is passed.
63
+
64
+ ## `createAudioCueTimeline(options?)` / `scheduleAudioCue`
65
+
66
+ Composes [`createPresentationTimeline`](presentation-cue.md). Drive `step` from the same clock as deterministic `cart.step`. Duplicate policy, delay, and easing are the presentation cue’s.
67
+
68
+ ```ts
69
+ import {
70
+ createAudioCueTimeline,
71
+ scheduleAudioCue,
72
+ AUDIO_CUE_SCHEDULED_EVENT,
73
+ AUDIO_CUE_STARTED_EVENT,
74
+ } from '@cyberart-io/engine';
75
+
76
+ const timeline = createAudioCueTimeline({
77
+ broker,
78
+ originFrame: 0,
79
+ reducedSensory: false,
80
+ });
81
+
82
+ scheduleAudioCue(timeline, {
83
+ name: 'overlay',
84
+ idempotencyKey: 'ripple-overlay',
85
+ assetId: 'overlay',
86
+ participantId: 'ambience',
87
+ channelId: 'ambience',
88
+ durationFrames: 4,
89
+ delayFrames: 1,
90
+ onDuplicate: 'ignore',
91
+ });
92
+
93
+ timeline.step(6);
94
+ ```
95
+
96
+ `AudioCueSpec` is `CueSpec` plus `assetId` (required), `channelId?`, `participantId?`, `priority?`, `gain?`. `channelId` defaults to `participantId` or `'master'`.
97
+
98
+ | `onDuplicate` | Effect |
99
+ |---|---|
100
+ | `replace` (presentation default) | Replace the live cue; emit a new `audio.cue.scheduled`. |
101
+ | `ignore` | Keep the existing cue; no second `scheduled`. |
102
+ | `reject` | `{ ok: false, reason: 'duplicate' }`. |
103
+
104
+ ## Cue events (stable names)
105
+
106
+ | `type` | When |
107
+ |---|---|
108
+ | `audio.cue.scheduled` | `play` / `scheduleAudioCue` accepted |
109
+ | `audio.cue.started` | Presentation start, authorized, not muted, asset ready |
110
+ | `audio.cue.skipped` | Reduced-sensory, muted, or unauthorized (reasons above) |
111
+ | `audio.cue.failed` | Failed audio asset (`asset-failed`) or cart teardown (`torn-down`) |
112
+
113
+ Each event: `{ type, atFrame, name, idempotencyKey, assetId, channelId, participantId?, reason?, progress }`. Two identical `play` / `step` / asset-event tapes produce identical lists.
114
+
115
+ A failed `ASSET_FAILED_EVENT` for the cue’s `assetId` emits `audio.cue.failed` and does not wait on I/O. Unknown assets stay scheduled until ready, failed, or the presentation cue completes (then `asset-failed` so `step` cannot hang).
116
+
117
+ ## Headless adapter
118
+
119
+ Event-only. No Web Audio. Import from **`@cyberart-io/engine/headless`** (also re-exported from `@cyberart-io/engine`).
120
+
121
+ ```ts
122
+ import { createHeadlessAudioAdapter } from '@cyberart-io/engine/headless';
123
+
124
+ const adapter = createHeadlessAudioAdapter({ originFrame: 0 });
125
+ await adapter.unlock();
126
+ adapter.handleHostEvent({
127
+ type: 'cyberart.asset.ready',
128
+ kind: 'state',
129
+ payload: { id: 'overlay' },
130
+ });
131
+ adapter.play({ /* AudioCueSpec */ });
132
+ adapter.step(4);
133
+ const { events } = adapter.snapshot();
134
+ adapter.destroy();
135
+ ```
136
+
137
+ Replay two runs with the same seed/tape and compare `events`. Do not compare speakers, meters, or decoded PCM — those are not deterministic across machines or browsers.
138
+
139
+ ## Host wiring
140
+
141
+ ```ts
142
+ const broker = createAudioBroker();
143
+ button.addEventListener('click', () => broker.unlock());
144
+
145
+ const runtime = createRuntime({
146
+ container,
147
+ audioBroker: broker,
148
+ audio: 'tone', // still required for start() to unlock when the cart declares it
149
+ });
150
+ ```
151
+
152
+ `runtime.unlockAudio()` / `cart.start()` call `broker.unlock()` when the cart (or `createRuntime({ audio })` hint) needs audio. A silent cart still never imports Tone.
@@ -0,0 +1,126 @@
1
+ # Browser / DOM host harness
2
+
3
+ Opt-in harness that mounts a caller-supplied host into a real viewport, optionally with a runtime group and compositor. It sets viewport / DPR / reduced-motion / input modality, dispatches pointer and keyboard through DOM coordinates, steps a deterministic clock, and captures composed frames plus accessibility HTML.
4
+
5
+ Import `createBrowserHarness` from **`@cyberart-io/engine`**. It does not load `node:fs`. In Vitest/jsdom, call `installHeadlessCanvas()` from **`@cyberart-io/engine/headless`** first so `Canvas2D` pixels exist. Production Player / kaleidoscope should not call this harness.
6
+
7
+ Related: [normalized geometry](normalized-geometry.md), [presentation adapter](presentation-adapter.md), [compositor](compositor.md), [headless harness](headless-harness.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-63-browser-harness.repro.spec.ts
15
+ ```
16
+
17
+ ## Why this exists
18
+
19
+ `createHeadlessHarness` injects canvas-pixel clicks. It cannot prove a host's DOM overlay, CSS geometry, resize/DPR anchors, focus, or scroll. This harness lets a host mount overlay controls, run its own reducer, route through carts, and compose with `createCompositor` / `captureComposedFrame`. jsdom software Canvas2D is the headless composition path; a Playwright-compatible adapter maps the same scenario API for a later Chromium swap.
20
+
21
+ Hosts drive the same API with their own overlay markup and event types; those names stay in the host, not in this module.
22
+
23
+ ## `createBrowserHarness(options)`
24
+
25
+ ```ts
26
+ import { createBrowserHarness } from '@cyberart-io/engine';
27
+ import { installHeadlessCanvas } from '@cyberart-io/engine/headless';
28
+
29
+ installHeadlessCanvas();
30
+
31
+ const harness = createBrowserHarness({
32
+ seed: `0x${'00'.repeat(32)}`,
33
+ viewport: { width: 1280, height: 800, deviceScaleFactor: 1 },
34
+ geometry,
35
+ contentWidth: 1920,
36
+ contentHeight: 1080,
37
+ mount(ctx) {
38
+ const hotspot = document.createElement('button');
39
+ hotspot.id = 'hotspot';
40
+ ctx.placeRegion('hotspot', hotspot);
41
+ ctx.root.append(hotspot);
42
+ return {
43
+ participants: [overlayA, overlayB],
44
+ compositor: { layers: [{ id: 'overlay-a', order: 0 }, { id: 'overlay-b', order: 1 }] },
45
+ ready(runtime) {
46
+ hotspot.addEventListener('click', () => {
47
+ runtime.publish({ type: 'host.intent.select', kind: 'intent', payload: { id: 'hotspot' } });
48
+ });
49
+ },
50
+ relayout() {
51
+ ctx.placeRegion('hotspot', hotspot);
52
+ },
53
+ destroy() {
54
+ hotspot.remove();
55
+ },
56
+ };
57
+ },
58
+ });
59
+
60
+ harness.goto();
61
+ harness.click('#hotspot');
62
+ await harness.step(1);
63
+ const shot = harness.screenshot();
64
+ const a11y = harness.accessibilitySnapshot();
65
+ const meta = harness.reproduction();
66
+ harness.destroy();
67
+ ```
68
+
69
+ The host owns overlay markup, reducer state, and event type names. The harness owns viewport metrics, DOM input dispatch, the optional group/compositor lifecycle, and cleanup.
70
+
71
+ ### Options
72
+
73
+ | Option | Default | Meaning |
74
+ |---|---|---|
75
+ | `seed` | `0x00…` | Default participant seed when a cart omits one. |
76
+ | `viewport.width` / `height` / `deviceScaleFactor` | `320` / `180` / `1` | CSS viewport and DPR. |
77
+ | `reducedMotion` | `false` | Stubs `matchMedia('(prefers-reduced-motion: reduce)')`. |
78
+ | `inputModality` | `'pointer'` | Recorded on the root (`data-input-modality`). |
79
+ | `geometry` | none | Optional geometry document for `placeRegion` / CSS hit-testing. |
80
+ | `contentWidth` / `contentHeight` / `fit` | viewport / `contain` | Intrinsic box for `createPresentationLayout`. |
81
+ | `mount` | none | Host builds DOM and returns carts / compositor / cleanup. |
82
+
83
+ ### Handle (Playwright-shaped names)
84
+
85
+ | Member | Meaning |
86
+ |---|---|
87
+ | `goto()` | No-op ready check (fixture is already mounted). |
88
+ | `setViewport(width, height, dpr?)` | Resize root, group canvases, compositor; call host `relayout`. |
89
+ | `click(selector)` / `click(x, y)` | Dispatch `pointerdown` / `pointerup` / `click` on DOM. |
90
+ | `key` / `focus` | Keyboard through the focused element. |
91
+ | `step` / `advance` | Deterministic group clock. |
92
+ | `screenshot` | `captureComposedFrame()` plus `root.outerHTML` when a compositor is mounted. |
93
+ | `accessibilitySnapshot` | Focus, `aria-live` text, HTML. |
94
+ | `reproduction` | Seed, viewport, DPR, reduced-motion, modality, action tape. |
95
+ | `destroy` | Host cleanup, group, compositor, listeners; blur focus; restore `matchMedia` / DPR. |
96
+
97
+ ## Playwright-compatible adapter (no Playwright dependency)
98
+
99
+ `@playwright/test` is **not** an engine dependency. CI can wrap the same harness:
100
+
101
+ ```ts
102
+ import { createBrowserHarness, createPlaywrightCompatibleAdapter } from '@cyberart-io/engine';
103
+
104
+ const harness = createBrowserHarness({ viewport: { width: 390, height: 844 }, mount });
105
+ const page = createPlaywrightCompatibleAdapter(harness);
106
+
107
+ await page.goto();
108
+ await page.setViewportSize({ width: 390, height: 844, deviceScaleFactor: 2 });
109
+ await page.click('#hotspot');
110
+ const shot = await page.screenshot();
111
+ await page.close();
112
+ ```
113
+
114
+ | Playwright `Page` | This adapter |
115
+ |---|---|
116
+ | `goto(url)` | `harness.goto()` (host already mounted) |
117
+ | `setViewportSize` | `harness.setViewport` |
118
+ | `click(selector)` | DOM click at the element's CSS box |
119
+ | `screenshot()` | Composed `ImageData` + HTML (not a Chromium PNG) |
120
+ | `keyboard.press` | `harness.key` |
121
+ | `locator(sel).click` | Same DOM click |
122
+ | `close()` | `harness.destroy()` |
123
+
124
+ To run the same scenario in Chromium later, keep these method names and swap the adapter body for `chromium.launch()` + `page.goto(hostUrl)` without changing tests.
125
+
126
+ Visual diffs stay on `assertPixelsEqual` / `compareImageData`. Do not add a second screenshot library.
@@ -39,14 +39,14 @@ import {
39
39
  } from '@cyberart-io/engine';
40
40
 
41
41
  const defined = defineCapabilityManifest({
42
- id: 'adventure.presentation',
42
+ id: 'host.presentation',
43
43
  runtime: { minContractVersion: 1, features: ['router'] },
44
44
  phases: ['loading', 'ready', 'error', 'unsupported'],
45
45
  managers: ['pointer', 'hostChannel'],
46
- assets: { kinds: ['image'], declarations: [{ id: 'room-bg', kind: 'image' }] },
47
- acceptedEvents: ['adventure.state.*'],
48
- emittedEvents: ['adventure.intent.exit-requested'],
49
- permissions: { emit: ['adventure.intent.*'], subscribe: ['adventure.state.*'] },
46
+ assets: { kinds: ['image'], declarations: [{ id: 'backdrop', kind: 'image' }] },
47
+ acceptedEvents: ['host.state.*'],
48
+ emittedEvents: ['host.intent.exit-requested'],
49
+ permissions: { emit: ['host.intent.*'], subscribe: ['host.state.*'] },
50
50
  integrations: ['tone'],
51
51
  });
52
52
  if (!defined.ok) throw new Error(defined.errors.map((e) => e.detail).join('; '));
@@ -55,8 +55,8 @@ const host = {
55
55
  contractVersion: 1,
56
56
  features: ['router'],
57
57
  integrations: ['tone'] as const,
58
- emit: ['adventure.intent.*'],
59
- subscribe: ['adventure.state.*'],
58
+ emit: ['host.intent.*'],
59
+ subscribe: ['host.state.*'],
60
60
  };
61
61
 
62
62
  const check = validateCapabilityManifest(defined.manifest, host);
@@ -82,3 +82,14 @@ const parsed = parseCapabilityManifest(JSON.stringify(defined.manifest));
82
82
  | `missing-integration` | Host does not provide `tone` / `midi` as required. |
83
83
  | `missing-manager` | Host listed `managers` but omitted one the cart requires. |
84
84
  | `unknown-permission` | Cart emit/subscribe permission is not on the host allowlist (only if host listed `emit` / `subscribe`). |
85
+ | `invalid-surface` / `invalid-clear-policy` / `invalid-blend` | Cart or host `surface` / `layers` values are the wrong shape or not in the allowlist. |
86
+ | `unsupported-surface` / `unsupported-layer` | Host omitted `surface.alpha` / `clearPolicy` or compositor blend modes the cart requires. |
87
+
88
+ Optional additive keys (omitted on existing carts):
89
+
90
+ ```ts
91
+ surface: { alpha: true, clearPolicy: 'transparent' }
92
+ layers: { compositor: true, blend: ['source-over', 'screen'] }
93
+ ```
94
+
95
+ See [compositor](compositor.md).
@@ -0,0 +1,103 @@
1
+ # Compositor
2
+
3
+ Transparent multi-cart compositor. Reads each runtime-group canvas with `getImageData`, blends onto a host image (or a transparent clear), and records declared layer order for headless capture. Hosts should not invent CSS `screen` hacks for black-backed carts.
4
+
5
+ Related: [runtime group](runtime-group.md), [headless harness](headless-harness.md), [capability manifest](capability-manifest.md).
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-64-compositor.repro.spec.ts
13
+ ```
14
+
15
+ ## When to use
16
+
17
+ | Surface | Import | Use |
18
+ |---|---|---|
19
+ | Production host | `createCompositor` from `@cyberart-io/engine` | Stack a host image under participant canvases (effects, overlays, debug). |
20
+ | Vitest / jsdom | same compositor after `installHeadlessCanvas()` | Headless `captureComposedFrame()` / `writeComposedFrame`. |
21
+
22
+ Browser vs headless pixel agreement is covered by the [browser harness](browser-harness.md). This module still emits a headless capture that includes `declaredOrder`.
23
+
24
+ ## `createCompositor(options)`
25
+
26
+ ```ts
27
+ import { createCompositor, createRuntimeGroup } from '@cyberart-io/engine';
28
+
29
+ const group = createRuntimeGroup({
30
+ participants: [baseCart, overlayCart],
31
+ });
32
+ const compositor = createCompositor({
33
+ group,
34
+ width: 320,
35
+ height: 180,
36
+ dpr: 1,
37
+ host: { image: backdropImageData },
38
+ clearPolicy: 'transparent',
39
+ layers: [
40
+ { id: 'overlay-a', order: 0, blend: 'source-over' },
41
+ { id: 'overlay-b', order: 1, blend: 'screen' },
42
+ ],
43
+ });
44
+
45
+ await group.step(1);
46
+ const frame = compositor.captureComposedFrame();
47
+ // frame.declaredOrder is the compose list; frame.imageData is the stack.
48
+ compositor.destroy();
49
+ ```
50
+
51
+ Typical stack: host image, then opaque overlay carts (`source-over`), then additive layers (`screen` so RGB `0,0,0` backing is identity unless `clearPolicy: 'transparent'` knocks it out). Later `order` values draw on top.
52
+
53
+ ### Options
54
+
55
+ | Option | Default | Meaning |
56
+ |---|---|---|
57
+ | `group` | required | `createRuntimeGroup` handle. |
58
+ | `layers` | required | One entry per participant id. Sorted by `order`, then id. |
59
+ | `width` / `height` / `dpr` | `320` / `180` / `1` | Shared viewport. `resize` updates the group canvases. |
60
+ | `host.image` / `host.color` | none | Back layer. Color is `#rgb` / `#rrggbb`. |
61
+ | `clearPolicy` | `transparent` | Host surface starts clear (never filled black). Per-layer transparent policy knocks out RGB `0,0,0` backing pixels on opaque cart canvases. |
62
+ | `offscreen` | `true` | Compose into a compositor-owned canvas when `target` is omitted. `false` requires `target`. A provided `target` is never destroyed. |
63
+
64
+ ### Layer fields
65
+
66
+ | Field | Default | Meaning |
67
+ |---|---|---|
68
+ | `id` | required | Runtime-group participant id. |
69
+ | `order` | required | Lower draws first. |
70
+ | `visible` | `true` | Skip compose and pointer hits when false. |
71
+ | `opacity` | `1` | 0..1. |
72
+ | `blend` | `source-over` | Also `screen` (in-engine; same modes as `CAPABILITY_BLEND_MODES`). |
73
+ | `clip` | none | Pixel rect; compose and pointer hits are clipped. |
74
+ | `pointerEvents` | `auto` | `none` skips hit testing and sets `canvas.style.pointerEvents`. |
75
+ | `clearPolicy` | compositor default | `transparent` knocks out black backing. |
76
+
77
+ ### Handle
78
+
79
+ | Member | Meaning |
80
+ |---|---|
81
+ | `compose()` | Blend host + visible layers; returns `ImageData`. |
82
+ | `captureComposedFrame()` | `{ imageData, pngDataUrl, declaredOrder, width, height }`. |
83
+ | `resize(w, h, dpr?)` | Shared viewport; calls `group.resize` on the buffer size. Order/blend survive `group.step`. Carts that cache `DimensionContext` should `group.reset()` after a size change. |
84
+ | `setLayer` / `layer` / `layers` | Mutate / inspect declarative settings. |
85
+ | `pointerTarget(x, y)` | Top-most visible layer with `pointerEvents: 'auto'` under the pixel. |
86
+ | `unmountLayer(id)` | `group.detach(id)` without clearing sibling canvases. |
87
+ | `inspect()` | Viewport, clear policy, layers, `declaredOrder` ids. |
88
+ | `destroy()` | Drops the host canvas only if the compositor created it. Does not destroy the group. |
89
+
90
+ `group.step` isolates per-cart update/render throws so a failing layer does not blank siblings.
91
+
92
+ Node tests that need a PNG file: `writeComposedFrame(compositor, path)` from `@cyberart-io/engine/headless`.
93
+
94
+ ## Capability keys (additive)
95
+
96
+ Optional manifest fields, omitted on existing carts:
97
+
98
+ ```ts
99
+ surface: { alpha: true, clearPolicy: 'transparent' }
100
+ layers: { compositor: true, blend: ['source-over', 'screen'] }
101
+ ```
102
+
103
+ `validateCapabilityManifest` reports `unsupported-surface` / `unsupported-layer` when the host does not list matching `surface` / `layers`. Carts that omit the keys keep the previous host check.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Host-controlled time, input, and asset completion so two runs with the same seed, actions, and `step` count match. Production kaleidoscope / Art Blocks **leave `deterministic` unset** (rAF, `performance.now()`, token hash).
4
4
 
5
- CI should use the [headless harness](headless-harness.md) on this path, not a mock engine. Router hosts: [events](events.md).
5
+ CI should use the [headless harness](headless-harness.md) on this path, not a mock engine. Router hosts: [events](events.md). Frame-local effects: [presentation cue](presentation-cue.md) (`timeline.step` beside `cart.step`).
6
6
 
7
7
  Back to the [package README](../README.md).
8
8
 
package/docs/events.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Events and router
2
2
 
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).
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`), [presentation cue](presentation-cue.md) (frame-local lifecycle, not routed unless you define a contract), [runtime group](runtime-group.md), [capability manifest](capability-manifest.md), [replay inspector](replay-inspector.md).
4
4
 
5
5
  Back to the [package README](../README.md).
6
6
 
@@ -16,7 +16,7 @@ pnpm exec vitest run packages/engine/src/canvas/cyb-59-event-contract.repro.spec
16
16
  | Path | Use |
17
17
  |---|---|
18
18
  | Mailbox only | One cart, host `dispatch` / cart `emit`. No permissions, hops, or loop checks. |
19
- | Router | Several carts, host reducer in the middle, Adventure-style intent vs state. |
19
+ | Router | Several carts, host reducer in the middle, Dotted-name intent vs state. |
20
20
  | Runtime group | Same router plus lockstep `step`, pause/reset/teardown, and a routed trace. |
21
21
 
22
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.
@@ -53,7 +53,7 @@ Routed events are normalized to `EventEnvelope` (`EVENT_ENVELOPE_VERSION = 1`).
53
53
  | Field | Set by | Meaning |
54
54
  |---|---|---|
55
55
  | `schemaVersion` | router | Always `1`. Other versions are `malformed`. |
56
- | `type` | emitter | Dotted name, e.g. `adventure.intent.exit-requested`. |
56
+ | `type` | emitter | Dotted name, e.g. `host.intent.exit-requested`. |
57
57
  | `kind` | explicit or inferred | `intent` \| `state` \| `diagnostic`. |
58
58
  | `source` | router | Participant id (or `host` / `router`). Claimed `source` is overwritten. |
59
59
  | `target` | emitter | Optional **participant id**, not a domain object (put the exit id in `payload`). |
@@ -68,13 +68,69 @@ Routed events are normalized to `EventEnvelope` (`EVENT_ENVELOPE_VERSION = 1`).
68
68
  `kind` on the input wins. Otherwise:
69
69
 
70
70
  - `cyberart.state.save` / `cyberart.state.load` are **intents** (historical names).
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.
71
+ - Else the first dotted segment of `type` that is `intent`, `state`, or `diagnostic` (not necessarily the second segment — `host.presentation.intent.cue-started` is an intent).
72
+ - Else `malformed` (`cannot infer kind`). `host.presentation.cue.started` does not infer a kind; define it with `defineIntent` / `defineStateEvent` / `defineDiagnostic` so the kind is in the name.
75
73
 
76
74
  Only the host (`publish`) or an `authoritative` participant may emit `state` / `diagnostic`. Carts emit allowed `intent` patterns. Domain data belongs in `payload`.
77
75
 
76
+ ## Typed contracts
77
+
78
+ 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.
79
+
80
+ ```ts
81
+ import {
82
+ defineIntent,
83
+ defineStateEvent,
84
+ createContractRegistry,
85
+ deriveAttachOptions,
86
+ createEventRouter,
87
+ } from '@cyberart-io/engine';
88
+
89
+ const exit = defineIntent('host.intent.exit-requested', {
90
+ version: 1,
91
+ fields: { exitId: { type: 'string' } },
92
+ });
93
+ const room = defineStateEvent('host.state.room-changed', {
94
+ version: 1,
95
+ fields: { roomId: { type: 'string' } },
96
+ });
97
+ if (!exit.ok || !room.ok) {
98
+ // structured errors — these helpers do not throw
99
+ }
100
+ const contracts = [exit.contract, room.contract];
101
+ const registry = createContractRegistry(contracts);
102
+ const router = createEventRouter({ validate: registry.asRouterValidate });
103
+ router.attach('presentation', runtime.hostChannel, deriveAttachOptions(contracts, 'cart'));
104
+ ```
105
+
106
+ `host.presentation.cue.started` has no `intent` / `state` / `diagnostic` segment, so `inferEventKind` returns undefined and `defineIntent` returns `{ ok: false, errors }` with `kind-mismatch`. Use `host.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.
107
+
108
+ | Helper | Kind stamped | Name rule |
109
+ |---|---|---|
110
+ | `defineIntent(type, schema)` | `intent` | `type` must contain an `intent` dotted segment |
111
+ | `defineStateEvent(type, schema)` | `state` | `type` must contain a `state` dotted segment |
112
+ | `defineDiagnostic(type, schema)` | `diagnostic` | `type` must contain a `diagnostic` dotted segment |
113
+
114
+ `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.
115
+
116
+ | Export | Role |
117
+ |---|---|
118
+ | `createContractRegistry(contracts)` | `get`, `manifest()` (JSON-serializable), `validateEnvelope`, `asRouterValidate` (safe to pass as `EventRouterOptions.validate`; does not rely on `this`) |
119
+ | `deriveAttachOptions(contracts, 'cart' \| 'authoritative')` | Cart: emit intent family patterns only. Authoritative: emit every contract family. Subscribe to non-intent families. |
120
+ | `verifyAttachOptions(options, contracts)` | `{ ok: true }` or unauthorized-emit errors when a non-authoritative attach can emit a `state` / `diagnostic` contract |
121
+ | `comparePayloadSchemas(from, to)` | `'identical'` / `'backward-compatible'` / `'breaking'` |
122
+ | `kindSegmentInType(type)` / `familyPatternForType(type)` | First kind segment; last-segment `*` glob (`host.presentation.intent.*`) |
123
+
124
+ Default router `emit: ['*.intent.*']` is **three** segments. Four-segment names such as `host.presentation.intent.cue-started` need `deriveAttachOptions` (or an explicit four-segment pattern). Duplicate `type`s in a registry last-win.
125
+
126
+ ### Payload versioning (`comparePayloadSchemas`)
127
+
128
+ | Change | Result |
129
+ |---|---|
130
+ | Same version, same fields | `identical` |
131
+ | Version bump, only new **optional** fields (or unchanged fields) | `backward-compatible` |
132
+ | Removed field, type change, optional → required, version downgrade, optional add **without** a version bump | `breaking` |
133
+
78
134
  ## `createEventRouter(options?)`
79
135
 
80
136
  ```ts
@@ -87,13 +143,13 @@ const router = createEventRouter({
87
143
  });
88
144
 
89
145
  router.attach('presentation', runtime.hostChannel, {
90
- emit: ['adventure.intent.*'],
91
- subscribe: ['adventure.state.*', 'cyberart.diagnostic.rejected'],
146
+ emit: ['host.intent.*'],
147
+ subscribe: ['host.state.*', 'cyberart.diagnostic.rejected'],
92
148
  });
93
149
 
94
- router.subscribe(['adventure.intent.*'], (event) => {
150
+ router.subscribe(['host.intent.*'], (event) => {
95
151
  router.publish(
96
- { type: 'adventure.state.room-changed', kind: 'state', payload: { roomId: 'brook' } },
152
+ { type: 'host.state.room-changed', kind: 'state', payload: { sceneId: 'beta' } },
97
153
  { cause: event },
98
154
  );
99
155
  });
@@ -116,6 +172,7 @@ router.turn(); // once per step / frame
116
172
  | `maxHops` | `DEFAULT_MAX_HOPS` (`8`) | Remaining hops on a new root. A caused follow-up gets `cause.hops - 1`. At 0 → `hop-limit`. |
117
173
  | `maxCorrelationPerTurn` | `16` | Events sharing a `correlationId` per turn → `storm-detected`. |
118
174
  | `maxIndex` | `256` | Bound on causation / idempotency maps (oldest keys dropped). |
175
+ | `maxDecisions` | `256` | Bound on `inspectDecisions()` (oldest dropped). |
119
176
 
120
177
  ### Router methods (`EventRouter`)
121
178
 
@@ -125,8 +182,13 @@ router.turn(); // once per step / frame
125
182
  | `detach(id)` | Unbind. In-flight targeted emits to this id fail `unknown-target`. |
126
183
  | `publish(event, extras?)` | Host emit. `extras.cause` sets causation and inherited idempotency/correlation. Returns the envelope, or `undefined` if rejected. |
127
184
  | `subscribe(patterns, listener)` | Host listener (not a cart). Returns unsubscribe. |
185
+ | `subscribeDecision(listener)` | Routing decisions: accepted, rejected, duplicate. |
186
+ | `inspectParticipants()` | Attached id / emit / subscribe / authoritative. |
187
+ | `inspectDecisions()` | Copy of the bounded decision log. |
128
188
  | `turn()` | Reset per-turn budgets and the auto-link causation cursor. Idempotency records **survive**. Call once per `step`. |
129
189
 
190
+ Causation trees and tape replay: [replay inspector](replay-inspector.md).
191
+
130
192
  ### `AttachOptions`
131
193
 
132
194
  | Option | Default | Meaning |
@@ -135,7 +197,7 @@ router.turn(); // once per step / frame
135
197
  | `subscribe` | `[]` | Types delivered to this channel. |
136
198
  | `authoritative` | `false` | If true, may emit `state` / `diagnostic` **when those types are also in `emit`**. |
137
199
 
138
- Patterns are dotted segments; `*` matches one segment. `adventure.intent.*` matches `adventure.intent.exit-requested`, not `adventure.intent.exit.requested`.
200
+ Patterns are dotted segments; `*` matches one segment. `host.intent.*` matches `host.intent.exit-requested`, not `host.intent.exit.requested`.
139
201
 
140
202
  ## Delivery and ordering
141
203
 
@@ -148,7 +210,7 @@ Cart-to-cart still goes through the router:
148
210
 
149
211
  ```ts
150
212
  presentationChannel.emit({
151
- type: 'adventure.intent.remark',
213
+ type: 'host.intent.remark',
152
214
  target: 'npc',
153
215
  payload: { text: 'Anyone there?' },
154
216
  idempotencyKey: 'hello-1',