feat(runtime): implement phases 1 and 2
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; media-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
||||
<title>XZBT 0.1</title>
|
||||
<style>/*__XZBT_STYLES__*/</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand"><h1>XZBT</h1><span id="runtime-version"></span></div>
|
||||
<div class="status-cluster" role="status" aria-live="polite">
|
||||
<span id="app-status">Starting…</span><span id="storage-status">Checking storage…</span>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<aside class="sidebar" aria-labelledby="library-title">
|
||||
<div class="section-heading"><h2 id="library-title">Exhibit library</h2><button id="import-button" class="primary" type="button">Import files</button></div>
|
||||
<input id="import-files" type="file" accept=".xzbt,application/json" multiple hidden>
|
||||
<div id="drop-zone" class="drop-zone">Drop .xzbt files here</div>
|
||||
<p id="empty-state" class="empty">No exhibits loaded.</p>
|
||||
<div id="library-list" class="exhibit-list"></div>
|
||||
</aside>
|
||||
<section class="workspace">
|
||||
<section class="stage" aria-live="polite">
|
||||
<div id="stage-empty" class="stage-card"><span class="eyebrow">Runtime ready</span><h2>Select an exhibit</h2><p>Import declarative XZBT 0.1 documents, then activate one from the library. Exhibits run without adding subject-specific code to this runtime.</p></div>
|
||||
<div id="stage-active" class="stage-card" hidden>
|
||||
<span class="eyebrow">Active exhibit</span><h2 id="active-name"></h2>
|
||||
<dl class="runtime-readout"><div><dt>Exhibit ID</dt><dd id="active-id"></dd></div><div><dt>Resolved seed</dt><dd id="active-seed"></dd></div><div style="grid-column:1/-1"><dt>Deterministic visual-stream preview</dt><dd id="active-sequence"></dd></div></dl>
|
||||
<p>The common grammar resolves stored parameters, mutable state, signals, bindings, actions, and temporary overrides. Audio and visual engines attach in later phases.</p>
|
||||
<section class="grammar-panel" aria-labelledby="configuration-title"><h3 id="configuration-title">Parameters</h3><div id="configuration"></div></section>
|
||||
<section class="grammar-panel" aria-labelledby="values-title"><h3 id="values-title">Resolved values</h3><ul id="resolved-values" class="resolved-values"></ul><p>Generated condition: <output id="condition-result">n/a</output></p></section>
|
||||
<button id="deactivate-button" type="button">Deactivate</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="diagnostics-panel" aria-labelledby="diagnostics-title">
|
||||
<div class="section-heading"><h2 id="diagnostics-title">Diagnostics <span id="diagnostic-count" class="count">0</span></h2><button id="clear-diagnostics" type="button">Clear</button></div>
|
||||
<p id="diagnostics-empty" class="empty">No diagnostics yet.</p><ol id="diagnostic-list" class="diagnostic-list"></ol>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<script>/*__XZBT_SCRIPT__*/</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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'
|
||||
]);
|
||||
@@ -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([]); }
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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(/<script>([\s\S]*)<\/script>/)[1];
|
||||
assert.doesNotThrow(() => new Function(script));
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { ActionExecutor } from '../src/runtime/actions.js';
|
||||
import { CommonGrammarPerformance } from '../src/runtime/performance.js';
|
||||
import { ResolutionEngine } from '../src/runtime/resolution.js';
|
||||
import { SeededRNG } from '../src/runtime/rng.js';
|
||||
import { RuntimeFault, parseDuration } from '../src/runtime/types.js';
|
||||
import { ConditionEvaluator, ValueResolver } from '../src/runtime/values.js';
|
||||
import { parseAndValidateExhibit } from '../src/runtime/validator.js';
|
||||
|
||||
const closeTo = (actual, expected, epsilon = 1e-12) => assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
|
||||
|
||||
function grammarDocument(overrides = {}) {
|
||||
return {
|
||||
xzbt: '0.1',
|
||||
meta: { id: 'grammar-study', name: 'Grammar Study' },
|
||||
runtime: { seed: 42 },
|
||||
parameters: {
|
||||
activity: { type: 'number', default: 0.2, min: 0, max: 1 },
|
||||
enabled: { type: 'boolean', default: true }
|
||||
},
|
||||
state: {
|
||||
energy: { type: 'number', initial: 0.1, min: 0, max: 1 },
|
||||
mirror: { type: 'number', initial: 0, min: 0, max: 1 },
|
||||
alert: { type: 'boolean', initial: false }
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test('production validator matches the GC2 fixture matrix', () => {
|
||||
const matrix = [
|
||||
['valid-minimal.xzbt', null], ['valid-full-feature.xzbt', null],
|
||||
['invalid-unknown-field.xzbt', 'ERR_UNKNOWN_FIELD'],
|
||||
['invalid-unsupported-version.xzbt', 'ERR_UNSUPPORTED_VERSION'],
|
||||
['invalid-id-syntax.xzbt', 'ERR_INVALID_ID'],
|
||||
['invalid-reference-missing.xzbt', 'ERR_INVALID_REFERENCE'],
|
||||
['invalid-type-mismatch.xzbt', 'ERR_TYPE_MISMATCH'],
|
||||
['invalid-cyclic-bindings.xzbt', 'ERR_CYCLIC_DEPENDENCY'],
|
||||
['invalid-out-of-bounds.xzbt', 'ERR_OUT_OF_BOUNDS'],
|
||||
['invalid-operator-arity.xzbt', 'ERR_INVALID_ARITY']
|
||||
];
|
||||
for (const [name, code] of matrix) {
|
||||
const source = readFileSync(new URL(`./fixtures/gc2/${name}`, import.meta.url), 'utf8');
|
||||
const result = parseAndValidateExhibit(source, name);
|
||||
if (code === null) assert.equal(result.valid, true, JSON.stringify(result.errors));
|
||||
else assert.ok(result.errors.some((entry) => entry.code === code), `${name}: ${JSON.stringify(result.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ValueSpec implements safe operators, references, random ranges, and weighted choice', () => {
|
||||
const resolver = new ValueResolver((path) => ({ 'state.value': 4 }[path]));
|
||||
const stream = new SeededRNG(42).stream('scenario', 'values:1');
|
||||
assert.equal(resolver.evaluate({ ref: 'state.value' }, stream), 4);
|
||||
assert.equal(resolver.evaluate({ op: 'divide', args: [9, 0] }, stream), 0);
|
||||
assert.equal(resolver.evaluate({ op: 'clamp', args: [12, 2, 8] }, stream), 8);
|
||||
assert.equal(resolver.evaluate({ op: 'lerp', args: [10, 20, 0.25] }, stream), 12.5);
|
||||
const random = resolver.evaluate({ random: { min: 2, max: 8, integer: true } }, stream);
|
||||
assert.ok(Number.isInteger(random) && random >= 2 && random <= 8);
|
||||
const choice = resolver.evaluate({ choose: [{ value: 'a', weight: 1 }, { value: 'b', weight: 3 }] }, stream);
|
||||
assert.ok(['a', 'b'].includes(choice));
|
||||
assert.throws(() => resolver.evaluate({ op: 'add', args: [1] }, stream), (error) => error.code === 'ERR_INVALID_ARITY');
|
||||
});
|
||||
|
||||
test('ConditionSpec composes, short-circuits, and forbids cross-type comparison', () => {
|
||||
const resolver = new ValueResolver((path) => ({ 'state.energy': 0.8, 'parameters.enabled': true }[path]));
|
||||
const conditions = new ConditionEvaluator(resolver);
|
||||
assert.equal(conditions.evaluate({ and: [
|
||||
{ op: 'gt', left: { ref: 'state.energy' }, right: 0.5 },
|
||||
{ not: { op: 'eq', left: { ref: 'parameters.enabled' }, right: false } }
|
||||
] }), true);
|
||||
assert.equal(conditions.evaluate({ or: [{ op: 'lt', left: 1, right: 0 }, { op: 'eq', left: 'x', right: 'x' }] }), true);
|
||||
assert.throws(() => conditions.evaluate({ op: 'eq', left: 1, right: '1' }), (error) => error.code === 'ERR_TYPE_MISMATCH');
|
||||
});
|
||||
|
||||
test('bindings resolve in same-tick dependency order and apply exact smoothing', () => {
|
||||
const document = grammarDocument({ bindings: [
|
||||
{ source: 'parameters.activity', target: 'state.energy', scale: 2, clamp: [0, 1], smoothing: '50ms' },
|
||||
{ source: 'state.energy', target: 'state.mirror' }
|
||||
] });
|
||||
const engine = new ResolutionEngine(document, new SeededRNG(42));
|
||||
assert.equal(engine.get('state.energy'), 0.4);
|
||||
assert.equal(engine.get('state.mirror'), 0.4);
|
||||
engine.setParameter('activity', 0.4);
|
||||
engine.advance(1000 / 60);
|
||||
const expected = 0.4 + (1 - Math.exp(-(1 / 60) / 0.05)) * (0.8 - 0.4);
|
||||
closeTo(engine.get('state.energy'), expected);
|
||||
closeTo(engine.get('state.mirror'), expected);
|
||||
});
|
||||
|
||||
test('a disabled binding exposes base and re-enables without replaying disabled time', () => {
|
||||
const document = grammarDocument({ bindings: [{
|
||||
source: 'parameters.activity', target: 'state.energy', smoothing: '1s',
|
||||
when: { op: 'eq', left: { ref: 'parameters.enabled' }, right: true }
|
||||
}] });
|
||||
const engine = new ResolutionEngine(document, new SeededRNG(42));
|
||||
assert.equal(engine.get('state.energy'), 0.2);
|
||||
engine.setParameter('enabled', false);
|
||||
assert.equal(engine.get('state.energy'), 0.1);
|
||||
engine.setParameter('activity', 0.9);
|
||||
engine.advance(5000);
|
||||
assert.equal(engine.get('state.energy'), 0.1);
|
||||
engine.setParameter('enabled', true);
|
||||
assert.equal(engine.get('state.energy'), 0.9);
|
||||
});
|
||||
|
||||
test('actions execute in document order and set state but never parameters', () => {
|
||||
const engine = new ResolutionEngine(grammarDocument(), new SeededRNG(42));
|
||||
const actions = new ActionExecutor(engine);
|
||||
actions.execute([
|
||||
{ type: 'set', target: 'state.alert', value: true },
|
||||
{ type: 'set', target: 'state.energy', value: 0.75, when: { op: 'eq', left: { ref: 'state.alert' }, right: true } }
|
||||
]);
|
||||
assert.equal(engine.get('state.alert'), true);
|
||||
assert.equal(engine.get('state.energy'), 0.75);
|
||||
assert.throws(() => actions.execute([{ type: 'set', target: 'parameters.activity', value: 1 }]), (error) => error.code === 'ERR_UNSUPPORTED_TARGET');
|
||||
});
|
||||
|
||||
test('numeric set transitions advance deterministically', () => {
|
||||
const engine = new ResolutionEngine(grammarDocument(), new SeededRNG(42));
|
||||
engine.setState('state.energy', 0.9, { duration: '1s', easing: 'linear' });
|
||||
engine.advance(500);
|
||||
closeTo(engine.get('state.energy'), 0.5);
|
||||
engine.advance(500);
|
||||
closeTo(engine.get('state.energy'), 0.9);
|
||||
assert.equal(parseDuration('1.5m'), 90_000);
|
||||
assert.throws(() => parseDuration('1m30s'), RuntimeFault);
|
||||
});
|
||||
|
||||
test('overrides preserve stored edits, use priority and sequence, and release to the live lower value', () => {
|
||||
const engine = new ResolutionEngine(grammarDocument(), new SeededRNG(42));
|
||||
const actions = new ActionExecutor(engine);
|
||||
const [lower] = actions.execute([{ type: 'override', target: 'parameters.activity', value: 0.7, scope: 'scenario', priority: 1, transition: { out: '1s' } }], { owner: 'scenario-1' });
|
||||
const [higher] = actions.execute([{ type: 'override', target: 'parameters.activity', value: 0.9, scope: 'scenario', priority: 2 }], { owner: 'scenario-2' });
|
||||
assert.equal(engine.get('parameters.activity'), 0.9);
|
||||
engine.overrides.beginRelease(higher.instanceId);
|
||||
engine.resolveAll();
|
||||
assert.equal(engine.get('parameters.activity'), 0.7);
|
||||
engine.setParameter('activity', 0.4);
|
||||
assert.equal(engine.parameters.get('activity'), 0.4);
|
||||
assert.equal(engine.get('parameters.activity'), 0.7);
|
||||
engine.overrides.beginRelease(lower.instanceId);
|
||||
engine.advance(500);
|
||||
closeTo(engine.get('parameters.activity'), 0.55);
|
||||
engine.advance(500);
|
||||
closeTo(engine.get('parameters.activity'), 0.4);
|
||||
});
|
||||
|
||||
test('duration overrides expire and a performance restores compatible stored parameters', async () => {
|
||||
const record = { id: 'grammar-study', document: grammarDocument() };
|
||||
const performance = new CommonGrammarPerformance(record, 42, { initialParameters: { activity: 0.6 } });
|
||||
await performance.activate();
|
||||
assert.equal(performance.engine.parameters.get('activity'), 0.6);
|
||||
performance.execute([{ type: 'override', target: 'parameters.activity', value: 1, scope: 'duration', duration: '250ms' }]);
|
||||
assert.equal(performance.engine.get('parameters.activity'), 1);
|
||||
performance.engine.advance(250);
|
||||
assert.equal(performance.engine.get('parameters.activity'), 0.6);
|
||||
await performance.deactivate();
|
||||
await performance.dispose();
|
||||
assert.equal(performance.state, 'disposed');
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const sourceFiles = Object.freeze([
|
||||
'src/runtime/constants.js',
|
||||
'src/runtime/diagnostics.js',
|
||||
'src/runtime/rng.js',
|
||||
'src/runtime/types.js',
|
||||
'src/runtime/values.js',
|
||||
'src/runtime/validator.js',
|
||||
'src/runtime/persistence.js',
|
||||
'src/runtime/library.js',
|
||||
'src/runtime/resolution.js',
|
||||
'src/runtime/actions.js',
|
||||
'src/runtime/performance.js',
|
||||
'src/runtime/activation.js',
|
||||
'src/runtime/app.js'
|
||||
]);
|
||||
|
||||
function normalized(path) {
|
||||
return readFileSync(resolve(repositoryRoot, path), 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
function bundleModule(source, path) {
|
||||
const withoutImports = source.replace(/^import\s+[\s\S]*?\s+from\s+['"][^'"]+['"];\s*$/gm, '');
|
||||
const withoutExports = withoutImports.replace(/\bexport\s+/g, '');
|
||||
return `\n/* ${path} */\n${withoutExports.trim()}\n`;
|
||||
}
|
||||
|
||||
export function buildStandalone(outputPath = resolve(repositoryRoot, 'XZBT.html')) {
|
||||
const template = normalized('src/XZBT.template.html');
|
||||
const styles = normalized('src/styles.css').trim();
|
||||
const script = `'use strict';\n(() => {\n${sourceFiles.map((path) => bundleModule(normalized(path), path)).join('')}\n})();`;
|
||||
if (!template.includes('/*__XZBT_STYLES__*/') || !template.includes('/*__XZBT_SCRIPT__*/')) {
|
||||
throw new Error('Standalone template is missing a build placeholder.');
|
||||
}
|
||||
const artifact = template
|
||||
.replace('/*__XZBT_STYLES__*/', () => styles)
|
||||
.replace('/*__XZBT_SCRIPT__*/', () => script)
|
||||
.replace(/\r\n/g, '\n');
|
||||
writeFileSync(outputPath, artifact, 'utf8');
|
||||
return { outputPath, bytes: Buffer.byteLength(artifact), sha256: createHash('sha256').update(artifact).digest('hex') };
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const outputIndex = process.argv.indexOf('--output');
|
||||
const outputPath = outputIndex >= 0 ? resolve(process.argv[outputIndex + 1]) : resolve(repositoryRoot, 'XZBT.html');
|
||||
const result = buildStandalone(outputPath);
|
||||
console.log(`Built ${result.outputPath}`);
|
||||
console.log(`SHA-256 ${result.sha256} (${result.bytes} bytes)`);
|
||||
}
|
||||
Reference in New Issue
Block a user