diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..0a49261 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.11.0 diff --git a/README.md b/README.md index effffd0..7cb3f94 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,30 @@ Copy `.env.example` to `.env` for local repository configuration. `.env` and its The repository's existing [LICENSE](LICENSE) is preserved. +## Phase 1–2 runtime + +Build the standalone, dependency-free runtime with the pinned Node version in `.nvmrc`: + +```powershell +npm run build +``` + +This deterministically combines the modules in `src/runtime`, the application shell, and local styles into `XZBT.html`. Open that file directly in a supported desktop Chromium browser, then import the two minimal exhibits from `exhibits`. Imported definitions and the last active exhibit are cached in IndexedDB and restored on reopen when browser storage is available. The diagnostics panel reports validation, lifecycle, and storage failures; storage failure leaves the current session usable. + +Run the Phase 1 production-module, lifecycle, fixture, PRNG-vector, cache, and reproducible-build tests with: + +```powershell +npm run test:phase1 +``` + +Run the Phase 2 common-grammar conformance suite with: + +```powershell +npm run test:phase2 +``` + +The active performance now supports typed parameters and state, read-only runtime signals, ValueSpec and ConditionSpec evaluation, ordered `set` and `override` actions, same-tick bindings with deterministic smoothing, numeric transitions, and priority-based temporary overrides. The application exposes generic Phase 2 parameter controls and resolved-value placeholders so these systems can be inspected without subject-specific runtime code. Audio, visuals, cadence, events, scenarios, and the final schema-driven UI remain assigned to later phases. + ## Local development server For automated browser development checks, start the dependency-free loopback server with: diff --git a/XZBT.html b/XZBT.html new file mode 100644 index 0000000..52c403c --- /dev/null +++ b/XZBT.html @@ -0,0 +1,1806 @@ + + + + + + + XZBT 0.1 + + + +
+

XZBT

+
+ Starting…Checking storage… +
+
+
+ +
+
+
Runtime ready

Select an exhibit

Import declarative XZBT 0.1 documents, then activate one from the library. Exhibits run without adding subject-specific code to this runtime.

+ +
+
+

Diagnostics 0

+

