generated from Labyricorn/labyricorn-project-template
One commit for the work accumulated in the working tree since Step 6.3, which had never been split into per-step commits: - src/local-surfaces.js + src/surface-url.js (new); src/ui.js, src/validation.js, src/connection.js and public/index.html updated for local-surface hosting and generic surface rendering - tests: local-surfaces (20), scifi-surfaces (24) and postmessage-interop (7) new; connection/museum-gallery/surface-validation suites updated - reference exhibits: shared/contract-core.js defaults to Contract 5.3 (major 5, minor 3, xzbt 5.3); museum-gallery advertises its surface catalog; aquarium/haunted-house/planetarium adapters updated - SciFi-XZBT (Step 6.7A/6.7B): surface-mode.js + surface-bus.js, Observation-surface boot branch, local-change hooks, view.pillars / view.warp-flight targets; fixture byte-identical to G:/.vibe/SciFi-XZBT - SciFi-XZBT contract adapter handshake fix: the inbound bridge filter no longer gates on an exact advisory xzbt value (Contract 5.3 §6.5), only on its presence/type, matching the host's own envelope validation; the adapter now advertises contract minor 3 / version 5.3.0, which it already implemented via the 5.3 surfaces field. Root cause of the five failing postmessage-interop tests (host hello was silently dropped). - docs: architecture 6.4 and 6.7A, reference 6.6 and 6.7; evidence logs; test-fixtures/PROVENANCE.md resync record Test results: NGN 154/154 (was 149/154); postmessage-interop 7/7 (was 2/7); SciFi contract harness 21/21, real-adapter suite 32/32. git diff --check clean for changed files; two pre-existing trailing-whitespace lines remain in test-fixtures/reference-exhibits/scifi/index.html, copied verbatim from the authoritative SciFi source. Step 6.7 live verification (browser Observation, packaged standalone) is still pending and is not claimed here.
1061 lines
34 KiB
JavaScript
1061 lines
34 KiB
JavaScript
/**
|
|
* XZBT Contract Adapter — Specification v5.3
|
|
*
|
|
* Implements the normalized contract interface for SciFi-XZBT:
|
|
* - Canonical external target catalog & descriptors
|
|
* - Safe mutation & impulse chokepoints
|
|
* - State revision & monotonic event sequence
|
|
* - Authoritative source attribution
|
|
* - Host communication bridge (postMessage)
|
|
*
|
|
* Zero external dependencies. Browser & Node compatible.
|
|
*/
|
|
|
|
(function () {
|
|
class XZBTContractAdapter {
|
|
constructor(options = {}) {
|
|
this.product = options.product || 'SciFi-XZBT';
|
|
this.version = options.version || '5.3.0';
|
|
this.build = options.build || 'production';
|
|
this.contractMajor = 5;
|
|
this.contractMinor = 3;
|
|
|
|
this.sessionId = null;
|
|
this.sessionActive = false;
|
|
this.eventSequence = 0;
|
|
this.stateRevision = 0;
|
|
this.registryRevision = 1;
|
|
this.operationQueue = Promise.resolve();
|
|
this.eventSink = null;
|
|
|
|
// Surface catalog (Contract 5.3 §31.2/§31.3). Advertised in describe()
|
|
// only when non-empty, so absence stays §31.3 form 1 ("absent").
|
|
this.surfaces = Array.isArray(options.surfaces) ? options.surfaces : null;
|
|
this.instanceId = options.instanceId || null;
|
|
|
|
// Session-independent local-change hooks (Step 6.7B). Unlike
|
|
// _emitEvent, these fire regardless of sessionActive so an
|
|
// exhibit-internal surface mirror keeps working with no NGN attached.
|
|
this.onLocalChange = typeof options.onLocalChange === 'function' ? options.onLocalChange : null;
|
|
this.onLocalAction = typeof options.onLocalAction === 'function' ? options.onLocalAction : null;
|
|
|
|
// Transport hooks
|
|
this.onMessage = options.onMessage || (() => {});
|
|
this.hostOrigin = options.hostOrigin || null; // optional origin filter
|
|
this.trustedSources = new Set();
|
|
|
|
// Subsystem binders & state accessors supplied by app.js
|
|
this.bindings = options.bindings || {};
|
|
|
|
// Capabilities: audio, speech, observation, display-text
|
|
// NOTE: midi capability removed in Step 3.5 — MIDI integration belongs to XZBT-NGN.
|
|
this.capabilities = new Map([
|
|
['audio', { id: 'audio', state: 'ready' }],
|
|
['speech', { id: 'speech', state: 'available' }],
|
|
['observation', { id: 'observation', state: 'ready' }],
|
|
['display-text', { id: 'display-text', state: 'ready' }]
|
|
]);
|
|
|
|
// Canonical target registry
|
|
this.targets = new Map();
|
|
this.legacyIdMap = new Map(); // internal ID -> canonical ID
|
|
this._initCanonicalTargets();
|
|
|
|
// Setup window listener if running in browser
|
|
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
|
this._setupWindowBridge();
|
|
}
|
|
}
|
|
|
|
_initCanonicalTargets() {
|
|
const reg = (target) => {
|
|
this.targets.set(target.id, target);
|
|
if (target.legacyId) {
|
|
this.legacyIdMap.set(target.legacyId, target.id);
|
|
}
|
|
};
|
|
|
|
// --- 1. Continuous Range Mixers & Controls (14 targets) ---
|
|
reg({
|
|
id: 'mix.master',
|
|
legacyId: 'master-vol',
|
|
label: 'Master Volume',
|
|
kind: 'range',
|
|
min: 0,
|
|
max: 1,
|
|
step: 0.01,
|
|
unit: 'ratio',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'audio',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'mix.hull.level',
|
|
legacyId: 'hull-vol',
|
|
label: 'Hull Drone Level',
|
|
kind: 'range',
|
|
min: 0,
|
|
max: 1,
|
|
step: 0.01,
|
|
unit: 'ratio',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'audio',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'mix.hull.frequency',
|
|
legacyId: 'hull-freq',
|
|
label: 'Hull Drone Frequency',
|
|
kind: 'range',
|
|
min: 30,
|
|
max: 120,
|
|
step: 1,
|
|
unit: 'hz',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'audio',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'mix.hull.cutoff',
|
|
legacyId: 'hull-cutoff',
|
|
label: 'Hull Drone Cutoff',
|
|
kind: 'range',
|
|
min: 40,
|
|
max: 400,
|
|
step: 1,
|
|
unit: 'hz',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'audio',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'mix.drive.level',
|
|
legacyId: 'warp-vol',
|
|
label: 'Drive / Core Level',
|
|
kind: 'range',
|
|
min: 0,
|
|
max: 1,
|
|
step: 0.01,
|
|
unit: 'ratio',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'audio',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'mix.drive.pulse-rate',
|
|
legacyId: 'warp-bpm',
|
|
label: 'Drive Pulse Rate',
|
|
kind: 'range',
|
|
min: 20,
|
|
max: 120,
|
|
step: 1,
|
|
unit: 'bpm',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'audio',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'mix.drive.carrier',
|
|
legacyId: 'warp-carrier',
|
|
label: 'Drive Carrier Frequency',
|
|
kind: 'range',
|
|
min: 30,
|
|
max: 140,
|
|
step: 0.5,
|
|
unit: 'hz',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'audio',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'mix.environment.level',
|
|
legacyId: 'air-vol',
|
|
label: 'Environmental Airflow Level',
|
|
kind: 'range',
|
|
min: 0,
|
|
max: 1,
|
|
step: 0.01,
|
|
unit: 'ratio',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'audio',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'mix.environment.cutoff',
|
|
legacyId: 'air-cutoff',
|
|
label: 'Environmental Filter Cutoff',
|
|
kind: 'range',
|
|
min: 500,
|
|
max: 4000,
|
|
step: 50,
|
|
unit: 'hz',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'audio',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'mix.telemetry.level',
|
|
legacyId: 'telemetry-vol',
|
|
label: 'Telemetry Level',
|
|
kind: 'range',
|
|
min: 0,
|
|
max: 1,
|
|
step: 0.01,
|
|
unit: 'ratio',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'audio',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'mix.telemetry.density',
|
|
legacyId: 'telemetry-density',
|
|
label: 'Telemetry Density',
|
|
kind: 'range',
|
|
min: 0,
|
|
max: 1,
|
|
step: 0.05,
|
|
unit: 'ratio',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'audio',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'speech.robot-amount',
|
|
legacyId: 'ai-robot-amount',
|
|
label: 'Speech Robotic Modulation',
|
|
kind: 'range',
|
|
min: 0,
|
|
max: 1,
|
|
step: 0.01,
|
|
unit: 'ratio',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'speech',
|
|
requires: ['speech']
|
|
});
|
|
|
|
reg({
|
|
id: 'fnc.level',
|
|
legacyId: 'fnc-level',
|
|
label: 'Futuristic Noise Cancellation Level',
|
|
kind: 'range',
|
|
min: 0,
|
|
max: 1,
|
|
step: 0.01,
|
|
unit: 'ratio',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'audio',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'view.activity',
|
|
legacyId: 'observation-activity',
|
|
label: 'Observation Visual Activity',
|
|
kind: 'range',
|
|
min: 0,
|
|
max: 1,
|
|
step: 0.01,
|
|
unit: 'ratio',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'observation',
|
|
requires: ['observation']
|
|
});
|
|
|
|
// --- 2. Persistent State Targets (Booleans & Selections) ---
|
|
reg({
|
|
id: 'transport.playing',
|
|
legacyId: 'master-play',
|
|
label: 'Audio Engine Online',
|
|
kind: 'state',
|
|
valueType: 'boolean',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'transport',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'transport.muted',
|
|
legacyId: 'mute',
|
|
label: 'Master Mute',
|
|
kind: 'state',
|
|
valueType: 'boolean',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'transport',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'fnc.enabled',
|
|
legacyId: 'fnc-enable',
|
|
label: 'Futuristic Noise Cancellation Active',
|
|
kind: 'state',
|
|
valueType: 'boolean',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'audio',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'view.observation',
|
|
legacyId: 'watch-experience',
|
|
label: 'Observation Viewport Active',
|
|
kind: 'state',
|
|
valueType: 'boolean',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: false,
|
|
category: 'observation',
|
|
requires: ['observation']
|
|
});
|
|
|
|
reg({
|
|
id: 'view.viewport-frame',
|
|
legacyId: 'observation-viewport',
|
|
label: 'Starship Bezel Frame Framing',
|
|
kind: 'state',
|
|
valueType: 'boolean',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'observation',
|
|
requires: ['observation']
|
|
});
|
|
|
|
reg({
|
|
id: 'view.pillars',
|
|
legacyId: 'observation-pillars',
|
|
label: 'Window Pillars / Mullions',
|
|
kind: 'state',
|
|
valueType: 'boolean',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'observation',
|
|
requires: ['observation']
|
|
});
|
|
|
|
reg({
|
|
id: 'view.warp-flight',
|
|
label: 'Warp Flight Mode',
|
|
kind: 'state',
|
|
valueType: 'boolean',
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'observation',
|
|
requires: ['observation']
|
|
});
|
|
|
|
reg({
|
|
id: 'alert.active',
|
|
label: 'Active Tactical Alert Status',
|
|
kind: 'selection',
|
|
options: [
|
|
{ value: 'none', label: 'Condition Green (Normal)' },
|
|
{ value: 'yellow', label: 'Yellow Alert' },
|
|
{ value: 'red', label: 'Red Alert' }
|
|
],
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'alert',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'universe.selected',
|
|
label: 'Active Sci-Fi Universe',
|
|
kind: 'selection',
|
|
options: [
|
|
{ value: 'starfleet', label: 'Starfleet / Galaxy' },
|
|
{ value: 'whataverse', label: 'Whataverse / Tardix' },
|
|
{ value: 'industrial', label: 'Industrial / Weyland' },
|
|
{ value: 'bioships', label: 'Bioships / Leviathan' },
|
|
{ value: 'retrofuture', label: 'Retro Future / 1960s' },
|
|
{ value: 'military', label: 'Colonial Military' },
|
|
{ value: 'deepspace', label: 'Deep Space Generation' },
|
|
{ value: 'outlaw', label: 'Frontier & Salvage' },
|
|
{ value: 'spacestations', label: 'Orbital Stations & Docks' },
|
|
{ value: 'comedy', label: 'Whimsical Fleet' }
|
|
],
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'universe',
|
|
requires: ['audio']
|
|
});
|
|
|
|
reg({
|
|
id: 'preset.selected',
|
|
label: 'Active Audio Preset',
|
|
kind: 'selection',
|
|
options: this._collectAllPresetOptions(),
|
|
readable: true,
|
|
writable: true,
|
|
restorable: true,
|
|
category: 'preset',
|
|
requires: ['audio']
|
|
});
|
|
|
|
// --- 3. Impulse Targets (Speech & Ticker) ---
|
|
reg({
|
|
id: 'speech.say',
|
|
label: 'Speak Text Line',
|
|
kind: 'impulse',
|
|
readable: false,
|
|
writable: false,
|
|
restorable: false,
|
|
category: 'speech',
|
|
requires: ['speech'],
|
|
arguments: [
|
|
{ name: 'text', type: 'string', required: true, maxLength: 2000 }
|
|
]
|
|
});
|
|
|
|
reg({
|
|
id: 'speech.generate-announcement',
|
|
legacyId: 'generate-announcement',
|
|
label: 'Generate Ambient AI Announcement',
|
|
kind: 'impulse',
|
|
readable: false,
|
|
writable: false,
|
|
restorable: false,
|
|
category: 'speech',
|
|
requires: ['speech']
|
|
});
|
|
|
|
reg({
|
|
id: 'display.ticker',
|
|
label: 'Display Ticker Message (Transient)',
|
|
kind: 'impulse',
|
|
readable: false,
|
|
writable: false,
|
|
restorable: false,
|
|
category: 'display',
|
|
requires: ['display-text'],
|
|
arguments: [
|
|
{ name: 'text', type: 'string', required: true, maxLength: 512 }
|
|
]
|
|
});
|
|
|
|
// --- 4. Canonical Universe Event Impulses ---
|
|
const eventImpulses = [
|
|
{ id: 'event.alert-red', legacyId: 'red-alert', label: 'Red Alert Impulse' },
|
|
{ id: 'event.warp-jump', label: 'Warp Jump Impulse' },
|
|
{ id: 'event.demat', label: 'Demat Cycle Impulse' },
|
|
{ id: 'event.vortex', label: 'Time Vortex Impulse' },
|
|
{ id: 'event.cloister', label: 'Cloister Bell Impulse' },
|
|
{ id: 'event.flak-barrage', label: 'Flak Barrage Impulse' },
|
|
{ id: 'event.ftl-jump', label: 'FTL Jump Impulse' },
|
|
{ id: 'event.singularity', label: 'Singularity Engage Impulse' },
|
|
{ id: 'event.solar-roar', label: 'Solar Flare Roar Impulse' },
|
|
{ id: 'event.afterburner', label: 'Afterburner Ignite Impulse' },
|
|
{ id: 'event.ludicrous-speed', label: 'Ludicrous Speed Impulse' },
|
|
{ id: 'event.improbability-flip', label: 'Improbability Drive Flip' }
|
|
];
|
|
|
|
eventImpulses.forEach(evt => {
|
|
reg({
|
|
id: evt.id,
|
|
legacyId: evt.legacyId,
|
|
label: evt.label,
|
|
kind: 'impulse',
|
|
readable: false,
|
|
writable: false,
|
|
restorable: false,
|
|
category: 'event',
|
|
requires: ['audio']
|
|
});
|
|
});
|
|
|
|
// --- 5. Canonical SFX Impulses ---
|
|
const sfxImpulses = [
|
|
{ id: 'sfx.comm-badge', label: 'Comm Badge Chime' },
|
|
{ id: 'sfx.door-swish', label: 'Pneumatic Door Swish' },
|
|
{ id: 'sfx.bosun-whistle', label: 'Bosun Pipe Call' },
|
|
{ id: 'sfx.medical-monitor', label: 'Medical Vital Monitor' },
|
|
{ id: 'sfx.cardassian-door', label: 'Heavy Bulkhead Door' },
|
|
{ id: 'sfx.turbolift', label: 'Turbochute Whoosh' },
|
|
{ id: 'sfx.replicator', label: 'Fabricator Materialize' },
|
|
{ id: 'sfx.transporter', label: 'Translocator Beam' },
|
|
{ id: 'sfx.console-chirp', label: 'Console Single Chirp' },
|
|
{ id: 'sfx.sonic', label: 'Sonic Acoustic Screwdriver' },
|
|
{ id: 'sfx.lever-clunk', label: 'Console Mechanical Lever Clunk' },
|
|
{ id: 'sfx.tardix-door', label: 'Tardix Wood & Lock Door' },
|
|
{ id: 'sfx.demat-switch', label: 'Demat Switch Rotor' },
|
|
{ id: 'sfx.telepathic-chime', label: 'Telepathic Circuit Chime' },
|
|
{ id: 'sfx.air-handler', label: 'Air Handler Valve Thud' },
|
|
{ id: 'sfx.mag-boot', label: 'Mag-Boot Solenoid Latch' },
|
|
{ id: 'sfx.comlock', label: 'Comlock Electronic Tone' },
|
|
{ id: 'sfx.solar-roar', label: 'Solar Radiation Wind' },
|
|
{ id: 'sfx.tape-clunk', label: 'Cassette Magnetic Transport Clunk' },
|
|
{ id: 'sfx.afterburner', label: 'Thruster Burner Pulse' },
|
|
{ id: 'sfx.geiger', label: 'Radiation Geiger Counter' },
|
|
{ id: 'sfx.dock-clamp', label: 'Air Valve & Dock Clamp' },
|
|
{ id: 'sfx.dradis-ping', label: 'Dradis Combat Sonar Ping' },
|
|
{ id: 'sfx.hal-chime', label: 'HAL Logic Melodic Chime' }
|
|
];
|
|
|
|
sfxImpulses.forEach(sfx => {
|
|
reg({
|
|
id: sfx.id,
|
|
label: sfx.label,
|
|
kind: 'impulse',
|
|
readable: false,
|
|
writable: false,
|
|
restorable: false,
|
|
category: 'sfx',
|
|
requires: ['audio']
|
|
});
|
|
});
|
|
}
|
|
|
|
_collectAllPresetOptions() {
|
|
const options = [];
|
|
if (typeof window !== 'undefined' && window.UniverseRegistry) {
|
|
for (const uId of Object.keys(window.UniverseRegistry)) {
|
|
const u = window.UniverseRegistry[uId];
|
|
if (u.presets) {
|
|
for (const pId of Object.keys(u.presets)) {
|
|
options.push({ value: pId, label: u.presets[pId].name || pId, universe: uId });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!options.length) {
|
|
options.push(
|
|
{ value: 'tng-bridge', label: 'Interprise-G: Main Bridge', universe: 'starfleet' },
|
|
{ value: 'tardix-1963', label: 'Type 40 TT: 1963 First Console', universe: 'whataverse' }
|
|
);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
_generateSessionId() {
|
|
return 'sess-' + Math.random().toString(36).substring(2, 10);
|
|
}
|
|
|
|
_setupWindowBridge() {
|
|
window.addEventListener('message', (e) => {
|
|
// Enforce safe origin filtering if configured
|
|
if (e.origin !== window.location.origin && e.origin !== this.hostOrigin) {
|
|
return;
|
|
}
|
|
// Enforce safe source: must be parent, opener, or self
|
|
if (e.source !== window.parent && e.source !== window.opener && e.source !== window) {
|
|
return;
|
|
}
|
|
// Ignore non-XZBT messages
|
|
if (!e.data || typeof e.data !== 'object' || typeof e.data.xzbt !== 'string') {
|
|
return;
|
|
}
|
|
if (e.data.sequence || e.data.type === 'error' || /\.result$/.test(e.data.type)) return;
|
|
|
|
const replySink = (msg) => {
|
|
try {
|
|
if (e.source && typeof e.source.postMessage === 'function') {
|
|
const targetOrigin = e.origin === 'null' ? '*' : e.origin;
|
|
e.source.postMessage(msg, targetOrigin);
|
|
}
|
|
} catch (err) {
|
|
console.warn('[XZBTContractAdapter] Failed to postMessage reply:', err);
|
|
}
|
|
};
|
|
|
|
this.handleMessage(e.data, 'host', replySink);
|
|
});
|
|
}
|
|
|
|
// Normalized event emission
|
|
_emitEvent(type, payload, correlationId = null, authoritativeSource = 'system') {
|
|
if (!this.sessionActive) return;
|
|
this.eventSequence++;
|
|
|
|
const evt = {
|
|
xzbt: '5.2',
|
|
type,
|
|
sessionId: this.sessionId,
|
|
sequence: this.eventSequence,
|
|
timestamp: Date.now(),
|
|
...payload
|
|
};
|
|
|
|
if (correlationId) {
|
|
evt.correlationId = correlationId;
|
|
}
|
|
if (authoritativeSource) {
|
|
evt.source = authoritativeSource;
|
|
}
|
|
|
|
this.onMessage(evt);
|
|
if (this.eventSink) this.eventSink(evt);
|
|
}
|
|
|
|
_enqueue(operation) {
|
|
const result = this.operationQueue.then(operation);
|
|
this.operationQueue = result.catch(() => {});
|
|
return result;
|
|
}
|
|
|
|
_commitChanges(before, source, correlationId) {
|
|
const after = this.getContractState();
|
|
const changes = [...this.targets.values()].filter(t => t.readable && t.kind !== 'impulse' && before[t.id] !== after[t.id]);
|
|
if (!changes.length) return false;
|
|
this.stateRevision++;
|
|
for (const target of changes) {
|
|
this._emitEvent(target.kind === 'selection' ? 'selection.changed' : 'state.changed', {
|
|
stateRevision: this.stateRevision, target: target.id, value: after[target.id]
|
|
}, correlationId, source);
|
|
}
|
|
// Session-independent mirror feed (Step 6.7B). _emitEvent above is
|
|
// gated on sessionActive and therefore silent with no NGN attached;
|
|
// onLocalChange is not, so an exhibit-internal surface mirror keeps
|
|
// working standalone. Fires for every changed target, same as above.
|
|
if (this.onLocalChange) {
|
|
for (const target of changes) {
|
|
this.onLocalChange(target.id, after[target.id], this.stateRevision);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Public API: Direct Mutation Chokepoint
|
|
applyMutation(targetId, rawValue, source = 'system', correlationId = null) {
|
|
return this._enqueue(() => this._applyMutation(targetId, rawValue, source, correlationId));
|
|
}
|
|
|
|
async _applyMutation(targetId, rawValue, source, correlationId) {
|
|
// Map legacy ID if necessary
|
|
const canonicalId = this.legacyIdMap.get(targetId) || targetId;
|
|
const target = this.targets.get(canonicalId);
|
|
|
|
if (!target) {
|
|
return { ok: false, code: 'UNKNOWN_TARGET', message: `Target '${targetId}' not found` };
|
|
}
|
|
|
|
if (!target.writable || target.kind === 'impulse') {
|
|
return { ok: false, code: 'TARGET_NOT_SETTABLE', message: `Target '${canonicalId}' is not writable` };
|
|
}
|
|
|
|
// Check capability availability
|
|
if (target.requires) {
|
|
for (const capId of target.requires) {
|
|
const cap = this.capabilities.get(capId);
|
|
if (cap && cap.state === 'unsupported') {
|
|
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: `Capability '${capId}' is unavailable` };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Value validation & clamping
|
|
let value = rawValue;
|
|
if (target.kind === 'range') {
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
return { ok: false, code: 'INVALID_VALUE', message: `Value must be a valid number` };
|
|
}
|
|
if (value < target.min || value > target.max) {
|
|
return {
|
|
ok: false,
|
|
code: 'INVALID_VALUE',
|
|
message: `Value ${value} is outside range [${target.min}, ${target.max}]`
|
|
};
|
|
}
|
|
const steps = (value - target.min) / target.step;
|
|
if (target.step && Math.abs(steps - Math.round(steps)) > 1e-7) {
|
|
return { ok: false, code: 'INVALID_VALUE', message: `Value must align to step ${target.step} from ${target.min}` };
|
|
}
|
|
} else if (target.kind === 'state') {
|
|
if (target.valueType === 'boolean' && typeof value !== 'boolean') {
|
|
return { ok: false, code: 'INVALID_VALUE', message: `Value must be a boolean` };
|
|
}
|
|
} else if (target.kind === 'selection') {
|
|
const validOptions = target.options.map(o => o.value);
|
|
if (!validOptions.includes(value)) {
|
|
return {
|
|
ok: false,
|
|
code: 'INVALID_VALUE',
|
|
message: `Value '${value}' is not a valid option for '${canonicalId}'. Valid: [${validOptions.join(', ')}]`
|
|
};
|
|
}
|
|
}
|
|
|
|
// Read current state to check for idempotent no-op
|
|
const currentState = this.getContractState();
|
|
const currentValue = currentState[canonicalId];
|
|
|
|
if (currentValue === value) {
|
|
// Idempotent no-op: succeeds immediately, NO revision increment, NO event
|
|
return { ok: true, noop: true, stateRevision: this.stateRevision };
|
|
}
|
|
|
|
// Special atomic handling for universe.selected:
|
|
// Must also change preset.selected in the same transaction
|
|
if (canonicalId === 'universe.selected') {
|
|
if (!this.bindings.setUniverse) {
|
|
return { ok: false, code: 'INTERNAL_ERROR', message: 'No universe setter bound' };
|
|
}
|
|
await this.bindings.setUniverse(value, source);
|
|
|
|
this._commitChanges(currentState, source, correlationId);
|
|
|
|
return { ok: true, stateRevision: this.stateRevision };
|
|
}
|
|
|
|
// Special handling for preset.selected: validate preset belongs to active universe
|
|
if (canonicalId === 'preset.selected') {
|
|
if (this.bindings.getUniverse && window.UniverseRegistry) {
|
|
const uId = this.bindings.getUniverse();
|
|
const u = window.UniverseRegistry[uId];
|
|
if (!u || !u.presets || !u.presets[value]) {
|
|
return {
|
|
ok: false,
|
|
code: 'INVALID_VALUE',
|
|
message: `Preset '${value}' does not belong to active universe '${uId}'`
|
|
};
|
|
}
|
|
}
|
|
if (this.bindings.selectPreset) {
|
|
await this.bindings.selectPreset(value, source);
|
|
}
|
|
this._commitChanges(currentState, source, correlationId);
|
|
return { ok: true, stateRevision: this.stateRevision };
|
|
}
|
|
|
|
// Apply direct underlying setter for other targets
|
|
const applyFn = this.bindings.setters ? this.bindings.setters[canonicalId] : null;
|
|
if (applyFn) {
|
|
try {
|
|
await applyFn(value, source);
|
|
} catch (err) {
|
|
return { ok: false, code: 'SETTER_ERROR', message: err.message || String(err) };
|
|
}
|
|
}
|
|
|
|
this._commitChanges(currentState, source, correlationId);
|
|
|
|
return { ok: true, stateRevision: this.stateRevision };
|
|
}
|
|
|
|
// Public API: Direct Impulse Invocation Chokepoint
|
|
invokeAction(targetId, args = {}, source = 'system', correlationId = null) {
|
|
return this._enqueue(() => this._invokeAction(targetId, args, source, correlationId));
|
|
}
|
|
|
|
async _invokeAction(targetId, args, source, correlationId) {
|
|
const canonicalId = this.legacyIdMap.get(targetId) || targetId;
|
|
const target = this.targets.get(canonicalId);
|
|
|
|
if (!target) {
|
|
return { ok: false, code: 'UNKNOWN_TARGET', message: `Target '${targetId}' not found` };
|
|
}
|
|
|
|
if (target.kind !== 'impulse') {
|
|
return { ok: false, code: 'TARGET_NOT_INVOKABLE', message: `Target '${canonicalId}' is not an impulse` };
|
|
}
|
|
|
|
// Validate args schema & text length limits if defined
|
|
const sanitizedArgs = (args && typeof args === 'object' && !Array.isArray(args)) ? { ...args } : {};
|
|
const argumentSpecs = target.arguments || [];
|
|
if (Object.keys(sanitizedArgs).some(key => !argumentSpecs.some(spec => spec.name === key))) {
|
|
return { ok: false, code: 'INVALID_VALUE', message: 'Unexpected argument key' };
|
|
}
|
|
if (argumentSpecs.length) {
|
|
for (const argSpec of argumentSpecs) {
|
|
const argKey = argSpec.name;
|
|
if (argSpec.required && (sanitizedArgs[argKey] === undefined || sanitizedArgs[argKey] === null)) {
|
|
return { ok: false, code: 'INVALID_VALUE', message: `Missing required argument '${argKey}'` };
|
|
}
|
|
if (sanitizedArgs[argKey] !== undefined) {
|
|
if (argSpec.type === 'string' && typeof sanitizedArgs[argKey] !== 'string') {
|
|
return { ok: false, code: 'INVALID_VALUE', message: `Argument '${argKey}' must be a string` };
|
|
}
|
|
if (argSpec.maxLength && typeof sanitizedArgs[argKey] === 'string') {
|
|
if (sanitizedArgs[argKey].length > argSpec.maxLength) {
|
|
return {
|
|
ok: false,
|
|
code: 'INVALID_VALUE',
|
|
message: `Argument '${argKey}' exceeds maximum length of ${argSpec.maxLength}`
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Contextual check: if target is a universe event or SFX, ensure capability is ready
|
|
if (target.requires) {
|
|
for (const capId of target.requires) {
|
|
const cap = this.capabilities.get(capId);
|
|
if (cap && cap.state === 'unsupported') {
|
|
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: `Capability '${capId}' is unavailable` };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Execute bound impulse handler
|
|
const before = this.getContractState();
|
|
const invoker = this.bindings.invokers ? this.bindings.invokers[canonicalId] : null;
|
|
if (invoker) {
|
|
try {
|
|
await invoker(sanitizedArgs, source);
|
|
} catch (err) {
|
|
return { ok: false, code: 'INVOCATION_FAILED', message: err.message || String(err) };
|
|
}
|
|
} else {
|
|
// Fallback for universe events or soundboard triggers
|
|
if (canonicalId.startsWith('event.') && this.bindings.handleUniverseEvent) {
|
|
const evtType = canonicalId.replace(/^event\./, '');
|
|
const handled = await this.bindings.handleUniverseEvent(evtType, source);
|
|
if (!handled) {
|
|
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: `Event '${evtType}' not available in active universe` };
|
|
}
|
|
} else if (canonicalId.startsWith('sfx.') && this.bindings.handleSoundboardTrigger) {
|
|
const sfxId = 'btn-' + canonicalId.replace(/^sfx\./, '');
|
|
const handled = await this.bindings.handleSoundboardTrigger(sfxId, source);
|
|
if (!handled) {
|
|
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: `SFX '${canonicalId}' not available in active universe` };
|
|
}
|
|
}
|
|
}
|
|
|
|
this._commitChanges(before, source, correlationId);
|
|
// Impulses only increment stateRevision if they change persistent state.
|
|
this._emitEvent('action.executed', {
|
|
target: canonicalId,
|
|
args: sanitizedArgs
|
|
}, correlationId, source);
|
|
|
|
// Session-independent mirror feed (Step 6.7B); see _commitChanges.
|
|
if (this.onLocalAction) {
|
|
this.onLocalAction(canonicalId, sanitizedArgs);
|
|
}
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
// Public API: Return contract-visible snapshot
|
|
getContractState() {
|
|
if (this.bindings.getState) {
|
|
return this.bindings.getState();
|
|
}
|
|
return {};
|
|
}
|
|
|
|
// Public API: Protocol Description
|
|
describe() {
|
|
const description = {
|
|
exhibit: {
|
|
product: this.product,
|
|
version: this.version,
|
|
build: this.build
|
|
},
|
|
contract: {
|
|
major: this.contractMajor,
|
|
minor: this.contractMinor
|
|
},
|
|
registryRevision: this.registryRevision,
|
|
stateRevision: this.stateRevision,
|
|
capabilities: Array.from(this.capabilities.values()),
|
|
targets: Array.from(this.targets.values())
|
|
};
|
|
if (this.surfaces && this.surfaces.length) {
|
|
description.surfaces = this.surfaces;
|
|
}
|
|
return description;
|
|
}
|
|
|
|
// Update Capability state and emit capability.changed
|
|
updateCapability(capabilityId, newState) {
|
|
const cap = this.capabilities.get(capabilityId);
|
|
if (!cap) return;
|
|
if (cap.state === newState) return;
|
|
cap.state = newState;
|
|
this._emitEvent('capability.changed', {
|
|
capability: capabilityId,
|
|
state: newState
|
|
}, null, 'system');
|
|
}
|
|
|
|
// Add target dynamically and emit registry.changed
|
|
addCustomTarget(spec) {
|
|
this.targets.set(spec.id, spec);
|
|
if (spec.legacyId) this.legacyIdMap.set(spec.legacyId, spec.id);
|
|
this.registryRevision++;
|
|
this._emitEvent('registry.changed', {
|
|
registryRevision: this.registryRevision
|
|
}, null, 'system');
|
|
}
|
|
|
|
// Handle inbound message (envelope) from host
|
|
async handleMessage(msg, transportSource = 'host', replySink = null) {
|
|
const sink = replySink || this.onMessage;
|
|
|
|
const sendError = (reqId, sessId, code, message) => {
|
|
const err = {
|
|
xzbt: '5.2',
|
|
type: 'error',
|
|
requestId: reqId || null,
|
|
sessionId: sessId || null,
|
|
ok: false,
|
|
error: { code, message }
|
|
};
|
|
sink(err);
|
|
return err;
|
|
};
|
|
|
|
const sendResult = (type, reqId, payload = {}) => {
|
|
const res = {
|
|
xzbt: '5.2',
|
|
type,
|
|
requestId,
|
|
sessionId: this.sessionId,
|
|
ok: true,
|
|
...payload
|
|
};
|
|
sink(res);
|
|
return res;
|
|
};
|
|
|
|
if (!msg || typeof msg !== 'object') {
|
|
return sendError(null, null, 'INVALID_MESSAGE', 'Message envelope must be an object');
|
|
}
|
|
|
|
const { type, requestId, sessionId } = msg;
|
|
|
|
// 1. Handshake
|
|
if (type === 'hello') {
|
|
const { supportedContractMajors } = msg;
|
|
const isSupported = Array.isArray(supportedContractMajors) &&
|
|
supportedContractMajors.includes(this.contractMajor);
|
|
|
|
if (!isSupported) {
|
|
return sendError(
|
|
requestId,
|
|
null,
|
|
'UNSUPPORTED_VERSION',
|
|
`Exhibit requires contract major ${this.contractMajor}`
|
|
);
|
|
}
|
|
|
|
// Establish session and reset sequence counter
|
|
this.sessionId = this._generateSessionId();
|
|
this.sessionActive = true;
|
|
this.eventSequence = 0;
|
|
this.eventSink = replySink;
|
|
|
|
return sendResult('hello.result', requestId, {
|
|
exhibit: {
|
|
product: this.product,
|
|
version: this.version,
|
|
build: this.build
|
|
},
|
|
contract: {
|
|
major: this.contractMajor,
|
|
minor: this.contractMinor
|
|
}
|
|
});
|
|
}
|
|
|
|
// 2. All other operations require an active session
|
|
if (!this.sessionActive || !sessionId || sessionId !== this.sessionId) {
|
|
return sendError(requestId, sessionId, 'INVALID_SESSION', 'Missing or invalid sessionId');
|
|
}
|
|
|
|
// Authoritative source: ignore host-supplied source
|
|
const authoritativeSource = transportSource || 'host';
|
|
|
|
switch (type) {
|
|
case 'describe':
|
|
return sendResult('describe.result', requestId, this.describe());
|
|
|
|
case 'state.get': {
|
|
const fullState = this.getContractState();
|
|
const values = {};
|
|
for (const [id, target] of this.targets.entries()) {
|
|
if (target.readable && target.kind !== 'impulse') {
|
|
values[id] = fullState[id];
|
|
}
|
|
}
|
|
return sendResult('state.result', requestId, {
|
|
stateRevision: this.stateRevision,
|
|
values
|
|
});
|
|
}
|
|
|
|
case 'set': {
|
|
const { target: targetId, value } = msg;
|
|
const res = await this.applyMutation(targetId, value, authoritativeSource, requestId);
|
|
if (!res.ok) {
|
|
return sendError(requestId, this.sessionId, res.code, res.message);
|
|
}
|
|
return sendResult('set.result', requestId);
|
|
}
|
|
|
|
case 'invoke': {
|
|
const { target: targetId, args } = msg;
|
|
const res = await this.invokeAction(targetId, args, authoritativeSource, requestId);
|
|
if (!res.ok) {
|
|
return sendError(requestId, this.sessionId, res.code, res.message);
|
|
}
|
|
return sendResult('invoke.result', requestId);
|
|
}
|
|
|
|
default:
|
|
return sendError(requestId, sessionId, 'INVALID_MESSAGE', `Unknown message type: ${type}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = { XZBTContractAdapter };
|
|
}
|
|
if (typeof window !== 'undefined') {
|
|
window.XZBTContractAdapter = XZBTContractAdapter;
|
|
}
|
|
})();
|