@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.
package/README.md CHANGED
@@ -89,13 +89,20 @@ Full API for the event router, deterministic replay, and CI harness (so agents c
89
89
 
90
90
  - [Events and router](docs/events.md) — mailbox, envelope, typed contracts, `createEventRouter`, hops, idempotency, rejections
91
91
  - [Deterministic mode](docs/deterministic-mode.md) — `step` / `schedule`, clocks, `ScriptedAction`, replay diffs
92
- - [Headless harness](docs/headless-harness.md) — `createHeadlessHarness` from `@cyberart-io/engine/headless`, jsdom canvas, inspect / screenshot
92
+ - [Headless harness](docs/headless-harness.md) — `createHeadlessHarness` from `@cyberart-io/engine/headless`, software Canvas2D, `compareImageData` / `assertPixelsEqual`, inspect / screenshot
93
93
  - [Presentation adapter](docs/presentation-adapter.md) — host-owned render model, intents, loading / error / unsupported
94
94
  - [Asset resolver](docs/asset-resolver.md) — host-pluggable images/audio/fonts/spritesheets, cache, preload, fallbacks
95
95
  - [Presentation cue](docs/presentation-cue.md) — deterministic `createPresentationTimeline`, duplicate policy, reduced-motion, lifecycle events
96
96
  - [Capability manifest](docs/capability-manifest.md) — versioned JSON for runtime features, phases, managers, assets, events, permissions, integrations
97
+ - [Executable modules](docs/executable-modules.md) — trusted versioned factories, host allowlists, isolation, per-module failures
97
98
  - [Normalized geometry](docs/normalized-geometry.md) — coordinate spaces, contain/cover/crop layout, landmarks, hit regions, debug overlay
98
99
  - [Runtime group](docs/runtime-group.md) — `createRuntimeGroup`, shared router attach, lockstep clock; `createHeadlessMultiCartHarness` from `@cyberart-io/engine/headless`
100
+ - [Compositor](docs/compositor.md) — `createCompositor`, transparent stacking, `screen` blend, `writeComposedFrame`
101
+ - [Browser harness](docs/browser-harness.md) — `createBrowserHarness`, DOM clicks, viewport/DPR, composed screenshots
102
+ - [Replay inspector](docs/replay-inspector.md) — `createReplayInspector`, causation trees, redacted export/import, headless replay
103
+ - [MIDI](docs/midi.md) — host-owned `MidiManager`, note/CC/pitch in and out, `inject` / fake port, structured `requestAccess`
104
+ - [Snapshots](docs/snapshots.md) — versioned envelope, `defineSnapshot` / `applySnapshotMigrations`, host-owned `hostState`
105
+ - [Audio](docs/audio.md) — `createAudioBroker`, `createAudioCueTimeline`, headless cue traces (PCM is not deterministic)
99
106
 
100
107
  ## Write a cart
101
108
 
@@ -150,6 +157,8 @@ const cart = runtime.mount(artProject, {
150
157
  | `audio` | none | Libraries to unlock if `unlockAudio()` runs before `mount`. After mount, the cart’s `metadata.audio` wins. |
151
158
  | `deterministic` | off | Host-controlled clock, `step`/`advance`, and scripted input/assets. Leave unset for live kaleidoscope / Art Blocks. |
152
159
  | `assets` | off | Host `AssetResolver` plus engine cache/preload. Carts keep logical refs. Leave unset when the piece has no media. |
160
+ | `audioBroker` | off | Shared `createAudioBroker` instance. Optional. Omit when the cart only uses `metadata.audio: 'tone'`. |
161
+ | `audioParticipantId` | none | Group participant id to authorize / teardown on this runtime. |
153
162
 
154
163
  `CartHandle` (what `mount` returns):
155
164
 
@@ -162,8 +171,10 @@ const cart = runtime.mount(artProject, {
162
171
  | `step(frames)` / `advance(ms)` | Deterministic ticks only. Throw if `deterministic` was not set. |
163
172
  | `schedule(action)` | Queue a pointer/key/host-event/asset for a future frame. |
164
173
  | `getClock()` / `getRandomState()` / `getReplayMetadata()` | Replay inspection. |
165
- | `exportState()` / `exportStateJSON()` | Pause-safe serializable bundle. |
166
- | `importState(bundle)` | Restore a bundle (or JSON string). |
174
+ | `exportState()` / `exportStateJSON()` | Pause-safe serializable `CartStateBundle` (schema 1 blob). |
175
+ | `importState(bundle)` | Restore a bundle, JSON string, or snapshot envelope (migrates then loads `engineState`). |
176
+ | `exportSnapshot()` / `exportSnapshotJSON()` | Schema 2 envelope wrapping the bundle plus clock, rng, optional `hostState`. |
177
+ | `importSnapshot(envelope)` | Migrate to the current schema and restore `engineState`. Hosts reapply `hostState`. |
167
178
  | `destroy()` | Unload this cart. Idempotent. |
168
179
  | `needsAudio` | True when the cart declared audio libraries. Use this to show a click overlay. |
169
180
  | `paused` / `isPrepared` / `isLoopRunning` | Loop flags. |
@@ -196,6 +207,14 @@ Install the peer: `npm i tone@^14.8.15`. Call `cart.start()` **inside a click or
196
207
 
197
208
  If a click can happen before `mount` finishes, pass the same `audio` into `createRuntime` and/or call `runtime.unlockAudio()` from that click. After mount, the cart’s metadata wins.
198
209
 
210
+ Several carts on one page should share **one** `createAudioBroker` (pass it to `createRuntime` / `createRuntimeGroup`). One `unlock()` covers authorized participants. Channel gain, mute, duck, and priority live on the broker; cue timing is `createAudioCueTimeline` / `scheduleAudioCue` (presentation-frame clock). Headless CI records cue events with `createHeadlessAudioAdapter` from `@cyberart-io/engine/headless` — the event trace is deterministic, PCM is not. Full API: [audio](docs/audio.md).
211
+
212
+ ## MIDI
213
+
214
+ Host-owned `MidiManager` — carts and hosts construct it. The runtime does not pass it into `getDefaultState`. Subscribe to note / CC / pitch, `inject` for tests without hardware, and `send` note-on/off, CC, or raw bytes through an injectable port. `requestAccess()` wraps `navigator.requestMIDIAccess` when present; a missing API or denied permission is a structured result, so silent carts keep running. Headless/jsdom has no Web MIDI; inject and send still work via a test port.
215
+
216
+ Full API and the reproduce command: [MIDI](docs/midi.md). Capability manifests already list `midi` as an integration.
217
+
199
218
  ## Seed
200
219
 
201
220
  Pass `seed` when you want a deterministic output (for example from `?hash=`):
@@ -244,9 +263,9 @@ CI and agents should drive the **same** `createRuntime({ deterministic })` path.
244
263
  - Carts and production hosts: `import { createRuntime } from '@cyberart-io/engine'`
245
264
  - Tests, CI, and frame capture: `import { createHeadlessHarness, createHeadlessMultiCartHarness } from '@cyberart-io/engine/headless'`
246
265
 
247
- `@cyberart-io/engine` does not re-export the harness. `installHeadlessCanvas` is the documented jsdom install (test-only). `createHeadlessHarness` sizes a container, mounts, and wraps step / input / inspect / snapshot.
266
+ `@cyberart-io/engine` does not re-export the harness. `installHeadlessCanvas` is the documented jsdom install (test-only). It attaches a deterministic software Canvas2D (paths, arcs, atlas text, transforms, clip, alpha, gradients, `drawImage`). Unsupported ops throw `HeadlessUnsupportedOperationError`. `createHeadlessHarness` sizes a container, mounts, and wraps step / input / inspect / snapshot. Pixel compare: `compareImageData`, `assertPixelsEqual`, `writeVisualArtifacts` (`CYBERART_UPDATE_GOLDEN=1` to refresh goldens).
248
267
 
249
- Full options, `click` clock rule, Node-only `captureFrame`, remount, and the reproduce command: [headless harness](docs/headless-harness.md).
268
+ Full options, `click` clock rule, Node-only `captureFrame`, remount, visual asserts, and the reproduce command: [headless harness](docs/headless-harness.md).
250
269
 
251
270
  ```ts
252
271
  import { createHeadlessHarness } from '@cyberart-io/engine/headless';
@@ -285,11 +304,11 @@ import {
285
304
  createEventRouter,
286
305
  } from '@cyberart-io/engine';
287
306
 
288
- const exit = defineIntent('adventure.intent.exit-requested', {
307
+ const exit = defineIntent('host.intent.exit-requested', {
289
308
  version: 1,
290
309
  fields: { exitId: { type: 'string' } },
291
310
  });
292
- const room = defineStateEvent('adventure.state.room-changed', {
311
+ const room = defineStateEvent('host.state.room-changed', {
293
312
  version: 1,
294
313
  fields: { roomId: { type: 'string' } },
295
314
  });
@@ -302,6 +321,26 @@ const router = createEventRouter({
302
321
  router.attach('presentation', runtime.hostChannel, deriveAttachOptions(contracts, 'cart'));
303
322
  ```
304
323
 
324
+ ## Presentation cue
325
+
326
+ Frame-stepped effects (overlays, pulses, scene fades). No `setTimeout` / rAF. Drive `step` from the same clock as deterministic `cart.step`.
327
+
328
+ ```ts
329
+ import { createPresentationTimeline } from '@cyberart-io/engine';
330
+
331
+ const timeline = createPresentationTimeline({ originFrame: 0, reducedMotion: false });
332
+ timeline.play({
333
+ name: 'checkmark',
334
+ idempotencyKey: 'gold',
335
+ durationFrames: 90,
336
+ easing: 'ease-out',
337
+ onDuplicate: 'replace',
338
+ });
339
+ timeline.step(90);
340
+ ```
341
+
342
+ Lifecycle names `cue.started` / `cue.completed` / `cue.cancelled` / `cue.replaced` are local to the timeline. They are not router envelopes unless you define a contract. Duplicate policy, late-play catch-up, and reduced-motion: [presentation cue](docs/presentation-cue.md).
343
+
305
344
  ## Capability manifest
306
345
 
307
346
  JSON for what a cart needs from the host. `validateCapabilityManifest` returns structured diagnostics instead of throwing.
@@ -313,14 +352,14 @@ import {
313
352
  } from '@cyberart-io/engine';
314
353
 
315
354
  const defined = defineCapabilityManifest({
316
- id: 'adventure.presentation',
355
+ id: 'host.presentation',
317
356
  runtime: { minContractVersion: 1, features: ['router'] },
318
357
  phases: ['loading', 'ready', 'error', 'unsupported'],
319
358
  managers: ['pointer', 'hostChannel'],
320
- assets: { kinds: ['image'], declarations: [{ id: 'room-bg', kind: 'image' }] },
321
- acceptedEvents: ['adventure.state.*'],
322
- emittedEvents: ['adventure.intent.exit-requested'],
323
- permissions: { emit: ['adventure.intent.*'], subscribe: ['adventure.state.*'] },
359
+ assets: { kinds: ['image'], declarations: [{ id: 'backdrop', kind: 'image' }] },
360
+ acceptedEvents: ['host.state.*'],
361
+ emittedEvents: ['host.intent.exit-requested'],
362
+ permissions: { emit: ['host.intent.*'], subscribe: ['host.state.*'] },
324
363
  integrations: ['tone'],
325
364
  });
326
365
  if (!defined.ok) throw new Error(defined.errors.map((e) => e.detail).join('; '));
@@ -328,13 +367,47 @@ validateCapabilityManifest(defined.manifest, {
328
367
  contractVersion: 1,
329
368
  features: ['router'],
330
369
  integrations: ['tone'],
331
- emit: ['adventure.intent.*'],
332
- subscribe: ['adventure.state.*'],
370
+ emit: ['host.intent.*'],
371
+ subscribe: ['host.state.*'],
333
372
  });
334
373
  ```
335
374
 
336
375
  Fields, host allowlists, and diagnostic codes: [capability manifest](docs/capability-manifest.md).
337
376
 
377
+ ## Executable modules
378
+
379
+ Trusted factories keyed by exact `id` + `version`. The host allowlists which refs may load. There is no `eval` / `new Function` path for cart-supplied strings.
380
+
381
+ ```ts
382
+ import { createExecutableModuleHost } from '@cyberart-io/engine';
383
+
384
+ const host = createExecutableModuleHost({
385
+ allowlist: [
386
+ { id: 'overlay-fx', version: '1.0.0' },
387
+ { id: 'host.module', version: '1.0.0' },
388
+ ],
389
+ limits: { maxInvokeMs: 16, maxInvokesPerTurn: 4 },
390
+ modules: [
391
+ {
392
+ id: 'overlay-fx',
393
+ version: '1.0.0',
394
+ create: (capabilities) => ({
395
+ invoke: (input, { signal }) => {
396
+ if (signal.aborted) return;
397
+ return { input, seed: capabilities.seed };
398
+ },
399
+ }),
400
+ },
401
+ ],
402
+ });
403
+
404
+ const allowed = await host.invoke({ id: 'overlay-fx', version: '1.0.0' });
405
+ await host.invoke({ id: 'overlay-fx', version: '2.0.0' }); // version-mismatch, not invoked
406
+ host.destroy();
407
+ ```
408
+
409
+ Unknown ids, wrong versions, and refs missing from the allowlist fail closed. A throwing module returns `{ ok: false, error }` and the next allowlisted invoke still runs. Isolation is in-process (trusted factories), not a worker or iframe. Carts may declare required refs as optional `modules.refs` on a [capability manifest](docs/capability-manifest.md). Full API and the reproduce command: [executable modules](docs/executable-modules.md).
410
+
338
411
  ## Runtime group
339
412
 
340
413
  Several production carts, one router, one lockstep clock. Use this instead of intercepting each `onEvent` and republishing by hand.
@@ -345,17 +418,17 @@ import { createRuntimeGroup } from '@cyberart-io/engine';
345
418
  const group = createRuntimeGroup({
346
419
  origin: 0,
347
420
  participants: [
348
- { id: 'effects', cart: effectsCart, emit: ['ambience.intent.*'], subscribe: ['adventure.state.*'] },
421
+ { id: 'effects', cart: effectsCart, emit: ['ambience.intent.*'], subscribe: ['host.state.*'] },
349
422
  { id: 'ambience', cart: ambienceCart, subscribe: ['ambience.intent.*'] },
350
423
  ],
351
424
  });
352
- group.publish({ type: 'adventure.state.loon-whistle', kind: 'state', payload: { habitat: 'pond' } });
425
+ group.publish({ type: 'host.state.accepted', kind: 'state', payload: { id: 'north' } });
353
426
  await group.step(2);
354
427
  const { trace } = await group.inspect();
355
428
  group.destroy();
356
429
  ```
357
430
 
358
- Headless / CI: `createHeadlessMultiCartHarness` from `@cyberart-io/engine/headless` is the same handle after `installHeadlessCanvas()`. Full options: [runtime group](docs/runtime-group.md).
431
+ Headless / CI: `createHeadlessMultiCartHarness` from `@cyberart-io/engine/headless` is the same handle after `installHeadlessCanvas()`. Full options: [runtime group](docs/runtime-group.md). Causation trees, redaction, and tape replay: [replay inspector](docs/replay-inspector.md).
359
432
 
360
433
  ## Normalized geometry
361
434
 
@@ -400,7 +473,7 @@ const adapter = attachPresentationAdapter(harness);
400
473
  adapter.present({
401
474
  contractVersion: 1,
402
475
  phase: 'ready',
403
- view: { title: 'Joiner Brook', regions: [] },
476
+ view: { title: 'Overlook', regions: [] },
404
477
  });
405
478
  ```
406
479
 
@@ -418,7 +491,7 @@ import {
418
491
  } from '@cyberart-io/engine';
419
492
 
420
493
  const roomAssets = [
421
- { id: 'room-bg', ref: 'moltazine:post/porch-1#primary-image', type: 'image' as const },
494
+ { id: 'backdrop', ref: 'moltazine:post/porch-1#primary-image', type: 'image' as const },
422
495
  {
423
496
  id: 'ambience',
424
497
  ref: 'world:asset/stream-loop',
@@ -453,14 +526,22 @@ The same declarations produce fixture URLs locally and CDN URLs (plus a typed CO
453
526
 
454
527
  ## Save and load
455
528
 
456
- Live `state` is not JSON-safe (audio nodes, managers, typed arrays, possible cycles). `snapshot()` is PNG + seed. Use export/import when you want the simulation itself:
529
+ Live `state` is not JSON-safe (audio nodes, managers, typed arrays, possible cycles). `snapshot()` is PNG + seed. Use export/import when you want the simulation itself.
530
+
531
+ `exportState` still returns the engine-owned `CartStateBundle` (`CART_STATE_BUNDLE_VERSION = 1`) so existing carts keep working. The versioned envelope (`SNAPSHOT_SCHEMA_VERSION = 2`) wraps that blob in `engineState` and keeps host-owned JSON in `hostState` (or a `hostStateRef`). Hosts persist the envelope; Cyberart is not the database. Full field list, migration registry, and diagnostic codes: [versioned snapshots](docs/snapshots.md).
457
532
 
458
533
  ```ts
459
534
  const bundle = await cart.exportState();
460
535
  const json = await cart.exportStateJSON();
536
+ const envelope = await cart.exportSnapshot({
537
+ cartVersion: '3',
538
+ hostState: { sceneId: 'alpha' },
539
+ });
461
540
 
462
541
  await cart.importState(bundle);
463
542
  await cart.importState(json);
543
+ await cart.importState(envelope);
544
+ await cart.importSnapshot(envelope);
464
545
  ```
465
546
 
466
547
  Both pause, wait until any in-flight `update` finishes, then restore the previous pause flag. Import tears down, runs `getDefaultState` again (fresh managers, empty audio graph), overlays the save onto that scaffold unless `getDefaultState` already returned the revived `customState`, and restores `framesElapsed`. Works on a prepared cart before `start()`. Throws if nothing is prepared, or if the bundle is incompatible (`IncompatibleCartStateError`).
@@ -524,7 +605,7 @@ A canvas the host adopted is left in place on destroy; a canvas the engine creat
524
605
 
525
606
  ## Publishing this package (maintainers)
526
607
 
527
- Not part of writing a cart. Engine source lives in `packages/engine/src/` (not mixed into the site). The npm tarball is built from `packages/engine/src/index.ts` and `packages/engine/src/headless.ts` and contains minified `dist/index.js` + `dist/headless.js`, rolled-up `.d.ts` for both, `LICENSE`, `README.md`, `docs/` (event / deterministic / harness / presentation-adapter / asset-resolver / presentation-cue API), and `package.json`. Site code that still imports `src/ui/lib/...` hits thin re-export shims so those paths keep working.
608
+ Not part of writing a cart. Engine source lives in `packages/engine/src/` (not mixed into the site). The npm tarball is built from `packages/engine/src/index.ts` and `packages/engine/src/headless.ts` and contains minified `dist/index.js` + `dist/headless.js`, rolled-up `.d.ts` for both, `LICENSE`, `README.md`, `docs/` (including compositor, browser harness, replay inspector, MIDI, executable modules, snapshots, and audio), and `package.json`. Site code that still imports `src/ui/lib/...` hits thin re-export shims so those paths keep working.
528
609
 
529
610
  ```bash
530
611
  pnpm run pack:engine