No diagnostics yet.

    +
    +
    +
    + + + diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index e52860d..e1d4a5f 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,18 +1,18 @@ # XZBT implementation status **Updated:** September 5, 2026 -**State:** Phase 0 complete; ready to begin Phase 1 runtime skeleton +**State:** Phase 2 common grammar complete; Phase 1 direct-file import/restart observation remains pending **Planning baseline:** `05fe2b4e021ba86e4a290d05b63c7cae0e386128` -**Exact demarcation:** GC1 direct-file feasibility (10/10 checks), GC2 shared format contracts, GC3 resolution semantics, GC4 clock/PRNG semantics, and GC5 ownership/failure semantics are complete at the Phase 0 contract-oracle level. The implementation plan maps GC6 measurements and GC7 completion/library/build checks to later milestones. No production runtime work has begun; real subsystem, Web Audio synchronization, performance, and soak evidence remain assigned to Phases 1–9. +**Exact demarcation:** GC1 direct-file feasibility (10/10 checks), GC2 shared format contracts, GC3 resolution semantics, GC4 clock/PRNG semantics, and GC5 ownership/failure semantics are complete at the Phase 0 contract-oracle level. The Phase 1 production runtime skeleton and Phase 2 common grammar are implemented and pass automated checks, but Phase 1's direct-file two-fixture restart observation remains open. Real audio, visual, cadence/event, scenario, final generated-UI, performance, and soak work remains assigned to later phases. The user requested sequential implementation with a stop on problems. The [manual version 3 evidence](evidence/phase0/2026-09-04-user-run-v3.md) verifies embedded data-URL worklet loading in direct-file Chrome. The subsequent [user-performed restart test](evidence/phase0/2026-09-04-user-restart.md) restored Blue Study activity 0.37 and master volume 0.19 immediately on reopening. Native tone output and AudioContext suspend/resume are also observed. Ordinary file import, selection of both exhibits, regular Chrome mode, and [directory cancellation/denial fallback](evidence/phase0/2026-09-05-user-directory-fallback.md) have been confirmed. | Phase | Status | Evidence or next prerequisite | | --- | --- | --- | | 0 — Contracts and feasibility | Complete | GC1 passed; GC2–GC5 shared contracts and traces passed; GC6/GC7 later gates scheduled and mapped | -| 1 — Runtime skeleton | Ready | Follow the [implementation plan](XZBT_0-1_Implementation_Plan.md) and preserve Phase 0 contract oracles | -| 2 — Common grammar | Not started | Complete Phase 1 and shared contracts | +| 1 — Runtime skeleton | Implemented; acceptance pending | [Automated evidence](evidence/phase1/2026-09-05-runtime-skeleton.md) passes production-module, lifecycle, PRNG-vector, cache/restore, fixture-validation, and deterministic-build tests. Direct-file two-fixture import/restart remains a user-observed gate. | +| 2 — Common grammar | Complete | [Automated evidence](evidence/phase2/2026-09-05-common-grammar.md) covers production GC2 conformance, typed values, signals, actions, same-tick bindings, transitions, override precedence/release, and parameter restoration. | | 3 — Audio engine | Not started | Earlier phases and audio contracts | | 4 — Visual engine | Not started | Earlier phases and visual contracts | | 5 — Events and cadence | Not started | Earlier phases and event/cadence contracts | @@ -43,4 +43,10 @@ The browser rejection explicitly forbids agent workarounds through alternate bro A dependency-free loopback development server is available at `tools/dev-server.mjs`. It serves the workspace at `http://localhost:5173/` for later automated development testing only and listens on both IPv4 and IPv6 loopback. It may support source-level UI or regression work after Phase 0, but direct-file operation remains the primary distribution architecture. -Phase 0 is complete. Begin Phase 1 with the runtime skeleton and reproducible modular-source build described in the implementation plan. Reuse the GC4 PRNG known vectors in the production `SeededRNG`; do not reinterpret the frozen GC3–GC5 contracts silently. The mandatory combined-load measurements, production subsystem conformance, two-hour development soak, and eight-hour release soak remain outstanding at their mapped later gates. +Phase 0 remains complete and the Phase 1 implementation now reuses the GC4 PRNG known vectors in the production `SeededRNG`. The mandatory direct-file Phase 1 observation, combined-load measurements, production subsystem conformance, two-hour development soak, and eight-hour release soak remain outstanding at their mapped gates. + +## Phase 1 implementation + +The modular runtime sources now live in `src/runtime` and build deterministically with `node tools/build-xzbt.mjs` into the self-contained root `XZBT.html`. The artifact has no external runtime dependencies or requests. Two minimal, subject-neutral fixtures live in `exhibits`; one uses fixed seed `42` and one requests cryptographic entropy. Phase 2 extends both with small common-grammar examples. + +Automated Phase 1 checks cover JSON/version/metadata failure, the frozen GC4 state/output vectors, byte-identical import behavior, in-memory restore, prepare-before-teardown activation recovery, and byte-identical standalone builds. The existing GC2–GC5 suites still pass when run in-process. Phase 1 is not marked accepted until the built artifact imports both fixtures directly from disk, restores them after a full browser restart, and records the Phase 1 GC6 environment details required by the implementation plan. diff --git a/docs/evidence/phase1/2026-09-05-runtime-skeleton.md b/docs/evidence/phase1/2026-09-05-runtime-skeleton.md new file mode 100644 index 0000000..5c16d6a --- /dev/null +++ b/docs/evidence/phase1/2026-09-05-runtime-skeleton.md @@ -0,0 +1,35 @@ +# Phase 1 runtime skeleton — automated evidence + +**Date:** September 5, 2026 +**Artifact:** `XZBT.html` +**SHA-256:** `8a444a17e06e3d2c4825fcb293004fde4da22c776154fba3d624cc50845c26c3` +**Result:** Implementation and automated verification passed; direct-file restart observation remains pending. + +## Delivered + +- deterministic modular-source build into one inline-script, inline-style HTML artifact; +- multi-file and drag/drop `.xzbt` loading; +- BOM-aware JSON parsing plus XZBT version, metadata, identifier, root-field, and seed validation; +- exhibit library and inert Phase 1 performance surface; +- prepare-before-teardown activation, complete disposal, and previous-exhibit recovery attempt; +- production `SeededRNG` using the frozen FNV-1a-32, SplitMix32, and `xoshiro128**` contract; +- IndexedDB definition cache and last-active-exhibit preference, with a visible session-only fallback; +- bounded structured diagnostics with severity, code, exhibit, section, object, property, and explanation fields; +- two minimal valid exhibits: fixed-seed and cryptographically randomized. + +## Automated verification + +`npm run test:phase1` passed 6 of 6 tests. Coverage includes both fixtures, malformed/version/metadata rejection, frozen GC4 PRNG vectors, identical-import no-op and restore, activation failure recovery, byte-identical clean builds, absence of external script/style dependencies, and generated-script compilation. + +The existing in-process contract suites also passed unchanged: + +- GC2: 12 of 12 +- GC3: 8 of 8 +- GC4: 7 of 7 +- GC5: 8 of 8 + +`git diff --check` reported no whitespace errors. + +## Open acceptance observation + +The repository records that prior browser tooling was not permitted to navigate directly to a `file:` URL, and forbids substituting another browser surface or raw command as a workaround. Consequently, this record does not claim the final Phase 1 direct-file acceptance observation. A supported desktop Chromium user run still needs to import both files from `exhibits`, switch between them, close the full browser, reopen the same `XZBT.html`, confirm that both cached exhibits return and the last exhibit activates, and record the Phase 1 GC6 environment fields. diff --git a/docs/evidence/phase2/2026-09-05-common-grammar.md b/docs/evidence/phase2/2026-09-05-common-grammar.md new file mode 100644 index 0000000..0f1997b --- /dev/null +++ b/docs/evidence/phase2/2026-09-05-common-grammar.md @@ -0,0 +1,52 @@ +# Phase 2 common grammar — automated evidence + +**Date:** September 5, 2026 +**Artifact:** `XZBT.html` +**SHA-256:** `8ea318535e97b25b51760b6c0e59e1a5af89d3f429f079de3b87230165008517` +**Result:** Phase 2 implementation and automated acceptance passed. + +## Delivered + +- typed parameter and simulation-state stores with strict coercion rejection and declared bounds; +- separately persisted parameter snapshots, with state recreated for each performance; +- read-only time, audio-analysis placeholder, pointer, viewport, and scenario-active signals; +- literal, reference, bounded random, weighted-choice, and safe-operation ValueSpec evaluation; +- comparison and short-circuit logical ConditionSpec evaluation; +- deterministic fixed-step common-grammar performance driver with the GC4 stall bound; +- same-tick dependency resolution for parameter/state bindings; +- numeric transforms, clamps, exact one-pole smoothing, conditional disable, and clean re-enable; +- ordered common action execution with `when`, `chance`, `critical`, persistent state `set`, and temporary `override`; +- numeric set transitions with the four standard easing modes; +- override runtime IDs, priority/activation-sequence precedence, scenario/duration lifetimes, attack/release transitions, owner release, and live lower-value restoration; +- generic inspection controls and resolved-value placeholders in the subject-neutral application shell; +- two updated minimal exhibits exercising conditional and unconditional common-grammar bindings. + +Subsystem-specific `sound`, `event`, `spawn`, `remove`, and `control` execution remains unavailable until the owning audio, visual, event/cadence, and scenario phases define and implement their capability contracts. The common executor rejects those actions explicitly instead of approximating their behavior. + +## Automated verification + +`npm run test:phase2` passed 9 of 9 production tests: + +1. production validator agreement with the GC2 fixture matrix; +2. ValueSpec references, random values, weighted choice, safe operations, and arity failure; +3. ConditionSpec composition, short-circuiting, and strict operand typing; +4. same-tick binding dependency order and exact smoothing; +5. conditional binding disable and re-enable behavior; +6. document-order state actions and parameter mutation rejection; +7. deterministic state transitions and duration parsing; +8. override precedence, stored-edit preservation, and live-base release; +9. duration expiry, compatible stored parameters, and performance disposal. + +Regression verification also passed: + +- Phase 1 production/build tests: 6 of 6 +- GC2 contract oracle: 12 of 12 +- GC3 contract oracle: 8 of 8 +- GC4 contract oracle: 7 of 7 +- GC5 contract oracle: 8 of 8 + +The standalone build test compiled the generated script and confirmed byte-identical clean outputs with no external runtime script or stylesheet references. + +## Remaining cross-phase observation + +The earlier direct-file browser restriction still prevents an agent-run full-browser restart check. Phase 1’s user-observed two-fixture import/cache/reopen gate therefore remains open; it does not change the passing Phase 2 semantic results above. diff --git a/exhibits/minimal-fixed.xzbt b/exhibits/minimal-fixed.xzbt new file mode 100644 index 0000000..c24106d --- /dev/null +++ b/exhibits/minimal-fixed.xzbt @@ -0,0 +1,52 @@ +{ + "xzbt": "0.1", + "meta": { + "id": "minimal-fixed", + "name": "Minimal Fixed", + "version": "1.0.0", + "author": "XZBT", + "description": "A minimal common-grammar exhibit with a fixed reproducible seed.", + "license": "CC0-1.0", + "tags": ["minimal", "deterministic"] + }, + "runtime": { + "seed": 42 + }, + "parameters": { + "activity": { + "type": "number", + "default": 0.4, + "min": 0, + "max": 1, + "step": 0.01, + "label": "Activity" + }, + "enabled": { + "type": "boolean", + "default": true, + "label": "Binding enabled" + } + }, + "state": { + "energy": { + "type": "number", + "initial": 0.1, + "min": 0, + "max": 1 + } + }, + "bindings": [ + { + "source": "parameters.activity", + "target": "state.energy", + "scale": 1.25, + "clamp": [0, 1], + "smoothing": "250ms", + "when": { + "op": "eq", + "left": { "ref": "parameters.enabled" }, + "right": true + } + } + ] +} diff --git a/exhibits/minimal-random.xzbt b/exhibits/minimal-random.xzbt new file mode 100644 index 0000000..fe468c0 --- /dev/null +++ b/exhibits/minimal-random.xzbt @@ -0,0 +1,45 @@ +{ + "xzbt": "0.1", + "meta": { + "id": "minimal-random", + "name": "Minimal Random", + "version": "1.0.0", + "author": "XZBT", + "description": "A minimal common-grammar exhibit that resolves a fresh cryptographic seed per performance.", + "license": "CC0-1.0", + "tags": ["minimal", "random"] + }, + "runtime": { + "seed": "random" + }, + "parameters": { + "mode": { + "type": "enum", + "values": ["still", "drift", "flow"], + "default": "drift", + "label": "Mode" + }, + "intensity": { + "type": "number", + "default": 0.65, + "min": 0, + "max": 1, + "step": 0.05, + "label": "Intensity" + } + }, + "state": { + "output": { + "type": "number", + "initial": 0, + "min": 0, + "max": 1 + } + }, + "bindings": [ + { + "source": "parameters.intensity", + "target": "state.output" + } + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3fc406c --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "xzbt-runtime", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": "24.11.0" + }, + "scripts": { + "build": "node tools/build-xzbt.mjs", + "test:phase1": "node test/phase1-runtime.test.mjs", + "test:phase2": "node test/phase2-common-grammar.test.mjs" + } +} diff --git a/src/XZBT.template.html b/src/XZBT.template.html new file mode 100644 index 0000000..f1e9a26 --- /dev/null +++ b/src/XZBT.template.html @@ -0,0 +1,45 @@ + + + + + + + XZBT 0.1 + + + +
    +

    XZBT

    +
    + Starting…Checking storage… +
    +
    +
    + +
    +
    +
    Runtime ready

    Select an exhibit

    Import declarative XZBT 0.1 documents, then activate one from the library. Exhibits run without adding subject-specific code to this runtime.

    + +
    +
    +

    Diagnostics 0

    +

    No diagnostics yet.

      +
      +
      +
      + + + diff --git a/src/runtime/actions.js b/src/runtime/actions.js new file mode 100644 index 0000000..adc84f2 --- /dev/null +++ b/src/runtime/actions.js @@ -0,0 +1,49 @@ +import { RuntimeFault, isRecord } from './types.js'; + +export class ActionExecutor { + constructor(engine, { diagnostics } = {}) { + this.engine = engine; + this.diagnostics = diagnostics; + this.invocationOrdinal = 0; + } + + execute(actions, context = {}) { + if (!Array.isArray(actions)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Actions must be an array.'); + const stream = this.engine.rng.stream('scenario', `actions:${++this.invocationOrdinal}`); + const results = []; + for (let index = 0; index < actions.length; index += 1) { + const action = actions[index]; + try { + results.push(this.executeOne(action, stream, context, `$.actions[${index}]`)); + } catch (error) { + const fault = error instanceof RuntimeFault ? error : new RuntimeFault('ERR_ACTION_FAILURE', error.message); + this.diagnostics?.error(fault.code, fault.message, { exhibitId: this.engine.document.meta.id, section: 'actions', objectId: action?.id ?? null, property: fault.path }); + results.push({ status: 'failed', error: fault }); + if (action?.critical !== false) throw fault; + } + } + return results; + } + + executeOne(action, stream, context, path) { + if (!isRecord(action) || typeof action.type !== 'string') throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Action requires a type.', path); + if (action.when !== undefined && !this.engine.evaluateCondition(action.when, stream, `${path}.when`)) return { status: 'skipped', reason: 'condition' }; + if (action.chance !== undefined) { + if (!Number.isFinite(action.chance) || action.chance < 0 || action.chance > 1) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Action chance must be from 0 through 1.', `${path}.chance`); + if (stream.nextFloat() >= action.chance) return { status: 'skipped', reason: 'chance' }; + } + + if (action.type === 'set') { + const value = this.engine.evaluateValue(action.value, stream, `${path}.value`); + this.engine.setState(action.target, value, action.transition); + return { status: 'executed', type: 'set', target: action.target, value }; + } + if (action.type === 'override') { + const value = this.engine.evaluateValue(action.value, stream, `${path}.value`); + const instanceId = this.engine.overrides.add(action, value, { owner: context.owner, inheritedPriority: context.priority }); + this.engine.resolveAll(); + return { status: 'executed', type: 'override', target: action.target, value, instanceId }; + } + throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Action '${action.type}' belongs to a later subsystem phase.`, `${path}.type`); + } +} diff --git a/src/runtime/activation.js b/src/runtime/activation.js new file mode 100644 index 0000000..9b763f9 --- /dev/null +++ b/src/runtime/activation.js @@ -0,0 +1,112 @@ +import { SeededRNG, resolveRootSeed } from './rng.js'; +import { CommonGrammarPerformance } from './performance.js'; + +export class InertPerformance { + constructor(record, rootSeed) { + this.record = record; + this.rootSeed = rootSeed; + this.rng = new SeededRNG(rootSeed); + this.state = 'prepared'; + this.resources = new Set(); + } + + async activate() { + if (this.state !== 'prepared') throw new Error(`Cannot activate a performance in state ${this.state}.`); + this.state = 'active'; + } + + async deactivate() { + if (this.state === 'active') this.state = 'inactive'; + this.resources.clear(); + } + + async dispose() { + this.resources.clear(); + this.state = 'disposed'; + } +} + +export class ActivationController { + constructor({ diagnostics, persistence, performanceFactory, cryptoProvider = globalThis.crypto } = {}) { + this.diagnostics = diagnostics; + this.persistence = persistence; + this.crypto = cryptoProvider; + this.performanceFactory = performanceFactory ?? ((record, seed) => new CommonGrammarPerformance(record, seed)); + this.current = null; + } + + prepare(record) { + const seed = resolveRootSeed(record.document.runtime?.seed ?? 'random', this.crypto); + const performance = this.performanceFactory(record, seed); + if (!performance || typeof performance.activate !== 'function' || typeof performance.deactivate !== 'function' || typeof performance.dispose !== 'function') { + throw new TypeError('Performance factory returned an invalid lifecycle object.'); + } + return { record, seed, performance }; + } + + async activate(record) { + let candidate; + try { + candidate = this.prepare(record); + } catch (error) { + this.diagnostics?.error('ERR_ACTIVATION_PREPARE', `Could not prepare exhibit: ${error.message}`, { exhibitId: record.id, section: 'activation' }); + return false; + } + + const previous = this.current; + if (previous) { + try { + await previous.performance.deactivate('replacement'); + await previous.performance.dispose(); + } catch (error) { + await candidate.performance.dispose(); + this.diagnostics?.error('ERR_DEACTIVATION', `Could not safely deactivate the current exhibit: ${error.message}`, { exhibitId: previous.record.id, section: 'activation' }); + return false; + } + this.current = null; + } + + try { + await candidate.performance.activate(); + this.current = candidate; + try { + await this.persistence?.savePreference('lastExhibitId', record.id); + } catch (error) { + this.diagnostics?.warn('WARN_STORAGE_WRITE_FAILED', `The active selection could not be remembered: ${error.message}`, { exhibitId: record.id, section: 'persistence' }); + } + this.diagnostics?.info('INFO_EXHIBIT_ACTIVATED', `${record.document.meta.name} activated with resolved seed ${candidate.seed}.`, { exhibitId: record.id, section: 'activation', property: 'runtime.seed' }); + return true; + } catch (error) { + await candidate.performance.dispose(); + this.diagnostics?.error('ERR_ACTIVATION', `Activation failed: ${error.message}`, { exhibitId: record.id, section: 'activation' }); + if (previous) await this.recover(previous); + return false; + } + } + + async recover(previous) { + try { + const recovery = this.prepare(previous.record); + await recovery.performance.activate(); + this.current = recovery; + this.diagnostics?.warn('WARN_ACTIVATION_RECOVERED', `The previous exhibit was restarted after activation failure.`, { exhibitId: previous.record.id, section: 'activation' }); + } catch (error) { + this.diagnostics?.error('ERR_ACTIVATION_RECOVERY', `The previous exhibit could not be restarted: ${error.message}`, { exhibitId: previous.record.id, section: 'activation' }); + } + } + + async deactivate() { + if (!this.current) return true; + const current = this.current; + this.current = null; + try { + await current.performance.deactivate('manual'); + await current.performance.dispose(); + this.diagnostics?.info('INFO_EXHIBIT_DEACTIVATED', `${current.record.document.meta.name} deactivated.`, { exhibitId: current.record.id, section: 'activation' }); + return true; + } catch (error) { + this.diagnostics?.error('ERR_DEACTIVATION', `Exhibit teardown failed: ${error.message}`, { exhibitId: current.record.id, section: 'activation' }); + return false; + } + } +} diff --git a/src/runtime/app.js b/src/runtime/app.js new file mode 100644 index 0000000..ccb4019 --- /dev/null +++ b/src/runtime/app.js @@ -0,0 +1,333 @@ +import { ActivationController } from './activation.js'; +import { Diagnostics } from './diagnostics.js'; +import { LibraryManager } from './library.js'; +import { PersistenceManager } from './persistence.js'; +import { XZBT_RUNTIME_VERSION } from './constants.js'; +import { CommonGrammarPerformance } from './performance.js'; + +function element(id) { return document.getElementById(id); } + +function text(tag, value, className) { + const node = document.createElement(tag); + node.textContent = value; + if (className) node.className = className; + return node; +} + +export class XZBTApplication { + constructor() { + this.diagnostics = new Diagnostics({ onChange: (entries) => this.renderDiagnostics(entries) }); + this.persistence = new PersistenceManager({ diagnostics: this.diagnostics }); + this.library = new LibraryManager({ persistence: this.persistence, diagnostics: this.diagnostics }); + this.parameterValues = {}; + this.activation = new ActivationController({ + persistence: this.persistence, + diagnostics: this.diagnostics, + performanceFactory: (record, seed) => new CommonGrammarPerformance(record, seed, { + diagnostics: this.diagnostics, + initialParameters: this.parameterValues[record.id], + onParameterChange: (values) => this.rememberParameters(record.id, values), + onUpdate: (engine) => { + if (this.activation.current?.record.id === record.id) this.renderValues(engine); + } + }) + }); + this.busy = false; + } + + async start() { + element('runtime-version').textContent = `Runtime ${XZBT_RUNTIME_VERSION}`; + this.bindEvents(); + await this.persistence.open(); + this.renderStorage(); + try { + const snapshot = await this.persistence.loadSnapshot(); + for (const [key, value] of Object.entries(snapshot.preferences)) { + if (key.startsWith('parameters:')) this.parameterValues[key.slice('parameters:'.length)] = value; + } + this.library.restore(snapshot.exhibits); + this.renderLibrary(); + const last = this.library.get(snapshot.preferences.lastExhibitId); + if (last) await this.activate(last.id); + } catch (error) { + this.diagnostics.warn('WARN_STORAGE_READ_FAILED', `Cached exhibits could not be restored: ${error.message}`, { section: 'persistence' }); + this.renderLibrary(); + } + this.setStatus(this.activation.current ? 'Exhibit active' : (this.library.list().length ? 'Library ready' : 'Import exhibits to begin')); + } + + bindEvents() { + element('import-files').addEventListener('change', async (event) => { + await this.importFiles([...event.target.files]); + event.target.value = ''; + }); + element('import-button').addEventListener('click', () => element('import-files').click()); + element('deactivate-button').addEventListener('click', () => this.deactivate()); + element('clear-diagnostics').addEventListener('click', () => this.diagnostics.clear()); + const dropZone = element('drop-zone'); + for (const type of ['dragenter', 'dragover']) dropZone.addEventListener(type, (event) => { + event.preventDefault(); + dropZone.classList.add('is-dragging'); + }); + for (const type of ['dragleave', 'drop']) dropZone.addEventListener(type, (event) => { + event.preventDefault(); + dropZone.classList.remove('is-dragging'); + }); + dropZone.addEventListener('drop', (event) => this.importFiles([...event.dataTransfer.files])); + window.addEventListener('unhandledrejection', (event) => { + this.diagnostics.error('ERR_RUNTIME', event.reason?.message ?? String(event.reason), { section: 'runtime' }); + }); + } + + async importFiles(files) { + if (this.busy) return; + const exhibitFiles = files.filter((file) => file.name.toLowerCase().endsWith('.xzbt')); + if (!exhibitFiles.length) { + this.diagnostics.warn('WARN_IMPORT_EMPTY', 'Choose one or more files with the .xzbt extension.', { section: 'loader' }); + return; + } + this.setBusy(true, `Importing ${exhibitFiles.length} exhibit${exhibitFiles.length === 1 ? '' : 's'}…`); + try { + for (const file of exhibitFiles) { + let source; + try { + source = await file.text(); + } catch (error) { + this.diagnostics.error('ERR_IMPORT_READ', `Could not read ${file.name}: ${error.message}`, { section: 'loader' }); + continue; + } + await this.library.importSource(source, file.name, { + confirmReplacement: async (existing, candidate) => window.confirm( + `${candidate.meta.name} has the same exhibit ID as ${existing.document.meta.name}, but different source bytes. Replace the cached exhibit?` + ) + }); + } + this.renderLibrary(); + } finally { + this.setBusy(false, 'Library ready'); + } + } + + async activate(id) { + if (this.busy || this.activation.current?.record.id === id) return; + const record = this.library.get(id); + if (!record) return; + this.setBusy(true, `Preparing ${record.document.meta.name}…`); + try { + await this.activation.activate(record); + this.renderLibrary(); + this.renderStage(); + } finally { + this.setBusy(false, this.activation.current ? 'Exhibit active' : 'Library ready'); + } + } + + async deactivate() { + if (this.busy || !this.activation.current) return; + this.setBusy(true, 'Deactivating exhibit…'); + try { + await this.activation.deactivate(); + this.renderLibrary(); + this.renderStage(); + } finally { + this.setBusy(false, 'Library ready'); + } + } + + renderLibrary() { + const records = this.library.list(); + const container = element('library-list'); + container.replaceChildren(); + element('empty-state').hidden = records.length > 0; + for (const record of records) { + const meta = record.document.meta; + const active = this.activation.current?.record.id === record.id; + const card = document.createElement('article'); + card.className = `exhibit-card${active ? ' is-active' : ''}`; + card.append(text('h3', meta.name)); + card.append(text('p', meta.description || 'Minimal XZBT 0.1 exhibit.', 'description')); + const details = [meta.author, meta.version && `v${meta.version}`, record.id].filter(Boolean).join(' · '); + card.append(text('p', details, 'meta')); + const button = text('button', active ? 'Active' : 'Activate'); + button.type = 'button'; + button.disabled = active || this.busy; + button.addEventListener('click', () => this.activate(record.id)); + card.append(button); + container.append(card); + } + } + + renderStage() { + const current = this.activation.current; + element('deactivate-button').disabled = !current || this.busy; + element('stage-empty').hidden = Boolean(current); + element('stage-active').hidden = !current; + if (!current) { + element('configuration').replaceChildren(); + element('resolved-values').replaceChildren(); + return; + } + const { meta } = current.record.document; + element('active-name').textContent = meta.name; + element('active-id').textContent = meta.id; + element('active-seed').textContent = String(current.seed); + const preview = current.performance.rng.stream('visual', 'runtime-preview:1'); + element('active-sequence').textContent = [preview.nextUint32(), preview.nextUint32(), preview.nextUint32()].join(' · '); + this.renderConfiguration(current.performance); + this.renderValues(current.performance.engine); + } + + renderConfiguration(performance) { + const container = element('configuration'); + container.replaceChildren(); + const definitions = performance.record.document.parameters ?? {}; + if (Object.keys(definitions).length === 0) { + container.append(text('p', 'This exhibit declares no parameters.', 'empty')); + return; + } + for (const [id, spec] of Object.entries(definitions)) { + const path = `parameters.${id}`; + const row = document.createElement('div'); + row.className = 'parameter-control'; + const label = text('label', spec.label ?? id); + label.htmlFor = `parameter-${id}`; + const badge = text('span', 'overridden', 'override-badge'); + badge.dataset.overrideFor = path; + badge.hidden = true; + label.append(badge); + const input = this.parameterInput(id, spec, performance.engine.parameters.get(id)); + const update = () => { + try { + const value = spec.type === 'boolean' ? input.checked : (spec.type === 'number' || spec.type === 'integer' ? Number(input.value) : input.value); + performance.setParameter(id, value); + } catch (error) { + this.diagnostics.error(error.code ?? 'ERR_TYPE_MISMATCH', error.message, { exhibitId: performance.record.id, section: 'parameters', objectId: id }); + } + }; + input.addEventListener(spec.type === 'number' ? 'input' : 'change', update); + row.append(label, input); + const override = text('button', 'Temporary override'); + override.type = 'button'; + override.className = 'compact'; + override.addEventListener('click', () => { + const value = this.demoOverrideValue(spec, performance.engine.parameters.get(id)); + try { + performance.execute([{ + type: 'override', target: path, value, scope: 'duration', duration: '3s', + transition: { in: spec.type === 'number' || spec.type === 'integer' ? '250ms' : '0ms', out: spec.type === 'number' || spec.type === 'integer' ? '750ms' : '0ms', easing: 'ease-in-out' } + }], { owner: 'phase2-ui', priority: 0 }); + } catch {} + }); + row.append(override); + container.append(row); + } + } + + parameterInput(id, spec, value) { + let input; + if (spec.type === 'enum') { + input = document.createElement('select'); + for (const optionValue of spec.values) { + const option = document.createElement('option'); + option.value = optionValue; + option.textContent = optionValue; + input.append(option); + } + input.value = value; + } else { + input = document.createElement('input'); + if (spec.type === 'boolean') { input.type = 'checkbox'; input.checked = value; } + else if (spec.type === 'color') { input.type = 'color'; input.value = value; } + else if (spec.type === 'number' || spec.type === 'integer') { + input.type = spec.min !== undefined && spec.max !== undefined ? 'range' : 'number'; + if (spec.min !== undefined) input.min = String(spec.min); + if (spec.max !== undefined) input.max = String(spec.max); + input.step = String(spec.step ?? (spec.type === 'integer' ? 1 : 'any')); + input.value = String(value); + } else { input.type = 'text'; input.value = value; } + } + input.id = `parameter-${id}`; + input.dataset.parameter = id; + return input; + } + + demoOverrideValue(spec, stored) { + if (spec.type === 'number' || spec.type === 'integer') return spec.max ?? (stored + 1); + if (spec.type === 'boolean') return !stored; + if (spec.type === 'enum') return spec.values[(spec.values.indexOf(stored) + 1) % spec.values.length]; + if (spec.type === 'color') return stored.toLowerCase() === '#b8ff5a' ? '#ffca5c' : '#b8ff5a'; + return `${stored}*`; + } + + renderValues(engine) { + const container = element('resolved-values'); + if (!container) return; + const snapshot = engine.snapshot(); + container.replaceChildren(); + for (const [path, value] of Object.entries(snapshot)) { + const row = document.createElement('li'); + row.append(text('code', path), text('output', typeof value === 'number' ? value.toFixed(4).replace(/0+$/, '').replace(/\.$/, '') : String(value))); + container.append(row); + } + for (const badge of document.querySelectorAll('[data-override-for]')) badge.hidden = !engine.overrides.has(badge.dataset.overrideFor); + const first = Object.entries(engine.document.parameters ?? {})[0]; + if (first) { + const [id, spec] = first; + const stored = engine.parameters.get(id); + const condition = (spec.type === 'number' || spec.type === 'integer') + ? { op: 'gt', left: { ref: `parameters.${id}` }, right: ((spec.min ?? 0) + (spec.max ?? 1)) / 2 } + : { op: 'eq', left: { ref: `parameters.${id}` }, right: stored }; + element('condition-result').textContent = String(engine.evaluateCondition(condition, engine.rng.stream('scenario', 'phase2-condition:1'))); + } else element('condition-result').textContent = 'n/a'; + } + + rememberParameters(exhibitId, values) { + this.parameterValues[exhibitId] = values; + this.persistence.savePreference(`parameters:${exhibitId}`, values).catch((error) => { + this.diagnostics.warn('WARN_STORAGE_WRITE_FAILED', `Parameter changes remain session-only: ${error.message}`, { exhibitId, section: 'persistence' }); + }); + } + + renderDiagnostics(entries) { + const container = element('diagnostic-list'); + if (!container) return; + container.replaceChildren(); + const newest = [...entries].reverse(); + element('diagnostic-count').textContent = String(entries.length); + element('diagnostics-empty').hidden = newest.length > 0; + for (const entry of newest) { + const row = document.createElement('li'); + row.className = `diagnostic ${entry.severity}`; + row.append(text('span', entry.severity.toUpperCase(), 'severity')); + row.append(text('code', entry.code)); + row.append(text('span', entry.message, 'diagnostic-message')); + const context = [entry.exhibitId, entry.section, entry.objectId, entry.property].filter(Boolean).join(' › '); + if (context) row.append(text('small', context)); + container.append(row); + } + } + + renderStorage() { + const node = element('storage-status'); + node.textContent = this.persistence.available ? 'Local cache available' : 'Session only'; + node.className = this.persistence.available ? 'storage-ready' : 'storage-warning'; + } + + setBusy(busy, status) { + this.busy = busy; + element('import-button').disabled = busy; + element('deactivate-button').disabled = busy || !this.activation.current; + this.setStatus(status); + this.renderLibrary(); + } + + setStatus(value) { element('app-status').textContent = value; } +} + +export async function bootstrap() { + const application = new XZBTApplication(); + globalThis.XZBT = Object.freeze({ application }); + await application.start(); +} + +void bootstrap(); diff --git a/src/runtime/constants.js b/src/runtime/constants.js new file mode 100644 index 0000000..6885ed4 --- /dev/null +++ b/src/runtime/constants.js @@ -0,0 +1,27 @@ +export const XZBT_FORMAT_VERSION = '0.1'; +export const XZBT_RUNTIME_VERSION = '0.1.0-phase2'; +export const UINT32_RANGE = 0x1_0000_0000; +export const RNG_DOMAINS = Object.freeze([ + 'cadence', + 'scenario', + 'visual', + 'sound', + 'manual-sample' +]); + +export const DATABASE = Object.freeze({ + name: 'xzbt-runtime-0.1', + version: 1, + exhibits: 'exhibits', + preferences: 'preferences' +}); + +export const ID_PATTERN = /^[a-z][a-z0-9_-]*$/; +export const ALLOWED_TOP_LEVEL_FIELDS = Object.freeze([ + 'xzbt', 'meta', 'runtime', 'parameters', 'ui', 'state', 'signals', + 'components', 'visuals', 'audio', 'sounds', 'cadence', 'modulators', + 'bindings', 'events', 'scenarios' +]); +export const ALLOWED_META_FIELDS = Object.freeze([ + 'id', 'name', 'version', 'author', 'description', 'license', 'tags' +]); diff --git a/src/runtime/diagnostics.js b/src/runtime/diagnostics.js new file mode 100644 index 0000000..c370f15 --- /dev/null +++ b/src/runtime/diagnostics.js @@ -0,0 +1,32 @@ +export class Diagnostics { + constructor({ limit = 250, onChange = () => {} } = {}) { + this.limit = limit; + this.onChange = onChange; + this.entries = []; + this.sequence = 0; + } + + add(severity, code, message, context = {}) { + const entry = Object.freeze({ + sequence: ++this.sequence, + timestamp: new Date().toISOString(), + severity, + code, + exhibitId: context.exhibitId ?? null, + section: context.section ?? null, + objectId: context.objectId ?? null, + property: context.property ?? null, + message + }); + this.entries.push(entry); + if (this.entries.length > this.limit) this.entries.shift(); + this.onChange(this.list()); + return entry; + } + + info(code, message, context) { return this.add('info', code, message, context); } + warn(code, message, context) { return this.add('warning', code, message, context); } + error(code, message, context) { return this.add('error', code, message, context); } + list() { return [...this.entries]; } + clear() { this.entries.length = 0; this.onChange([]); } +} diff --git a/src/runtime/library.js b/src/runtime/library.js new file mode 100644 index 0000000..fb3b8fc --- /dev/null +++ b/src/runtime/library.js @@ -0,0 +1,91 @@ +import { parseAndValidateExhibit } from './validator.js'; + +function bytesToHex(bytes) { + return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +export async function sha256(source, cryptoProvider = globalThis.crypto) { + if (!cryptoProvider?.subtle) throw new Error('Web Crypto SHA-256 is unavailable.'); + const digest = await cryptoProvider.subtle.digest('SHA-256', new TextEncoder().encode(source)); + return bytesToHex(new Uint8Array(digest)); +} + +export class LibraryManager { + constructor({ persistence, diagnostics, cryptoProvider = globalThis.crypto } = {}) { + this.persistence = persistence; + this.diagnostics = diagnostics; + this.crypto = cryptoProvider; + this.records = new Map(); + } + + list() { + return [...this.records.values()].sort((left, right) => left.document.meta.name.localeCompare(right.document.meta.name)); + } + + get(id) { return this.records.get(id) ?? null; } + + restore(records) { + for (const record of records) { + const checked = validateStoredRecord(record); + if (!checked.valid) { + this.diagnostics?.error('ERR_CACHE_INVALID', `Cached exhibit was ignored: ${checked.message}`, { exhibitId: record?.id ?? null, section: 'persistence' }); + continue; + } + this.records.set(record.id, record); + } + if (this.records.size > 0) this.diagnostics?.info('INFO_LIBRARY_RESTORED', `Restored ${this.records.size} cached exhibit${this.records.size === 1 ? '' : 's'}.`, { section: 'library' }); + } + + async importSource(source, filename, { confirmReplacement = async () => false } = {}) { + const result = parseAndValidateExhibit(source, filename); + if (!result.valid) { + for (const error of result.errors) this.diagnostics?.error(error.code, error.message, { exhibitId: result.document?.meta?.id ?? null, section: error.section ?? 'loader', objectId: error.objectId, property: error.property }); + return { status: 'invalid', ...result }; + } + + let digest; + try { + digest = await sha256(result.source, this.crypto); + } catch (error) { + this.diagnostics?.error('ERR_IMPORT_DIGEST', error.message, { exhibitId: result.document.meta.id, section: 'loader' }); + return { status: 'invalid', valid: false, errors: [{ code: 'ERR_IMPORT_DIGEST', path: '$', message: error.message }] }; + } + + const id = result.document.meta.id; + const existing = this.records.get(id); + if (existing?.digest === digest) { + this.diagnostics?.info('INFO_IMPORT_IDENTICAL', `${result.document.meta.name} is already in the library; no changes were made.`, { exhibitId: id, section: 'library' }); + return { status: 'identical', record: existing }; + } + if (existing && !(await confirmReplacement(existing, result.document))) { + this.diagnostics?.info('INFO_IMPORT_REPLACEMENT_CANCELLED', `Replacement of ${existing.document.meta.name} was cancelled.`, { exhibitId: id, section: 'library' }); + return { status: 'cancelled', record: existing }; + } + + const record = Object.freeze({ + id, + digest, + source: result.source, + document: result.document, + sourceInfo: { kind: 'file', name: filename }, + importedAt: new Date().toISOString() + }); + this.records.set(id, record); + try { + await this.persistence?.saveExhibit(record); + } catch (error) { + this.diagnostics?.warn('WARN_STORAGE_WRITE_FAILED', `The exhibit is available for this session but could not be cached: ${error.message}`, { exhibitId: id, section: 'persistence' }); + } + this.diagnostics?.info(existing ? 'INFO_EXHIBIT_REPLACED' : 'INFO_EXHIBIT_IMPORTED', `${result.document.meta.name} ${existing ? 'replaced' : 'imported'}.`, { exhibitId: id, section: 'library' }); + return { status: existing ? 'replaced' : 'imported', record }; + } +} + +function validateStoredRecord(record) { + if (!record || typeof record.source !== 'string' || typeof record.digest !== 'string' || record.id !== record.document?.meta?.id) { + return { valid: false, message: 'record shape is invalid.' }; + } + const result = parseAndValidateExhibit(record.source, record.sourceInfo?.name ?? 'cached.xzbt'); + if (!result.valid || result.document.meta.id !== record.id) return { valid: false, message: 'definition no longer validates.' }; + return { valid: true }; +} diff --git a/src/runtime/performance.js b/src/runtime/performance.js new file mode 100644 index 0000000..87c46af --- /dev/null +++ b/src/runtime/performance.js @@ -0,0 +1,112 @@ +import { ActionExecutor } from './actions.js'; +import { SeededRNG } from './rng.js'; +import { ResolutionEngine } from './resolution.js'; + +export class CommonGrammarPerformance { + constructor(record, rootSeed, options = {}) { + this.record = record; + this.rootSeed = rootSeed; + this.rng = new SeededRNG(rootSeed); + this.diagnostics = options.diagnostics; + this.onUpdate = options.onUpdate ?? (() => {}); + this.engine = new ResolutionEngine(record.document, this.rng, { + diagnostics: this.diagnostics, + initialParameters: options.initialParameters, + onParameterChange: options.onParameterChange + }); + this.actions = new ActionExecutor(this.engine, { diagnostics: this.diagnostics }); + this.state = 'prepared'; + this.resources = new Set(); + this.frameRequest = null; + this.lastFrame = undefined; + this.accumulator = 0; + this.boundFrame = (now) => this.frame(now); + this.boundPointer = (event) => { + this.engine.signals.set('signals.pointer.x', event.clientX); + this.engine.signals.set('signals.pointer.y', event.clientY); + this.engine.invalidate(); + }; + this.boundResize = () => { + this.engine.signals.set('signals.viewport.width', globalThis.innerWidth ?? 0); + this.engine.signals.set('signals.viewport.height', globalThis.innerHeight ?? 0); + this.engine.invalidate(); + }; + } + + async activate() { + if (this.state !== 'prepared') throw new Error(`Cannot activate a performance in state ${this.state}.`); + this.state = 'active'; + this.onUpdate(this.engine); + if (typeof globalThis.addEventListener === 'function') { + globalThis.addEventListener('pointermove', this.boundPointer, { passive: true }); + globalThis.addEventListener('resize', this.boundResize, { passive: true }); + } + if (typeof globalThis.requestAnimationFrame === 'function') this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame); + } + + frame(now) { + if (this.state !== 'active') return; + if (this.lastFrame === undefined) this.lastFrame = now; + const observed = Math.max(0, now - this.lastFrame); + this.lastFrame = now; + const accepted = Math.min(observed, 250); + this.accumulator += accepted; + const step = 1000 / 60; + let ticks = 0; + while (this.accumulator + 1e-9 >= step && ticks < 8) { + this.engine.advance(step); + this.accumulator -= step; + ticks += 1; + } + if (observed > 250 || this.accumulator >= step) { + this.accumulator = Math.min(this.accumulator, step - 1e-9); + this.diagnostics?.warn('WARN_CLOCK_STALL', 'Discarded excess elapsed time to preserve the fixed-step work bound.', { exhibitId: this.record.id, section: 'scheduler' }); + } + if (ticks > 0) this.onUpdate(this.engine); + this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame); + } + + setParameter(id, value) { + const result = this.engine.setParameter(id, value); + this.onUpdate(this.engine); + return result; + } + + execute(actionArray, context) { + const result = this.actions.execute(actionArray, context); + this.onUpdate(this.engine); + return result; + } + + releaseOverride(id) { + const released = this.engine.overrides.beginRelease(id); + this.engine.resolveAll(); + this.onUpdate(this.engine); + return released; + } + + async deactivate() { + if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest); + this.frameRequest = null; + this.lastFrame = undefined; + if (typeof globalThis.removeEventListener === 'function') { + globalThis.removeEventListener('pointermove', this.boundPointer); + globalThis.removeEventListener('resize', this.boundResize); + } + if (this.state === 'active') this.state = 'inactive'; + this.engine.overrides.clear(); + this.resources.clear(); + } + + async dispose() { + if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest); + this.frameRequest = null; + if (typeof globalThis.removeEventListener === 'function') { + globalThis.removeEventListener('pointermove', this.boundPointer); + globalThis.removeEventListener('resize', this.boundResize); + } + this.engine.dispose(); + this.resources.clear(); + this.state = 'disposed'; + } +} diff --git a/src/runtime/persistence.js b/src/runtime/persistence.js new file mode 100644 index 0000000..c888c62 --- /dev/null +++ b/src/runtime/persistence.js @@ -0,0 +1,79 @@ +import { DATABASE } from './constants.js'; + +function requestResult(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed.')); + }); +} + +function transactionDone(transaction) { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted.')); + transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed.')); + }); +} + +export class PersistenceManager { + constructor({ indexedDBProvider = globalThis.indexedDB, diagnostics } = {}) { + this.indexedDB = indexedDBProvider; + this.diagnostics = diagnostics; + this.database = null; + this.available = false; + } + + async open() { + if (!this.indexedDB) return this.failOpen('IndexedDB is unavailable in this browser context.'); + try { + const request = this.indexedDB.open(DATABASE.name, DATABASE.version); + request.onupgradeneeded = () => { + const database = request.result; + if (!database.objectStoreNames.contains(DATABASE.exhibits)) database.createObjectStore(DATABASE.exhibits, { keyPath: 'id' }); + if (!database.objectStoreNames.contains(DATABASE.preferences)) database.createObjectStore(DATABASE.preferences, { keyPath: 'key' }); + }; + this.database = await requestResult(request); + this.database.onversionchange = () => { + this.database.close(); + this.database = null; + this.available = false; + }; + this.available = true; + this.diagnostics?.info('INFO_STORAGE_READY', 'Local exhibit storage is ready.', { section: 'persistence' }); + return true; + } catch (error) { + return this.failOpen(error.message); + } + } + + failOpen(reason) { + this.available = false; + this.diagnostics?.warn('WARN_STORAGE_UNAVAILABLE', `Session-only mode: ${reason} Imported exhibits must be imported again on a later launch.`, { section: 'persistence' }); + return false; + } + + async loadSnapshot() { + if (!this.available) return { exhibits: [], preferences: {} }; + const transaction = this.database.transaction([DATABASE.exhibits, DATABASE.preferences], 'readonly'); + const exhibitsRequest = transaction.objectStore(DATABASE.exhibits).getAll(); + const preferencesRequest = transaction.objectStore(DATABASE.preferences).getAll(); + const [exhibits, rows] = await Promise.all([requestResult(exhibitsRequest), requestResult(preferencesRequest), transactionDone(transaction)]); + return { exhibits, preferences: Object.fromEntries(rows.map(({ key, value }) => [key, value])) }; + } + + async saveExhibit(record) { + if (!this.available) return false; + const transaction = this.database.transaction(DATABASE.exhibits, 'readwrite'); + transaction.objectStore(DATABASE.exhibits).put(record); + await transactionDone(transaction); + return true; + } + + async savePreference(key, value) { + if (!this.available) return false; + const transaction = this.database.transaction(DATABASE.preferences, 'readwrite'); + transaction.objectStore(DATABASE.preferences).put({ key, value }); + await transactionDone(transaction); + return true; + } +} diff --git a/src/runtime/resolution.js b/src/runtime/resolution.js new file mode 100644 index 0000000..932b82c --- /dev/null +++ b/src/runtime/resolution.js @@ -0,0 +1,305 @@ +import { + RUNTIME_SIGNAL_TYPES, + RuntimeFault, + clamp, + easingValue, + isNumericType, + lerp, + normalizeForSpec, + parseDuration, + roundHalfAwayFromZero, + valueMatchesType +} from './types.js'; +import { ConditionEvaluator, ValueResolver } from './values.js'; + +const STEP_SECONDS = 1 / 60; + +export class SignalProvider { + constructor() { + this.values = new Map([ + ['signals.time.elapsed', 0], ['signals.time.delta', 0], + ['signals.audio.low', 0], ['signals.audio.mid', 0], ['signals.audio.high', 0], ['signals.audio.energy', 0], + ['signals.pointer.x', 0], ['signals.pointer.y', 0], + ['signals.viewport.width', globalThis.innerWidth ?? 0], ['signals.viewport.height', globalThis.innerHeight ?? 0], + ['signals.scenario.active', false] + ]); + } + + get(path) { + if (!this.values.has(path)) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown runtime signal '${path}'.`, path); + return this.values.get(path); + } + + set(path, value) { + const type = RUNTIME_SIGNAL_TYPES[path]; + if (!type) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown runtime signal '${path}'.`, path); + if ((type === 'number' && (!Number.isFinite(value))) || (type === 'boolean' && typeof value !== 'boolean')) throw new RuntimeFault('ERR_TYPE_MISMATCH', `Signal '${path}' requires ${type}.`, path); + this.values.set(path, value); + } + + updateTime(elapsedSeconds, deltaSeconds) { + this.values.set('signals.time.elapsed', elapsedSeconds); + this.values.set('signals.time.delta', deltaSeconds); + } +} + +export class OverrideStack { + constructor(engine) { + this.engine = engine; + this.instances = new Map(); + this.activationSequence = 0; + } + + add(action, sampledValue, { owner = 'performance', inheritedPriority = 0 } = {}) { + const target = this.engine.target(action.target); + if (!target) throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Override target '${action.target}' is not exposed.`, action.target); + const normalized = normalizeForSpec(target.spec, sampledValue); + if (action.scope !== 'scenario' && action.scope !== 'duration') throw new RuntimeFault('ERR_SCHEMA_VALIDATION', "Override scope must be 'scenario' or 'duration'."); + const durationMs = action.scope === 'duration' ? parseDuration(action.duration, '$.duration') : null; + if (action.scope === 'duration' && durationMs <= 0) throw new RuntimeFault('ERR_INVALID_DURATION', 'Duration overrides require duration greater than 0ms.'); + if (action.scope === 'scenario' && action.duration !== undefined) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Scenario-scoped overrides cannot declare duration.'); + const priority = action.priority ?? inheritedPriority; + if (!Number.isInteger(priority) || priority < -1000 || priority > 1000) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Override priority must be an integer from -1000 through 1000.'); + const transition = action.transition ?? {}; + const attackMs = parseDuration(transition.in ?? '0ms', '$.transition.in'); + const releaseMs = parseDuration(transition.out ?? '0ms', '$.transition.out'); + const easing = transition.easing ?? 'linear'; + easingValue(easing, 0); + if (!isNumericType(target.spec.type) && (attackMs > 0 || releaseMs > 0)) throw new RuntimeFault('ERR_INVALID_TRANSITION', 'Non-numeric overrides require zero-duration transitions.'); + const sequence = ++this.activationSequence; + const instance = { + id: `instances.override-${sequence}`, + authorId: action.id ?? null, + target: action.target, + owner, + scope: action.scope, + priority, + activationSequence: sequence, + sampledValue: normalized, + attackOrigin: this.engine.get(action.target), + attackMs, + releaseMs, + easing, + activatedAt: this.engine.logicalMilliseconds, + expiresAt: durationMs === null ? null : this.engine.logicalMilliseconds + durationMs, + phase: 'active', + releaseStartedAt: null, + releaseStartValue: null, + lastValue: null + }; + this.instances.set(instance.id, instance); + this.engine.invalidate(); + return instance.id; + } + + beginRelease(id) { + const instance = this.instances.get(id); + if (!instance || instance.phase === 'releasing') return false; + if (instance.releaseMs === 0) { + this.instances.delete(id); + } else { + instance.phase = 'releasing'; + instance.releaseStartedAt = this.engine.logicalMilliseconds; + instance.releaseStartValue = instance.lastValue ?? instance.sampledValue; + } + this.engine.invalidate(); + return true; + } + + releaseOwner(owner) { + for (const instance of [...this.instances.values()]) if (instance.owner === owner && instance.scope === 'scenario') this.beginRelease(instance.id); + } + + advance() { + const now = this.engine.logicalMilliseconds; + for (const instance of [...this.instances.values()]) { + if (instance.phase === 'active' && instance.expiresAt !== null && now >= instance.expiresAt) this.beginRelease(instance.id); + if (instance.phase === 'releasing' && now - instance.releaseStartedAt >= instance.releaseMs) this.instances.delete(instance.id); + } + } + + forTarget(path) { return [...this.instances.values()].filter((instance) => instance.target === path); } + has(path) { return this.forTarget(path).length > 0; } + + select(path) { + return this.forTarget(path).reduce((winner, candidate) => { + if (!winner || candidate.priority > winner.priority || (candidate.priority === winner.priority && candidate.activationSequence > winner.activationSequence)) return candidate; + return winner; + }, null); + } + + value(instance, lowerValue) { + const now = this.engine.logicalMilliseconds; + let value = instance.sampledValue; + if (instance.phase === 'releasing') { + const progress = (now - instance.releaseStartedAt) / instance.releaseMs; + value = lerp(lowerValue, instance.releaseStartValue, 1 - easingValue(instance.easing, progress)); + } else if (instance.attackMs > 0) { + const progress = (now - instance.activatedAt) / instance.attackMs; + value = lerp(instance.attackOrigin, instance.sampledValue, easingValue(instance.easing, progress)); + } + instance.lastValue = value; + return value; + } + + clear() { this.instances.clear(); this.engine.invalidate(); } +} + +export class ResolutionEngine { + constructor(document, rootRng, { diagnostics, initialParameters = {}, onParameterChange = () => {} } = {}) { + this.document = document; + this.rng = rootRng; + this.diagnostics = diagnostics; + this.onParameterChange = onParameterChange; + this.parameters = new Map(); + this.state = new Map(); + this.signals = new SignalProvider(); + this.bindings = (document.bindings ?? []).map((binding, index) => ({ definition: binding, index, smoother: undefined, enabled: false, lastTick: -1 })); + this.transitions = new Map(); + this.resolved = new Map(); + this.resolving = new Set(); + this.tickIndex = 0; + this.logicalMilliseconds = 0; + this.valueResolver = new ValueResolver((path) => this.get(path)); + this.conditions = new ConditionEvaluator(this.valueResolver); + this.overrides = new OverrideStack(this); + for (const [id, spec] of Object.entries(document.parameters ?? {})) { + const candidate = Object.hasOwn(initialParameters, id) ? initialParameters[id] : spec.default; + this.parameters.set(id, valueMatchesType(spec.type, candidate, spec) ? normalizeForSpec(spec, candidate, { clampNumeric: true }) : spec.default); + } + for (const [id, spec] of Object.entries(document.state ?? {})) this.state.set(id, spec.initial); + this.resolveAll(); + } + + target(path) { + const [namespace, id, extra] = path.split('.'); + if (extra !== undefined) return null; + if (namespace === 'parameters' && this.document.parameters?.[id]) return { namespace, id, spec: this.document.parameters[id] }; + if (namespace === 'state' && this.document.state?.[id]) return { namespace, id, spec: this.document.state[id] }; + return null; + } + + base(path) { + const target = this.target(path); + if (!target) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown value reference '${path}'.`, path); + return target.namespace === 'parameters' ? this.parameters.get(target.id) : this.state.get(target.id); + } + + get(path) { + if (path.startsWith('signals.')) return this.signals.get(path); + if (this.resolved.has(path)) return this.resolved.get(path); + return this.resolveTarget(path); + } + + resolveAll() { + this.resolved.clear(); + for (const id of Object.keys(this.document.parameters ?? {})) this.resolveTarget(`parameters.${id}`); + for (const id of Object.keys(this.document.state ?? {})) this.resolveTarget(`state.${id}`); + return this.snapshot(); + } + + resolveTarget(path) { + if (this.resolved.has(path)) return this.resolved.get(path); + const target = this.target(path); + if (!target) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown or unsupported reference '${path}'.`, path); + if (this.resolving.has(path)) throw new RuntimeFault('ERR_CYCLIC_DEPENDENCY', `Resolution cycle reached '${path}'.`, path); + this.resolving.add(path); + try { + let lower = this.base(path); + const binding = this.bindings.find((item) => item.definition.target === path); + if (binding) lower = this.bindingValue(binding, target, lower); + const winner = this.overrides.select(path); + let value = winner ? this.overrides.value(winner, lower) : lower; + if (isNumericType(target.spec.type)) { + if (target.spec.type === 'integer') value = roundHalfAwayFromZero(value); + value = clamp(value, target.spec.min ?? -Infinity, target.spec.max ?? Infinity); + } + this.resolved.set(path, value); + return value; + } finally { + this.resolving.delete(path); + } + } + + bindingValue(binding, target, baseValue) { + const definition = binding.definition; + const conditionStream = this.rng.stream('scenario', `binding-${binding.index}:condition`); + const enabled = definition.when === undefined || this.conditions.evaluate(definition.when, conditionStream, `$.bindings[${binding.index}].when`); + if (!enabled) { + binding.enabled = false; + binding.smoother = undefined; + return baseValue; + } + const source = this.get(definition.source); + const numeric = typeof source === 'number' && isNumericType(target.spec.type); + let value = source; + if (numeric) { + value = (source * (definition.scale ?? 1)) + (definition.offset ?? 0); + if (definition.clamp) value = clamp(value, definition.clamp[0], definition.clamp[1]); + const tau = parseDuration(definition.smoothing ?? '0ms') / 1000; + if (!binding.enabled || binding.smoother === undefined) { + binding.smoother = value; + binding.lastTick = this.tickIndex; + } else if (binding.lastTick !== this.tickIndex) { + if (tau === 0) binding.smoother = value; + else binding.smoother += (1 - Math.exp(-STEP_SECONDS / tau)) * (value - binding.smoother); + binding.lastTick = this.tickIndex; + } + value = binding.smoother; + } + binding.enabled = true; + return normalizeForSpec(target.spec, value, { clampNumeric: true }); + } + + setParameter(id, value) { + const spec = this.document.parameters?.[id]; + if (!spec) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown parameter '${id}'.`, `parameters.${id}`); + this.parameters.set(id, normalizeForSpec(spec, value)); + this.invalidate(); + this.onParameterChange(this.parameterSnapshot()); + return this.resolveAll(); + } + + setState(path, value, transition = {}) { + const target = this.target(path); + if (!target || target.namespace !== 'state') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `set may target state only; got '${path}'.`, path); + const normalized = normalizeForSpec(target.spec, value); + const durationMs = parseDuration(transition.duration ?? '0ms', '$.transition.duration'); + const easing = transition.easing ?? 'linear'; + easingValue(easing, 0); + if (!isNumericType(target.spec.type) && durationMs > 0) throw new RuntimeFault('ERR_INVALID_TRANSITION', 'Non-numeric state values require zero-duration transitions.'); + if (durationMs === 0) { + this.state.set(target.id, normalized); + this.transitions.delete(path); + } else { + this.transitions.set(path, { path, from: this.base(path), to: normalized, start: this.logicalMilliseconds, durationMs, easing, spec: target.spec }); + } + this.invalidate(); + return this.resolveAll(); + } + + advance(milliseconds = 1000 / 60) { + if (!Number.isFinite(milliseconds) || milliseconds < 0) throw new RangeError('Advance duration must be finite and nonnegative.'); + this.logicalMilliseconds += milliseconds; + this.tickIndex += 1; + this.signals.updateTime(this.logicalMilliseconds / 1000, milliseconds / 1000); + for (const [path, transition] of [...this.transitions]) { + const progress = clamp((this.logicalMilliseconds - transition.start) / transition.durationMs, 0, 1); + let value = lerp(transition.from, transition.to, easingValue(transition.easing, progress)); + if (transition.spec.type === 'integer') value = roundHalfAwayFromZero(value); + value = normalizeForSpec(transition.spec, value, { clampNumeric: true }); + this.state.set(path.split('.')[1], value); + if (progress >= 1) this.transitions.delete(path); + } + this.overrides.advance(); + return this.resolveAll(); + } + + evaluateValue(spec, stream, path) { return this.valueResolver.evaluate(spec, stream, path); } + evaluateCondition(spec, stream, path) { return this.conditions.evaluate(spec, stream, path); } + invalidate() { this.resolved.clear(); } + parameterSnapshot() { return Object.fromEntries(this.parameters); } + stateSnapshot() { return Object.fromEntries(this.state); } + snapshot() { return Object.fromEntries(this.resolved); } + dispose() { this.overrides.clear(); this.transitions.clear(); this.resolved.clear(); } +} diff --git a/src/runtime/rng.js b/src/runtime/rng.js new file mode 100644 index 0000000..3f58832 --- /dev/null +++ b/src/runtime/rng.js @@ -0,0 +1,103 @@ +import { RNG_DOMAINS, UINT32_RANGE } from './constants.js'; + +function rotateLeft(value, count) { + return ((value << count) | (value >>> (32 - count))) >>> 0; +} + +function multiply32(left, right) { + return Math.imul(left, right) >>> 0; +} + +export function fnv1a32(text) { + let hash = 0x811c9dc5; + for (const byte of new TextEncoder().encode(text)) { + hash ^= byte; + hash = multiply32(hash, 0x01000193); + } + return hash >>> 0; +} + +export function splitMix32(seed) { + let state = seed >>> 0; + return () => { + state = (state + 0x9e3779b9) >>> 0; + let value = state; + value = multiply32(value ^ (value >>> 16), 0x21f0aaad); + value = multiply32(value ^ (value >>> 15), 0x735a2d97); + return (value ^ (value >>> 15)) >>> 0; + }; +} + +export function deriveStreamState(rootSeed, domain, stableInstanceKey) { + validateRootSeed(rootSeed); + if (!RNG_DOMAINS.includes(domain)) throw new RangeError(`Unknown XZBT random domain: ${domain}.`); + if (typeof stableInstanceKey !== 'string' || stableInstanceKey.length === 0) { + throw new TypeError('A non-empty stable instance key is required.'); + } + const hash = fnv1a32(`xzbt-0.1\0${rootSeed}\0${domain}\0${stableInstanceKey}`); + const expand = splitMix32(hash); + const state = [expand(), expand(), expand(), expand()]; + if (state.every((word) => word === 0)) state[3] = 1; + return state; +} + +export class RandomStream { + constructor(state) { + if (!Array.isArray(state) || state.length !== 4 || state.some((word) => !Number.isInteger(word))) { + throw new TypeError('xoshiro128** state must contain four integer words.'); + } + this.state = state.map((word) => word >>> 0); + if (this.state.every((word) => word === 0)) throw new RangeError('xoshiro128** state cannot be all zero.'); + } + + nextUint32() { + const state = this.state; + const result = multiply32(rotateLeft(multiply32(state[1], 5), 7), 9); + const temporary = (state[1] << 9) >>> 0; + state[2] ^= state[0]; + state[3] ^= state[1]; + state[1] ^= state[2]; + state[0] ^= state[3]; + state[2] ^= temporary; + state[3] = rotateLeft(state[3], 11); + for (let index = 0; index < 4; index++) state[index] >>>= 0; + return result >>> 0; + } + + nextFloat() { return this.nextUint32() / UINT32_RANGE; } + + nextInteger(minimum, maximum) { + if (!Number.isSafeInteger(minimum) || !Number.isSafeInteger(maximum) || maximum < minimum) { + throw new RangeError('Integer bounds must be safe integers with maximum >= minimum.'); + } + const span = maximum - minimum + 1; + if (span > UINT32_RANGE) throw new RangeError('Integer range cannot exceed 2^32 values.'); + const limit = UINT32_RANGE - (UINT32_RANGE % span); + let value; + do value = this.nextUint32(); while (value >= limit); + return minimum + (value % span); + } +} + +export class SeededRNG { + constructor(rootSeed) { + this.rootSeed = validateRootSeed(rootSeed); + } + + stream(domain, stableInstanceKey) { + return new RandomStream(deriveStreamState(this.rootSeed, domain, stableInstanceKey)); + } +} + +export function validateRootSeed(seed) { + if (!Number.isInteger(seed) || seed < 0 || seed >= UINT32_RANGE) { + throw new RangeError('Root seed must be an unsigned 32-bit integer.'); + } + return seed >>> 0; +} + +export function resolveRootSeed(authoredSeed = 'random', cryptoProvider = globalThis.crypto) { + if (authoredSeed !== 'random') return validateRootSeed(authoredSeed); + if (!cryptoProvider?.getRandomValues) throw new Error('Cryptographic entropy is unavailable; a random exhibit seed cannot be resolved.'); + return cryptoProvider.getRandomValues(new Uint32Array(1))[0] >>> 0; +} diff --git a/src/runtime/types.js b/src/runtime/types.js new file mode 100644 index 0000000..52a1520 --- /dev/null +++ b/src/runtime/types.js @@ -0,0 +1,92 @@ +export const PARAMETER_TYPES = Object.freeze(['number', 'integer', 'boolean', 'string', 'color', 'enum']); +export const STATE_TYPES = Object.freeze(['number', 'integer', 'boolean', 'string']); +export const EASINGS = Object.freeze(['linear', 'ease-in', 'ease-out', 'ease-in-out']); +export const DURATION_PATTERN = /^([0-9]+(?:\.[0-9]+)?)(ms|s|m|h)$/; +export const RUNTIME_SIGNAL_TYPES = Object.freeze({ + 'signals.time.elapsed': 'number', + 'signals.time.delta': 'number', + 'signals.audio.low': 'number', + 'signals.audio.mid': 'number', + 'signals.audio.high': 'number', + 'signals.audio.energy': 'number', + 'signals.pointer.x': 'number', + 'signals.pointer.y': 'number', + 'signals.viewport.width': 'number', + 'signals.viewport.height': 'number', + 'signals.scenario.active': 'boolean' +}); + +export class RuntimeFault extends Error { + constructor(code, message, path = '$') { + super(message); + this.name = 'RuntimeFault'; + this.code = code; + this.path = path; + } +} + +export function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isNumericType(type) { return type === 'number' || type === 'integer'; } + +export function clamp(value, minimum = -Infinity, maximum = Infinity) { + return Math.min(maximum, Math.max(minimum, value)); +} + +export function roundHalfAwayFromZero(value) { + return Math.sign(value) * Math.floor(Math.abs(value) + 0.5); +} + +export function easingValue(name, progress) { + const value = clamp(progress, 0, 1); + switch (name) { + case 'linear': return value; + case 'ease-in': return value * value; + case 'ease-out': return 1 - ((1 - value) * (1 - value)); + case 'ease-in-out': return value < 0.5 ? 2 * value * value : 1 - (((-2 * value + 2) ** 2) / 2); + default: throw new RuntimeFault('ERR_INVALID_TRANSITION', `Unsupported easing '${name}'.`); + } +} + +export function lerp(from, to, amount) { return from + ((to - from) * amount); } + +export function parseDuration(value, path = '$') { + if (typeof value !== 'string') throw new RuntimeFault('ERR_INVALID_DURATION', 'Duration must be a single-unit string.', path); + const match = DURATION_PATTERN.exec(value); + if (!match) throw new RuntimeFault('ERR_INVALID_DURATION', `Invalid duration '${value}'.`, path); + const scalar = Number(match[1]); + const multipliers = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }; + const milliseconds = scalar * multipliers[match[2]]; + if (!Number.isFinite(milliseconds)) throw new RuntimeFault('ERR_INVALID_DURATION', `Duration '${value}' is not finite.`, path); + return milliseconds; +} + +export function valueMatchesType(type, value, spec = {}) { + if (type === 'number') return typeof value === 'number' && Number.isFinite(value); + if (type === 'integer') return Number.isInteger(value); + if (type === 'boolean') return typeof value === 'boolean'; + if (type === 'string') return typeof value === 'string'; + if (type === 'color') return typeof value === 'string' && value.trim().length > 0; + if (type === 'enum') return typeof value === 'string' && Array.isArray(spec.values) && spec.values.includes(value); + return false; +} + +export function normalizeForSpec(spec, value, { clampNumeric = false } = {}) { + if (!valueMatchesType(spec.type, value, spec)) { + throw new RuntimeFault('ERR_TYPE_MISMATCH', `Value ${JSON.stringify(value)} does not match type '${spec.type}'.`); + } + if (isNumericType(spec.type)) { + let result = value; + if (spec.type === 'integer') result = roundHalfAwayFromZero(result); + const minimum = spec.min ?? -Infinity; + const maximum = spec.max ?? Infinity; + if (!clampNumeric && (result < minimum || result > maximum)) { + throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Value ${result} is outside [${minimum}, ${maximum}].`); + } + result = clamp(result, minimum, maximum); + return result; + } + return value; +} diff --git a/src/runtime/validator.js b/src/runtime/validator.js new file mode 100644 index 0000000..7aa59d1 --- /dev/null +++ b/src/runtime/validator.js @@ -0,0 +1,267 @@ +import { + ALLOWED_META_FIELDS, + ALLOWED_TOP_LEVEL_FIELDS, + ID_PATTERN, + UINT32_RANGE, + XZBT_FORMAT_VERSION +} from './constants.js'; +import { + DURATION_PATTERN, + PARAMETER_TYPES, + RUNTIME_SIGNAL_TYPES, + STATE_TYPES, + isNumericType, + valueMatchesType +} from './types.js'; + +function issue(code, path, message, context = {}) { + return { code, path, message, ...context }; +} + +function isPlainObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function parseExhibit(source, filename = 'document.xzbt') { + if (typeof source !== 'string') { + return { valid: false, document: null, errors: [issue('ERR_SCHEMA_VALIDATION', '$', 'Exhibit source must be UTF-8 JSON text.')], filename }; + } + const normalizedSource = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source; + try { + return { valid: true, document: JSON.parse(normalizedSource), errors: [], filename, source: normalizedSource }; + } catch (error) { + return { valid: false, document: null, errors: [issue('ERR_SCHEMA_VALIDATION', '$', `Malformed JSON: ${error.message}`)], filename, source: normalizedSource }; + } +} + +export function validateExhibit(document, filename = 'document.xzbt') { + const errors = []; + const add = (code, path, message, context) => errors.push(issue(code, path, message, context)); + if (!isPlainObject(document)) { + add('ERR_SCHEMA_VALIDATION', '$', 'Top-level document must be a JSON object.'); + return { valid: false, errors, warnings: [], filename }; + } + + if (document.xzbt !== XZBT_FORMAT_VERSION) { + add('ERR_UNSUPPORTED_VERSION', '$.xzbt', `Unsupported XZBT format version: '${document.xzbt}'. Expected '${XZBT_FORMAT_VERSION}'.`, { property: 'xzbt' }); + } + for (const field of Object.keys(document)) { + if (!ALLOWED_TOP_LEVEL_FIELDS.includes(field)) add('ERR_UNKNOWN_FIELD', `$.${field}`, `Unrecognized top-level field: '${field}'.`, { section: field }); + } + + if (!isPlainObject(document.meta)) { + add('ERR_SCHEMA_VALIDATION', '$.meta', 'Missing or invalid required object: meta.', { section: 'meta' }); + } else { + for (const field of Object.keys(document.meta)) { + if (!ALLOWED_META_FIELDS.includes(field)) add('ERR_UNKNOWN_FIELD', `$.meta.${field}`, `Unrecognized metadata field: '${field}'.`, { section: 'meta', property: field }); + } + const { id, name, version, author, description, license, tags } = document.meta; + if (typeof id !== 'string' || !ID_PATTERN.test(id) || id.length > 64) { + add('ERR_INVALID_ID', '$.meta.id', "Exhibit id must match ^[a-z][a-z0-9_-]*$ and contain at most 64 characters.", { section: 'meta', property: 'id' }); + } + if (typeof name !== 'string' || name.trim().length === 0 || name.length > 128) { + add('ERR_SCHEMA_VALIDATION', '$.meta.name', 'Exhibit name must be a non-empty string of at most 128 characters.', { section: 'meta', property: 'name' }); + } + if (version !== undefined && typeof version !== 'string') add('ERR_TYPE_MISMATCH', '$.meta.version', 'Exhibit version must be a string.', { section: 'meta', property: 'version' }); + if (author !== undefined && (typeof author !== 'string' || author.length > 128)) add('ERR_TYPE_MISMATCH', '$.meta.author', 'Author must be a string of at most 128 characters.', { section: 'meta', property: 'author' }); + if (description !== undefined && (typeof description !== 'string' || description.length > 1024)) add('ERR_TYPE_MISMATCH', '$.meta.description', 'Description must be a string of at most 1024 characters.', { section: 'meta', property: 'description' }); + if (license !== undefined && typeof license !== 'string') add('ERR_TYPE_MISMATCH', '$.meta.license', 'License must be a string.', { section: 'meta', property: 'license' }); + if (tags !== undefined && (!Array.isArray(tags) || tags.length > 16 || tags.some((tag) => typeof tag !== 'string' || tag.length > 32))) { + add('ERR_TYPE_MISMATCH', '$.meta.tags', 'Tags must contain at most 16 strings of at most 32 characters.', { section: 'meta', property: 'tags' }); + } + } + + if (document.runtime !== undefined) { + if (!isPlainObject(document.runtime)) { + add('ERR_SCHEMA_VALIDATION', '$.runtime', 'runtime must be an object.', { section: 'runtime' }); + } else { + for (const field of Object.keys(document.runtime)) { + if (field !== 'seed') add('ERR_UNKNOWN_FIELD', `$.runtime.${field}`, `Unrecognized runtime field: '${field}'.`, { section: 'runtime', property: field }); + } + const seed = document.runtime.seed; + if (seed !== undefined && seed !== 'random' && (!Number.isInteger(seed) || seed < 0 || seed >= UINT32_RANGE)) { + add('ERR_OUT_OF_BOUNDS', '$.runtime.seed', "Seed must be 'random' or an unsigned 32-bit integer.", { section: 'runtime', property: 'seed' }); + } + } + } + + validateDefinitions(document, errors); + validateBindings(document, errors); + + return { valid: errors.length === 0, errors, warnings: [], filename }; +} + +const PARAMETER_FIELDS = new Set(['type', 'default', 'min', 'max', 'step', 'values', 'label', 'unit']); +const STATE_FIELDS = new Set(['type', 'initial', 'min', 'max']); +const BINDING_FIELDS = new Set(['source', 'target', 'scale', 'offset', 'clamp', 'smoothing', 'when']); +const VALUE_OPERATORS = Object.freeze({ abs: 1, negate: 1, round: 1, floor: 1, ceil: 1, add: 2, subtract: 2, multiply: 2, divide: 2, min: 2, max: 2, clamp: 3, lerp: 3 }); +const COMPARISONS = new Set(['eq', 'ne', 'gt', 'gte', 'lt', 'lte']); + +function pushError(errors, code, path, message) { errors.push(issue(code, path, message)); } + +function validateDefinitions(document, errors) { + validateDefinitionMap(document.parameters, 'parameters', PARAMETER_TYPES, 'default', PARAMETER_FIELDS, errors); + validateDefinitionMap(document.state, 'state', STATE_TYPES, 'initial', STATE_FIELDS, errors); +} + +function validateDefinitionMap(definitions, namespace, allowedTypes, valueField, allowedFields, errors) { + if (definitions === undefined) return; + if (!isPlainObject(definitions)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.${namespace}`, `${namespace} must be an object.`); + return; + } + for (const [id, spec] of Object.entries(definitions)) { + const path = `$.${namespace}.${id}`; + if (!ID_PATTERN.test(id)) pushError(errors, 'ERR_INVALID_ID', path, `${namespace} ID '${id}' is invalid.`); + if (!isPlainObject(spec)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', path, `${namespace} definition must be an object.`); + continue; + } + for (const field of Object.keys(spec)) if (!allowedFields.has(field)) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized ${namespace} field '${field}'.`); + if (!allowedTypes.includes(spec.type)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.type`, `Unsupported ${namespace} type '${spec.type}'.`); + if (!Object.hasOwn(spec, valueField)) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.${valueField}`, `Missing required field '${valueField}'.`); + else if (!valueMatchesType(spec.type, spec[valueField], spec)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.${valueField}`, `Value does not match declared type '${spec.type}'.`); + if (spec.min !== undefined && !Number.isFinite(spec.min)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.min`, 'min must be a finite number.'); + if (spec.max !== undefined && !Number.isFinite(spec.max)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.max`, 'max must be a finite number.'); + if (Number.isFinite(spec.min) && Number.isFinite(spec.max) && spec.min > spec.max) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.min`, 'min cannot exceed max.'); + if (isNumericType(spec.type) && typeof spec[valueField] === 'number') { + if (spec.min !== undefined && spec[valueField] < spec.min) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.${valueField}`, `${valueField} is below min.`); + if (spec.max !== undefined && spec[valueField] > spec.max) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.${valueField}`, `${valueField} is above max.`); + } + if (namespace === 'parameters' && spec.step !== undefined && (!Number.isFinite(spec.step) || spec.step <= 0)) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.step`, 'step must be a finite positive number.'); + if (spec.type === 'enum') { + if (!Array.isArray(spec.values) || spec.values.length === 0 || spec.values.some((value) => typeof value !== 'string')) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.values`, 'Enum values must be a non-empty string array.'); + else if (!spec.values.includes(spec[valueField])) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.${valueField}`, 'Enum default is not declared in values.'); + } + } +} + +function referenceType(document, path) { + if (typeof path !== 'string') return null; + const parts = path.split('.'); + if (parts.length === 2 && parts[0] === 'parameters') return document.parameters?.[parts[1]]?.type ?? null; + if (parts.length === 2 && parts[0] === 'state') return document.state?.[parts[1]]?.type ?? null; + if (parts[0] === 'signals') return RUNTIME_SIGNAL_TYPES[path] ?? null; + if (parts.length === 2 && parts[0] === 'modulators') return document.modulators?.[parts[1]] ? 'number' : null; + if (parts.length === 4 && parts[0] === 'audio' && parts[1] === 'buses' && parts[3] === 'gain') return document.audio?.buses?.[parts[2]] ? 'number' : null; + return null; +} + +function validateReference(document, path, location, errors) { + if (typeof path !== 'string' || !/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/.test(path) || !referenceType(document, path)) pushError(errors, 'ERR_INVALID_REFERENCE', location, `Reference '${path}' does not resolve.`); +} + +function validateValueSpec(document, spec, path, errors) { + if (typeof spec === 'number') { + if (!Number.isFinite(spec)) pushError(errors, 'ERR_TYPE_MISMATCH', path, 'Number must be finite.'); + return; + } + if (typeof spec === 'string' || typeof spec === 'boolean') return; + if (!isPlainObject(spec)) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'Invalid ValueSpec.'); + return; + } + const forms = ['ref', 'random', 'choose', 'op'].filter((field) => Object.hasOwn(spec, field)); + if (forms.length !== 1) { + pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'ValueSpec must use exactly one recognized form.'); + return; + } + const form = forms[0]; + for (const field of Object.keys(spec)) if (field !== form && !(form === 'op' && field === 'args')) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized ValueSpec field '${field}'.`); + if (form === 'ref') return validateReference(document, spec.ref, `${path}.ref`, errors); + if (form === 'random') { + const random = spec.random; + if (!isPlainObject(random)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.random`, 'random must be an object.'); + for (const field of Object.keys(random)) if (!['min', 'max', 'integer', 'distribution'].includes(field)) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.random.${field}`, `Unknown random field '${field}'.`); + if (!Number.isFinite(random.min) || !Number.isFinite(random.max)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.random`, 'random min and max must be finite numbers.'); + else if (random.min > random.max) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.random`, 'random min cannot exceed max.'); + if (random.integer !== undefined && typeof random.integer !== 'boolean') pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.random.integer`, 'integer must be boolean.'); + if (random.distribution !== undefined && !['uniform', 'gaussian'].includes(random.distribution)) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.random.distribution`, 'Unsupported distribution.'); + return; + } + if (form === 'choose') { + if (!Array.isArray(spec.choose) || spec.choose.length === 0) return pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.choose`, 'choose must be a non-empty array.'); + spec.choose.forEach((option, index) => { + if (!isPlainObject(option) || !Object.hasOwn(option, 'value') || !Number.isFinite(option.weight) || option.weight <= 0) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.choose[${index}]`, 'Choice requires value and a finite positive weight.'); + else validateValueSpec(document, option.value, `${path}.choose[${index}].value`, errors); + }); + return; + } + if (!Object.hasOwn(VALUE_OPERATORS, spec.op)) pushError(errors, 'ERR_INVALID_OPERATOR', `${path}.op`, `Unknown operator '${spec.op}'.`); + if (!Array.isArray(spec.args)) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.args`, 'Operator args must be an array.'); + else { + if (spec.args.length !== VALUE_OPERATORS[spec.op]) pushError(errors, 'ERR_INVALID_ARITY', `${path}.args`, `Operator '${spec.op}' has invalid arity.`); + spec.args.forEach((argument, index) => validateValueSpec(document, argument, `${path}.args[${index}]`, errors)); + } +} + +function validateCondition(document, condition, path, errors) { + if (!isPlainObject(condition)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'ConditionSpec must be an object.'); + if (Object.hasOwn(condition, 'and') || Object.hasOwn(condition, 'or')) { + const key = Object.hasOwn(condition, 'and') ? 'and' : 'or'; + if (Object.keys(condition).length !== 1) pushError(errors, 'ERR_UNKNOWN_FIELD', path, 'Logical condition contains extra fields.'); + if (!Array.isArray(condition[key]) || condition[key].length === 0) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.${key}`, `${key} requires a non-empty array.`); + else condition[key].forEach((child, index) => validateCondition(document, child, `${path}.${key}[${index}]`, errors)); + return; + } + if (Object.hasOwn(condition, 'not')) { + if (Object.keys(condition).length !== 1) pushError(errors, 'ERR_UNKNOWN_FIELD', path, 'not condition contains extra fields.'); + return validateCondition(document, condition.not, `${path}.not`, errors); + } + if (!COMPARISONS.has(condition.op)) pushError(errors, 'ERR_INVALID_OPERATOR', `${path}.op`, `Unknown comparison '${condition.op}'.`); + if (!Object.hasOwn(condition, 'left') || !Object.hasOwn(condition, 'right')) pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'Comparison requires left and right.'); + else { + validateValueSpec(document, condition.left, `${path}.left`, errors); + validateValueSpec(document, condition.right, `${path}.right`, errors); + } +} + +function validateBindings(document, errors) { + if (document.bindings === undefined) return; + if (!Array.isArray(document.bindings)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', '$.bindings', 'bindings must be an array.'); + const writers = new Map(); + const dependencies = new Map(); + document.bindings.forEach((binding, index) => { + const path = `$.bindings[${index}]`; + if (!isPlainObject(binding)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'Binding must be an object.'); + for (const field of Object.keys(binding)) if (!BINDING_FIELDS.has(field)) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unknown binding field '${field}'.`); + validateReference(document, binding.source, `${path}.source`, errors); + validateReference(document, binding.target, `${path}.target`, errors); + if (!(typeof binding.target === 'string' && (/^(parameters|state)\.[a-z][a-z0-9_-]*$/.test(binding.target) || /^audio\.buses\.[a-z][a-z0-9_-]*\.gain$/.test(binding.target)))) pushError(errors, 'ERR_UNSUPPORTED_TARGET', `${path}.target`, `Binding target '${binding.target}' is not exposed.`); + for (const field of ['scale', 'offset']) if (binding[field] !== undefined && !Number.isFinite(binding[field])) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.${field}`, `${field} must be finite.`); + if (binding.clamp !== undefined && (!Array.isArray(binding.clamp) || binding.clamp.length !== 2 || binding.clamp.some((value) => !Number.isFinite(value)))) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.clamp`, 'clamp must contain two finite numbers.'); + else if (binding.clamp?.[0] > binding.clamp?.[1]) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.clamp`, 'clamp minimum cannot exceed maximum.'); + if (binding.smoothing !== undefined && (typeof binding.smoothing !== 'string' || !DURATION_PATTERN.test(binding.smoothing))) pushError(errors, 'ERR_INVALID_DURATION', `${path}.smoothing`, 'Invalid smoothing duration.'); + if (binding.when !== undefined) validateCondition(document, binding.when, `${path}.when`, errors); + const sourceType = referenceType(document, binding.source); + const targetType = referenceType(document, binding.target); + if (sourceType && targetType) { + const transformed = binding.scale !== undefined || binding.offset !== undefined || binding.clamp !== undefined || (binding.smoothing !== undefined && binding.smoothing !== '0ms'); + if (transformed && (!isNumericType(sourceType) || !isNumericType(targetType))) pushError(errors, 'ERR_TYPE_MISMATCH', path, 'Binding transforms require numeric endpoints.'); + else if (!transformed && sourceType !== targetType && !(isNumericType(sourceType) && isNumericType(targetType))) pushError(errors, 'ERR_TYPE_MISMATCH', path, 'Binding endpoint types do not match.'); + } + if (typeof binding.target === 'string') { + if (writers.has(binding.target)) pushError(errors, 'ERR_CONFLICTING_BINDING', `${path}.target`, `Target already written by binding ${writers.get(binding.target)}.`); + else writers.set(binding.target, index); + if (!dependencies.has(binding.target)) dependencies.set(binding.target, []); + if (typeof binding.source === 'string') dependencies.get(binding.target).push(binding.source); + } + }); + const visiting = new Set(); + const visited = new Set(); + const visit = (node, trail) => { + if (visiting.has(node)) return pushError(errors, 'ERR_CYCLIC_DEPENDENCY', '$.bindings', `Binding cycle: ${[...trail, node].join(' -> ')}.`); + if (visited.has(node)) return; + visiting.add(node); + for (const source of dependencies.get(node) ?? []) visit(source, [...trail, node]); + visiting.delete(node); + visited.add(node); + }; + for (const target of dependencies.keys()) visit(target, []); +} + +export function parseAndValidateExhibit(source, filename) { + const parsed = parseExhibit(source, filename); + if (!parsed.valid) return parsed; + return { ...validateExhibit(parsed.document, filename), document: parsed.document, source: parsed.source }; +} diff --git a/src/runtime/values.js b/src/runtime/values.js new file mode 100644 index 0000000..1fc1bf0 --- /dev/null +++ b/src/runtime/values.js @@ -0,0 +1,106 @@ +import { RuntimeFault, clamp, isRecord } from './types.js'; + +const OPERATOR_ARITY = Object.freeze({ + abs: 1, negate: 1, round: 1, floor: 1, ceil: 1, + add: 2, subtract: 2, multiply: 2, divide: 2, min: 2, max: 2, + clamp: 3, lerp: 3 +}); + +export class ValueResolver { + constructor(resolveReference) { + this.resolveReference = resolveReference; + } + + evaluate(spec, stream, path = '$') { + if (typeof spec === 'number') { + if (!Number.isFinite(spec)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Numeric literals must be finite.', path); + return spec; + } + if (typeof spec === 'string' || typeof spec === 'boolean') return spec; + if (!isRecord(spec)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Invalid ValueSpec.', path); + + if (Object.hasOwn(spec, 'ref')) return this.resolveReference(spec.ref); + if (Object.hasOwn(spec, 'random')) { + if (!stream) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Random ValueSpec requires an owning random stream.', path); + const { min, max, integer = false } = spec.random; + if (!Number.isFinite(min) || !Number.isFinite(max) || max < min || typeof integer !== 'boolean') { + throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Random range requires finite min <= max and an optional boolean integer flag.', `${path}.random`); + } + if (integer && Math.ceil(min) > Math.floor(max)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Integer random range contains no integers.', `${path}.random`); + return integer ? stream.nextInteger(Math.ceil(min), Math.floor(max)) : min + (stream.nextFloat() * (max - min)); + } + if (Object.hasOwn(spec, 'choose')) { + if (!stream || !Array.isArray(spec.choose) || spec.choose.length === 0) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Weighted choice requires a non-empty list and an owning stream.', `${path}.choose`); + let total = 0; + for (const option of spec.choose) { + if (!isRecord(option) || !Number.isFinite(option.weight) || option.weight <= 0 || !Object.hasOwn(option, 'value')) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Every choice requires a value and a finite positive weight.', `${path}.choose`); + total += option.weight; + } + const selected = stream.nextFloat() * total; + let cumulative = 0; + for (let index = 0; index < spec.choose.length; index += 1) { + cumulative += spec.choose[index].weight; + if (selected < cumulative || index === spec.choose.length - 1) return this.evaluate(spec.choose[index].value, stream, `${path}.choose[${index}].value`); + } + } + if (Object.hasOwn(spec, 'op')) { + const arity = OPERATOR_ARITY[spec.op]; + if (!arity) throw new RuntimeFault('ERR_INVALID_OPERATOR', `Unsupported ValueSpec operator '${spec.op}'.`, `${path}.op`); + if (!Array.isArray(spec.args) || spec.args.length !== arity) throw new RuntimeFault('ERR_INVALID_ARITY', `Operator '${spec.op}' requires ${arity} arguments.`, `${path}.args`); + const values = spec.args.map((argument, index) => this.evaluate(argument, stream, `${path}.args[${index}]`)); + if (values.some((value) => typeof value !== 'number' || !Number.isFinite(value))) throw new RuntimeFault('ERR_TYPE_MISMATCH', `Operator '${spec.op}' requires finite numbers.`, path); + let result; + switch (spec.op) { + case 'abs': result = Math.abs(values[0]); break; + case 'negate': result = -values[0]; break; + case 'round': result = Math.round(values[0]); break; + case 'floor': result = Math.floor(values[0]); break; + case 'ceil': result = Math.ceil(values[0]); break; + case 'add': result = values[0] + values[1]; break; + case 'subtract': result = values[0] - values[1]; break; + case 'multiply': result = values[0] * values[1]; break; + case 'divide': result = values[1] === 0 ? 0 : values[0] / values[1]; break; + case 'min': result = Math.min(values[0], values[1]); break; + case 'max': result = Math.max(values[0], values[1]); break; + case 'clamp': + if (values[1] > values[2]) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Clamp minimum cannot exceed maximum.', path); + result = clamp(values[0], values[1], values[2]); + break; + case 'lerp': result = values[0] + ((values[1] - values[0]) * values[2]); break; + } + if (!Number.isFinite(result)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Operator '${spec.op}' produced a non-finite result.`, path); + return result; + } + throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Unrecognized ValueSpec form.', path); + } +} + +export class ConditionEvaluator { + constructor(valueResolver) { this.values = valueResolver; } + + evaluate(condition, stream, path = '$') { + if (!isRecord(condition)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'ConditionSpec must be an object.', path); + if (Object.hasOwn(condition, 'and')) { + if (!Array.isArray(condition.and) || condition.and.length === 0) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'and requires a non-empty array.', `${path}.and`); + return condition.and.every((child, index) => this.evaluate(child, stream, `${path}.and[${index}]`)); + } + if (Object.hasOwn(condition, 'or')) { + if (!Array.isArray(condition.or) || condition.or.length === 0) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'or requires a non-empty array.', `${path}.or`); + return condition.or.some((child, index) => this.evaluate(child, stream, `${path}.or[${index}]`)); + } + if (Object.hasOwn(condition, 'not')) return !this.evaluate(condition.not, stream, `${path}.not`); + const comparisons = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte']; + if (!comparisons.includes(condition.op) || !Object.hasOwn(condition, 'left') || !Object.hasOwn(condition, 'right')) throw new RuntimeFault('ERR_INVALID_OPERATOR', `Unsupported comparison operator '${condition.op}'.`, `${path}.op`); + const left = this.values.evaluate(condition.left, stream, `${path}.left`); + const right = this.values.evaluate(condition.right, stream, `${path}.right`); + if (typeof left !== typeof right || !['number', 'boolean', 'string'].includes(typeof left)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Condition operands must have compatible primitive types.', path); + switch (condition.op) { + case 'eq': return left === right; + case 'ne': return left !== right; + case 'gt': return left > right; + case 'gte': return left >= right; + case 'lt': return left < right; + case 'lte': return left <= right; + } + } +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..6a52916 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,79 @@ +:root { + color-scheme: dark; + --ink: #f3f3ef; + --muted: #9ba09b; + --surface: #151817; + --surface-2: #1d211f; + --line: #323834; + --accent: #b8ff5a; + --accent-dark: #20330d; + --danger: #ff7b72; + --warning: #ffca5c; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; min-height: 100vh; color: var(--ink); background: #0b0d0c; } +button, input { font: inherit; } +button { border: 1px solid var(--line); border-radius: 0.55rem; padding: 0.66rem 0.9rem; color: var(--ink); background: var(--surface-2); cursor: pointer; } +button:hover:not(:disabled), button:focus-visible { border-color: var(--accent); outline: none; } +button:disabled { cursor: not-allowed; opacity: 0.45; } +select, input { width: 100%; accent-color: var(--accent); color: var(--ink); background: #0f1210; border: 1px solid var(--line); border-radius: 0.45rem; padding: 0.5rem; } +.primary { color: #10130e; border-color: var(--accent); background: var(--accent); font-weight: 700; } +.app-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem clamp(1rem, 3vw, 2.5rem); border-bottom: 1px solid var(--line); } +.brand { display: flex; align-items: baseline; gap: 0.8rem; } +.brand h1 { margin: 0; letter-spacing: 0.22em; font-size: 1.15rem; } +.brand span, .status-cluster { color: var(--muted); font-size: 0.8rem; } +.status-cluster { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem 1rem; } +.storage-warning { color: var(--warning); } +.storage-ready { color: var(--accent); } +main { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; min-height: calc(100vh - 65px); } +.sidebar { padding: 1.25rem; border-right: 1px solid var(--line); background: #101311; } +.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; } +.section-heading h2 { margin: 0; font-size: 0.95rem; letter-spacing: 0.08em; text-transform: uppercase; } +.drop-zone { padding: 1rem; margin-bottom: 1rem; text-align: center; color: var(--muted); border: 1px dashed var(--line); border-radius: 0.75rem; } +.drop-zone.is-dragging { color: var(--accent); border-color: var(--accent); background: var(--accent-dark); } +.exhibit-list { display: grid; gap: 0.75rem; } +.exhibit-card { padding: 1rem; border: 1px solid var(--line); border-radius: 0.8rem; background: var(--surface); } +.exhibit-card.is-active { border-color: var(--accent); box-shadow: inset 3px 0 var(--accent); } +.exhibit-card h3, .exhibit-card p { margin: 0 0 0.55rem; } +.exhibit-card .description { color: #c7cbc7; font-size: 0.9rem; line-height: 1.45; } +.exhibit-card .meta { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.72rem; } +.workspace { display: grid; grid-template-rows: minmax(300px, 1fr) minmax(220px, 38vh); min-width: 0; } +.stage { display: grid; place-items: center; padding: clamp(1.5rem, 5vw, 5rem); background: radial-gradient(circle at 50% 45%, #1a211c, #0b0d0c 62%); } +.stage-card { width: min(660px, 100%); padding: clamp(1.5rem, 4vw, 3rem); border: 1px solid var(--line); border-radius: 1rem; background: rgba(16, 19, 17, 0.88); } +.eyebrow { color: var(--accent); letter-spacing: 0.12em; text-transform: uppercase; font-size: 0.75rem; } +.stage-card h2 { margin: 0.35rem 0 0.7rem; font-size: clamp(1.7rem, 4vw, 3rem); } +.stage-card p { color: var(--muted); line-height: 1.55; } +.runtime-readout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; margin: 1.5rem 0; } +.runtime-readout div { padding: 0.8rem; border: 1px solid var(--line); border-radius: 0.55rem; overflow: hidden; } +.runtime-readout dt { color: var(--muted); font-size: 0.72rem; text-transform: uppercase; } +.runtime-readout dd { margin: 0.35rem 0 0; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; } +.grammar-panel { margin: 1rem 0; padding-top: 0.8rem; border-top: 1px solid var(--line); } +.grammar-panel h3 { margin: 0 0 0.7rem; font-size: 0.78rem; letter-spacing: 0.08em; text-transform: uppercase; } +.parameter-control { display: grid; grid-template-columns: minmax(8rem, 1fr) minmax(8rem, 1.5fr) auto; gap: 0.65rem; align-items: center; margin: 0.55rem 0; } +.parameter-control label { color: #d9ddd9; font-size: 0.82rem; } +.compact { padding: 0.45rem 0.6rem; font-size: 0.75rem; } +.override-badge { margin-left: 0.5rem; padding: 0.12rem 0.35rem; color: var(--accent); background: var(--accent-dark); border-radius: 999px; font-size: 0.62rem; text-transform: uppercase; } +.resolved-values { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.35rem 1rem; padding: 0; list-style: none; } +.resolved-values li { display: flex; justify-content: space-between; gap: 1rem; min-width: 0; font-size: 0.76rem; } +.resolved-values code { color: var(--muted); overflow: hidden; text-overflow: ellipsis; } +.resolved-values output, #condition-result { color: var(--accent); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } +.diagnostics-panel { min-height: 0; padding: 1rem 1.25rem; border-top: 1px solid var(--line); background: var(--surface); overflow: auto; } +.count { display: inline-grid; place-items: center; min-width: 1.6rem; min-height: 1.6rem; border-radius: 999px; color: var(--accent); background: var(--accent-dark); } +.diagnostic-list { display: grid; gap: 0.55rem; padding: 0; margin: 0; list-style: none; } +.diagnostic { display: grid; grid-template-columns: 4.5rem minmax(9rem, auto) 1fr; gap: 0.65rem; align-items: baseline; padding: 0.7rem; border-left: 3px solid var(--line); background: #101311; font-size: 0.82rem; } +.diagnostic.error { border-color: var(--danger); } +.diagnostic.warning { border-color: var(--warning); } +.diagnostic.info { border-color: var(--accent); } +.diagnostic .severity, .diagnostic small { color: var(--muted); font-size: 0.68rem; } +.diagnostic small { grid-column: 3; } +.empty { color: var(--muted); text-align: center; } +[hidden] { display: none !important; } +@media (max-width: 760px) { + main { grid-template-columns: 1fr; } + .sidebar { border-right: 0; border-bottom: 1px solid var(--line); } + .workspace { grid-template-rows: auto auto; } + .diagnostic { grid-template-columns: 1fr; } + .diagnostic small { grid-column: 1; } +} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; } } diff --git a/test/phase1-runtime.test.mjs b/test/phase1-runtime.test.mjs new file mode 100644 index 0000000..c7b38f0 --- /dev/null +++ b/test/phase1-runtime.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; +import { ActivationController } from '../src/runtime/activation.js'; +import { Diagnostics } from '../src/runtime/diagnostics.js'; +import { LibraryManager } from '../src/runtime/library.js'; +import { deriveStreamState, SeededRNG } from '../src/runtime/rng.js'; +import { parseAndValidateExhibit } from '../src/runtime/validator.js'; +import { buildStandalone } from '../tools/build-xzbt.mjs'; + +const fixedSource = readFileSync(resolve('exhibits/minimal-fixed.xzbt'), 'utf8'); +const randomSource = readFileSync(resolve('exhibits/minimal-random.xzbt'), 'utf8'); + +test('the two Phase 1 exhibits parse and validate', () => { + assert.equal(parseAndValidateExhibit(fixedSource, 'minimal-fixed.xzbt').valid, true); + assert.equal(parseAndValidateExhibit(randomSource, 'minimal-random.xzbt').valid, true); +}); + +test('malformed JSON, unsupported versions, and invalid metadata fail predictably', () => { + assert.equal(parseAndValidateExhibit('{', 'broken.xzbt').errors[0].code, 'ERR_SCHEMA_VALIDATION'); + const unsupported = JSON.stringify({ xzbt: '9', meta: { id: 'valid-id', name: 'Name' } }); + assert.ok(parseAndValidateExhibit(unsupported).errors.some(({ code }) => code === 'ERR_UNSUPPORTED_VERSION')); + const invalidMeta = JSON.stringify({ xzbt: '0.1', meta: { id: 'Not Valid', name: '' } }); + const codes = parseAndValidateExhibit(invalidMeta).errors.map(({ code }) => code); + assert.ok(codes.includes('ERR_INVALID_ID')); + assert.ok(codes.includes('ERR_SCHEMA_VALIDATION')); +}); + +test('production PRNG preserves the frozen GC4 vectors', () => { + assert.deepEqual(deriveStreamState(42, 'scenario', 'storm:1'), [471422921, 3327820418, 120898024, 15893603]); + const stream = new SeededRNG(42).stream('scenario', 'storm:1'); + assert.deepEqual([stream.nextUint32(), stream.nextUint32(), stream.nextUint32(), stream.nextUint32()], [4101533927, 4149236977, 2113172072, 1672809915]); +}); + +test('library imports, ignores identical bytes, persists, and restores records', async () => { + const saved = []; + const persistence = { async saveExhibit(record) { saved.push(record); } }; + const library = new LibraryManager({ persistence, diagnostics: new Diagnostics() }); + assert.equal((await library.importSource(fixedSource, 'minimal-fixed.xzbt')).status, 'imported'); + assert.equal((await library.importSource(fixedSource, 'minimal-fixed.xzbt')).status, 'identical'); + assert.equal(saved.length, 1); + const restored = new LibraryManager({ diagnostics: new Diagnostics() }); + restored.restore(saved); + assert.equal(restored.get('minimal-fixed').document.meta.name, 'Minimal Fixed'); +}); + +test('activation prepares before teardown and recovers the previous exhibit on failure', async () => { + const events = []; + let shouldFail = false; + const make = (record) => ({ + rng: new SeededRNG(42), + async activate() { events.push(`activate:${record.id}`); if (shouldFail && record.id === 'next') throw new Error('injected'); }, + async deactivate() { events.push(`deactivate:${record.id}`); }, + async dispose() { events.push(`dispose:${record.id}`); } + }); + const controller = new ActivationController({ diagnostics: new Diagnostics(), performanceFactory: make }); + const record = (id) => ({ id, document: { meta: { id, name: id }, runtime: { seed: 42 } } }); + assert.equal(await controller.activate(record('previous')), true); + shouldFail = true; + assert.equal(await controller.activate(record('next')), false); + assert.equal(controller.current.record.id, 'previous'); + assert.deepEqual(events, ['activate:previous', 'deactivate:previous', 'dispose:previous', 'activate:next', 'dispose:next', 'activate:previous']); +}); + +test('two clean standalone builds are byte-identical and self-contained', () => { + const directory = mkdtempSync(join(tmpdir(), 'xzbt-phase1-')); + const first = join(directory, 'first.html'); + const second = join(directory, 'second.html'); + buildStandalone(first); + buildStandalone(second); + const firstBytes = readFileSync(first); + const secondBytes = readFileSync(second); + assert.deepEqual(firstBytes, secondBytes); + const html = firstBytes.toString('utf8'); + assert.doesNotMatch(html, /<(script|link)[^>]+(?:src|href)=/i); + assert.doesNotMatch(html, /\bimport\s+[^;(]/); + assert.equal(createHash('sha256').update(firstBytes).digest('hex'), createHash('sha256').update(secondBytes).digest('hex')); + const script = html.match(/