generated from Labyricorn/labyricorn-project-template
1005 lines
32 KiB
JavaScript
1005 lines
32 KiB
JavaScript
/**
|
|
* XZBT Contract Adapter — Specification v5.2
|
|
*
|
|
* 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.2.0';
|
|
this.build = options.build || 'production';
|
|
this.contractMajor = 5;
|
|
this.contractMinor = 2;
|
|
|
|
this.sessionId = null;
|
|
this.sessionActive = false;
|
|
this.eventSequence = 0;
|
|
this.stateRevision = 0;
|
|
this.registryRevision = 1;
|
|
this.operationQueue = Promise.resolve();
|
|
this.eventSink = 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: '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'],
|
|
args: {
|
|
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'],
|
|
args: {
|
|
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' || e.data.xzbt !== '5.2') {
|
|
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);
|
|
}
|
|
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 } : {};
|
|
if (Object.keys(sanitizedArgs).some(key => !target.args || !target.args[key])) {
|
|
return { ok: false, code: 'INVALID_VALUE', message: 'Unexpected argument key' };
|
|
}
|
|
if (target.args) {
|
|
for (const [argKey, argSpec] of Object.entries(target.args)) {
|
|
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);
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
// Public API: Return contract-visible snapshot
|
|
getContractState() {
|
|
if (this.bindings.getState) {
|
|
return this.bindings.getState();
|
|
}
|
|
return {};
|
|
}
|
|
|
|
// Public API: Protocol Description
|
|
describe() {
|
|
return {
|
|
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())
|
|
};
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
})();
|