@cyberart-io/engine 0.0.8 → 0.0.10

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.
@@ -2,7 +2,7 @@
2
2
 
3
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
- Deterministic timing: [deterministic mode](deterministic-mode.md). CI: [headless harness](headless-harness.md). Event mailbox: [events](events.md).
5
+ Deterministic timing: [deterministic mode](deterministic-mode.md). CI: [headless harness](headless-harness.md). Event mailbox: [events](events.md). Built on this: [remote cart manifests](remote-cart-manifest.md) (`createDeclaredAssetResolver`, declared URLs and hashes only), [content revision](content-revision.md) (resolves every declared asset before one atomic bundle swap), [selection trace](selection-trace.md) (records which logical ref, hash, and fallback won).
6
6
 
7
7
  Back to the [package README](../README.md).
8
8
 
package/docs/audio.md CHANGED
@@ -8,6 +8,7 @@ Back to the [package README](../README.md). Related: [presentation cue](presenta
8
8
 
9
9
  ```bash
10
10
  pnpm exec vitest run packages/engine/src/canvas/cyb-67-audio.repro.spec.ts
11
+ pnpm exec vitest run packages/engine/src/canvas/cyb-102-audio-sidecar-restore.repro.spec.ts
11
12
  ```
12
13
 
13
14
  ## Why this exists
@@ -136,6 +137,29 @@ adapter.destroy();
136
137
 
137
138
  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
 
140
+ ## Snapshot schema (version 1)
141
+
142
+ `AudioCueTimeline.snapshot()` / `createHeadlessAudioAdapter().snapshot()` write a versioned sidecar. `schemaVersion` is `1` (`AUDIO_CUE_SNAPSHOT_SCHEMA_VERSION`). Restore validates the **entire** payload before mutation. Unknown schema, unknown cue idempotency keys, or malformed JSON return `{ ok: false, errors }` and leave live state unchanged.
143
+
144
+ ```ts
145
+ const snap = adapter.snapshot();
146
+ adapter.play({ /* another cue */ });
147
+ const restored = adapter.restore(JSON.parse(JSON.stringify(snap)));
148
+ if (!restored.ok) throw new Error(restored.errors.map((e) => e.detail).join('; '));
149
+ ```
150
+
151
+ | Field | Meaning |
152
+ |---|---|
153
+ | `schemaVersion` | `1`. Any other value fails closed. |
154
+ | `frame` | Presentation clock at save. |
155
+ | `reducedSensory` | Skip-playback flag. Restored onto the timeline. |
156
+ | `cues` | Live cue views (`scheduled` / `started` / `skipped`; completed and failed rows are event-log only). |
157
+ | `events` | Deterministic cue event log. |
158
+ | `muted` | Headless adapter only. Restored via `broker.mute()` / `unmute()`. |
159
+ | `unlock` | Headless adapter only. **Observation.** Unlock cannot be faithfully restored (it is async and may require a user gesture). Restore applies cue / mute / event log only; `snapshot().unlock` after reload is the live broker status. |
160
+
161
+ `parseAudioCueSnapshot(input)` is the shared validator. Browser (`@cyberart-io/engine`) and headless (`@cyberart-io/engine/headless`) share the same semantic contract.
162
+
139
163
  ## Host wiring
140
164
 
141
165
  ```ts
@@ -4,7 +4,7 @@ Opt-in harness that mounts a caller-supplied host into a real viewport, optional
4
4
 
5
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
6
 
7
- Related: [normalized geometry](normalized-geometry.md), [presentation adapter](presentation-adapter.md), [compositor](compositor.md), [headless harness](headless-harness.md).
7
+ Related: [normalized geometry](normalized-geometry.md), [presentation adapter](presentation-adapter.md), [compositor](compositor.md), [headless harness](headless-harness.md). Built on this: [production scenario](production-scenario.md) (mounts the full host/runtime/cart composition through this harness), [semantic layers](semantic-layers.md) (`semanticLayers` option, a11y snapshot).
8
8
 
9
9
  Back to the [package README](../README.md).
10
10
 
@@ -76,7 +76,7 @@ The host owns overlay markup, reducer state, and event type names. The harness o
76
76
  | `viewport.width` / `height` / `deviceScaleFactor` | `320` / `180` / `1` | CSS viewport and DPR. |
77
77
  | `reducedMotion` | `false` | Stubs `matchMedia('(prefers-reduced-motion: reduce)')`. |
78
78
  | `inputModality` | `'pointer'` | Recorded on the root (`data-input-modality`). |
79
- | `geometry` | none | Optional geometry document for `placeRegion` / CSS hit-testing. |
79
+ | `geometry` | none | Optional geometry document for `placeRegion` / CSS hit-testing. Replace later with `setGeometry`. |
80
80
  | `contentWidth` / `contentHeight` / `fit` | viewport / `contain` | Intrinsic box for `createPresentationLayout`. |
81
81
  | `mount` | none | Host builds DOM and returns carts / compositor / cleanup. |
82
82
 
@@ -86,6 +86,7 @@ The host owns overlay markup, reducer state, and event type names. The harness o
86
86
  |---|---|
87
87
  | `goto()` | No-op ready check (fixture is already mounted). |
88
88
  | `setViewport(width, height, dpr?)` | Resize root, group canvases, compositor; call host `relayout`. |
89
+ | `setGeometry(document?)` | Replace the geometry document used by `placeRegion` and CSS hit-testing; call host `relayout`. Pointer hit-tests only regions in the active document (semantic hits whose ids are not in that document are ignored). |
89
90
  | `click(selector)` / `click(x, y)` | Dispatch `pointerdown` / `pointerup` / `click` on DOM. |
90
91
  | `key` / `focus` | Keyboard through the focused element. |
91
92
  | `step` / `advance` | Deterministic group clock. |
@@ -1,6 +1,6 @@
1
1
  # Capability manifest
2
2
 
3
- Versioned JSON for what a cart needs from the host: runtime contract, lifecycle phases, managers, asset kinds, event patterns, permissions, and integrations. Related: [events](events.md) (emit / subscribe patterns), [asset resolver](asset-resolver.md) (kinds only — this file does not resolve URLs), [calculation carts](calculation-carts.md) (`kind`).
3
+ Versioned JSON for what a cart needs from the host: runtime contract, lifecycle phases, managers, asset kinds, event patterns, permissions, and integrations. Related: [events](events.md) (emit / subscribe patterns), [asset resolver](asset-resolver.md) (kinds only — this file does not resolve URLs), [calculation carts](calculation-carts.md) (`kind`). Built on this: [remote cart manifests](remote-cart-manifest.md) (signed catalog with optional nested `capabilities` + host `grants`), [content revision](content-revision.md) (required grants checked before activation).
4
4
 
5
5
  Back to the [package README](../README.md).
6
6
 
@@ -0,0 +1,64 @@
1
+ # Content revision activation
2
+
3
+ Atomic staging and commit for an external content revision (room art, masks, portraits, line audio). Adapters load catalogs; the activator resolves every declared asset and swaps one complete bundle. Consumers see the last known good revision or the new revision — never a mix.
4
+
5
+ Related: [Asset resolver](asset-resolver.md) (`createDeclaredAssetResolver`), [Remote cart manifests](remote-cart-manifest.md) (`verifyRemoteCartSignature`, grants), [Capability manifest](capability-manifest.md), [Snapshots](snapshots.md). 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-79-content-revision.repro.spec.ts
11
+ ```
12
+
13
+ ## When to use
14
+
15
+ | Surface | Import | Use |
16
+ |---|---|---|
17
+ | Production host | `createContentRevisionActivator` from `@cyberart-io/engine` | Persist `snapshot()` in envelope `hostState`. Read `activeBundle()` / `inspect()` for presentation. |
18
+ | Vitest / jsdom | same, or `@cyberart-io/engine/headless` | `createStaticContentAdapter` plus in-process `createRemoteContentAdapter({ fetch })`. |
19
+
20
+ Moltazine World is the first **adapter**, not a hard-coded SDK. Tests inject a fake remote fetch. There is no product UI here.
21
+
22
+ ## Contract
23
+
24
+ 1. **Identity.** A bundle is `contentId` + exact `revision` + `manifestVersion` + asset hashes + publisher/source + adapter id. Snapshots never store `"latest"`.
25
+ 2. **Adapters.** `createStaticContentAdapter` (local catalog) and `createRemoteContentAdapter` (injected fetch, signed CYB-74 JSON, optional `{ world, revision, manifest }` envelope) implement the same load/activation contract. Static `discover` picks the numeric-aware newest revision id (`10` beats `9`).
26
+ 3. **Staging.** Resolve every declared asset, validate schema, hashes, signatures, and required grants **before** `activeBundle()` changes.
27
+ 4. **Commit.** One atomic swap, exactly one `content.revision.state.activated` event per accepted revision. Idempotent activate of the live bundle/revision id does not emit again.
28
+ 5. **Visibility.** `inspect()` / `activeBundle()` return a complete revision or `null`. Staging identity may appear on `inspect().staging`; resources stay on the live bundle.
29
+ 6. **Rollback.** Last-known-good is retained. Hash/schema/capability/health-check failure keeps or restores that bundle and emits a machine-readable rejection. `rollback()` is idempotent.
30
+ 7. **Optional assets** may omit or use a declared hashed fallback. Required assets must all succeed.
31
+ 8. **Supersede.** A newer `activate` aborts in-flight staging, emits `superseded`, then stages the new revision. A stale health check after a newer commit returns `stale-health-check`, not `superseded`.
32
+ 9. **Pin.** `pin()` freezes live movement after a bundle is active: `activate` of another revision, `rollback`, and `restore` that would change the live bundle all fail with `pinned`. Discover still works. Idempotent activate/rollback of the current bundle is allowed.
33
+ 10. **Restore.** Validate the entire snapshot (schema version, exact revisions known to adapters) before mutate. Unknown version or unknown revision leaves the live bundle unchanged.
34
+ 11. **Cache.** Byte cache is keyed by digest and re-hashed on reuse.
35
+
36
+ ```ts
37
+ import {
38
+ createContentRevisionActivator,
39
+ createRemoteContentAdapter,
40
+ createStaticContentAdapter,
41
+ } from '@cyberart-io/engine';
42
+
43
+ const activator = createContentRevisionActivator({
44
+ adapters: [staticAdapter, remoteAdapter],
45
+ grants: createHostGrantSet(['audio']),
46
+ });
47
+
48
+ await activator.activate({ contentId: 'room-overlook', revision: '8' });
49
+ activator.activeBundle(); // complete revision 8, or null
50
+ activator.snapshot(); // exact revision + hashes for hostState
51
+ ```
52
+
53
+ | Event | When |
54
+ |---|---|
55
+ | `content.revision.state.discovered` | Adapter reported an exact revision |
56
+ | `content.revision.state.staging` | Resolve/validate started; presentation unchanged |
57
+ | `content.revision.state.validated` | Declared assets, hashes, and capabilities passed |
58
+ | `content.revision.state.rejected` | Staging or validation failed; live bundle unchanged |
59
+ | `content.revision.state.activated` | Atomic commit of one complete bundle |
60
+ | `content.revision.state.rolled-back` | Health-check/activation failure or host `rollback()` |
61
+ | `content.revision.state.superseded` | Newer revision cancelled this staging attempt |
62
+ | `content.revision.diagnostic.lifecycle` | Machine-readable rejection detail |
63
+
64
+ Contracts: `contentRevisionEventContracts()`.
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`), [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).
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). Modules that publish typed `*.state.*` / `*.diagnostic.*` contracts through this router: [content revision](content-revision.md), [job orchestration](job-orchestration.md), [world graph](world-graph.md), [world patch](world-patch.md), [production scenario](production-scenario.md), [presentation bindings](presentation-bindings.md), [presentation sequences](presentation-sequences.md).
4
4
 
5
5
  Back to the [package README](../README.md).
6
6
 
@@ -4,7 +4,7 @@ CI / agent wrapper around production `createRuntime({ deterministic })`. Not a s
4
4
 
5
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
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`).
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`). Whole production composition in CI: [production scenario](production-scenario.md). Headless fakes for the framework modules also live on this entry: `createHeadlessAudioAdapter` ([presentation sequences](presentation-sequences.md)), `createHeadlessJobWorker` ([job orchestration](job-orchestration.md)).
8
8
 
9
9
  Back to the [package README](../README.md).
10
10
 
@@ -0,0 +1,55 @@
1
+ # Job orchestration
2
+
3
+ Durable lifecycle for long-running generated work (world chunks, media transforms). The coordinator models queueing, progress, retries, evaluator feedback, leases, and host-owned apply. Workers perform the task; wall-clock completion never mutates authoritative host state.
4
+
5
+ Related: [Events and router](events.md) (CYB-59 contracts), [Replay inspector](replay-inspector.md) (`DEFAULT_SENSITIVE_KEYS` / `REDACTED_VALUE`), [Snapshots](snapshots.md). Built on this: [World patch](world-patch.md) (a `ready` job result becomes one transactional world revision under host `commit`), [World graph](world-graph.md) (generated frontier work). 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-80-job-orchestration.repro.spec.ts
11
+ ```
12
+
13
+ ## When to use
14
+
15
+ | Surface | Import | Use |
16
+ |---|---|---|
17
+ | Production host | `createJobCoordinator` from `@cyberart-io/engine` | Persist `snapshot()` in envelope `hostState`. Call `accept(jobId)` only under host policy. |
18
+ | Vitest / jsdom | same, or `@cyberart-io/engine/headless` | `createHeadlessJobWorker` controls complete/fail timing. `createMemoryJobPersistence` is enough for refresh tests. |
19
+
20
+ ## Contract
21
+
22
+ 1. **Definitions** are versioned: request schema, result kind, retry/lease/timeout, declared fallback.
23
+ 2. **Submit** with a stable idempotency key and a correlation id for the initiating player intent. Duplicate keys return the same job.
24
+ 3. **States:** queued → claimed → awaiting-evaluation → ready → applied, plus retry-scheduled, failed, canceled, superseded.
25
+ 4. **Results** are refs (`uri` + `kind`), not embedded media/world payloads.
26
+ 5. **Evaluator** may accept, reject, or request a bounded revision.
27
+ 6. **Host apply** is `accept(jobId)` on `ready` only. Worker `complete` stops at awaiting-evaluation.
28
+ 7. **Restore** validates JSON and schema version before mutate. Tampered snapshots return `{ ok: false }` and leave jobs unchanged.
29
+ 8. **Export traces** and replay-inspector payloads redact `JOB_REDACTED_KEYS` / `DEFAULT_SENSITIVE_KEYS` (`prompt`, `credentials`, `credential`, `authorization`, `apiKey`, `secret`) via `REDACTED_VALUE`. `token` is not in that set — pass it in `createReplayInspector({ redactedKeys: ['token'] })` when a cart uses that name. Reason codes stay visible.
30
+
31
+ ```ts
32
+ import {
33
+ createHeadlessJobWorker,
34
+ createJobCoordinator,
35
+ } from '@cyberart-io/engine/headless';
36
+
37
+ const worker = createHeadlessJobWorker();
38
+ const jobs = createJobCoordinator({
39
+ definitions: [frontierDefinition],
40
+ worker,
41
+ now: () => clock,
42
+ });
43
+
44
+ jobs.submit({
45
+ definitionId: 'frontier-generate',
46
+ idempotencyKey: 'frontier:day-1',
47
+ request: { prompt: '...', seed },
48
+ correlationId: playerIntentId,
49
+ });
50
+ jobs.claim('worker-1');
51
+ worker.complete(jobId, { schemaVersion: 1, kind: 'world-chunk', uri: 'ref://...' });
52
+ jobs.evaluate(jobId, 'accept');
53
+ // host policy: e.g. only at the day boundary
54
+ jobs.accept(jobId);
55
+ ```
@@ -0,0 +1,50 @@
1
+ # Portal lifecycle
2
+
3
+ One enter / exit / abort contract for leaving a cart, inhabiting another exclusively, and returning a versioned outcome. Arcade cabinet, painting, dream, wormhole, book, and nested world are host metaphors over the same primitive — not separate lifecycles.
4
+
5
+ Related: [runtime group](runtime-group.md) (per-participant suspend), [remote cart manifests](remote-cart-manifest.md) (`HostGrantSet` transfer), [snapshots](snapshots.md). 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-75-portal-lifecycle.repro.spec.ts
11
+ ```
12
+
13
+ ## When to use
14
+
15
+ | Surface | Import | Use |
16
+ |---|---|---|
17
+ | Production host | `createPortalLifecycle`, `cabinetPortal`, `paintingPortal` from `@cyberart-io/engine` | Chrome / back-stack / a11y stay on the host. Carts declare portal targets and outcome schema versions. |
18
+ | Vitest / jsdom | same, or from `@cyberart-io/engine/headless` | Pair with `createRuntimeGroup` after `installHeadlessCanvas()`. |
19
+
20
+ `cabinetPortal` / `paintingPortal` only stamp `metaphor`. They call the same `enter` / `exit` / `abort`.
21
+
22
+ ## Contract
23
+
24
+ 1. **Enter** another cart (exclusive presentation).
25
+ 2. Pass seed/clock/state/persistence and transfer exclusive grants (`audio`, `controller`, `fullscreen`) for the child's lifetime.
26
+ 3. **Suspend** the parent: group `step` skips it; cart state stays snapshot-safe.
27
+ 4. **Exit** with `{ schemaVersion: 1, kind: 'completed' | 'aborted', payload? }`.
28
+ 5. Nested enter is stack-ordered. Aborting the top frame does not rewrite ancestor `parentState`.
29
+ 6. Restore validates JSON and participant ids before mutate. A mid-portal snapshot restores parent-suspended + child-active, reapplies extra child grants, and transfers audio broker authorization the same way enter does. Tampered or unknown-id JSON returns `{ ok: false }` and leaves suspend flags and broker grants unchanged.
30
+
31
+ ```ts
32
+ import {
33
+ cabinetPortal,
34
+ createPortalLifecycle,
35
+ paintingPortal,
36
+ } from '@cyberart-io/engine';
37
+
38
+ const portal = createPortalLifecycle({
39
+ group,
40
+ grants: { lobby: lobbyGrants, game: gameGrants },
41
+ rootId: 'lobby',
42
+ declarations: [{ id: 'lobby', targets: ['game'] }],
43
+ });
44
+
45
+ cabinetPortal(portal).enter({ from: 'lobby', to: 'game', seed });
46
+ const outcome = portal.exit({ score: 108 });
47
+ paintingPortal(portal).enter({ from: 'lobby', to: 'gallery' });
48
+ ```
49
+
50
+ Host-owned chrome reads `portal.stack()` and `portal.activeId()`. Persist `portal.snapshot()` inside envelope `hostState`.
@@ -0,0 +1,93 @@
1
+ # Presentation bindings
2
+
3
+ Declarative, versioned mapping from a host-supplied state projection onto presentation resources (visual versions, presence, props, semantic regions, hotspots, sequences, assets). The host reducer stays authoritative. Cyberart evaluates a bounded manifest and applies **one complete presentation revision** — or the declared fail-closed defaults. Never a mixed scene.
4
+
5
+ Related: [Visual layers](visual-layers.md), [Semantic layers](semantic-layers.md), [Presentation sequences](presentation-sequences.md), [Events](events.md), [Snapshots](snapshots.md). 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-83-presentation-bindings.repro.spec.ts
11
+ ```
12
+
13
+ ## When to use
14
+
15
+ | Surface | Import | Use |
16
+ |---|---|---|
17
+ | Production host | `definePresentationBindings` / `createPresentationBindingRuntime` from `@cyberart-io/engine` | Pass a JSON projection into `apply`. Persist `snapshot()` in envelope `hostState`. |
18
+ | Vitest / jsdom | same, or `@cyberart-io/engine/headless` | Headless and browser entrypoints evaluate the same winners. |
19
+
20
+ The framework does not own inventory, doors, or character location. Hosts define typed projections (CYB-59). Generic fixture ids only: `room`, `overlay`, `whistle`, `door`, `npc`, `lobby`, `hotspot-whistle`, `hotspot-door`, `line-enter`.
21
+
22
+ ## Contract
23
+
24
+ 1. **Manifest.** `schemaVersion` `1`, stable binding ids, JSON only. Callbacks and `eval` are rejected at define time.
25
+ 2. **Predicates.** Constrained expressions over dotted paths (`eq` / `neq` / `present`) or registered typed selectors. No ambient store access.
26
+ 3. **Targets.** Visual-layer versions, presence, props, semantic regions, hotspot enablement, sequences, asset bindings.
27
+ 4. **Conflicts.** Higher priority wins. Equal priority on the same target is a define-time error. Defaults must cover every target key bindings can select.
28
+ 5. **Atomic apply.** Evaluate the full target set, preflight live resources, then apply. `visual.play` / `sequences.playSequence` results are honored: `{ ok: false, reason: 'duplicate' }` is ignored (`onDuplicate: 'ignore'`); other failures abort as `unknown-resource`. On apply failure, controller snapshots are restored and `liveTargets` is not committed. Invalid/missing projections apply the **complete** default set. Restore applies targets before writing revision / selectedBindingIds / override; a failed apply leaves those fields and live presentation unchanged.
29
+ 6. **Change minimization.** Unchanged targets are not replayed or remounted. An already-selected matching sequence is not restarted when unrelated projection fields change.
30
+ 7. **Overrides.** `setOverride` can change a11y labels / reduced-sensory presentation without mutating the projection or selected binding ids.
31
+ 8. **Snapshot.** Records projection revision + selected binding ids + resolved targets. Restore validates schema before mutate.
32
+
33
+ ```ts
34
+ import {
35
+ createPresentationBindingRuntime,
36
+ definePresentationBindings,
37
+ } from '@cyberart-io/engine';
38
+
39
+ const defined = definePresentationBindings({
40
+ id: 'lobby-room',
41
+ schemaVersion: 1,
42
+ bindings: [
43
+ {
44
+ id: 'night-room',
45
+ priority: 30,
46
+ when: { path: 'timeOfDay', eq: 'night' },
47
+ targets: [{ kind: 'visual-layer', layerId: 'room', version: 'night' }],
48
+ },
49
+ ],
50
+ defaults: [{ kind: 'visual-layer', layerId: 'room', version: 'day' }],
51
+ });
52
+ if (!defined.ok) throw new Error(defined.errors.map((e) => e.detail).join('; '));
53
+
54
+ const runtime = createPresentationBindingRuntime({
55
+ manifest: defined.manifest,
56
+ visual,
57
+ semantic,
58
+ sequences,
59
+ });
60
+
61
+ runtime.apply(
62
+ { timeOfDay: 'night', inventory: { loonWhistle: false } },
63
+ { revision: 83 },
64
+ );
65
+ runtime.inspect(); // winners + explanation
66
+ runtime.snapshot(); // revision + selectedBindingIds for hostState
67
+ ```
68
+
69
+ `createPresentationBindingRuntime` re-validates the manifest and throws if it is invalid.
70
+
71
+ ## Predicates
72
+
73
+ | Form | Match |
74
+ |---|---|
75
+ | `{ path: "inventory.loonWhistle", eq: false }` | dotted path equals a JSON primitive |
76
+ | `{ path: "door.roomSeven", neq: "locked" }` | path exists and is not the value, or is missing |
77
+ | `{ path: "timeOfDay", present: true }` | path exists and is not `null` |
78
+ | `{ selector: "isNight" }` | host-registered own-property `(projection) => boolean`. Inherited keys such as `constructor` do not match. |
79
+ | `{ all: [...] }` / `{ any: [...] }` / `{ not: ... }` | boolean combinations |
80
+
81
+ Unknown fields on the manifest, bindings, predicates, or targets fail closed at define time.
82
+
83
+ ## Apply, explanation, restore
84
+
85
+ `apply(projection, { revision })` returns `selectedBindingIds`, `explanation` (considered / winners / rejected alternatives / fallback reason), and `inspect()`. Visual swaps use `visual.play` with `durationFrames: 0` so one accepted revision is one coherent layer set. Play results are checked: duplicate-ignore is ok, unknown version / invalid / busy abort and roll the visual / semantic / sequence controllers back. Semantic presence/focus is `setRegionState`. `hitTestEnabled` (and hotspot `enabled` when a `regionId` is set) writes the region's hit-test policy through `semantic.restore`: `false` becomes `pass-through`, `true` restores the region's prior absorbing policy. Sequences use `playSequence` / `skip`. `restore` calls `applyTargets` before committing revision, selected binding ids, or override. `setOverride({ reducedSensory: true })` skips an active bound sequence; clearing the override and reapplying the same projection starts it again.
86
+
87
+ | Event | When |
88
+ |---|---|
89
+ | `presentation.binding.state.applied` | One complete presentation revision committed |
90
+ | `presentation.binding.state.rejected` | Preflight or apply failed; live presentation unchanged |
91
+ | `presentation.binding.diagnostic.lifecycle` | Machine-readable rejection detail |
92
+
93
+ Contracts: `presentationBindingEventContracts()`.
@@ -0,0 +1,150 @@
1
+ # Presentation sequences
2
+
3
+ One frame-stepped clock for a semantic beat that spans audio, captions/text, semantic-region state, visual layers, and typed cues. Hosts pass content through bindings. The engine does not own dialogue graphs, relationship state, inventory, or game rules.
4
+
5
+ Related: [presentation cue](presentation-cue.md), [audio](audio.md), [semantic layers](semantic-layers.md), [visual layers](visual-layers.md), [events](events.md). 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-78-presentation-sequence.repro.spec.ts
11
+ ```
12
+
13
+ ## When to use
14
+
15
+ | Surface | Import | Use |
16
+ |---|---|---|
17
+ | Production host | `definePresentationSequence` / `createPresentationSequencePlayer` from `@cyberart-io/engine` | Author JSON sequences; `playSequence` with bindings. |
18
+ | Vitest / jsdom | same, plus `createHeadlessAudioAdapter` from `@cyberart-io/engine/headless` | Silent cue traces; `inspectCueIntent` without audible playback. |
19
+
20
+ Generic ids only: `speaker`, `caption`, `room-bg`, `line-1`. Drive `step` from the same clock as deterministic `cart.step`. There are no `setTimeout` / rAF timers.
21
+
22
+ ## `definePresentationSequence({ id, tracks, interruptionPolicy, fallbacks })`
23
+
24
+ Authored content must be JSON-serializable. Callbacks and ambient host access are rejected at define time.
25
+
26
+ ```ts
27
+ import {
28
+ definePresentationSequence,
29
+ createPresentationSequencePlayer,
30
+ } from '@cyberart-io/engine';
31
+
32
+ const defined = definePresentationSequence({
33
+ id: 'spoken-line',
34
+ interruptionPolicy: 'replace', // 'replace' | 'queue' | 'reject' | 'ignore'
35
+ tracks: [
36
+ {
37
+ id: 'beat',
38
+ kind: 'cue',
39
+ steps: [
40
+ {
41
+ id: 'line-1',
42
+ timing: { kind: 'absolute', atFrame: 0 },
43
+ durationFrames: 6,
44
+ effect: { kind: 'cue', name: 'line-1' },
45
+ },
46
+ ],
47
+ },
48
+ {
49
+ id: 'audio',
50
+ kind: 'audio',
51
+ steps: [
52
+ {
53
+ id: 'line-1-audio',
54
+ timing: { kind: 'simultaneous', withStepId: 'line-1', order: 0 },
55
+ durationFrames: 6,
56
+ effect: { kind: 'audio', assetBinding: 'line' },
57
+ },
58
+ ],
59
+ },
60
+ ],
61
+ fallbacks: [
62
+ {
63
+ id: 'text-only',
64
+ when: { capability: 'audio', status: 'failed' },
65
+ omitTrackIds: ['audio'],
66
+ },
67
+ ],
68
+ });
69
+ ```
70
+
71
+ Step timing: `absolute` (`atFrame`), `relative` (`afterStepId`, `delayFrames`), or `simultaneous` (`withStepId`, `order`). Completion is `duration` (default) or `immediate`. Simultaneous steps at one frame run in `order`, then `trackId`, then `stepId`. On each `step`, due completions run before new activations so a `relative` follow-up with `delayFrames: 0` does not clobber the finishing step's restore.
72
+
73
+ ## `playSequence(sequenceId, { invocationId, bindings })`
74
+
75
+ ```ts
76
+ const player = createPresentationSequencePlayer({
77
+ originFrame: 0,
78
+ audio: headlessAudio, // or createAudioCueTimeline
79
+ semantic,
80
+ visual,
81
+ sequences: [defined.sequence],
82
+ });
83
+
84
+ player.playSequence('spoken-line', {
85
+ invocationId: 'inv-1',
86
+ idempotencyKey: 'inv-1',
87
+ bindings: {
88
+ assets: { line: 'line-1' },
89
+ captions: { line: 'spoken line' },
90
+ regions: { speaker: 'speaker' },
91
+ layers: { room: 'room-bg' },
92
+ versions: { room: 'v1' },
93
+ },
94
+ });
95
+ player.step(6);
96
+ ```
97
+
98
+ The player steps bound audio and visual controllers with the same delta so one sequence owns the clock. Caption visibility lives on the player. Semantic focus is `setRegionState`. Visual dim/restore is `visual.play({ kind: 'hide' | 'show' })`. Cue tracks use `createPresentationTimeline`.
99
+
100
+ ## Policies
101
+
102
+ | Policy | Behavior |
103
+ |---|---|
104
+ | `skip()` | Restores caption / audio / focus / visual under one policy, emits `skipped`. A second skip is a no-op. The next `playSequence` is available exactly once. |
105
+ | `replay()` | Restarts the last invocation from playhead 0. |
106
+ | `pause()` / `resume()` | Freeze or continue the shared clock. `step` is ignored while paused. |
107
+ | `replace` (default interruption) | Incoming play interrupts the active invocation (`interrupted`) and starts the new one. |
108
+ | `queue` | Incoming play waits until the active invocation completes, skips, or cancels. |
109
+ | `reject` | Incoming play returns `{ ok: false, reason: 'busy' }`. |
110
+ | `ignore` | Incoming play keeps the active invocation. |
111
+ | `cancel()` | Aborts as `interrupted` without semantic completion. |
112
+ | Same `idempotencyKey` | Idempotent: no second `started`. |
113
+
114
+ ## Fallbacks and reduced sensory
115
+
116
+ Readiness is probed before `started`. Failed / unavailable / unauthorized / muted audio selects the matching authored fallback (typically `omitTrackIds: ['audio']`) instead of throwing and leaving mixed channels. Semantic steps still complete.
117
+
118
+ `reducedMotion: true` shortens step duration (`skip` / `complete` → 0, or `{ durationFrames: n }`). `reducedSensory: true` omits audio tracks. Caption, focus, and completion still run.
119
+
120
+ ## Snapshot / mid-line restore
121
+
122
+ `schemaVersion` is `PRESENTATION_SEQUENCE_SNAPSHOT_SCHEMA_VERSION` (`1`). Restore **fails closed** on unknown version or unknown sequence id and leaves live state unchanged. Unknown queued ids are rejected in that pre-mutation check, before live adapters are released. After mutation, unknown queue rows are skipped rather than failed: `fail()` restores JS backup but does not re-apply adapter effects.
123
+
124
+ Mid-line policy:
125
+
126
+ 1. Restore the playhead (`frame`, `startedAtFrame`, completed and active step ids, selected fallback, bindings).
127
+ 2. Re-apply currently active step effects (caption visibility, speaker focus, visual hide/show, remaining audio/cue duration).
128
+ 3. Do not re-run completed steps.
129
+ 4. A **skipped** invocation restores as skipped: remaining duration is not played, channels stay released.
130
+ 5. Hosts should pass fresh or already-restored audio / semantic / visual adapters; this module does not seek those clocks backward.
131
+
132
+ ## Lifecycle events (CYB-59 contracts)
133
+
134
+ `presentationSequenceEventContracts()` registers:
135
+
136
+ | `type` | When |
137
+ |---|---|
138
+ | `presentation.sequence.state.requested` | `playSequence` accepted |
139
+ | `presentation.sequence.state.preloading` | Capability probe |
140
+ | `presentation.sequence.state.ready` | Fallback selected if needed |
141
+ | `presentation.sequence.state.started` | First steps may apply |
142
+ | `presentation.sequence.state.step-started` | Step becomes active |
143
+ | `presentation.sequence.state.step-completed` | Duration elapsed |
144
+ | `presentation.sequence.state.skipped` | `skip()` |
145
+ | `presentation.sequence.state.interrupted` | replace / cancel |
146
+ | `presentation.sequence.state.failed` | Unrecoverable (reserved) |
147
+ | `presentation.sequence.state.completed` | All steps terminal |
148
+ | `presentation.sequence.diagnostic.lifecycle` | Diagnostics |
149
+
150
+ `inspectCueIntent(sequenceId, { fallbackId })` returns the planned steps without calling the audio adapter.